Compare commits
213
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
c9d87be289 | ||
|
|
012ea1eeb6 | ||
|
|
920af7bde2 | ||
|
|
88a8e795d1 | ||
|
|
44ae5cef5d | ||
|
|
518d9a4d22 | ||
|
|
602b1553a2 | ||
|
|
bd0881fb4b | ||
|
|
72f507163a | ||
|
|
f7a3dbaa2d | ||
|
|
c525f3a11f | ||
|
|
2777607ab5 | ||
|
|
a222cbb839 | ||
|
|
a9dd3e6e55 | ||
|
|
905995ab44 | ||
|
|
0ef8f05dc4 | ||
|
|
f7402a3531 | ||
|
|
dd378990fe | ||
|
|
a23bf34ccf | ||
|
|
a724c0823d | ||
|
|
cda6cf8583 | ||
|
|
c09e87fa4e | ||
|
|
418a05e343 | ||
|
|
0bcf73e1f9 | ||
|
|
ab1a0c0891 | ||
|
|
1a55241791 | ||
|
|
9d437a90c9 | ||
|
|
5351afe508 | ||
|
|
c80e351212 | ||
|
|
916a0d0f0a | ||
|
|
a333192df5 | ||
|
|
38cf7a3569 | ||
|
|
760a26b007 | ||
|
|
47f3e424ff | ||
|
|
f731775346 | ||
|
|
3c5486cc5c | ||
|
|
544515feba | ||
|
|
9a54da0808 | ||
|
|
8bc1f16e2c | ||
|
|
5dc7ce6a22 | ||
|
|
ff408747cd | ||
|
|
6636dd8e98 | ||
|
|
15ab82f1c8 | ||
|
|
0ea9c0ede1 | ||
|
|
c496d90d5a | ||
|
|
c101870b71 | ||
|
|
9a4ceddcb0 | ||
|
|
50233cadc1 | ||
|
|
8d88100b15 | ||
|
|
59d6e2ae05 | ||
|
|
e2b5c6d673 | ||
|
|
da89d6426f | ||
|
|
c19209655b | ||
|
|
d456bdf4b5 | ||
|
|
f06b17a194 | ||
|
|
675194075e | ||
|
|
0ecccc4746 | ||
|
|
a7b41c95c7 | ||
|
|
88d1498eac | ||
|
|
31ae04329d | ||
|
|
eae6304124 | ||
|
|
97c6d609ac | ||
|
|
44c442ddd3 | ||
|
|
f9250ec1f1 | ||
|
|
bff3e1af09 | ||
|
|
6dbe30b3f6 | ||
|
|
8a920acead | ||
|
|
929f652b8e | ||
|
|
610768501e | ||
|
|
427e6e1862 | ||
|
|
bfa5e5f474 | ||
|
|
213425379c | ||
|
|
c3444aed24 | ||
|
|
368bd199e5 | ||
|
|
2298d8eea4 | ||
|
|
7bb0bbbb46 | ||
|
|
deec5581ae | ||
|
|
1de5632428 | ||
|
|
2dfd76825b | ||
|
|
06fc8bc2fb | ||
|
|
75322390b8 | ||
|
|
d1312f1f09 | ||
|
|
75acbbd633 | ||
|
|
a37fb8f773 | ||
|
|
9d0d971500 | ||
|
|
1813e1bfa4 | ||
|
|
f9806d7f7f | ||
|
|
2d3adfc6a6 |
No files matched your search
@@ -4,3 +4,7 @@ updates:
|
|||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
- package-ecosystem: "bundler"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "never"
|
||||||
@@ -4,10 +4,15 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- v**
|
- v**
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag_name:
|
||||||
|
description: 'Release tag name (e.g. v4.4.3)'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
env:
|
env:
|
||||||
TAG_NAME: ${{ github.ref_name }}
|
TAG_NAME: ${{ github.event.inputs.tag_name || github.ref_name }}
|
||||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
@@ -16,66 +21,40 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v6
|
uses: gradle/actions/setup-gradle@v4
|
||||||
|
|
||||||
- name: Assemble Artifacts
|
- name: Assemble APK
|
||||||
run: ./gradlew assembleRelease bundleRelease
|
run: ./gradlew assembleRelease
|
||||||
|
|
||||||
- name: Sign APK
|
- name: Sign APK
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||||
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
|
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
|
||||||
|
VERSION=${TAG_NAME#v}
|
||||||
|
SIGNED_APK="app/build/outputs/apk/release/Look4Sat-Pro-${VERSION}.apk"
|
||||||
BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
|
BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
|
||||||
${BUILD_TOOLS}apksigner sign \
|
${BUILD_TOOLS}apksigner sign \
|
||||||
--ks keystore.jks \
|
--ks keystore.jks \
|
||||||
--ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
|
--ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
|
||||||
--ks-key-alias ${{ secrets.KEY_ALIAS }} \
|
--ks-key-alias ${{ secrets.KEY_ALIAS }} \
|
||||||
--key-pass pass:${{ secrets.KEY_PASSWORD }} \
|
--key-pass pass:${{ secrets.KEY_PASSWORD }} \
|
||||||
--out app/build/outputs/apk/release/look4sat.apk \
|
--out "$SIGNED_APK" \
|
||||||
"$APK"
|
"$APK"
|
||||||
rm keystore.jks
|
rm keystore.jks
|
||||||
|
echo "SIGNED_APK=$SIGNED_APK" >> "$GITHUB_ENV"
|
||||||
- name: Sign Bundle
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
|
||||||
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
|
||||||
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
|
|
||||||
-keystore keystore.jks \
|
|
||||||
-storepass ${{ secrets.KEY_STORE_PASSWORD }} \
|
|
||||||
-keypass ${{ secrets.KEY_PASSWORD }} \
|
|
||||||
"$AAB" ${{ secrets.KEY_ALIAS }}
|
|
||||||
rm keystore.jks
|
|
||||||
|
|
||||||
- name: Setup Ruby
|
|
||||||
uses: ruby/setup-ruby@v1
|
|
||||||
with:
|
|
||||||
ruby-version: '3.4'
|
|
||||||
|
|
||||||
- name: Deploy Bundle to Google Play
|
|
||||||
run: |
|
|
||||||
gem install multi_json
|
|
||||||
gem install fastlane --no-document
|
|
||||||
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
|
||||||
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
|
|
||||||
fastlane supply \
|
|
||||||
--aab "$AAB" \
|
|
||||||
--json_key service_account.json \
|
|
||||||
--package_name com.rtbishop.look4sat \
|
|
||||||
--track production \
|
|
||||||
--skip_upload_images true \
|
|
||||||
--skip_upload_screenshots true \
|
|
||||||
rm service_account.json
|
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
gh release create $TAG_NAME --title=$TAG_NAME --generate-notes
|
gh release create $TAG_NAME --title=$TAG_NAME --generate-notes
|
||||||
gh release upload $TAG_NAME app/build/outputs/apk/release/look4sat.apk
|
gh release upload $TAG_NAME "$SIGNED_APK"
|
||||||
+6
-2
@@ -39,12 +39,15 @@ captures/
|
|||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
# Keystore files
|
# Keystore files
|
||||||
# Uncomment the following line if you do not want to check your keystore files in.
|
*.jks
|
||||||
#*.jks
|
*.keystore
|
||||||
/*.properties
|
/*.properties
|
||||||
/keystore.properties
|
/keystore.properties
|
||||||
/app/keystore.jks
|
/app/keystore.jks
|
||||||
|
|
||||||
|
# Hermes agent workspace (plans, local notes)
|
||||||
|
.hermes/
|
||||||
|
|
||||||
# External native build folder generated in Android Studio 2.2 and later
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
|
|
||||||
@@ -67,3 +70,4 @@ fastlane/readme.md
|
|||||||
/app/release/output-metadata.json
|
/app/release/output-metadata.json
|
||||||
/app/release/
|
/app/release/
|
||||||
/.kotlin/sessions/
|
/.kotlin/sessions/
|
||||||
|
.hermes/
|
||||||
@@ -8,16 +8,15 @@ All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) po
|
|||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
||||||
satellites using TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays
|
satellites using Celestrak/SatNOGS orbital data, calculates positions via SGP4/SDP4, and predicts passes relative to
|
||||||
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
|
the user's location. Features include polar radar visualization, SSTV image decoding, and ground track mapping. No ads,
|
||||||
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
|
no tracking, no network required after initial data download.
|
||||||
data download.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture & Design
|
||||||
|
|
||||||
**MVI (Model-View-Intent)** with unidirectional data flow:
|
**MVI (Model-View-Intent)** with unidirectional data flow:
|
||||||
- `State` data class → exposed via `StateFlow` from ViewModel
|
- `State` data class (named `<Feature>State`) exposed via `StateFlow` from ViewModel
|
||||||
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
|
- `Action` sealed interface (named `<Feature>Action`) dispatched to ViewModel's `onAction()`
|
||||||
- Jetpack Compose UI observes state and recomposes reactively
|
- Jetpack Compose UI observes state and recomposes reactively
|
||||||
|
|
||||||
**Clean Architecture layers:**
|
**Clean Architecture layers:**
|
||||||
@@ -34,9 +33,11 @@ data download.
|
|||||||
| `feature:satellites` | Satellite list, filtering, selection |
|
| `feature:satellites` | Satellite list, filtering, selection |
|
||||||
| `feature:settings` | User preferences |
|
| `feature:settings` | User preferences |
|
||||||
|
|
||||||
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
|
**Feature isolation:**
|
||||||
|
- `feature:*` modules depend only on `core:domain` and `core:presentation`.
|
||||||
|
- No feature-to-feature dependencies; cross-feature communication goes through core layers.
|
||||||
|
|
||||||
## Build & Run
|
## Build & Platform
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
# Debug build
|
# Debug build
|
||||||
@@ -50,54 +51,55 @@ data download.
|
|||||||
```
|
```
|
||||||
|
|
||||||
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
||||||
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
|
- **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/`
|
||||||
|
|
||||||
## Key Libraries
|
## Tech Stack
|
||||||
|
|
||||||
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
||||||
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
|
- **Navigation3**: Type-safe navigation with `@Serializable` nav keys
|
||||||
- **Room** (KSP code generation) for local satellite/TLE storage
|
- **Room** (KSP code generation) for local satellite/orbital storage
|
||||||
- **OkHttp** 5.x for TLE downloads
|
- **OkHttp** 5.x for data downloads
|
||||||
- **OSMDroid** for map rendering
|
- **OSMDroid** for map rendering
|
||||||
- **Kotlin Serialization** for navigation args and data parsing
|
- **Kotlin Serialization** for navigation args and parsing
|
||||||
- **Coroutines** + `StateFlow` for async/reactive patterns
|
- **Coroutines** + `StateFlow` for async/reactive patterns
|
||||||
|
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh)
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- **Minimal dependencies**: Avoid adding libraries when a simple manual solution exists. Fewer deps = less maintenance.
|
|
||||||
- **DI**: Manual — ViewModels use companion `factory()` methods with `IMainContainer` interface.
|
|
||||||
- **Navigation**: Type-safe Compose Navigation3 with `@Serializable` data classes as nav keys.
|
|
||||||
- **State naming**: `<Feature>State` data class + `<Feature>Action` sealed interface per feature.
|
|
||||||
- **No feature-to-feature deps**: All cross-feature communication goes through core layers.
|
|
||||||
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh).
|
|
||||||
|
|
||||||
## Data Formats & Migration
|
## Data Formats & Migration
|
||||||
|
|
||||||
**TLE vs. OMM/CSV format:**
|
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
|
||||||
|
|
||||||
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
|
- **TLE format**: Legacy 3-line element format limited by 5-digit NORAD IDs
|
||||||
|
- **OMM/CSV format**: Successor format with ISO 8601 timestamps and larger NORAD ID support
|
||||||
|
- New 5-digit NORAD IDs are exhausted; TLE is officially deprecated and OMM/CSV is the clear default
|
||||||
|
- `DataParser.kt` supports both via `parseTLEStream()` and `parseCSVStream()`
|
||||||
|
- Downloads auto-detect format; both produce identical `OrbitalData` objects
|
||||||
|
- Existing code already supports transparent source transition without feature changes
|
||||||
|
- Refresh orbital data weekly for accurate pass prediction (orbital decay)
|
||||||
|
|
||||||
- **TLE format**: Traditional 3-line element format (deprecated). NORAD catalog numbers are 5-digit integers, which
|
## Engineering Heuristics (Lazy = Efficient)
|
||||||
are running out of space. Celestrak has signaled that TLE format will eventually be phased out.
|
|
||||||
- **OMM/CSV format**: The future standard. CSV files contain the same orbital parameters as TLE but use ISO 8601
|
|
||||||
timestamps and support larger NORAD IDs. Celestrak and SatNOGS already provide OMM data in CSV format.
|
|
||||||
|
|
||||||
**Current implementation:**
|
- Treat "lazy" as efficient, not careless: the best code is the code never written.
|
||||||
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly
|
- First understand the task and trace the real flow end-to-end, then climb this ladder:
|
||||||
- TLE data is downloaded from configured sources and stored in Room database
|
1. Does this need to be built now? (YAGNI)
|
||||||
- When downloading satellite data, the app automatically detects format and parses accordingly
|
2. Does it already exist in this codebase? Reuse helpers/patterns before rewriting.
|
||||||
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching
|
3. Does Kotlin/Java stdlib already solve it?
|
||||||
|
4. Does the Android/platform API already solve it?
|
||||||
|
5. Does an already-installed dependency solve it?
|
||||||
|
6. Can this be simpler (including one-liner simple)?
|
||||||
|
7. Only then: write the minimum code that works.
|
||||||
|
- Prefer deletion to addition, boring over clever, and the fewest touched files.
|
||||||
|
- Avoid new abstractions, dependencies, and boilerplate unless explicitly requested.
|
||||||
|
- Manual DI only: ViewModels use companion `factory()` methods with `IMainContainer`.
|
||||||
|
- Release builds use ProGuard: avoid reflection-heavy libraries unless explicitly approved.
|
||||||
|
- When two options are similar in size, choose the edge-case-correct one.
|
||||||
|
- If you keep a deliberate simplification (for example O(n^2) scan or global lock), leave a short comment with the ceiling and upgrade path.
|
||||||
|
- For complex asks, challenge scope when appropriate: "Do you need X, or does Y already cover it?"
|
||||||
|
|
||||||
**Migration path:**
|
## Bug-Fix Policy
|
||||||
As NORAD catalog space becomes constrained, OMM/CSV will become the primary format. Look4Sat is already positioned
|
|
||||||
to handle this transition without code changes — existing users can continue using TLE files while new sources
|
|
||||||
transition to OMM/CSV automatically.
|
|
||||||
|
|
||||||
## Code Style
|
- Fix root cause, not just the reported symptom.
|
||||||
|
- If touching a shared function, inspect callers and prefer one shared fix over per-caller patches.
|
||||||
- Prefer **short, focused functions** — single responsibility, easy to read.
|
- The smallest correct diff wins only after behavior is understood.
|
||||||
- **Exceptions**: Composable functions and math-heavy algorithms (SGP4/SDP4) may be longer.
|
|
||||||
- Strict code style — no dead code, no unused imports, consistent formatting.
|
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
@@ -105,12 +107,9 @@ transition to OMM/CSV automatically.
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Orbital math lives in `core:domain/predict/` — it's dense vector math (SGP4/SDP4). Tread carefully.
|
- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully.
|
||||||
- TLE/OMM data must be refreshed weekly for accurate predictions (satellite orbits decay). TLE format is legacy and
|
|
||||||
will eventually be deprecated in favor of OMM/CSV as NORAD catalog numbers approach the 5-digit limit.
|
|
||||||
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
||||||
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
|
- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules.
|
||||||
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
|
|
||||||
|
|
||||||
## Copilot Working Mode: Code-Only
|
## Copilot Working Mode: Code-Only
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
GNU GENERAL PUBLIC LICENSE
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 29 June 2007
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
@@ -7,17 +7,15 @@
|
|||||||
|
|
||||||
Preamble
|
Preamble
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
software and other kinds of works.
|
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
|
The licenses for most software and other practical works are designed
|
||||||
to take away your freedom to share and change the works. By contrast,
|
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
|
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
|
software for all its users.
|
||||||
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.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
When we speak of free software, we are referring to freedom, not
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
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
|
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.
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
To protect your rights, we need to prevent others from denying you
|
Developers that use our General Public Licenses protect your rights
|
||||||
these rights or asking you to surrender the rights. Therefore, you have
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
certain responsibilities if you distribute copies of the software, or if
|
you this License which gives you legal permission to copy, distribute
|
||||||
you modify it: responsibilities to respect the freedom of others.
|
and/or modify the software.
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether
|
A secondary benefit of defending all users' freedom is that
|
||||||
gratis or for a fee, you must pass on to the recipients the same
|
improvements made in alternate versions of the program, if they
|
||||||
freedoms that you received. You must make sure that they, too, receive
|
receive widespread use, become available for other developers to
|
||||||
or can get the source code. And you must show them these terms so they
|
incorporate. Many developers of free software are heartened and
|
||||||
know their rights.
|
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:
|
The GNU Affero General Public License is designed specifically to
|
||||||
(1) assert copyright on the software, and (2) offer you this License
|
ensure that, in such cases, the modified source code becomes available
|
||||||
giving you legal permission to copy, distribute and/or modify it.
|
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
|
An older license, called the Affero General Public License and
|
||||||
that there is no warranty for this free software. For both users' and
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
authors' sake, the GPL requires that modified versions be marked as
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
changed, so that their problems will not be attributed erroneously to
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
authors of previous versions.
|
this license.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
The precise terms and conditions for copying, distribution and
|
||||||
modification follow.
|
modification follow.
|
||||||
@@ -72,7 +60,7 @@ modification follow.
|
|||||||
|
|
||||||
0. Definitions.
|
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
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
works, such as semiconductor masks.
|
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
|
the Program, the only way you could satisfy both those terms and this
|
||||||
License would be to refrain entirely from conveying the Program.
|
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
|
Notwithstanding any other provision of this License, you have
|
||||||
permission to link or combine any covered work with a work licensed
|
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
|
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,
|
License will continue to apply to the part which is the covered work,
|
||||||
but the special requirements of the GNU Affero General Public License,
|
but the work with which it is combined will remain governed by version
|
||||||
section 13, concerning interaction through a network will apply to the
|
3 of the GNU General Public License.
|
||||||
combination as such.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
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
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
be similar in spirit to the present version, but may differ in detail to
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
address new problems or concerns.
|
address new problems or concerns.
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
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
|
Public License "or any later version" applies to it, you have the
|
||||||
option of following the terms and conditions either of that numbered
|
option of following the terms and conditions either of that numbered
|
||||||
version or of any later version published by the Free Software
|
version or of any later version published by the Free Software
|
||||||
Foundation. If the Program does not specify a version number of the
|
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.
|
by the Free Software Foundation.
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
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
|
public statement of acceptance of a version permanently authorizes you
|
||||||
to choose that version for the Program.
|
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>
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
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
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
(at your option) any later version.
|
(at your option) any later version.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
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/>.
|
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.
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short
|
If your software can interact with users remotely through a computer
|
||||||
notice like this when it starts in an interactive mode:
|
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
|
||||||
<program> Copyright (C) <year> <name of author>
|
interface could display a "Source" link that leads users to an archive
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
of the code. There are many ways you could offer source, and different
|
||||||
This is free software, and you are welcome to redistribute it
|
solutions will be better for different programs; see section 13 for the
|
||||||
under certain conditions; type `show c' for details.
|
specific requirements.
|
||||||
|
|
||||||
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".
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
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.
|
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/>.
|
<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
|
* 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.
|
* 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
|
## 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>
|
<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: 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://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?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>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
@@ -1,3 +1,50 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.convention.applicationPlugin)
|
alias(libs.plugins.convention.applicationPlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load signing config from keystore.properties (gitignored, never commit credentials)
|
||||||
|
val keystoreProperties = Properties().apply {
|
||||||
|
val propsFile = rootProject.file("keystore.properties")
|
||||||
|
if (propsFile.exists()) propsFile.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
defaultConfig {
|
||||||
|
// ONNX Runtime 的 AAR 自带 4 个架构共 115MB 原生库(arm64 28M / armv7 20M /
|
||||||
|
// x86 33M / x86_64 34M)。x86 系列只有模拟器用得到, 全打包会让 APK 从 8MB
|
||||||
|
// 涨到 135MB。仅保留真机需要的两个 ABI。
|
||||||
|
ndk {
|
||||||
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
androidResources {
|
||||||
|
// 显式保留全部语言(防 shrinkResources 丢弃 in/id 印尼语配置); AGP 9 用 localeFilters
|
||||||
|
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) {
|
||||||
|
create("release") {
|
||||||
|
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String
|
||||||
|
keyPassword = keystoreProperties["keyPassword"] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.**
|
||||||
@@ -14,7 +14,10 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
<application
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<application
|
||||||
android:name=".MainApplication"
|
android:name=".MainApplication"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
@@ -43,7 +46,11 @@
|
|||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||||
android:value="com.rtbishop.look4sat" />
|
android:value="com.rtbishop.look4sat.bg7nta" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name="com.rtbishop.look4sat.app.AprsForegroundService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package com.rtbishop.look4sat.app
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.widget.Toast
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import com.rtbishop.look4sat.MainApplication
|
||||||
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
|
||||||
|
import com.rtbishop.look4sat.core.data.aprs.AprsStore
|
||||||
|
import com.rtbishop.look4sat.core.data.aprs.AprsReporter
|
||||||
|
import com.rtbishop.look4sat.core.data.aprs.AprsState
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_START = AprsStore.ACTION_START
|
||||||
|
const val ACTION_STOP = AprsStore.ACTION_STOP
|
||||||
|
const val ACTION_REPORT_NOW = AprsStore.ACTION_REPORT_NOW
|
||||||
|
const val CHANNEL_ID = "aprs_service"
|
||||||
|
const val NOTIF_ID = 101
|
||||||
|
}
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var reporter: AprsReporter? = null
|
||||||
|
private var lastState: AprsState = AprsState.Idle
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_STOP -> stopReporting()
|
||||||
|
ACTION_REPORT_NOW -> {
|
||||||
|
if (reporter == null) {
|
||||||
|
// Service not running: start it first (Toast hint when not configured)
|
||||||
|
startReporting()
|
||||||
|
}
|
||||||
|
reporter?.reportNow()
|
||||||
|
}
|
||||||
|
else -> startReporting()
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Toast.makeText(this, getString(R.string.aprs_toast_not_configured), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
stopSelf()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startForegroundWithNotification(cfg)
|
||||||
|
val rep = AprsReporter(
|
||||||
|
configProvider = { AprsStore.loadConfig(this) },
|
||||||
|
positionProvider = { stationPosition() },
|
||||||
|
onState = { lastState = it },
|
||||||
|
onReport = { report ->
|
||||||
|
AprsStore.saveLastReport(this, report.ok, report.detail)
|
||||||
|
updateNotification(cfg)
|
||||||
|
// Report result always surfaces: success = short Toast, failure = long Toast + reason
|
||||||
|
val msg = if (report.ok) {
|
||||||
|
getString(R.string.aprs_toast_ok)
|
||||||
|
} else {
|
||||||
|
getString(R.string.aprs_toast_fail, report.detail)
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
Toast.makeText(this, msg,
|
||||||
|
if (report.ok) Toast.LENGTH_SHORT else Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
reporter = rep
|
||||||
|
rep.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopReporting() {
|
||||||
|
reporter?.stop()
|
||||||
|
reporter = null
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startForegroundWithNotification(cfg: AprsConfig) {
|
||||||
|
try {
|
||||||
|
val notif = buildNotification(cfg)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIF_ID, notif)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Vendor ROM / old-system safety net: foreground-start failure only stops the service, never crashes the app
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(cfg: AprsConfig): Notification {
|
||||||
|
val pi = PendingIntent.getActivity(
|
||||||
|
this, 0, packageManager.getLaunchIntentForPackage(packageName),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val stopPi = PendingIntent.getService(
|
||||||
|
this, 1, Intent(this, AprsForegroundService::class.java).setAction(ACTION_STOP),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val stateText = when (lastState) {
|
||||||
|
AprsState.Connected -> getString(R.string.aprs_notif_connected)
|
||||||
|
AprsState.Error -> getString(R.string.aprs_notif_error)
|
||||||
|
else -> getString(R.string.aprs_notif_running)
|
||||||
|
}
|
||||||
|
return Notification.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_radios)
|
||||||
|
.setContentTitle(getString(R.string.aprs_notif_title, cfg.callsign))
|
||||||
|
.setContentText(stateText)
|
||||||
|
.setContentIntent(pi)
|
||||||
|
.setOngoing(true)
|
||||||
|
.addAction(0, getString(R.string.aprs_notif_stop), stopPi)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification(cfg: AprsConfig) {
|
||||||
|
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
nm.notify(NOTIF_ID, buildNotification(cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}.getOrNull()
|
||||||
|
if (station != null && (station.latitude != 0.0 || station.longitude != 0.0)) {
|
||||||
|
return Pair(station.latitude, station.longitude)
|
||||||
|
}
|
||||||
|
// 2. Fallback: last live GPS position
|
||||||
|
return runCatching {
|
||||||
|
val lm = getSystemService(Context.LOCATION_SERVICE) as android.location.LocationManager
|
||||||
|
val providers = listOf(
|
||||||
|
android.location.LocationManager.GPS_PROVIDER,
|
||||||
|
android.location.LocationManager.NETWORK_PROVIDER
|
||||||
|
)
|
||||||
|
for (p in providers) {
|
||||||
|
val loc = lm.getLastKnownLocation(p) ?: continue
|
||||||
|
if (loc.latitude != 0.0 || loc.longitude != 0.0) {
|
||||||
|
return Pair(loc.latitude, loc.longitude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createChannel() {
|
||||||
|
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID, getString(R.string.aprs_notif_channel),
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
)
|
||||||
|
nm.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
reporter?.stop()
|
||||||
|
reporter = null
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
observeNightFilterState()
|
observeNightFilterState()
|
||||||
setContent {
|
setContent {
|
||||||
MainTheme(isDarkTheme = true) { MainScreen() }
|
MainTheme(isDarkTheme = true) { NavRoot() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,29 @@ class MainApplication : Application(), IContainerProvider {
|
|||||||
|
|
||||||
private lateinit var container: IMainContainer
|
private lateinit var container: IMainContainer
|
||||||
|
|
||||||
|
/** 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 ->
|
||||||
|
runCatching {
|
||||||
|
val log = StringBuilder()
|
||||||
|
log.append("=== Crash ${System.currentTimeMillis()} ===\n")
|
||||||
|
log.append("Thread: ").append(thread.name).append("\n")
|
||||||
|
val sw = java.io.StringWriter()
|
||||||
|
throwable.printStackTrace(java.io.PrintWriter(sw))
|
||||||
|
log.append(sw.toString()).append("\n")
|
||||||
|
val file = java.io.File(filesDir, "crash_log.txt")
|
||||||
|
file.appendText(log.toString())
|
||||||
|
}
|
||||||
|
defaultHandler?.uncaughtException(thread, throwable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun getMainContainer(): IMainContainer = container
|
override fun getMainContainer(): IMainContainer = container
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
installCrashHandler()
|
||||||
container = MainContainer(this)
|
container = MainContainer(this)
|
||||||
// trigger automatic update every 48 hours
|
// trigger automatic update every 48 hours
|
||||||
container.appScope.launch { checkAutoUpdate() }
|
container.appScope.launch { checkAutoUpdate() }
|
||||||
|
|||||||
@@ -17,6 +17,13 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat
|
package com.rtbishop.look4sat
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
@@ -25,8 +32,6 @@ import androidx.compose.animation.core.rememberInfiniteTransition
|
|||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
|
||||||
import androidx.compose.animation.scaleOut
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
@@ -44,7 +49,7 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||||
@@ -53,10 +58,14 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -72,6 +81,8 @@ import androidx.navigation3.runtime.rememberNavBackStack
|
|||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||||
|
import com.rtbishop.look4sat.core.domain.navigation.MenuLayout
|
||||||
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
||||||
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
||||||
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
||||||
@@ -79,44 +90,52 @@ import com.rtbishop.look4sat.core.presentation.RadarDestination
|
|||||||
import com.rtbishop.look4sat.core.presentation.Screen
|
import com.rtbishop.look4sat.core.presentation.Screen
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
||||||
|
import com.rtbishop.look4sat.feature.cw.CwDecodeScreen
|
||||||
import com.rtbishop.look4sat.feature.map.MapDestination
|
import com.rtbishop.look4sat.feature.map.MapDestination
|
||||||
import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
||||||
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
||||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||||
|
import com.rtbishop.look4sat.feature.radar.WavelogLogScreen
|
||||||
|
import com.rtbishop.look4sat.feature.roaming.RoamingScreen
|
||||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||||
|
import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NavRoot(deeplink: String? = null) {
|
fun NavRoot(deeplink: String? = null) {
|
||||||
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||||
val deeplinkResolver = DeeplinkResolver()
|
val deeplinkResolver = DeeplinkResolver()
|
||||||
LaunchedEffect(deeplink) {
|
LaunchedEffect(deeplink) {
|
||||||
deeplink?.let {
|
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
||||||
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
|
|
||||||
rootBackStack.add(destination)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||||
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
||||||
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
// 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(
|
NavDisplay(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
backStack = rootBackStack,
|
backStack = rootBackStack,
|
||||||
onBack = navigateBack,
|
onBack = navigateBack,
|
||||||
transitionSpec = { slideInTransition },
|
transitionSpec = { pushTransition },
|
||||||
popTransitionSpec = { slideOutTransition },
|
popTransitionSpec = { popTransition },
|
||||||
predictivePopTransitionSpec = { slideOutTransition },
|
predictivePopTransitionSpec = { popTransition },
|
||||||
entryDecorators = listOf(
|
entryDecorators = listOf(
|
||||||
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
),
|
),
|
||||||
entryProvider = entryProvider {
|
entryProvider = entryProvider {
|
||||||
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
|
entry<Screen.Passes> { MainScreen() }
|
||||||
entry<RadarDestination> {
|
entry<RadarDestination> {
|
||||||
Scaffold { innerPadding ->
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
RadarDestination(navigateUp = navigateBack)
|
RadarDestination(navigateUp = navigateBack)
|
||||||
innerPadding.calculateTopPadding()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,17 +143,40 @@ fun NavRoot(deeplink: String? = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
fun MainScreen() {
|
||||||
val backStack = rememberNavBackStack(Screen.Passes)
|
val backStack = rememberNavBackStack(Screen.Passes)
|
||||||
val currentKey = backStack.lastOrNull()
|
val currentKey = backStack.lastOrNull()
|
||||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||||
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
||||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Map, Screen.Settings)
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||||
|
// 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 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
|
// Activity-scoped so the mutual query results survive navigation to Radar and back
|
||||||
val mutualViewModel: MutualViewModel = viewModel(
|
val mutualViewModel: MutualViewModel = viewModel(
|
||||||
viewModelStoreOwner = context as ViewModelStoreOwner,
|
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||||
@@ -149,27 +191,41 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
) {
|
) {
|
||||||
NavigationSuiteScaffold(
|
NavigationSuiteScaffold(
|
||||||
navigationSuiteItems = {
|
navigationSuiteItems = {
|
||||||
navItems.forEach { screen ->
|
mainNavItems.forEach { screen ->
|
||||||
val isSelected = when (currentKey) {
|
// Screen subclasses are data objects, so identity is enough and
|
||||||
is Screen.Satellites -> screen is Screen.Satellites
|
// newly added pages highlight without touching this call site.
|
||||||
is Screen.Passes -> screen is Screen.Passes
|
val isSelected = currentKey == screen
|
||||||
is Screen.Radar -> screen is Screen.Radar
|
|
||||||
is Screen.Mutual -> screen is Screen.Mutual
|
|
||||||
is Screen.Map -> screen is Screen.Map
|
|
||||||
is Screen.Settings -> screen is Screen.Settings
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
item(
|
item(
|
||||||
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(screen.iconResId),
|
||||||
|
contentDescription = stringResource(screen.titleResId)
|
||||||
|
)
|
||||||
|
},
|
||||||
label = { Text(stringResource(screen.titleResId)) },
|
label = { Text(stringResource(screen.titleResId)) },
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (isSelected) return@item
|
if (isSelected) return@item
|
||||||
|
moreExpanded = false
|
||||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||||
if (screen !is Screen.Passes) backStack.add(screen)
|
if (screen !is Screen.Passes) backStack.add(screen)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// More-menu button (fixed slot 6; shown only when the sub-menu is non-empty)
|
||||||
|
if (moreNavItems.isNotEmpty()) {
|
||||||
|
item(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(com.rtbishop.look4sat.R.drawable.ic_more),
|
||||||
|
stringResource(com.rtbishop.look4sat.core.presentation.R.string.nav_more)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(com.rtbishop.look4sat.core.presentation.R.string.nav_more)) },
|
||||||
|
selected = moreExpanded,
|
||||||
|
onClick = { moreExpanded = !moreExpanded }
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
||||||
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||||
@@ -180,99 +236,138 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
else -> NavigationSuiteType.ShortNavigationBarMedium
|
else -> NavigationSuiteType.ShortNavigationBarMedium
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Column {
|
Box {
|
||||||
NavDisplay(
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
backStack = backStack,
|
NavDisplay(
|
||||||
modifier = Modifier.weight(1f),
|
backStack = backStack,
|
||||||
onBack = navigateBack,
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
transitionSpec = { fadeTransition },
|
onBack = navigateBack,
|
||||||
popTransitionSpec = { fadeTransition },
|
transitionSpec = { fadeTransition },
|
||||||
predictivePopTransitionSpec = { fadeTransition },
|
popTransitionSpec = { fadeTransition },
|
||||||
entryDecorators = listOf(
|
predictivePopTransitionSpec = { fadeTransition },
|
||||||
// Required for saving Compose state per entry
|
entryDecorators = listOf(
|
||||||
rememberSaveableStateHolderNavEntryDecorator(),
|
// Required for saving Compose state per entry
|
||||||
// Required for ViewModel scoping per entry
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
rememberViewModelStoreNavEntryDecorator()
|
// Required for ViewModel scoping per entry
|
||||||
),
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
entryProvider = entryProvider {
|
),
|
||||||
entry<Screen.Satellites> {
|
entryProvider = entryProvider {
|
||||||
SatellitesDestination(navigateUp = navigateBack)
|
entry<Screen.Satellites> {
|
||||||
}
|
SatellitesDestination(navigateUp = navigateBack)
|
||||||
entry<Screen.Passes> {
|
|
||||||
PassesDestination { catNum, aosTime ->
|
|
||||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
|
||||||
backStack.add(Screen.Radar)
|
|
||||||
// navigateToRadar()
|
|
||||||
}
|
}
|
||||||
}
|
entry<Screen.Passes> {
|
||||||
entry<Screen.Radar> {
|
PassesDestination { catNum, aosTime ->
|
||||||
RadarDestination(navigateUp = navigateBack)
|
container.setMutualPassData(MutualPassData())
|
||||||
}
|
|
||||||
entry<Screen.Map> {
|
|
||||||
MapDestination()
|
|
||||||
}
|
|
||||||
entry<Screen.Mutual> {
|
|
||||||
MutualScreen(
|
|
||||||
viewModel = mutualViewModel,
|
|
||||||
navigateUp = navigateBack,
|
|
||||||
navigateToRadar = { catNum, aosTime, pass ->
|
|
||||||
pass?.let { container.setMutualPassData(it) }
|
|
||||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
backStack.add(Screen.Radar)
|
backStack.add(Screen.Radar)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
entry<Screen.Radar> {
|
||||||
|
RadarDestination(navigateUp = navigateBack)
|
||||||
|
}
|
||||||
|
entry<Screen.Map> {
|
||||||
|
MapDestination()
|
||||||
|
}
|
||||||
|
entry<Screen.Mutual> {
|
||||||
|
MutualScreen(
|
||||||
|
viewModel = mutualViewModel,
|
||||||
|
navigateUp = navigateBack,
|
||||||
|
navigateToRadar = { catNum, aosTime, pass ->
|
||||||
|
container.setMutualPassData(pass ?: MutualPassData())
|
||||||
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
|
backStack.add(Screen.Radar)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
entry<Screen.Roaming> {
|
||||||
|
RoamingScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.CwDecode> {
|
||||||
|
CwDecodeScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.AmSat> {
|
||||||
|
SatStatusDestination()
|
||||||
|
}
|
||||||
|
entry<Screen.WavelogLog> {
|
||||||
|
WavelogLogScreen(queue = container.wavelogQueue)
|
||||||
|
}
|
||||||
|
entry<Screen.Settings> {
|
||||||
|
SettingsDestination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// Radio tracking status banner
|
||||||
|
if (trackingState.isActive) {
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
||||||
|
val alpha by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 1f, targetValue = 0.4f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(1000, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
), label = "pulseAlpha"
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
|
.clickable {
|
||||||
|
val pass = trackingState.currentPass
|
||||||
|
if (pass != null) {
|
||||||
|
container.setMutualPassData(MutualPassData())
|
||||||
|
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||||
|
backStack.add(Screen.Radar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(com.rtbishop.look4sat.core.presentation.R.string.tracking_status, trackingState.currentPass?.name ?: ""),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
val txOk = if (trackingState.txConnected) "TX" else ""
|
||||||
|
val rxOk = if (trackingState.rxConnected) "RX" else ""
|
||||||
|
Text(
|
||||||
|
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
entry<Screen.Settings> {
|
}
|
||||||
SettingsDestination()
|
}
|
||||||
|
// More-menu popup panel (slim strip anchored to the bottom-right corner)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = moreExpanded,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
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,
|
||||||
|
currentKey = currentKey,
|
||||||
|
onDismiss = { moreExpanded = false },
|
||||||
|
onSelect = { screen ->
|
||||||
|
moreExpanded = false
|
||||||
|
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||||
|
if (screen !is Screen.Passes) backStack.add(screen)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
// Radio tracking status banner
|
|
||||||
if (trackingState.isActive) {
|
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
|
||||||
val alpha by infiniteTransition.animateFloat(
|
|
||||||
initialValue = 1f, targetValue = 0.4f,
|
|
||||||
animationSpec = infiniteRepeatable(
|
|
||||||
animation = tween(1000, easing = LinearEasing),
|
|
||||||
repeatMode = RepeatMode.Reverse
|
|
||||||
), label = "pulseAlpha"
|
|
||||||
)
|
)
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
|
||||||
.clickable {
|
|
||||||
val pass = trackingState.currentPass
|
|
||||||
if (pass != null) {
|
|
||||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
|
||||||
backStack.add(Screen.Radar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(8.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
|
|
||||||
fontSize = 13.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
val txOk = if (trackingState.txConnected) "TX" else ""
|
|
||||||
val rxOk = if (trackingState.rxConnected) "RX" else ""
|
|
||||||
Text(
|
|
||||||
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/*
|
||||||
|
* MoreMenuPopup.kt - bottom-nav "More" second-level menu popup panel (4.5.1).
|
||||||
|
*
|
||||||
|
* 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.BorderStroke
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
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
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation3.runtime.NavKey
|
||||||
|
import com.rtbishop.look4sat.core.presentation.Screen
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MoreMenuPopup(
|
||||||
|
items: List<Screen>,
|
||||||
|
currentKey: NavKey?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSelect: (Screen) -> Unit
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
// 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)
|
||||||
|
.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.surfaceContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||||
|
items.forEach { screen ->
|
||||||
|
// 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
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onSelect(screen) }
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(screen.iconResId),
|
||||||
|
contentDescription = stringResource(screen.titleResId),
|
||||||
|
tint = if (isSelected) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(screen.titleResId),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
color = if (isSelected) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "›",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z" />
|
||||||
|
</vector>
|
||||||
+3
@@ -31,12 +31,15 @@ internal class ApplicationPlugin : Plugin<Project> {
|
|||||||
implementation(project(":core:data"))
|
implementation(project(":core:data"))
|
||||||
implementation(project(":core:domain"))
|
implementation(project(":core:domain"))
|
||||||
implementation(project(":core:presentation"))
|
implementation(project(":core:presentation"))
|
||||||
|
implementation(project(":feature:cw"))
|
||||||
implementation(project(":feature:map"))
|
implementation(project(":feature:map"))
|
||||||
implementation(project(":feature:mutual"))
|
implementation(project(":feature:mutual"))
|
||||||
implementation(project(":feature:passes"))
|
implementation(project(":feature:passes"))
|
||||||
implementation(project(":feature:radar"))
|
implementation(project(":feature:radar"))
|
||||||
|
implementation(project(":feature:roaming"))
|
||||||
implementation(project(":feature:satellites"))
|
implementation(project(":feature:satellites"))
|
||||||
implementation(project(":feature:settings"))
|
implementation(project(":feature:settings"))
|
||||||
|
implementation(project(":feature:status"))
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
implementation(libs.compose.material3.adaptive)
|
implementation(libs.compose.material3.adaptive)
|
||||||
implementation(libs.compose.navigation3)
|
implementation(libs.compose.navigation3)
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ internal fun Project.setupAndroidApp() {
|
|||||||
namespace = libs.versions.packageName.get()
|
namespace = libs.versions.packageName.get()
|
||||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = libs.versions.packageName.get()
|
applicationId = libs.versions.applicationId.get()
|
||||||
minSdk = libs.versions.minSdk.get().toInt()
|
minSdk = libs.versions.minSdk.get().toInt()
|
||||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||||
versionName = libs.versions.appVersionName.get()
|
versionName = libs.versions.appVersionName.get()
|
||||||
|
|||||||
@@ -5,3 +5,9 @@ plugins {
|
|||||||
android {
|
android {
|
||||||
namespace = "com.rtbishop.look4sat.core.data"
|
namespace = "com.rtbishop.look4sat.core.data"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// DeepCW 神经网络 CW 解码推理。ONNX 推理属 Android 平台依赖, 放此处而非
|
||||||
|
// core:domain —— 后者须保持纯 Kotlin/JVM 以留 KMP 迁移余地 (见 AGENTS.md)。
|
||||||
|
implementation(libs.other.onnxruntime)
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.aprs
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.OutputStreamWriter
|
||||||
|
import java.io.PrintWriter
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APRS-IS TCP client (reverse-ported from APRSdroid TcpUploader.scala).
|
||||||
|
* Plain-text protocol: one login line + one packet per line; 30 s reconnect after drop.
|
||||||
|
*/
|
||||||
|
class AprsIsClient(
|
||||||
|
private val host: String,
|
||||||
|
private val port: Int,
|
||||||
|
private val callsign: String,
|
||||||
|
private val ssid: String,
|
||||||
|
private val passcode: Int,
|
||||||
|
private val version: String,
|
||||||
|
private val filter: String = "",
|
||||||
|
private val timeoutSec: Int = 120
|
||||||
|
) {
|
||||||
|
private var socket: Socket? = null
|
||||||
|
private var writer: PrintWriter? = null
|
||||||
|
private var reader: BufferedReader? = null
|
||||||
|
private val lock = Any()
|
||||||
|
|
||||||
|
val isConnected: Boolean
|
||||||
|
get() = synchronized(lock) { socket?.isConnected == true && !socket!!.isClosed }
|
||||||
|
|
||||||
|
/** Connect + login (synchronous/blocking; call from a background thread) */
|
||||||
|
@Throws(Exception::class)
|
||||||
|
fun connect() {
|
||||||
|
disconnect()
|
||||||
|
val s = Socket()
|
||||||
|
try {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
// Login line
|
||||||
|
val login = AprsPacket.formatLogin(callsign, ssid, passcode, version) + filter
|
||||||
|
writer?.println(login)
|
||||||
|
// Read the login response (aprsc replies # 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())
|
||||||
|
}
|
||||||
|
// Restore timeout
|
||||||
|
s.soTimeout = timeoutSec * 1000
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Close the local socket before re-throwing, so it does not leak when
|
||||||
|
// an exception is raised after s.connect() but before socket = s.
|
||||||
|
// Otherwise periodic reconnect attempts (AprsReporter every 1–60 min)
|
||||||
|
// accumulate leaked fds until the process cannot open any more files.
|
||||||
|
runCatching { s.close() }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends one APRS packet (one line) and tries to read the server ack.
|
||||||
|
* Returns null=failed to send; Pair(ok, detail)=result (server error text lives in detail)
|
||||||
|
*/
|
||||||
|
fun sendPacket(packetLine: String): Pair<Boolean, String>? {
|
||||||
|
synchronized(lock) {
|
||||||
|
val w = writer ?: return null
|
||||||
|
w.println(packetLine)
|
||||||
|
if (w.checkError()) return Pair(false, "write failed")
|
||||||
|
// Read the server response inside the same lock: disconnect() (called
|
||||||
|
// concurrently from stop()/reconnect on another thread) nulls
|
||||||
|
// writer/reader/socket and closes them. Reading outside the lock raced
|
||||||
|
// with that: the response read could hit a just-closed socket and the
|
||||||
|
// swallowing runCatching reported Pair(true,"OK") for a packet that
|
||||||
|
// never left, or read through a stale reference. Serialising keeps
|
||||||
|
// the read on the connection this thread just wrote to. The 3 s read
|
||||||
|
// timeout bounds how long a concurrent disconnect waits.
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
s.soTimeout = oldTimeout
|
||||||
|
}
|
||||||
|
}.getOrElse { Pair(true, "OK") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read one line (server response; throws on timeout) */
|
||||||
|
fun readLine(): String? {
|
||||||
|
return reader?.readLine()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
synchronized(lock) {
|
||||||
|
runCatching { writer?.close() }
|
||||||
|
runCatching { reader?.close() }
|
||||||
|
runCatching { socket?.close() }
|
||||||
|
writer = null
|
||||||
|
reader = null
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.aprs
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
|
||||||
|
import com.rtbishop.look4sat.core.domain.aprs.AprsPosition
|
||||||
|
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 connection state */
|
||||||
|
enum class AprsState { Idle, Connecting, Connected, Disconnected, Error }
|
||||||
|
|
||||||
|
/** APRS config (persisted in SharedPreferences, saved as filled) */
|
||||||
|
data class AprsConfig(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val server: String = "euro.aprs2.net",
|
||||||
|
val port: Int = 14580,
|
||||||
|
val callsign: String = "",
|
||||||
|
val ssid: String = "",
|
||||||
|
val passcode: String = "",
|
||||||
|
val intervalMin: Int = 5,
|
||||||
|
val statusText: String = "Look4Sat APRS",
|
||||||
|
val symbolTable: String = "/",
|
||||||
|
val symbolCode: String = ">",
|
||||||
|
val includeCourseSpeed: Boolean = true,
|
||||||
|
val includeAltitude: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
/** APRS report result */
|
||||||
|
data class AprsReport(
|
||||||
|
val timestamp: Long,
|
||||||
|
val packet: String,
|
||||||
|
val ok: Boolean,
|
||||||
|
val detail: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Report scheduler (periodic + manual trigger); connection management lives in the foreground service */
|
||||||
|
class AprsReporter(
|
||||||
|
private val configProvider: () -> AprsConfig,
|
||||||
|
private val positionProvider: () -> Pair<Double, Double>? = { null },
|
||||||
|
private val onState: (AprsState) -> Unit = {},
|
||||||
|
private val onReport: (AprsReport) -> Unit = {}
|
||||||
|
) {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var client: AprsIsClient? = null
|
||||||
|
private var job: Job? = null
|
||||||
|
private var manualJob: Job? = null
|
||||||
|
|
||||||
|
val isRunning: Boolean get() = job?.isActive == true
|
||||||
|
|
||||||
|
/** Start periodic reporting (called by the foreground service) */
|
||||||
|
fun start() {
|
||||||
|
stop()
|
||||||
|
val cfg = configProvider()
|
||||||
|
if (!cfg.enabled || cfg.callsign.isBlank()) {
|
||||||
|
onState(AprsState.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
job = scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
reportOnce()
|
||||||
|
delay(cfg.intervalMin.coerceAtLeast(1) * 60_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
job?.cancel()
|
||||||
|
job = null
|
||||||
|
runCatching { client?.disconnect() }
|
||||||
|
client = null
|
||||||
|
onState(AprsState.Idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trigger one report manually (immediately, without waiting for the cycle) */
|
||||||
|
fun reportNow() {
|
||||||
|
manualJob?.cancel()
|
||||||
|
manualJob = scope.launch { reportOnce() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun reportOnce() {
|
||||||
|
val cfg = configProvider()
|
||||||
|
if (!cfg.enabled || cfg.callsign.isBlank()) return
|
||||||
|
onState(AprsState.Connecting)
|
||||||
|
try {
|
||||||
|
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"
|
||||||
|
).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 detail = result?.second ?: "no connection"
|
||||||
|
onReport(AprsReport(System.currentTimeMillis(), packetLine, ok, detail))
|
||||||
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build position packet: BG7NTA-5>APRS:=DDMM.MMN/DDDMM.MME<status text */
|
||||||
|
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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.aprs
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APRS config storage + service action constants (shared by feature/settings and the app service,
|
||||||
|
* so feature never depends on app).
|
||||||
|
*/
|
||||||
|
object AprsStore {
|
||||||
|
|
||||||
|
const val ACTION_START = "com.rtbishop.look4sat.aprs.START"
|
||||||
|
const val ACTION_STOP = "com.rtbishop.look4sat.aprs.STOP"
|
||||||
|
const val ACTION_REPORT_NOW = "com.rtbishop.look4sat.aprs.REPORT_NOW"
|
||||||
|
const val SERVICE_CLASS = "com.rtbishop.look4sat.app.AprsForegroundService"
|
||||||
|
|
||||||
|
private const val PREFS = "aprs_config"
|
||||||
|
private const val KEY_ENABLED = "enabled"
|
||||||
|
private const val KEY_SERVER = "server"
|
||||||
|
private const val KEY_PORT = "port"
|
||||||
|
private const val KEY_CALLSIGN = "callsign"
|
||||||
|
private const val KEY_SSID = "ssid"
|
||||||
|
private const val KEY_PASSCODE = "passcode"
|
||||||
|
private const val KEY_INTERVAL = "interval"
|
||||||
|
private const val KEY_STATUS = "status"
|
||||||
|
private const val KEY_SYMBOL_TABLE = "symbol_table"
|
||||||
|
private const val KEY_SYMBOL_CODE = "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(
|
||||||
|
enabled = p.getBoolean(KEY_ENABLED, false),
|
||||||
|
server = p.getString(KEY_SERVER, "euro.aprs2.net") ?: "euro.aprs2.net",
|
||||||
|
port = p.getInt(KEY_PORT, 14580),
|
||||||
|
callsign = p.getString(KEY_CALLSIGN, "") ?: "",
|
||||||
|
ssid = p.getString(KEY_SSID, "") ?: "",
|
||||||
|
passcode = p.getString(KEY_PASSCODE, "") ?: "",
|
||||||
|
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, ">") ?: ">"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
return LastReport(
|
||||||
|
time = p.getLong(KEY_LAST_TIME, 0L),
|
||||||
|
ok = p.getBoolean(KEY_LAST_OK, false),
|
||||||
|
detail = p.getString(KEY_LAST_DETAIL, "") ?: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveLastReport(context: Context, ok: Boolean, detail: String) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||||
|
.putLong(KEY_LAST_TIME, System.currentTimeMillis())
|
||||||
|
.putBoolean(KEY_LAST_OK, ok)
|
||||||
|
.putString(KEY_LAST_DETAIL, detail)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save config */
|
||||||
|
fun saveConfig(context: Context, cfg: AprsConfig) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||||
|
.putBoolean(KEY_ENABLED, cfg.enabled)
|
||||||
|
.putString(KEY_SERVER, cfg.server)
|
||||||
|
.putInt(KEY_PORT, cfg.port)
|
||||||
|
.putString(KEY_CALLSIGN, cfg.callsign)
|
||||||
|
.putString(KEY_SSID, cfg.ssid)
|
||||||
|
.putString(KEY_PASSCODE, cfg.passcode)
|
||||||
|
.putInt(KEY_INTERVAL, cfg.intervalMin)
|
||||||
|
.putString(KEY_STATUS, cfg.statusText)
|
||||||
|
.putString(KEY_SYMBOL_TABLE, cfg.symbolTable)
|
||||||
|
.putString(KEY_SYMBOL_CODE, cfg.symbolCode)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
/*
|
||||||
|
* 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.CwDeepSpectrogram
|
||||||
|
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.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.
|
||||||
|
*/
|
||||||
|
class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||||
|
|
||||||
|
private 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. */
|
||||||
|
const val ARCHIVE_SECONDS = 15.0
|
||||||
|
val ARCHIVE_THRESHOLD: Int = (CwDeepSpectrogram.SAMPLE_RATE * ARCHIVE_SECONDS).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _decodedText = MutableStateFlow("")
|
||||||
|
override val decodedText: StateFlow<String> = _decodedText.asStateFlow()
|
||||||
|
|
||||||
|
private val _historyText = MutableStateFlow("")
|
||||||
|
override val historyText: StateFlow<String> = _historyText.asStateFlow()
|
||||||
|
|
||||||
|
private val _estimatedPitch = MutableStateFlow<Float?>(null)
|
||||||
|
override val estimatedPitch: StateFlow<Float?> = _estimatedPitch.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]. Archiving in ~15 s chunks
|
||||||
|
* keeps the extra inference cheap (short window) while long enough to be
|
||||||
|
* decoded accurately — the content has already been through the 20 s window
|
||||||
|
* many times, so a slightly shorter archive decode loses almost nothing.
|
||||||
|
*/
|
||||||
|
private val archiveBuffer = FloatArray(CwDeepBuffer.DEFAULT_MAX_SECONDS.toInt() * CwDeepSpectrogram.SAMPLE_RATE)
|
||||||
|
private var archiveSize = 0
|
||||||
|
|
||||||
|
/** Held while inference runs so slow devices skip work instead of queuing it. */
|
||||||
|
private val inferenceLock = Mutex()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
val resampled = CwDeepSpectrogram.resampleLinear(
|
||||||
|
samples, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
||||||
|
)
|
||||||
|
val shouldRedecode = buffer.append(resampled)
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) = 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)
|
||||||
|
if (text.isNotEmpty()) {
|
||||||
|
_historyText.value += text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
_estimatedPitch.value = (absoluteBin * binHz).toFloat()
|
||||||
|
|
||||||
|
val mean = total / count
|
||||||
|
_signalStrength.value = ((bestValue - mean) / bestValue).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun reset() {
|
||||||
|
buffer.reset()
|
||||||
|
_decodedText.value = ""
|
||||||
|
_historyText.value = ""
|
||||||
|
archiveSize = 0
|
||||||
|
_estimatedPitch.value = null
|
||||||
|
_signalStrength.value = 0f
|
||||||
|
_lastInferenceMs.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
@Query("DELETE FROM entries")
|
||||||
suspend fun deleteEntries()
|
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>
|
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM radios")
|
@Query("SELECT COUNT(*) FROM radios")
|
||||||
|
|||||||
+14
@@ -76,10 +76,12 @@ class BluetoothReporter(
|
|||||||
private fun ensureRotatorConnected() {
|
private fun ensureRotatorConnected() {
|
||||||
if (rotatorConnected || rotatorConnecting || rotatorDeviceId.isBlank()) return
|
if (rotatorConnected || rotatorConnecting || rotatorDeviceId.isBlank()) return
|
||||||
reporterScope.launch {
|
reporterScope.launch {
|
||||||
|
var opened: android.bluetooth.BluetoothSocket? = null
|
||||||
try {
|
try {
|
||||||
rotatorConnecting = true
|
rotatorConnecting = true
|
||||||
val device = bluetoothManager.adapter.getRemoteDevice(rotatorDeviceId)
|
val device = bluetoothManager.adapter.getRemoteDevice(rotatorDeviceId)
|
||||||
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||||
|
opened = socket
|
||||||
socket.connect()
|
socket.connect()
|
||||||
rotatorSocket = socket
|
rotatorSocket = socket
|
||||||
rotatorStream = socket.outputStream
|
rotatorStream = socket.outputStream
|
||||||
@@ -87,6 +89,11 @@ class BluetoothReporter(
|
|||||||
Log.i(tag, "Rotator connected to $rotatorDeviceId")
|
Log.i(tag, "Rotator connected to $rotatorDeviceId")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(tag, "Rotator connect error: ${e.message}")
|
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
|
rotatorConnected = false
|
||||||
} finally {
|
} finally {
|
||||||
rotatorConnecting = false
|
rotatorConnecting = false
|
||||||
@@ -97,10 +104,12 @@ class BluetoothReporter(
|
|||||||
private fun ensureFrequencyConnected() {
|
private fun ensureFrequencyConnected() {
|
||||||
if (frequencyConnected || frequencyConnecting || frequencyDeviceId.isBlank()) return
|
if (frequencyConnected || frequencyConnecting || frequencyDeviceId.isBlank()) return
|
||||||
reporterScope.launch {
|
reporterScope.launch {
|
||||||
|
var opened: android.bluetooth.BluetoothSocket? = null
|
||||||
try {
|
try {
|
||||||
frequencyConnecting = true
|
frequencyConnecting = true
|
||||||
val device = bluetoothManager.adapter.getRemoteDevice(frequencyDeviceId)
|
val device = bluetoothManager.adapter.getRemoteDevice(frequencyDeviceId)
|
||||||
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||||
|
opened = socket
|
||||||
socket.connect()
|
socket.connect()
|
||||||
frequencySocket = socket
|
frequencySocket = socket
|
||||||
frequencyStream = socket.outputStream
|
frequencyStream = socket.outputStream
|
||||||
@@ -108,6 +117,11 @@ class BluetoothReporter(
|
|||||||
Log.i(tag, "Frequency connected to $frequencyDeviceId")
|
Log.i(tag, "Frequency connected to $frequencyDeviceId")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(tag, "Frequency connect error: ${e.message}")
|
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
|
frequencyConnected = false
|
||||||
} finally {
|
} finally {
|
||||||
frequencyConnecting = false
|
frequencyConnecting = false
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ class Ft817Controller(
|
|||||||
private val commandDelayMs = 200L
|
private val commandDelayMs = 200L
|
||||||
private val maxAckReadFailures = 3
|
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 socket: BluetoothSocket? = null
|
||||||
private var outputStream: OutputStream? = null
|
private var outputStream: OutputStream? = null
|
||||||
private var inputStream: InputStream? = null
|
private var inputStream: InputStream? = null
|
||||||
@@ -53,9 +56,11 @@ class Ft817Controller(
|
|||||||
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
|
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
|
||||||
if (isConnected) return@withContext true
|
if (isConnected) return@withContext true
|
||||||
if (deviceAddress.isBlank()) return@withContext false
|
if (deviceAddress.isBlank()) return@withContext false
|
||||||
|
var opened: android.bluetooth.BluetoothSocket? = null
|
||||||
try {
|
try {
|
||||||
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
|
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
|
||||||
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||||
|
opened = btSocket
|
||||||
btSocket.connect()
|
btSocket.connect()
|
||||||
socket = btSocket
|
socket = btSocket
|
||||||
outputStream = btSocket.outputStream
|
outputStream = btSocket.outputStream
|
||||||
@@ -66,6 +71,13 @@ class Ft817Controller(
|
|||||||
true
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(tag, "Connect error: ${e.message}")
|
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
|
isConnected = false
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -91,6 +103,16 @@ class Ft817Controller(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
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 {
|
ioMutex.withLock {
|
||||||
sendCommandWithAck(Ft817CatProtocol.buildSetFreqCommand(frequencyHz))
|
sendCommandWithAck(Ft817CatProtocol.buildSetFreqCommand(frequencyHz))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,9 +67,11 @@ class Ic705Controller(
|
|||||||
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
|
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
|
||||||
if (isConnected) return@withContext true
|
if (isConnected) return@withContext true
|
||||||
if (deviceAddress.isBlank()) return@withContext false
|
if (deviceAddress.isBlank()) return@withContext false
|
||||||
|
var opened: android.bluetooth.BluetoothSocket? = null
|
||||||
try {
|
try {
|
||||||
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
|
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
|
||||||
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||||
|
opened = btSocket
|
||||||
btSocket.connect()
|
btSocket.connect()
|
||||||
socket = btSocket
|
socket = btSocket
|
||||||
outputStream = btSocket.outputStream
|
outputStream = btSocket.outputStream
|
||||||
@@ -84,6 +86,13 @@ class Ic705Controller(
|
|||||||
true
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(tag, "Connect error: ${e.message}")
|
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
|
isConnected = false
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-2
@@ -71,14 +71,19 @@ class NetworkReporter(
|
|||||||
private fun ensureRotatorConnected() {
|
private fun ensureRotatorConnected() {
|
||||||
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
|
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
|
||||||
reporterScope.launch {
|
reporterScope.launch {
|
||||||
|
var opened: SocketChannel? = null
|
||||||
try {
|
try {
|
||||||
rotatorConnecting = true
|
rotatorConnecting = true
|
||||||
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
opened = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
||||||
|
rotatorSocket = opened
|
||||||
rotatorConnected = true
|
rotatorConnected = true
|
||||||
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
|
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter rotator connect error: ${e.message}")
|
println("NetworkReporter rotator connect error: ${e.message}")
|
||||||
rotatorConnected = false
|
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 {
|
} finally {
|
||||||
rotatorConnecting = false
|
rotatorConnecting = false
|
||||||
}
|
}
|
||||||
@@ -88,14 +93,17 @@ class NetworkReporter(
|
|||||||
private fun ensureFrequencyConnected() {
|
private fun ensureFrequencyConnected() {
|
||||||
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
|
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
|
||||||
reporterScope.launch {
|
reporterScope.launch {
|
||||||
|
var opened: SocketChannel? = null
|
||||||
try {
|
try {
|
||||||
frequencyConnecting = true
|
frequencyConnecting = true
|
||||||
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
opened = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
||||||
|
frequencySocket = opened
|
||||||
frequencyConnected = true
|
frequencyConnected = true
|
||||||
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
|
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter frequency connect error: ${e.message}")
|
println("NetworkReporter frequency connect error: ${e.message}")
|
||||||
frequencyConnected = false
|
frequencyConnected = false
|
||||||
|
opened?.close()
|
||||||
} finally {
|
} finally {
|
||||||
frequencyConnecting = false
|
frequencyConnecting = false
|
||||||
}
|
}
|
||||||
@@ -111,6 +119,17 @@ class NetworkReporter(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter write error: ${e.message}")
|
println("NetworkReporter write error: ${e.message}")
|
||||||
onError()
|
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 (tuningRadio.isEmpty()) {
|
||||||
if (txNow != null && txNow.isConnected && txRadioFreq != null) {
|
if (txNow != null && txNow.isConnected && txRadioFreq != null) {
|
||||||
txNow.setFrequency(txRadioFreq)
|
// Only remember the frequency we actually wrote: if the radio
|
||||||
lastSetTxFreq = txRadioFreq.toDouble()
|
// 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) {
|
if (rxNow != null && rxNow.isConnected && rxRadioFreq != null) {
|
||||||
rxNow.setFrequency(rxRadioFreq)
|
if (rxNow.setFrequency(rxRadioFreq)) {
|
||||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,13 +456,15 @@ class RadioTrackingService(
|
|||||||
// 0x25/00 = active (RX) VFO, 0x25/01 = inactive (TX) VFO.
|
// 0x25/00 = active (RX) VFO, 0x25/01 = inactive (TX) VFO.
|
||||||
if (rxRadioFreq != null) {
|
if (rxRadioFreq != null) {
|
||||||
Log.d(tag, "Split loop RX (0x25/00): ${rxRadioFreq}Hz")
|
Log.d(tag, "Split loop RX (0x25/00): ${rxRadioFreq}Hz")
|
||||||
radio.setWorkingFrequency(rxRadioFreq)
|
if (radio.setWorkingFrequency(rxRadioFreq)) {
|
||||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (txRadioFreq != null) {
|
if (txRadioFreq != null) {
|
||||||
Log.d(tag, "Split loop TX (0x25/01): ${txRadioFreq}Hz")
|
Log.d(tag, "Split loop TX (0x25/01): ${txRadioFreq}Hz")
|
||||||
radio.setTxVfoFrequency(txRadioFreq)
|
if (radio.setTxVfoFrequency(txRadioFreq)) {
|
||||||
lastSetTxFreq = txRadioFreq.toDouble()
|
lastSetTxFreq = txRadioFreq.toDouble()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
|||||||
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
||||||
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
||||||
|
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
|
||||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
||||||
@@ -40,6 +41,9 @@ import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
|||||||
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
||||||
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
||||||
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.IWavelogQueueStore
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
@@ -66,16 +70,19 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import com.rtbishop.look4sat.core.data.wavelog.LotwSatellitesRepo
|
||||||
|
|
||||||
class MainContainer(private val context: Context) : IMainContainer {
|
class MainContainer(private val context: Context) : IMainContainer {
|
||||||
|
|
||||||
private val localSource = provideLocalSource()
|
private val localSource = provideLocalSource()
|
||||||
|
private val remoteSource by lazy { provideRemoteSource() }
|
||||||
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
|
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
|
||||||
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
|
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
|
||||||
override val settingsRepo = provideSettingsRepo()
|
override val settingsRepo = provideSettingsRepo()
|
||||||
override val selectionRepo = provideSelectionRepo()
|
override val selectionRepo = provideSelectionRepo()
|
||||||
override val satelliteRepo = provideSatelliteRepo()
|
override val satelliteRepo = provideSatelliteRepo()
|
||||||
override val databaseRepo = provideDatabaseRepo()
|
override val databaseRepo = provideDatabaseRepo()
|
||||||
|
override val amSatRepo by lazy { AmSatRepository(remoteSource) }
|
||||||
override val radioTrackingService: IRadioTrackingService by lazy {
|
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||||
@@ -90,12 +97,40 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
|
|
||||||
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
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 provideShowToast(): IShowToast = ShowToast(context)
|
||||||
|
|
||||||
override fun provideAudioCapture(): IAudioCapture = AudioCapture()
|
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)
|
||||||
|
|
||||||
override fun provideSaveImage(): ISaveImage = SaveImage(context)
|
override fun provideSaveImage(): ISaveImage = SaveImage(context)
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
override fun load(): String = prefs.getString("wavelog_queue", "[]") ?: "[]"
|
||||||
|
override fun save(json: String) = prefs.edit().putString("wavelog_queue", json).apply()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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
|
||||||
|
|
||||||
override fun provideBluetoothReporter(): IReporter {
|
override fun provideBluetoothReporter(): IReporter {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
val rc = settingsRepo.rcSettings.value
|
val rc = settingsRepo.rcSettings.value
|
||||||
@@ -149,7 +184,6 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
private fun provideDatabaseRepo(): IDatabaseRepo {
|
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||||
val dbDispatcher = Dispatchers.Default
|
val dbDispatcher = Dispatchers.Default
|
||||||
val dataParser = DataParser(dbDispatcher)
|
val dataParser = DataParser(dbDispatcher)
|
||||||
val remoteSource = provideRemoteSource()
|
|
||||||
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +194,14 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun provideRemoteSource(): IRemoteSource {
|
private fun provideRemoteSource(): IRemoteSource {
|
||||||
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
|
return RemoteSource(
|
||||||
|
Dispatchers.IO, context.contentResolver,
|
||||||
|
OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
.readTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideSatelliteRepo(): ISatelliteRepo {
|
private fun provideSatelliteRepo(): ISatelliteRepo {
|
||||||
@@ -176,6 +217,6 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
val appPrefsFileName = "${context.packageName}_preferences"
|
val appPrefsFileName = "${context.packageName}_preferences"
|
||||||
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
||||||
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
||||||
return SettingsRepo(manager, appPreferences, appVersionName)
|
return SettingsRepo(context, manager, appPreferences, appVersionName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
/** One report from the AMSAT API (data layer model). */
|
||||||
|
private 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 {
|
||||||
|
|
||||||
|
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) }
|
||||||
|
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse catalog JSON to list of satellite names */
|
||||||
|
private fun parseCatalog(json: String): List<String> {
|
||||||
|
return try {
|
||||||
|
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 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 (5 days x 12 slots) per catalog satellite, slotting reports by age. */
|
||||||
|
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||||
|
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"))
|
||||||
|
val labels = (0 until 3).map { d ->
|
||||||
|
utc.timeInMillis = (nowSec - d * 86400L) * 1000
|
||||||
|
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
|
||||||
|
}
|
||||||
|
return names.map { name ->
|
||||||
|
val slots = (0 until 36).map { slotIdx ->
|
||||||
|
val slotStart = nowSec - (slotIdx + 1) * 7200L
|
||||||
|
val slotEnd = nowSec - slotIdx * 7200L
|
||||||
|
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
|
||||||
|
if (inSlot.isEmpty()) {
|
||||||
|
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
|
||||||
|
} else {
|
||||||
|
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
|
||||||
|
SatSlot(
|
||||||
|
statusColor = statusColorOf(newest.report),
|
||||||
|
count = inSlot.size,
|
||||||
|
reportIds = inSlot.map { it.id }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val days = (0 until 3).map { d ->
|
||||||
|
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,23 +69,33 @@ class DatabaseRepo(
|
|||||||
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||||
val tleUrls = buildMap {
|
val tleUrls = buildMap {
|
||||||
putAll(Sources.satelliteDataUrls)
|
putAll(Sources.satelliteDataUrls)
|
||||||
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
|
// Switch on + non-empty URL -> All uses the custom URL; otherwise the default URL (online-update default source)
|
||||||
|
put("All", if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank())
|
||||||
|
dataSourcesSettings.tleUrl else Sources.defaultTleUrl)
|
||||||
}.filterValues { it.isNotBlank() }
|
}.filterValues { it.isNotBlank() }
|
||||||
val radioUrls = buildMap {
|
val radioUrls = buildMap {
|
||||||
putAll(Sources.transceiversDataUrls)
|
putAll(Sources.transceiversDataUrls)
|
||||||
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
|
put("SatNOGS", if (dataSourcesSettings.useCustomTransceivers && dataSourcesSettings.transceiversUrl.isNotBlank())
|
||||||
|
dataSourcesSettings.transceiversUrl else Sources.defaultTransceiversUrl)
|
||||||
}.filterValues { it.isNotBlank() }
|
}.filterValues { it.isNotBlank() }
|
||||||
// launch all network requests concurrently
|
// launch all network requests concurrently
|
||||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||||
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||||
|
// Count successful sources: zero successes = update failed (timestamp untouched, exception surfaced in the UI)
|
||||||
|
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")
|
||||||
|
}
|
||||||
// parse fetched data concurrently and associate with types
|
// parse fetched data concurrently and associate with types
|
||||||
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
val importedEntries = tleResults.flatMap { (url, stream) ->
|
||||||
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
||||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
||||||
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
val importedRadios = radioResults.flatMap { (url, stream) ->
|
||||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
||||||
}
|
}
|
||||||
// insert parsed data into the database
|
// insert parsed data into the database
|
||||||
|
|||||||
+90
-70
@@ -34,9 +34,13 @@ import kotlinx.coroutines.coroutineScope
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class SatelliteRepo(
|
class SatelliteRepo(
|
||||||
private val dispatcher: CoroutineDispatcher,
|
private val dispatcher: CoroutineDispatcher,
|
||||||
@@ -50,6 +54,10 @@ class SatelliteRepo(
|
|||||||
private val _isCalculating = MutableStateFlow(false)
|
private val _isCalculating = MutableStateFlow(false)
|
||||||
override val isCalculating: StateFlow<Boolean> = _isCalculating
|
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())
|
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
|
||||||
override val satellites: StateFlow<List<OrbitalObject>> = _satellites
|
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 getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||||
|
|
||||||
override suspend fun initRepository() = withContext(dispatcher) {
|
override suspend fun initRepository() = withContext(dispatcher) {
|
||||||
settingsRepo.selectedIds.collect { selectedIds ->
|
combine(
|
||||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
settingsRepo.selectedIds,
|
||||||
val settings = settingsRepo.passesSettings.value
|
settingsRepo.stationPosition
|
||||||
calculatePasses(
|
) { selectedIds, _ -> selectedIds }
|
||||||
time = System.currentTimeMillis(),
|
.collect { selectedIds ->
|
||||||
hoursAhead = settings.hoursAhead,
|
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||||
minElevation = settings.minElevation,
|
val settings = settingsRepo.passesSettings.value
|
||||||
aosStartMinute = settings.aosStartMinute,
|
calculatePasses(
|
||||||
aosEndMinute = settings.aosEndMinute,
|
time = System.currentTimeMillis(),
|
||||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
hoursAhead = settings.hoursAhead,
|
||||||
modes = settings.selectedModes
|
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 {
|
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
|
||||||
@@ -95,14 +107,8 @@ class SatelliteRepo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getRadios(
|
override suspend fun getRadios(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio> {
|
||||||
sat: OrbitalObject,
|
|
||||||
pos: GeoPos,
|
|
||||||
radios: List<SatRadio>,
|
|
||||||
time: Long
|
|
||||||
): List<SatRadio> {
|
|
||||||
return withContext(dispatcher) {
|
return withContext(dispatcher) {
|
||||||
val satPos = sat.getPosition(pos, time)
|
|
||||||
radios.map { transmitter ->
|
radios.map { transmitter ->
|
||||||
transmitter.copy(
|
transmitter.copy(
|
||||||
downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) },
|
downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) },
|
||||||
@@ -123,45 +129,55 @@ class SatelliteRepo(
|
|||||||
invertAosTimeWindow: Boolean,
|
invertAosTimeWindow: Boolean,
|
||||||
modes: List<String>
|
modes: List<String>
|
||||||
) {
|
) {
|
||||||
_isCalculating.value = true
|
// Queue behind any in-flight calculation rather than dropping this call:
|
||||||
// Normalize to the start of the current minute so that coarse 60-second stepping
|
// every invocation carries filter settings the user just chose, so a
|
||||||
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
|
// dropped one leaves the list showing results for the previous filter.
|
||||||
val normalizedTime = time / 60_000L * 60_000L
|
calculationMutex.withLock {
|
||||||
val currentSatellites = _satellites.value
|
_isCalculating.value = true
|
||||||
withContext(dispatcher) {
|
try {
|
||||||
val idsWithModes = localStorage.getIdsWithModes(modes)
|
// Normalize to the start of the current minute so that coarse 60-second stepping
|
||||||
val stationPos = settingsRepo.stationPosition.value
|
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
|
||||||
val filteredSatellites = if (idsWithModes.isEmpty()) {
|
val normalizedTime = time / 60_000L * 60_000L
|
||||||
currentSatellites
|
val currentSatellites = _satellites.value
|
||||||
} else {
|
withContext(dispatcher) {
|
||||||
currentSatellites.filter { it.data.catnum in idsWithModes }
|
val idsWithModes = localStorage.getIdsWithModes(modes)
|
||||||
}
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
// Compute passes for each satellite in parallel
|
val filteredSatellites = if (idsWithModes.isEmpty()) {
|
||||||
val passLists = coroutineScope {
|
currentSatellites
|
||||||
filteredSatellites.map { satellite ->
|
} else {
|
||||||
async { satellite.getPasses(stationPos, normalizedTime, hoursAhead) }
|
currentSatellites.filter { it.data.catnum in idsWithModes }
|
||||||
}.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)
|
|
||||||
}
|
}
|
||||||
|
// 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(
|
private fun isAosInRange(
|
||||||
@@ -176,7 +192,7 @@ class SatelliteRepo(
|
|||||||
val inRange = if (aosStartMinute <= aosEndMinute) {
|
val inRange = if (aosStartMinute <= aosEndMinute) {
|
||||||
aosMinute in aosStartMinute..aosEndMinute
|
aosMinute in aosStartMinute..aosEndMinute
|
||||||
} else {
|
} else {
|
||||||
aosMinute >= aosStartMinute || aosMinute <= aosEndMinute
|
aosMinute !in (aosEndMinute + 1)..<aosStartMinute
|
||||||
}
|
}
|
||||||
return if (invertAosTimeWindow) !inRange else inRange
|
return if (invertAosTimeWindow) !inRange else inRange
|
||||||
}
|
}
|
||||||
@@ -242,13 +258,16 @@ class SatelliteRepo(
|
|||||||
if (elevation > maxElevation) maxElevation = elevation
|
if (elevation > maxElevation) maxElevation = elevation
|
||||||
} while (elevation < 0.0)
|
} while (elevation < 0.0)
|
||||||
|
|
||||||
// refine AOS to ~500ms precision
|
// refine AOS to ~500ms precision via binary search.
|
||||||
calendarTimeMillis -= 60L * 1000L
|
// Elevation is monotonic across the horizon crossing, so binary search
|
||||||
do {
|
// finds the crossing in ~8 SGP4 calls instead of up to 120 linear steps.
|
||||||
calendarTimeMillis += 500L
|
var aosLo = calendarTimeMillis - 60L * 1000L // elevation < 0 (below horizon)
|
||||||
elevation = sat.getElevation(pos, calendarTimeMillis)
|
var aosHi = calendarTimeMillis // elevation >= 0 (above horizon)
|
||||||
if (elevation > maxElevation) maxElevation = elevation
|
while (aosHi - aosLo > 500L) {
|
||||||
} while (elevation < 0.0)
|
val mid = (aosLo + aosHi) / 2
|
||||||
|
if (sat.getElevation(pos, mid) < 0.0) aosLo = mid else aosHi = mid
|
||||||
|
}
|
||||||
|
calendarTimeMillis = aosHi
|
||||||
|
|
||||||
// Get full position for AOS data (azimuth, altitude)
|
// Get full position for AOS data (azimuth, altitude)
|
||||||
val aosPos = sat.getFullPosition(pos, calendarTimeMillis)
|
val aosPos = sat.getFullPosition(pos, calendarTimeMillis)
|
||||||
@@ -262,13 +281,14 @@ class SatelliteRepo(
|
|||||||
if (elevation > maxElevation) maxElevation = elevation
|
if (elevation > maxElevation) maxElevation = elevation
|
||||||
} while (elevation > 0.0)
|
} while (elevation > 0.0)
|
||||||
|
|
||||||
// refine LOS to ~500ms precision
|
// refine LOS to ~500ms precision via binary search (same monotonic argument)
|
||||||
calendarTimeMillis -= 30L * 1000L
|
var losLo = calendarTimeMillis - 30L * 1000L // elevation > 0 (above horizon)
|
||||||
do {
|
var losHi = calendarTimeMillis // elevation <= 0 (below horizon)
|
||||||
calendarTimeMillis += 500L
|
while (losHi - losLo > 500L) {
|
||||||
elevation = sat.getElevation(pos, calendarTimeMillis)
|
val mid = (losLo + losHi) / 2
|
||||||
if (elevation > maxElevation) maxElevation = elevation
|
if (sat.getElevation(pos, mid) > 0.0) losLo = mid else losHi = mid
|
||||||
} while (elevation > 0.0)
|
}
|
||||||
|
calendarTimeMillis = losHi
|
||||||
|
|
||||||
// Get full position for LOS data (azimuth, altitude)
|
// Get full position for LOS data (azimuth, altitude)
|
||||||
val losPos = sat.getFullPosition(pos, calendarTimeMillis)
|
val losPos = sat.getFullPosition(pos, calendarTimeMillis)
|
||||||
|
|||||||
+16
-13
@@ -38,16 +38,16 @@ class SelectionRepo(
|
|||||||
) : ISelectionRepo {
|
) : ISelectionRepo {
|
||||||
|
|
||||||
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
||||||
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
|
|
||||||
private val currentQuery = MutableStateFlow("")
|
private val currentQuery = MutableStateFlow("")
|
||||||
|
|
||||||
// Resolve type IDs once when types change, then filter items reactively.
|
// Resolve sat IDs once when modes change, then filter items reactively.
|
||||||
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
||||||
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
// Directly observe settingsRepo.selectedSatModes to ensure real-time sync across screens.
|
||||||
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
private val itemsWithModes = settingsRepo.selectedSatModes.flatMapLatest { list: List<String> ->
|
||||||
|
val catnumSet: Set<Int>? = if (list.isEmpty()) {
|
||||||
null // null = no filtering
|
null // null = no filtering
|
||||||
} else {
|
} else {
|
||||||
val ids = settingsRepo.getSatelliteTypesIds(types)
|
val ids = localSource.getIdsWithModes(list)
|
||||||
if (ids.isEmpty()) null else ids.toHashSet()
|
if (ids.isEmpty()) null else ids.toHashSet()
|
||||||
}
|
}
|
||||||
currentItems.map { items ->
|
currentItems.map { items ->
|
||||||
@@ -56,14 +56,18 @@ class SelectionRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
||||||
itemsWithTypes.map { items -> filterByQuery(items, query) }
|
itemsWithModes.map { items ->
|
||||||
|
filterByQuery(items, query).sortedWith(
|
||||||
|
compareByDescending<SatItem> { it.isSelected }
|
||||||
|
.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 {
|
override fun getModesList() = Sources.satelliteModes
|
||||||
removeAt(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
||||||
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
|
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
|
||||||
@@ -73,9 +77,8 @@ class SelectionRepo(
|
|||||||
return@withContext itemsWithQuery
|
return@withContext itemsWithQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setTypes(types: List<String>) {
|
override suspend fun setModes(modes: List<String>) {
|
||||||
currentTypes.value = types
|
settingsRepo.setSelectedSatModes(modes)
|
||||||
settingsRepo.setSelectedTypes(types)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setQuery(query: String) {
|
override suspend fun setQuery(query: String) {
|
||||||
|
|||||||
+135
-45
@@ -29,16 +29,21 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
|||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||||
import com.rtbishop.look4sat.core.domain.utility.round
|
import com.rtbishop.look4sat.core.domain.utility.round
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
class SettingsRepo(
|
class SettingsRepo(
|
||||||
|
private val context: android.content.Context,
|
||||||
private val locationManager: LocationManager,
|
private val locationManager: LocationManager,
|
||||||
private val preferences: SharedPreferences,
|
private val preferences: SharedPreferences,
|
||||||
override val appVersionName: String
|
override val appVersionName: String
|
||||||
@@ -67,15 +72,17 @@ class SettingsRepo(
|
|||||||
private val keyFrequencyAddress = "frequencyAddress"
|
private val keyFrequencyAddress = "frequencyAddress"
|
||||||
private val keyFrequencyPort = "frequencyPort"
|
private val keyFrequencyPort = "frequencyPort"
|
||||||
private val keyFrequencyFormat = "frequencyFormat"
|
private val keyFrequencyFormat = "frequencyFormat"
|
||||||
|
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
|
||||||
private val keySelectedIds = "selectedIds"
|
private val keySelectedIds = "selectedIds"
|
||||||
private val keySelectedTypes = "selectedTypes"
|
private val keySelectedSatModes = "selectedSatModes"
|
||||||
private val keySelectedModes = "selectedModes"
|
|
||||||
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
||||||
private val keyStateOfSensors = "stateOfSensors"
|
private val keyStateOfSensors = "stateOfSensors"
|
||||||
private val keyStateOfSweep = "stateOfSweep"
|
private val keyStateOfSweep = "stateOfSweep"
|
||||||
private val keyStateOfUtc = "stateOfUtc"
|
private val keyStateOfUtc = "stateOfUtc"
|
||||||
private val keyStateOfLightTheme = "stateOfLightTheme"
|
private val keyStateOfLightTheme = "stateOfLightTheme"
|
||||||
private val keyStateOfNightMode = "stateOfNightMode"
|
private val keyStateOfNightMode = "stateOfNightMode"
|
||||||
|
private val keyHiddenScreens = "hiddenScreens"
|
||||||
|
private val keyScreenOrder = "screenOrder"
|
||||||
private val keyStationAltitude = "stationAltitude"
|
private val keyStationAltitude = "stationAltitude"
|
||||||
private val keyStationLatitude = "stationLatitude"
|
private val keyStationLatitude = "stationLatitude"
|
||||||
private val keyStationLongitude = "stationLongitude"
|
private val keyStationLongitude = "stationLongitude"
|
||||||
@@ -91,13 +98,21 @@ class SettingsRepo(
|
|||||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||||
private val keyTleUrl = "tleUrl"
|
private val keyTleUrl = "tleUrl"
|
||||||
private val keyTransceiversUrl = "transceiversUrl"
|
private val keyTransceiversUrl = "transceiversUrl"
|
||||||
|
private val keySubMenuOrder = "subMenuOrder"
|
||||||
|
private val keyWavelogUrl = "wavelogUrl"
|
||||||
|
private val keyWavelogApiKey = "wavelogApiKey"
|
||||||
|
private val keyWavelogStationId = "wavelogStationId"
|
||||||
|
private val keyWavelogAutoUpload = "wavelogAutoUpload"
|
||||||
|
private val keyRadarCompassOffset = "radarCompassOffset"
|
||||||
|
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
|
||||||
|
|
||||||
private val separatorComma = ","
|
private val separatorComma = ","
|
||||||
|
|
||||||
//region # Satellites selection settings
|
//region # Satellites selection settings
|
||||||
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
||||||
private val _typesSelection = MutableStateFlow(getSelectedTypes())
|
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
|
||||||
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
|
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
|
||||||
override val selectedTypes: StateFlow<List<String>> = _typesSelection
|
override val selectedSatModes: StateFlow<List<String>> = _satelliteModeSelection
|
||||||
|
|
||||||
override fun setSelectedIds(ids: List<Int>) {
|
override fun setSelectedIds(ids: List<Int>) {
|
||||||
val selectionString = ids.joinToString(separatorComma)
|
val selectionString = ids.joinToString(separatorComma)
|
||||||
@@ -105,10 +120,10 @@ class SettingsRepo(
|
|||||||
_satelliteSelection.value = ids
|
_satelliteSelection.value = ids
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setSelectedTypes(types: List<String>) {
|
override fun setSelectedSatModes(modes: List<String>) {
|
||||||
val typesString = types.joinToString(separatorComma)
|
val modesString = modes.joinToString(separatorComma)
|
||||||
preferences.edit { putString(keySelectedTypes, typesString) }
|
preferences.edit { putString(keySelectedSatModes, modesString) }
|
||||||
_typesSelection.value = types
|
_satelliteModeSelection.value = modes
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSelectedIds(): List<Int> {
|
private fun getSelectedIds(): List<Int> {
|
||||||
@@ -117,10 +132,10 @@ class SettingsRepo(
|
|||||||
return selectionString.split(separatorComma).map { it.toInt() }
|
return selectionString.split(separatorComma).map { it.toInt() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSelectedTypes(): List<String> {
|
private fun getSelectedSatModes(): List<String> {
|
||||||
val typesString = preferences.getString(keySelectedTypes, "Amateur")
|
val modesString = preferences.getString(keySelectedSatModes, null)
|
||||||
if (typesString.isNullOrEmpty()) return emptyList()
|
if (modesString.isNullOrEmpty()) return emptyList()
|
||||||
return typesString.split(separatorComma)
|
return modesString.split(separatorComma).sorted()
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -135,7 +150,6 @@ class SettingsRepo(
|
|||||||
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
||||||
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
||||||
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
||||||
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
|
|
||||||
_passesSettings.value = settings
|
_passesSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,16 +160,13 @@ class SettingsRepo(
|
|||||||
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
||||||
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
||||||
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
|
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
|
||||||
val selectedModesString = preferences.getString(keySelectedModes, null)
|
|
||||||
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
|
|
||||||
return PassesSettings(
|
return PassesSettings(
|
||||||
showDeepSpace,
|
showDeepSpace,
|
||||||
hoursAhead,
|
hoursAhead,
|
||||||
minElevation,
|
minElevation,
|
||||||
aosStartMinute,
|
aosStartMinute,
|
||||||
aosEndMinute,
|
aosEndMinute,
|
||||||
invertAosTimeWindow,
|
invertAosTimeWindow
|
||||||
selectedModes
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
@@ -172,29 +183,50 @@ class SettingsRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean {
|
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
|
val locator = positionToQth(latitude, newLongitude) ?: return false
|
||||||
setStationPosition(latitude, newLongitude, altitude, locator)
|
setStationPosition(latitude, newLongitude, altitude, locator)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setStationPosition(): Boolean {
|
/** GPS fix: one-shot getCurrentLocation (GPS first, 15 s timeout); returns true only with a fix */
|
||||||
if (!LocationManagerCompat.isLocationEnabled(locationManager)) return false
|
override suspend fun setStationPosition(): Boolean {
|
||||||
try {
|
// Permission gate: fail fast without location permission (no swallowed exceptions)
|
||||||
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
|
if (androidx.core.content.ContextCompat.checkSelfPermission(
|
||||||
val hasNet = LocationManagerCompat.hasProvider(locationManager, providerNet)
|
context, android.Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
val provider = if (hasGps) providerGps else if (hasNet) providerNet else providerDef
|
) != android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
val location = locationManager.getLastKnownLocation(providerDef)
|
) {
|
||||||
if (location == null || System.currentTimeMillis() - location.time > 600_000L) {
|
println("GPS: no fine location permission")
|
||||||
println("Requesting location for $provider provider")
|
return false
|
||||||
locationManager.requestLocationUpdates(provider, 0L, 0f, this)
|
}
|
||||||
} else {
|
if (!LocationManagerCompat.isLocationEnabled(locationManager)) return false
|
||||||
setStationPosition(location.latitude, location.longitude, location.altitude)
|
return kotlinx.coroutines.suspendCancellableCoroutine { cont ->
|
||||||
}
|
val signal = android.os.CancellationSignal()
|
||||||
} catch (exception: SecurityException) {
|
val handler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||||
println("No permissions were given - $exception")
|
val executor = java.util.concurrent.Executor { handler.post(it) }
|
||||||
|
// 15 s timeout
|
||||||
|
val timeout = handler.postDelayed({
|
||||||
|
signal.cancel()
|
||||||
|
if (cont.isActive) cont.resume(false) { }
|
||||||
|
}, 15_000L)
|
||||||
|
val listener = androidx.core.util.Consumer<Location> { location ->
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
setStationPosition(location.latitude, location.longitude, location.altitude)
|
||||||
|
if (cont.isActive) cont.resume(true) { }
|
||||||
|
}
|
||||||
|
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
|
||||||
|
val provider = if (hasGps) providerGps else providerNet
|
||||||
|
try {
|
||||||
|
LocationManagerCompat.getCurrentLocation(locationManager, provider, signal, executor, listener)
|
||||||
|
} catch (exception: SecurityException) {
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
if (cont.isActive) cont.resume(false) { }
|
||||||
|
}
|
||||||
|
cont.invokeOnCancellation { signal.cancel(); handler.removeCallbacksAndMessages(null) }
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setStationPosition(locator: String): Boolean {
|
override fun setStationPosition(locator: String): Boolean {
|
||||||
@@ -213,8 +245,8 @@ class SettingsRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setStationPosition(latitude: Double, longitude: Double, altitude: Double, locator: String) {
|
private fun setStationPosition(latitude: Double, longitude: Double, altitude: Double, locator: String) {
|
||||||
val newLat = latitude.round(4)
|
val newLat = latitude.round(5)
|
||||||
val newLon = longitude.round(4)
|
val newLon = longitude.round(5)
|
||||||
val newAlt = altitude.round(1)
|
val newAlt = altitude.round(1)
|
||||||
val timestamp = System.currentTimeMillis()
|
val timestamp = System.currentTimeMillis()
|
||||||
println("Received new Position($newLat, $newLon, $newAlt) & Locator $locator")
|
println("Received new Position($newLat, $newLon, $newAlt) & Locator $locator")
|
||||||
@@ -235,6 +267,7 @@ class SettingsRepo(
|
|||||||
private val _databaseState = MutableStateFlow(getDatabaseState())
|
private val _databaseState = MutableStateFlow(getDatabaseState())
|
||||||
override val databaseState: StateFlow<DatabaseState> = _databaseState
|
override val databaseState: StateFlow<DatabaseState> = _databaseState
|
||||||
|
|
||||||
|
|
||||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> {
|
override fun getSatelliteTypesIds(types: List<String>): List<Int> {
|
||||||
val idsSet = mutableSetOf<Int>()
|
val idsSet = mutableSetOf<Int>()
|
||||||
types.forEach { type ->
|
types.forEach { type ->
|
||||||
@@ -298,6 +331,10 @@ class SettingsRepo(
|
|||||||
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
||||||
|
|
||||||
override fun updateRCSettings(settings: RCSettings) {
|
override fun updateRCSettings(settings: RCSettings) {
|
||||||
|
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
|
||||||
|
Constants.FREQ_OFFSET_MIN_HZ,
|
||||||
|
Constants.FREQ_OFFSET_MAX_HZ
|
||||||
|
)
|
||||||
preferences.edit {
|
preferences.edit {
|
||||||
putBoolean(keyRotatorState, settings.rotatorState)
|
putBoolean(keyRotatorState, settings.rotatorState)
|
||||||
putString(keyRotatorAddress, settings.rotatorAddress)
|
putString(keyRotatorAddress, settings.rotatorAddress)
|
||||||
@@ -307,6 +344,7 @@ class SettingsRepo(
|
|||||||
putString(keyFrequencyAddress, settings.frequencyAddress)
|
putString(keyFrequencyAddress, settings.frequencyAddress)
|
||||||
putString(keyFrequencyPort, settings.frequencyPort)
|
putString(keyFrequencyPort, settings.frequencyPort)
|
||||||
putString(keyFrequencyFormat, settings.frequencyFormat)
|
putString(keyFrequencyFormat, settings.frequencyFormat)
|
||||||
|
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
|
||||||
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
||||||
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
||||||
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
||||||
@@ -315,7 +353,7 @@ class SettingsRepo(
|
|||||||
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
||||||
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
||||||
}
|
}
|
||||||
_rcSettings.value = settings
|
_rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRCSettings(): RCSettings = RCSettings(
|
private fun getRCSettings(): RCSettings = RCSettings(
|
||||||
@@ -327,6 +365,8 @@ class SettingsRepo(
|
|||||||
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
|
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
|
||||||
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
|
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
|
||||||
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
|
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),
|
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
|
||||||
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
||||||
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
||||||
@@ -356,6 +396,16 @@ class SettingsRepo(
|
|||||||
putString(keySstvMode, new.sstvMode)
|
putString(keySstvMode, new.sstvMode)
|
||||||
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
||||||
putLong(keyHighElevation, new.highElevation.toRawBits())
|
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||||
|
putStringSet(keyHiddenScreens, new.hiddenScreens.toSet())
|
||||||
|
putString(keyScreenOrder, new.screenOrder.joinToString(","))
|
||||||
|
putString(keySubMenuOrder, new.subMenuOrder.joinToString(","))
|
||||||
|
putString(keyWavelogUrl, new.wavelogUrl)
|
||||||
|
putString(keyWavelogApiKey, new.wavelogApiKey)
|
||||||
|
putString(keyWavelogStationId, new.wavelogStationId)
|
||||||
|
putBoolean(keyWavelogAutoUpload, new.wavelogAutoUpload)
|
||||||
|
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
|
||||||
|
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
|
||||||
|
|
||||||
}
|
}
|
||||||
new
|
new
|
||||||
}
|
}
|
||||||
@@ -371,8 +421,17 @@ class SettingsRepo(
|
|||||||
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
||||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||||
|
hiddenScreens = preferences.getStringSet(keyHiddenScreens, emptySet())?.toList() ?: emptyList(),
|
||||||
|
screenOrder = preferences.getString(keyScreenOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
|
||||||
|
subMenuOrder = preferences.getString(keySubMenuOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
|
||||||
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
||||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
|
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
|
||||||
|
wavelogUrl = preferences.getString(keyWavelogUrl, null) ?: "",
|
||||||
|
wavelogApiKey = preferences.getString(keyWavelogApiKey, null) ?: "",
|
||||||
|
wavelogStationId = preferences.getString(keyWavelogStationId, null) ?: "",
|
||||||
|
wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false),
|
||||||
|
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
|
||||||
|
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f)
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -390,12 +449,21 @@ class SettingsRepo(
|
|||||||
_dataSourcesSettings.value = settings
|
_dataSourcesSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
private fun getDataSourcesSettings(): DataSourcesSettings {
|
||||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
|
// 4.4.8 fix: legacy example.com placeholder URLs count as unconfigured -> replaced with the real default URL and the switch forced off,
|
||||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
|
// otherwise the online All/SatNOGS sources would point at the wrong address and fail to update
|
||||||
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
|
val storedTleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl
|
||||||
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
|
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
|
||||||
|
return DataSourcesSettings(
|
||||||
|
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false) && tleUrl != Sources.defaultTleUrl,
|
||||||
|
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false) && txUrl != Sources.defaultTransceiversUrl,
|
||||||
|
tleUrl = tleUrl,
|
||||||
|
transceiversUrl = txUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Radio control settings
|
//region # Radio control settings
|
||||||
@@ -435,5 +503,27 @@ class SettingsRepo(
|
|||||||
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
|
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
|
||||||
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -20,11 +20,11 @@ package com.rtbishop.look4sat.core.data.source
|
|||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
class RemoteSource(
|
class RemoteSource(
|
||||||
@@ -37,6 +37,8 @@ class RemoteSource(
|
|||||||
try {
|
try {
|
||||||
val fileUri = uri.toUri()
|
val fileUri = uri.toUri()
|
||||||
contentResolver.openInputStream(fileUri)?.buffered()
|
contentResolver.openInputStream(fileUri)?.buffered()
|
||||||
|
} catch (exception: CancellationException) {
|
||||||
|
throw exception
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
println("RemoteSource file stream exception: $exception")
|
println("RemoteSource file stream exception: $exception")
|
||||||
null
|
null
|
||||||
@@ -46,13 +48,57 @@ class RemoteSource(
|
|||||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
||||||
try {
|
try {
|
||||||
val networkRequest = Request.Builder().url(url).build()
|
val networkRequest = Request.Builder().url(url).build()
|
||||||
httpClient.newCall(networkRequest).execute().use { response ->
|
val response = httpClient.newCall(networkRequest).execute()
|
||||||
if (!response.isSuccessful) return@withContext null
|
if (!response.isSuccessful) {
|
||||||
ByteArrayInputStream(response.body.bytes())
|
response.close()
|
||||||
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
// Return the body stream directly as the caller is responsible for closing it
|
||||||
|
// That returns the connection to OkHttp's pool
|
||||||
|
response.body.byteStream().buffered()
|
||||||
|
} catch (exception: CancellationException) {
|
||||||
|
throw exception
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
println("RemoteSource network stream exception: $exception")
|
println("RemoteSource network stream exception: $exception")
|
||||||
null
|
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.5")
|
||||||
|
.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.5")
|
||||||
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -56,8 +56,14 @@ class AudioCapture : IAudioCapture {
|
|||||||
if (read > 0) emit(if (read == chunkSize) buffer.copyOf() else buffer.copyOfRange(0, read))
|
if (read > 0) emit(if (read == chunkSize) buffer.copyOf() else buffer.copyOfRange(0, read))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
recorder.stop()
|
// stop() on a recorder that never started throws
|
||||||
recorder.release()
|
// 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)
|
}.flowOn(Dispatchers.IO)
|
||||||
}
|
}
|
||||||
@@ -25,4 +25,8 @@ class ShowToast(private val context: Context) : IShowToast {
|
|||||||
override fun invoke(message: String) {
|
override fun invoke(message: String) {
|
||||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun invoke(resId: Int) {
|
||||||
|
invoke(context.getString(resId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-6
@@ -102,7 +102,8 @@ class DatabaseRepoTest {
|
|||||||
repository.updateFromRemote()
|
repository.updateFromRemote()
|
||||||
|
|
||||||
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
||||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
// New semantics: switch on + non-empty URL -> the All source uses the custom URL, data lands in the All type
|
||||||
|
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validCsvStream(): InputStream = """
|
private fun validCsvStream(): InputStream = """
|
||||||
@@ -124,6 +125,10 @@ private class FakeRemoteSource : IRemoteSource {
|
|||||||
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
||||||
|
|
||||||
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
|
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
|
||||||
|
|
||||||
|
override suspend fun getAmSatCatalog(): String? = null
|
||||||
|
|
||||||
|
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeLocalSource : ILocalSource {
|
private class FakeLocalSource : ILocalSource {
|
||||||
@@ -165,10 +170,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
|
|
||||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
override val selectedSatModes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||||
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
|
PassesSettings(hoursAhead = 24, minElevation = 0.0)
|
||||||
)
|
)
|
||||||
|
|
||||||
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||||
@@ -176,7 +181,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||||
|
|
||||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||||
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
|
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||||
)
|
)
|
||||||
|
|
||||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||||
@@ -193,13 +198,13 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
|
|
||||||
override fun setSelectedIds(ids: List<Int>) = Unit
|
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||||
|
|
||||||
override fun setSelectedTypes(types: List<String>) = Unit
|
override fun setSelectedSatModes(modes: List<String>) = Unit
|
||||||
|
|
||||||
override fun setPassesSettings(settings: PassesSettings) = Unit
|
override fun setPassesSettings(settings: PassesSettings) = Unit
|
||||||
|
|
||||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
|
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
|
override fun setStationPosition(locator: String): Boolean = true
|
||||||
|
|
||||||
@@ -222,6 +227,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||||
|
|
||||||
|
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||||
|
|
||||||
|
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
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,3 +1,8 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.convention.coreDomainPlugin)
|
alias(libs.plugins.convention.coreDomainPlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// 编译期使用 org.json(构造/解析 WaveLog API 请求体); 运行时用 Android 系统自带的 org.json
|
||||||
|
compileOnly("org.json:json:20240303")
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.aprs
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.round
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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
|
||||||
|
var i = 0
|
||||||
|
while (i <= s.length - 2) {
|
||||||
|
hash = hash xor (s[i].code * 256 + s[i + 1].code)
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
return hash and 0x7FFF
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Login line: 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Callsign-SSID join (BG7NTA + 5 -> BG7NTA-5) */
|
||||||
|
fun formatCallSsid(callsign: String, ssid: String): String {
|
||||||
|
if (ssid.isNullOrEmpty()) return callsign
|
||||||
|
return "$callsign-$ssid"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional distance filter: filter r/lat/lon/dist */
|
||||||
|
fun formatRangeFilter(latitude: Double, longitude: Double, distKm: Int): String {
|
||||||
|
return String.format(Locale.ROOT, "r/%.3f/%.3f/%d", latitude, longitude, distKm)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ""
|
||||||
|
val feet = (altitudeMeters * 3.2808399).toInt().coerceIn(0, 999999)
|
||||||
|
return String.format(Locale.ROOT, "/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().coerceIn(0, 999)
|
||||||
|
val course = ((bearing.toInt() % 360) + 360) % 360
|
||||||
|
return String.format(Locale.ROOT, "/%03d/%03d", course, knots)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APRS position encoding (reverse-ported from ab0oo Position.java).
|
||||||
|
* Uncompressed: DDMM.MMN/DDDMM.MME; compressed: base91.
|
||||||
|
*/
|
||||||
|
class AprsPosition(
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val symbolTable: Char,
|
||||||
|
val symbolCode: Char,
|
||||||
|
val positionAmbiguity: Int = 0
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compressed format (base91, standard APRS algorithm) */
|
||||||
|
fun toCompressedString(): String {
|
||||||
|
val jRound = round((90.0 - latitude) * 380926.0).toLong()
|
||||||
|
val j = jRound / 753571 + 33
|
||||||
|
val j2 = jRound % 753571
|
||||||
|
val j3 = j2 / 8281 + 33
|
||||||
|
val j4 = j2 % 8281
|
||||||
|
val i = (j4 % 91).toInt() + 33
|
||||||
|
val jRound2 = round((longitude + 180.0) * 190463.0).toLong()
|
||||||
|
val j5 = jRound2 / 753571 + 33
|
||||||
|
val j6 = jRound2 % 753571
|
||||||
|
val j7 = 33 + j6 / 8281
|
||||||
|
val j8 = j6 % 8281
|
||||||
|
return "" + symbolTable + j.toInt().toChar() + j3.toInt().toChar() +
|
||||||
|
((j4 / 91).toInt() + 33).toChar() + i.toChar() +
|
||||||
|
j5.toInt().toChar() + j7.toInt().toChar() +
|
||||||
|
((j8 / 91).toInt() + 33).toChar() +
|
||||||
|
((j8 % 91).toInt() + 33).toChar() + symbolCode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
val degrees = iRound / 6000
|
||||||
|
val minutes = (iRound / 100) % 60
|
||||||
|
val hundredths = iRound % 100
|
||||||
|
val frac = when (positionAmbiguity) {
|
||||||
|
1 -> " . "
|
||||||
|
2 -> String.format(Locale.ROOT, "%d . ", minutes / 10)
|
||||||
|
3 -> String.format(Locale.ROOT, "%02d. ", minutes)
|
||||||
|
4 -> String.format(Locale.ROOT, "%02d.%d ", minutes, hundredths / 10)
|
||||||
|
else -> String.format(Locale.ROOT, "%02d.%02d", minutes, hundredths)
|
||||||
|
}
|
||||||
|
return if (isLat) {
|
||||||
|
val ns = if (value >= 0) 'N' else 'S'
|
||||||
|
String.format(Locale.ROOT, "%02d%s%c", degrees, frac, ns)
|
||||||
|
} else {
|
||||||
|
val ew = if (value >= 0) 'E' else 'W'
|
||||||
|
String.format(Locale.ROOT, "%03d%s%c", degrees, frac, ew)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.cw
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bayesian Morse timing decoder.
|
|
||||||
* Replaces hard thresholds with probability-based decision making.
|
|
||||||
*
|
|
||||||
* Inspired by VE3NEA's CW Skimmer approach:
|
|
||||||
* "Instead of making a hard decision at every input sample whether the signal
|
|
||||||
* is present or not, compute the probability that the signal is present."
|
|
||||||
*
|
|
||||||
* Uses Gaussian probability density centered on expected durations:
|
|
||||||
* P(dit | duration) = exp(-(duration - dotMs)^2 / (2 * variance^2))
|
|
||||||
* P(dash | duration) = exp(-(duration - 3*dotMs)^2 / (2 * variance^2))
|
|
||||||
*/
|
|
||||||
internal class CwBayesianDecoder {
|
|
||||||
|
|
||||||
// Morse timing parameters
|
|
||||||
private var dotDurationMs = 60f // initial 20 WPM
|
|
||||||
private var speedWpm = 20f
|
|
||||||
|
|
||||||
// Current symbol being accumulated
|
|
||||||
private var currentSymbol = StringBuilder()
|
|
||||||
private var textBuffer = StringBuilder()
|
|
||||||
|
|
||||||
// Recent dit lengths for speed estimation
|
|
||||||
private val recentDits = mutableListOf<Float>()
|
|
||||||
|
|
||||||
// Output
|
|
||||||
private var _decodedText = ""
|
|
||||||
val decodedText: String get() = _decodedText
|
|
||||||
|
|
||||||
/** Gaussian probability. */
|
|
||||||
private fun gaussianProb(durationMs: Float, expectedMs: Float, varianceMs: Float): Float {
|
|
||||||
if (varianceMs <= 0f) return 0f
|
|
||||||
val diff = durationMs - expectedMs
|
|
||||||
return kotlin.math.exp(-(diff * diff) / (2 * varianceMs * varianceMs))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Process a tone duration. Returns the symbol type with highest probability. */
|
|
||||||
fun processTone(durationMs: Float): ToneResult {
|
|
||||||
val ditProb = gaussianProb(durationMs, dotDurationMs, dotDurationMs * 0.4f)
|
|
||||||
val dashProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
|
|
||||||
|
|
||||||
return if (ditProb > dashProb && ditProb > 0.05f) {
|
|
||||||
currentSymbol.append('0')
|
|
||||||
recentDits.add(durationMs)
|
|
||||||
updateSpeed()
|
|
||||||
ToneResult('0', ditProb)
|
|
||||||
} else if (dashProb > 0.05f) {
|
|
||||||
currentSymbol.append('1')
|
|
||||||
ToneResult('1', dashProb)
|
|
||||||
} else {
|
|
||||||
ToneResult(null, 0f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Process a gap duration. Returns decoded character or null. */
|
|
||||||
fun processGap(durationMs: Float): Char? {
|
|
||||||
if (currentSymbol.isEmpty()) {
|
|
||||||
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
|
|
||||||
if (wordProb > 0.2f) {
|
|
||||||
textBuffer.append(' ')
|
|
||||||
_decodedText = textBuffer.toString()
|
|
||||||
return ' '
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
val interCharProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
|
|
||||||
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
|
|
||||||
|
|
||||||
if (wordProb > interCharProb && wordProb > 0.2f) {
|
|
||||||
val char = flushSymbol()
|
|
||||||
textBuffer.append(' ')
|
|
||||||
_decodedText = textBuffer.toString()
|
|
||||||
return char
|
|
||||||
}
|
|
||||||
if (interCharProb > 0.15f) {
|
|
||||||
val char = flushSymbol()
|
|
||||||
_decodedText = textBuffer.toString()
|
|
||||||
return char
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun flushSymbol(): Char? {
|
|
||||||
if (currentSymbol.isEmpty()) return null
|
|
||||||
val morse = currentSymbol.toString()
|
|
||||||
currentSymbol.clear()
|
|
||||||
val char = morseToChar(morse)
|
|
||||||
if (char != null) textBuffer.append(char)
|
|
||||||
return char
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateSpeed() {
|
|
||||||
if (recentDits.size < 3) return
|
|
||||||
val sorted = recentDits.sorted()
|
|
||||||
val median = sorted[sorted.size / 2]
|
|
||||||
if (median > 0f) {
|
|
||||||
dotDurationMs = dotDurationMs * 0.7f + median * 0.3f
|
|
||||||
val wpm = 60.0f / (50.0f * dotDurationMs / 1000.0f)
|
|
||||||
if (wpm in 5f..55f) speedWpm = wpm
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getSpeed(): Float = speedWpm
|
|
||||||
|
|
||||||
fun reset() {
|
|
||||||
dotDurationMs = 60f
|
|
||||||
speedWpm = 20f
|
|
||||||
recentDits.clear()
|
|
||||||
currentSymbol.clear()
|
|
||||||
textBuffer.clear()
|
|
||||||
_decodedText = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val MORSE_TABLE = mapOf(
|
|
||||||
"01" to 'A', "1000" to 'B', "1010" to 'C', "100" to 'D', "0" to 'E',
|
|
||||||
"0010" to 'F', "110" to 'G', "0000" to 'H', "00" to 'I', "0111" to 'J',
|
|
||||||
"101" to 'K', "0100" to 'L', "11" to 'M', "10" to 'N', "111" to 'O',
|
|
||||||
"0110" to 'P', "1101" to 'Q', "010" to 'R', "000" to 'S', "1" to 'T',
|
|
||||||
"001" to 'U', "0001" to 'V', "011" to 'W', "1001" to 'X', "1011" to 'Y',
|
|
||||||
"1100" to 'Z', "01111" to '1', "00111" to '2', "00011" to '3',
|
|
||||||
"00001" to '4', "00000" to '5', "10000" to '6', "11000" to '7',
|
|
||||||
"11100" to '8', "11110" to '9', "11111" to '0',
|
|
||||||
"010101" to '.', "110011" to ',', "001100" to '?', "011110" to '\'',
|
|
||||||
"101011" to '!', "10010" to '/', "10110" to '(', "101101" to ')',
|
|
||||||
"01000" to '&', "111000" to ':', "101010" to ';', "10001" to '=',
|
|
||||||
"01010" to '+', "100001" to '-', "001101" to '_', "010010" to '"',
|
|
||||||
"0001001" to '$', "011010" to '@'
|
|
||||||
)
|
|
||||||
fun morseToChar(morse: String): Char? = MORSE_TABLE[morse]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class ToneResult(val symbol: Char?, val probability: Float)
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.cw
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Multi-channel CW signal tracker.
|
|
||||||
* Monitors the spectrogram for active frequency bins and extracts
|
|
||||||
* energy envelopes for each detected signal.
|
|
||||||
*
|
|
||||||
* Inspired by CW Skimmer's multi-channel approach:
|
|
||||||
* tracks all active signals in the passband simultaneously,
|
|
||||||
* selects the best one for decoded output.
|
|
||||||
*/
|
|
||||||
internal class CwChannelTracker(
|
|
||||||
private val spectrogram: CwSpectrogram,
|
|
||||||
private val maxChannels: Int = 3
|
|
||||||
) {
|
|
||||||
data class Channel(
|
|
||||||
val bin: Int,
|
|
||||||
val frequency: Float,
|
|
||||||
var active: Boolean = false,
|
|
||||||
var energy: Float = 0f,
|
|
||||||
val history: MutableList<Float> = mutableListOf(),
|
|
||||||
var confidence: Float = 0f
|
|
||||||
)
|
|
||||||
|
|
||||||
private val channels = Array(maxChannels) { Channel(0, 0f) }
|
|
||||||
|
|
||||||
/** Scan the current spectrogram column and update channel tracking. */
|
|
||||||
fun update(): List<Channel> {
|
|
||||||
val col = spectrogram.getCurrentColumn()
|
|
||||||
val peaks = findPeaks(col, threshold = 0.3f, minDistance = 2)
|
|
||||||
|
|
||||||
// Update existing channels
|
|
||||||
for (ch in channels) {
|
|
||||||
if (ch.active) {
|
|
||||||
if (peaks.contains(ch.bin)) {
|
|
||||||
ch.energy = col[ch.bin]
|
|
||||||
ch.history.add(ch.energy)
|
|
||||||
if (ch.history.size > 40) ch.history.removeAt(0)
|
|
||||||
ch.confidence = computeConfidence(ch.history)
|
|
||||||
} else {
|
|
||||||
// Signal lost — decay confidence
|
|
||||||
ch.history.add(0f)
|
|
||||||
if (ch.history.size > 40) ch.history.removeAt(0)
|
|
||||||
ch.confidence *= 0.9f
|
|
||||||
if (ch.confidence < 0.1f) ch.active = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assign new peaks to inactive channels
|
|
||||||
var peakIdx = 0
|
|
||||||
for (ch in channels) {
|
|
||||||
if (!ch.active && peakIdx < peaks.size) {
|
|
||||||
val bin = peaks[peakIdx]
|
|
||||||
val freq = spectrogram.binToFreq(bin)
|
|
||||||
// Re-initialize channel
|
|
||||||
channels[peakIdx] = Channel(bin, freq, true, col[bin], mutableListOf(), 0.5f)
|
|
||||||
peakIdx++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return channels.filter { it.active }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Find peak bins in the spectrum. */
|
|
||||||
private fun findPeaks(spectrum: FloatArray, threshold: Float, minDistance: Int): List<Int> {
|
|
||||||
val peaks = mutableListOf<Int>()
|
|
||||||
for (i in 1 until spectrum.size - 1) {
|
|
||||||
if (spectrum[i] > spectrum[i - 1] && spectrum[i] > spectrum[i + 1] && spectrum[i] > threshold) {
|
|
||||||
if (peaks.isEmpty() || i - peaks.last() >= minDistance) {
|
|
||||||
peaks.add(i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return peaks.sortedByDescending { spectrum[it] }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compute confidence from energy history. Lower variance = higher confidence. */
|
|
||||||
private fun computeConfidence(history: List<Float>): Float {
|
|
||||||
if (history.size < 10) return 0.3f
|
|
||||||
val recent = history.takeLast(10)
|
|
||||||
val mean = recent.average().toFloat()
|
|
||||||
val variance = recent.map { (it - mean) * (it - mean) }.average().toFloat()
|
|
||||||
return if (mean > 0f) (mean / (mean + variance + 0.1f)).coerceIn(0f, 1f) else 0f
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the channel with highest confidence. */
|
|
||||||
fun getBestChannel(): Channel? {
|
|
||||||
return channels.filter { it.active }.maxByOrNull { it.confidence }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reset() {
|
|
||||||
for (i in channels.indices) {
|
|
||||||
channels[i] = Channel(0, 0f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.cw
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CW (Morse code) decoder v3 — Spectrogram-based multi-channel Bayesian decoder.
|
|
||||||
*
|
|
||||||
* Architecture inspired by Morse Expert / CW Skimmer (VE3NEA):
|
|
||||||
* 1. FFT spectrogram creates a frequency×time matrix
|
|
||||||
* 2. Multi-channel peak detector finds all active signals
|
|
||||||
* 3. Per-channel energy envelope extraction
|
|
||||||
* 4. Bayesian probability for symbol timing (Gaussian likelihood)
|
|
||||||
* 5. Best channel selected for output
|
|
||||||
*
|
|
||||||
* Timing analysis is performed per spectrogram column (hop).
|
|
||||||
* Each column represents hopSize/sampleRate seconds of audio.
|
|
||||||
*/
|
|
||||||
class CwDecoder(
|
|
||||||
val sampleRate: Int = 8000,
|
|
||||||
cwToneFreq: Float = -1f // ignored in v3 (auto-detect via spectrogram)
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
private const val FFT_SIZE = 256
|
|
||||||
private const val HOP_SIZE = 64
|
|
||||||
}
|
|
||||||
|
|
||||||
private val spectrogram = CwSpectrogram(
|
|
||||||
fftSize = FFT_SIZE,
|
|
||||||
hopSize = HOP_SIZE,
|
|
||||||
sampleRate = sampleRate,
|
|
||||||
minBin = 6,
|
|
||||||
maxBin = 38,
|
|
||||||
historyCols = 40
|
|
||||||
)
|
|
||||||
private val channelTracker = CwChannelTracker(spectrogram, maxChannels = 3)
|
|
||||||
private val bayesianDecoder = CwBayesianDecoder()
|
|
||||||
|
|
||||||
// Timing state per channel
|
|
||||||
private data class ChannelTiming(
|
|
||||||
var isSignal: Boolean = false,
|
|
||||||
var toneTicks: Int = 0,
|
|
||||||
var gapTicks: Int = 0
|
|
||||||
)
|
|
||||||
private val timingStates = Array(3) { ChannelTiming() }
|
|
||||||
|
|
||||||
// Time per spectrogram column in milliseconds
|
|
||||||
private val tickMs = 1000f * HOP_SIZE / sampleRate
|
|
||||||
|
|
||||||
// Output flows
|
|
||||||
private val _decodedTextFlow = MutableStateFlow("")
|
|
||||||
val decodedTextFlow: StateFlow<String> = _decodedTextFlow
|
|
||||||
|
|
||||||
private val _signalStrength = MutableStateFlow(0f)
|
|
||||||
val signalStrength: StateFlow<Float> = _signalStrength
|
|
||||||
|
|
||||||
private val _estimatedPitch = MutableStateFlow<Float?>(null)
|
|
||||||
val estimatedPitch: StateFlow<Float?> = _estimatedPitch
|
|
||||||
|
|
||||||
private val _estimatedSpeed = MutableStateFlow<Float?>(null)
|
|
||||||
val estimatedSpeed: StateFlow<Float?> = _estimatedSpeed
|
|
||||||
|
|
||||||
private var frameCount = 0
|
|
||||||
|
|
||||||
init {
|
|
||||||
if (cwToneFreq > 0f) {
|
|
||||||
_estimatedPitch.value = cwToneFreq
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun processBuffer(buffer: FloatArray) {
|
|
||||||
// 1. Feed samples to spectrogram
|
|
||||||
spectrogram.addSamples(buffer)
|
|
||||||
|
|
||||||
// 2. Get number of new columns generated
|
|
||||||
val newCols = spectrogram.getNewColumns()
|
|
||||||
if (newCols == 0) return
|
|
||||||
|
|
||||||
// 3. Update channel tracker (uses latest column for peak detection)
|
|
||||||
val activeChannels = channelTracker.update()
|
|
||||||
|
|
||||||
// 4. Process each new column for timing analysis
|
|
||||||
// Columns are indexed 0..historyCols-1, where historyCols-1 is the newest
|
|
||||||
val baseIdx = (spectrogram.historyCols - newCols).coerceAtLeast(0)
|
|
||||||
for (colOffset in 0 until newCols) {
|
|
||||||
val col = spectrogram.getColumn(baseIdx + colOffset)
|
|
||||||
|
|
||||||
for ((idx, channel) in activeChannels.withIndex()) {
|
|
||||||
if (idx >= timingStates.size) break
|
|
||||||
val state = timingStates[idx]
|
|
||||||
val energy = if (channel.bin in col.indices) col[channel.bin] else 0f
|
|
||||||
|
|
||||||
// Adaptive threshold
|
|
||||||
val threshold = 0.3f + (energy - 0.3f) * 0.3f
|
|
||||||
|
|
||||||
if (energy > threshold) {
|
|
||||||
if (!state.isSignal) {
|
|
||||||
if (state.gapTicks > 0) {
|
|
||||||
val gapMs = state.gapTicks * tickMs
|
|
||||||
bayesianDecoder.processGap(gapMs)
|
|
||||||
}
|
|
||||||
state.gapTicks = 0
|
|
||||||
state.isSignal = true
|
|
||||||
}
|
|
||||||
state.toneTicks++
|
|
||||||
} else {
|
|
||||||
if (state.isSignal) {
|
|
||||||
if (state.toneTicks > 0) {
|
|
||||||
val toneMs = state.toneTicks * tickMs
|
|
||||||
bayesianDecoder.processTone(toneMs)
|
|
||||||
}
|
|
||||||
state.toneTicks = 0
|
|
||||||
state.isSignal = false
|
|
||||||
}
|
|
||||||
state.gapTicks++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Update outputs
|
|
||||||
frameCount++
|
|
||||||
if (frameCount % 5 == 0) {
|
|
||||||
val bestChannel = channelTracker.getBestChannel()
|
|
||||||
if (bestChannel != null) {
|
|
||||||
_estimatedPitch.value = bestChannel.frequency
|
|
||||||
_signalStrength.value = bestChannel.confidence
|
|
||||||
_estimatedSpeed.value = bayesianDecoder.getSpeed()
|
|
||||||
}
|
|
||||||
_decodedTextFlow.value = bayesianDecoder.decodedText
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resetDecoder() {
|
|
||||||
spectrogram.reset()
|
|
||||||
channelTracker.reset()
|
|
||||||
bayesianDecoder.reset()
|
|
||||||
for (state in timingStates) {
|
|
||||||
state.isSignal = false
|
|
||||||
state.toneTicks = 0
|
|
||||||
state.gapTicks = 0
|
|
||||||
}
|
|
||||||
frameCount = 0
|
|
||||||
_decodedTextFlow.value = ""
|
|
||||||
_signalStrength.value = 0f
|
|
||||||
_estimatedPitch.value = null
|
|
||||||
_estimatedSpeed.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
private const val MIN_FREQ_HZ = 400.0
|
||||||
|
private const val MAX_FREQ_HZ = 1200.0
|
||||||
|
|
||||||
|
/** Number of frequency bins the model expects. */
|
||||||
|
const val FREQUENCY_BINS = 65
|
||||||
|
|
||||||
|
/** 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): 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, MIN_FREQ_HZ, MAX_FREQ_HZ
|
||||||
|
)
|
||||||
|
val bins = stopBin - startBin
|
||||||
|
require(bins == FREQUENCY_BINS) {
|
||||||
|
"expected $FREQUENCY_BINS bins, computed $bins"
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.cw
|
|
||||||
|
|
||||||
import kotlin.math.PI
|
|
||||||
import kotlin.math.cos
|
|
||||||
import kotlin.math.sin
|
|
||||||
import kotlin.math.sqrt
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DSP utilities for CW (Morse code) decoding.
|
|
||||||
* Pure Kotlin, no NDK required.
|
|
||||||
*/
|
|
||||||
internal object CwDsp {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Design a simple bandpass FIR filter coefficients using windowed sinc method.
|
|
||||||
* @param lowCutoff lower cutoff frequency (Hz) as fraction of sampleRate
|
|
||||||
* @param highCutoff upper cutoff frequency (Hz) as fraction of sampleRate
|
|
||||||
* @param taps filter length (must be odd)
|
|
||||||
*/
|
|
||||||
fun bandpassFir(lowCutoff: Double, highCutoff: Double, taps: Int): FloatArray {
|
|
||||||
val n = if (taps % 2 == 0) taps + 1 else taps
|
|
||||||
val half = n / 2
|
|
||||||
val coeffs = FloatArray(n)
|
|
||||||
for (i in 0 until n) {
|
|
||||||
val idx = i - half
|
|
||||||
if (idx == 0) {
|
|
||||||
coeffs[i] = (2.0 * (highCutoff - lowCutoff)).toFloat()
|
|
||||||
} else {
|
|
||||||
val x = PI * idx
|
|
||||||
coeffs[i] = ((sin(2 * highCutoff * x) - sin(2 * lowCutoff * x)) / x).toFloat()
|
|
||||||
}
|
|
||||||
// Hamming window
|
|
||||||
coeffs[i] = (coeffs[i] * (0.54 - 0.46 * cos(2 * PI * i / (n - 1)))).toFloat()
|
|
||||||
}
|
|
||||||
// Normalize
|
|
||||||
val sum = coeffs.sum()
|
|
||||||
if (sum != 0f) for (i in 0 until n) coeffs[i] /= sum
|
|
||||||
return coeffs
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Apply FIR filter to a buffer. */
|
|
||||||
fun applyFir(buffer: FloatArray, coeffs: FloatArray): FloatArray {
|
|
||||||
val out = FloatArray(buffer.size)
|
|
||||||
for (i in buffer.indices) {
|
|
||||||
var sum = 0f
|
|
||||||
for (j in coeffs.indices) {
|
|
||||||
val idx = i - j
|
|
||||||
if (idx >= 0) sum += buffer[idx] * coeffs[j]
|
|
||||||
}
|
|
||||||
out[i] = sum
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Simple envelope detector: abs + low-pass smoothing. */
|
|
||||||
fun envelope(signal: FloatArray, alpha: Float = 0.1f): FloatArray {
|
|
||||||
val env = FloatArray(signal.size)
|
|
||||||
var s = 0f
|
|
||||||
for (i in signal.indices) {
|
|
||||||
s = alpha * kotlin.math.abs(signal[i]) + (1 - alpha) * s
|
|
||||||
env[i] = s
|
|
||||||
}
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Estimate noise floor from envelope for adaptive thresholding. */
|
|
||||||
fun noiseFloor(env: FloatArray, fraction: Float = 0.3f): Float {
|
|
||||||
val sorted = env.sortedArray()
|
|
||||||
val median = sorted[sorted.size / 2]
|
|
||||||
return median + (sorted[sorted.size * 9 / 10] - median) * fraction
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Simple Goertzel to detect a specific tone frequency. */
|
|
||||||
fun goertzel(buffer: FloatArray, targetFreq: Float, sampleRate: Int): Float {
|
|
||||||
val omega = 2.0 * PI * targetFreq / sampleRate
|
|
||||||
val coeff = 2.0 * cos(omega)
|
|
||||||
var s0 = 0.0; var s1 = 0.0; var s2 = 0.0
|
|
||||||
for (sample in buffer) {
|
|
||||||
s0 = sample.toDouble() + coeff * s1 - s2
|
|
||||||
s2 = s1; s1 = s0
|
|
||||||
}
|
|
||||||
val power = s2 * s2 + s1 * s1 - coeff * s1 * s2
|
|
||||||
return sqrt(kotlin.math.abs(power)).toFloat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.cw
|
|
||||||
|
|
||||||
import kotlin.math.cos
|
|
||||||
import kotlin.math.sqrt
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Radix-2 FFT for real-valued input.
|
|
||||||
* Produces magnitude spectrum for the first N/2+1 bins.
|
|
||||||
* Used by CwSpectrogram for time-frequency analysis.
|
|
||||||
*/
|
|
||||||
internal class CwFFT(private val n: Int) {
|
|
||||||
init {
|
|
||||||
require(n > 0 && n and (n - 1) == 0) { "FFT size must be power of 2, got $n" }
|
|
||||||
}
|
|
||||||
|
|
||||||
private val cosTable = FloatArray(n / 2)
|
|
||||||
private val sinTable = FloatArray(n / 2)
|
|
||||||
|
|
||||||
init {
|
|
||||||
for (i in 0 until n / 2) {
|
|
||||||
val angle = -2.0 * kotlin.math.PI * i / n
|
|
||||||
cosTable[i] = cos(angle).toFloat()
|
|
||||||
sinTable[i] = kotlin.math.sin(angle).toFloat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compute magnitude spectrum for real input. Returns array of size n/2+1. */
|
|
||||||
fun magnitudeSpectrum(input: FloatArray): FloatArray {
|
|
||||||
require(input.size == n) { "Input size must be $n, got ${input.size}" }
|
|
||||||
|
|
||||||
val real = input.copyOf()
|
|
||||||
val imag = FloatArray(n)
|
|
||||||
|
|
||||||
// Bit-reversal permutation
|
|
||||||
var j = 0
|
|
||||||
for (i in 1 until n) {
|
|
||||||
var bit = n shr 1
|
|
||||||
while (j and bit != 0) { j = j xor bit; bit = bit shr 1 }
|
|
||||||
j = j xor bit
|
|
||||||
if (i < j) {
|
|
||||||
var tmp = real[i]; real[i] = real[j]; real[j] = tmp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Radix-2 Cooley-Tukey FFT
|
|
||||||
var len = 2
|
|
||||||
while (len <= n) {
|
|
||||||
val half = len / 2
|
|
||||||
val step = n / len
|
|
||||||
for (i in 0 until n step len) {
|
|
||||||
for (k in 0 until half) {
|
|
||||||
val tReal = real[i + k + half] * cosTable[k * step] - imag[i + k + half] * sinTable[k * step]
|
|
||||||
val tImag = real[i + k + half] * sinTable[k * step] + imag[i + k + half] * cosTable[k * step]
|
|
||||||
real[i + k + half] = real[i + k] - tReal
|
|
||||||
imag[i + k + half] = imag[i + k] - tImag
|
|
||||||
real[i + k] += tReal
|
|
||||||
imag[i + k] += tImag
|
|
||||||
}
|
|
||||||
}
|
|
||||||
len = len shl 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Magnitude spectrum (first N/2+1 bins)
|
|
||||||
val mag = FloatArray(n / 2 + 1)
|
|
||||||
for (i in 0..n / 2) {
|
|
||||||
mag[i] = sqrt(real[i] * real[i] + imag[i] * imag[i]) / n
|
|
||||||
}
|
|
||||||
return mag
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.cw
|
|
||||||
|
|
||||||
/**
|
|
||||||
* First-order IIR filters.
|
|
||||||
* Ported from ggmorse/src/filter.h
|
|
||||||
*/
|
|
||||||
internal class CwFilter {
|
|
||||||
private var z1 = 0f
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val PI_F = 3.141592653589793f
|
|
||||||
}
|
|
||||||
|
|
||||||
fun highPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
|
|
||||||
val rc = 1.0f / (2f * PI_F * cutoffHz)
|
|
||||||
val dt = 1.0f / sampleRate
|
|
||||||
val alpha = dt / (rc + dt)
|
|
||||||
z1 = alpha * (z1 + sample - z1)
|
|
||||||
return sample - z1
|
|
||||||
}
|
|
||||||
|
|
||||||
fun lowPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
|
|
||||||
val rc = 1.0f / (2f * PI_F * cutoffHz)
|
|
||||||
val dt = 1.0f / sampleRate
|
|
||||||
val alpha = dt / (rc + dt)
|
|
||||||
z1 += alpha * (sample - z1)
|
|
||||||
return z1
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reset() { z1 = 0f }
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.cw
|
|
||||||
|
|
||||||
import kotlin.math.cos
|
|
||||||
import kotlin.math.sqrt
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Running Goertzel filter for CW tone detection.
|
|
||||||
* Tracks a specific frequency over time with a sliding window.
|
|
||||||
* Ported from ggmorse/src/goertzel.h
|
|
||||||
*/
|
|
||||||
internal class CwGoertzel {
|
|
||||||
private var s1 = 0.0
|
|
||||||
private var s2 = 0.0
|
|
||||||
private var coeff = 0.0
|
|
||||||
|
|
||||||
fun init(sampleRate: Float, targetFreq: Float) {
|
|
||||||
val omega = 2.0 * kotlin.math.PI * targetFreq / sampleRate
|
|
||||||
coeff = 2.0 * cos(omega)
|
|
||||||
s1 = 0.0
|
|
||||||
s2 = 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
fun process(sample: Float) {
|
|
||||||
val s0 = sample.toDouble() + coeff * s1 - s2
|
|
||||||
s2 = s1
|
|
||||||
s1 = s0
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getPower(): Float {
|
|
||||||
return sqrt(s2 * s2 + s1 * s1 - coeff * s1 * s2).toFloat()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reset() {
|
|
||||||
s1 = 0.0
|
|
||||||
s2 = 0.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.cw
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple linear resampler.
|
|
||||||
* Downsamples from input sample rate to output sample rate.
|
|
||||||
* Ported from ggmorse/src/resampler.h
|
|
||||||
*/
|
|
||||||
internal class CwResampler(private val inputRate: Float, private val outputRate: Float) {
|
|
||||||
private val ratio = inputRate / outputRate
|
|
||||||
private var lastSample = 0f
|
|
||||||
|
|
||||||
fun process(input: FloatArray): FloatArray {
|
|
||||||
if (ratio <= 0f || input.isEmpty()) return input
|
|
||||||
val outputLen = (input.size / ratio).toInt() + 1
|
|
||||||
val output = FloatArray(outputLen)
|
|
||||||
var idx = 0f
|
|
||||||
for (i in output.indices) {
|
|
||||||
val intIdx = idx.toInt()
|
|
||||||
val frac = idx - intIdx
|
|
||||||
if (intIdx + 1 < input.size) {
|
|
||||||
output[i] = input[intIdx] * (1 - frac) + input[intIdx + 1] * frac
|
|
||||||
} else if (intIdx < input.size) {
|
|
||||||
output[i] = input[intIdx] * (1 - frac) + lastSample * frac
|
|
||||||
} else {
|
|
||||||
output[i] = lastSample
|
|
||||||
}
|
|
||||||
idx += ratio
|
|
||||||
}
|
|
||||||
lastSample = input.lastOrNull() ?: lastSample
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reset() {
|
|
||||||
lastSample = 0f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.cw
|
|
||||||
|
|
||||||
import kotlin.math.cos
|
|
||||||
import kotlin.math.sin
|
|
||||||
import kotlin.math.sqrt
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lightweight pitch detector using DFT at specific frequency bins.
|
|
||||||
* Only scans [200, 1200] Hz in configurable steps — much faster than full FFT.
|
|
||||||
* Ported from ggmorse/src/stfft.h (simplified for CW use case).
|
|
||||||
*/
|
|
||||||
internal class CwPitchDetector(
|
|
||||||
private val sampleRate: Float,
|
|
||||||
private val minFreq: Float = 200f,
|
|
||||||
private val maxFreq: Float = 1200f,
|
|
||||||
private val stepHz: Float = 10f
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* Find the dominant pitch frequency in the buffer.
|
|
||||||
* Returns null if no significant pitch found.
|
|
||||||
*/
|
|
||||||
fun findPitch(buffer: FloatArray): Float? {
|
|
||||||
if (buffer.isEmpty()) return null
|
|
||||||
var bestFreq = 0f
|
|
||||||
var bestPower = 0f
|
|
||||||
var freq = minFreq
|
|
||||||
while (freq <= maxFreq) {
|
|
||||||
var real = 0.0
|
|
||||||
var imag = 0.0
|
|
||||||
val omega = 2.0 * kotlin.math.PI * freq / sampleRate
|
|
||||||
for (i in buffer.indices) {
|
|
||||||
real += buffer[i] * cos(omega * i)
|
|
||||||
imag += buffer[i] * -sin(omega * i)
|
|
||||||
}
|
|
||||||
val power = (real * real + imag * imag).toFloat()
|
|
||||||
if (power > bestPower) {
|
|
||||||
bestPower = power
|
|
||||||
bestFreq = freq
|
|
||||||
}
|
|
||||||
freq += stepHz
|
|
||||||
}
|
|
||||||
return if (bestPower > 0.001f) bestFreq else null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.cw
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sliding-window spectrogram for CW decoding.
|
|
||||||
* Maintains a time-frequency matrix updated with each audio frame.
|
|
||||||
*
|
|
||||||
* FFT size: 256, hop size: 64, sample rate: 4000 (or native)
|
|
||||||
* Frequency bins: 6..38 (187-1187 Hz, covers typical CW range)
|
|
||||||
* History: 40 columns (320 ms window)
|
|
||||||
* Time resolution: 64/4000 = 16 ms, Frequency resolution: 4000/256 = 15.625 Hz
|
|
||||||
*/
|
|
||||||
internal class CwSpectrogram(
|
|
||||||
private val fftSize: Int = 256,
|
|
||||||
private val hopSize: Int = 64,
|
|
||||||
private val sampleRate: Int = 4000,
|
|
||||||
private val minBin: Int = 6,
|
|
||||||
private val maxBin: Int = 38,
|
|
||||||
val historyCols: Int = 40
|
|
||||||
) {
|
|
||||||
private val fft = CwFFT(fftSize)
|
|
||||||
val numBins: Int get() = maxBin - minBin + 1
|
|
||||||
|
|
||||||
// Hanning window
|
|
||||||
private val hanning = FloatArray(fftSize) {
|
|
||||||
(0.5 - 0.5 * kotlin.math.cos(2.0 * kotlin.math.PI * it / (fftSize - 1))).toFloat()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spectrogram data: [timeCol][freqBin]
|
|
||||||
private val spectrogram = Array(historyCols) { FloatArray(numBins) }
|
|
||||||
private var currentCol = 0
|
|
||||||
private var samplesBuffered = 0
|
|
||||||
private val buffer = FloatArray(fftSize)
|
|
||||||
|
|
||||||
// Per-bin running energy for normalization
|
|
||||||
private val binEnergy = FloatArray(numBins) { 1f }
|
|
||||||
private val alpha = 0.95f
|
|
||||||
|
|
||||||
// Counter for new columns generated since last check
|
|
||||||
private var newColumnCount = 0
|
|
||||||
|
|
||||||
/** Add audio samples, compute FFTs for each complete hop. */
|
|
||||||
fun addSamples(samples: FloatArray) {
|
|
||||||
var offset = 0
|
|
||||||
while (offset < samples.size) {
|
|
||||||
val needed = fftSize - samplesBuffered
|
|
||||||
val copyLen = minOf(needed, samples.size - offset)
|
|
||||||
System.arraycopy(samples, offset, buffer, samplesBuffered, copyLen)
|
|
||||||
samplesBuffered += copyLen
|
|
||||||
offset += copyLen
|
|
||||||
|
|
||||||
if (samplesBuffered >= fftSize) {
|
|
||||||
processFrame()
|
|
||||||
newColumnCount++
|
|
||||||
// Shift buffer: keep last (fftSize - hopSize) samples
|
|
||||||
System.arraycopy(buffer, hopSize, buffer, 0, fftSize - hopSize)
|
|
||||||
samplesBuffered = fftSize - hopSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get number of new columns generated since the last call to this method. */
|
|
||||||
fun getNewColumns(): Int {
|
|
||||||
val count = newColumnCount
|
|
||||||
newColumnCount = 0
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun processFrame() {
|
|
||||||
// Apply Hanning window
|
|
||||||
val windowed = FloatArray(fftSize) { buffer[it] * hanning[it] }
|
|
||||||
|
|
||||||
// Compute FFT magnitude spectrum
|
|
||||||
val mag = fft.magnitudeSpectrum(windowed)
|
|
||||||
|
|
||||||
// Update spectrogram column
|
|
||||||
val col = spectrogram[currentCol]
|
|
||||||
for (b in 0 until numBins) {
|
|
||||||
val binIdx = minBin + b
|
|
||||||
val rawMag = mag[binIdx]
|
|
||||||
// Running energy normalization
|
|
||||||
binEnergy[b] = alpha * binEnergy[b] + (1 - alpha) * rawMag
|
|
||||||
col[b] = if (binEnergy[b] > 1e-6f) rawMag / binEnergy[b] else 0f
|
|
||||||
}
|
|
||||||
|
|
||||||
currentCol = (currentCol + 1) % historyCols
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the current spectrogram as a 2D array in chronological order. */
|
|
||||||
fun getSpectrogram(): Array<FloatArray> {
|
|
||||||
val result = Array(historyCols) { i ->
|
|
||||||
val srcIdx = (currentCol + i) % historyCols
|
|
||||||
spectrogram[srcIdx].copyOf()
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the most recent column (current energy across all frequencies). */
|
|
||||||
fun getCurrentColumn(): FloatArray {
|
|
||||||
val prevCol = (currentCol - 1 + historyCols) % historyCols
|
|
||||||
return spectrogram[prevCol].copyOf()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get a column by index from the history (0 = oldest, historyCols-1 = newest). */
|
|
||||||
fun getColumn(index: Int): FloatArray {
|
|
||||||
val clamped = index.coerceIn(0, historyCols - 1)
|
|
||||||
val srcIdx = (currentCol - historyCols + clamped + historyCols) % historyCols
|
|
||||||
return spectrogram[srcIdx].copyOf()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Find the frequency bin with peak energy. Returns -1 if no significant signal. */
|
|
||||||
fun findPeakBin(): Int {
|
|
||||||
val col = getCurrentColumn()
|
|
||||||
var maxBin = -1
|
|
||||||
var maxVal = 0f
|
|
||||||
for (i in col.indices) {
|
|
||||||
if (col[i] > maxVal) {
|
|
||||||
maxVal = col[i]
|
|
||||||
maxBin = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return if (maxVal > 0.3f) maxBin else -1
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get energy at a specific bin over the last N columns in chronological order. */
|
|
||||||
fun getBinEnergy(bin: Int, numCols: Int): FloatArray {
|
|
||||||
val clamped = minOf(numCols, historyCols)
|
|
||||||
val result = FloatArray(clamped)
|
|
||||||
for (i in 0 until clamped) {
|
|
||||||
val colIdx = (currentCol - clamped + i + historyCols) % historyCols
|
|
||||||
result[i] = spectrogram[colIdx][bin]
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the bin index for a frequency in Hz. */
|
|
||||||
fun freqToBin(freqHz: Float): Int {
|
|
||||||
val bin = (freqHz * fftSize / sampleRate).toInt()
|
|
||||||
return (bin - minBin).coerceIn(0, numBins - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the center frequency for a bin. */
|
|
||||||
fun binToFreq(bin: Int): Float {
|
|
||||||
return (minBin + bin).toFloat() * sampleRate / fftSize
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reset() {
|
|
||||||
for (col in spectrogram) col.fill(0f)
|
|
||||||
currentCol = 0
|
|
||||||
samplesBuffered = 0
|
|
||||||
buffer.fill(0f)
|
|
||||||
binEnergy.fill(1f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/*
|
||||||
|
* 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>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanent transcript of everything that has scrolled out of the live
|
||||||
|
* window. Unlike [decodedText] this only ever grows (until [reset]); it is
|
||||||
|
* what the user reads back after a signal has passed.
|
||||||
|
*/
|
||||||
|
val historyText: StateFlow<String>
|
||||||
|
|
||||||
|
/** Detected tone frequency in Hz, or null before a tone is found. */
|
||||||
|
val estimatedPitch: 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?>
|
||||||
|
|
||||||
|
/** 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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.model
|
||||||
|
|
||||||
|
object Constants {
|
||||||
|
const val FREQ_OFFSET_MIN_HZ = -50_000L
|
||||||
|
const val FREQ_OFFSET_MAX_HZ = 50_000L
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.model
|
||||||
|
|
||||||
|
/** One satellite status report (AMSAT site tooltip data) */
|
||||||
|
data class SatReport(
|
||||||
|
val id: String, // 报告 ID(a885153)
|
||||||
|
val statusText: String, // Heard / Telemetry Only / Not Heard ...
|
||||||
|
val call: String, // 呼号
|
||||||
|
val grid: String, // 网格坐标(可为空)
|
||||||
|
val dateUtc: String, // 2026-08-04
|
||||||
|
val timeUtc: String // 2:46-:59 UTC
|
||||||
|
)
|
||||||
|
|
||||||
|
/** State of one 2-hour slot */
|
||||||
|
data class SatSlot(
|
||||||
|
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
|
||||||
|
val count: Int, // 报告数量(0 = 无)
|
||||||
|
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One satellite day (12 two-hour slots) */
|
||||||
|
data class SatDay(
|
||||||
|
val dateLabel: String, // "Aug 4"
|
||||||
|
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One satellite, 5 days of state */
|
||||||
|
data class SatStatus(
|
||||||
|
val name: String, // "AO-123_[FM]"
|
||||||
|
val days: List<SatDay> // 5 天(新→旧)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Overall page parse result */
|
||||||
|
data class SatStatusPage(
|
||||||
|
val fetchedAtUtcMs: Long,
|
||||||
|
val statuses: List<SatStatus>,
|
||||||
|
val reports: Map<String, SatReport> // id → 报告
|
||||||
|
)
|
||||||
@@ -29,8 +29,7 @@ data class PassesSettings(
|
|||||||
val minElevation: Double,
|
val minElevation: Double,
|
||||||
val aosStartMinute: Int = 0,
|
val aosStartMinute: Int = 0,
|
||||||
val aosEndMinute: Int = 23 * 60 + 59,
|
val aosEndMinute: Int = 23 * 60 + 59,
|
||||||
val invertAosTimeWindow: Boolean = false,
|
val invertAosTimeWindow: Boolean = false
|
||||||
val selectedModes: List<String>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RCSettings(
|
data class RCSettings(
|
||||||
@@ -42,6 +41,7 @@ data class RCSettings(
|
|||||||
val frequencyAddress: String,
|
val frequencyAddress: String,
|
||||||
val frequencyPort: String,
|
val frequencyPort: String,
|
||||||
val frequencyFormat: String,
|
val frequencyFormat: String,
|
||||||
|
val frequencyOffsetHz: Long = 0L,
|
||||||
val bluetoothRotatorState: Boolean,
|
val bluetoothRotatorState: Boolean,
|
||||||
val bluetoothRotatorFormat: String,
|
val bluetoothRotatorFormat: String,
|
||||||
val bluetoothRotatorName: String,
|
val bluetoothRotatorName: String,
|
||||||
@@ -62,7 +62,21 @@ data class OtherSettings(
|
|||||||
val shouldSeeWhatsNew: Boolean,
|
val shouldSeeWhatsNew: Boolean,
|
||||||
val sstvMode: String = "Auto",
|
val sstvMode: String = "Auto",
|
||||||
val lowElevation: Double = 15.0,
|
val lowElevation: Double = 15.0,
|
||||||
val highElevation: Double = 45.0
|
val highElevation: Double = 45.0,
|
||||||
|
// UI settings: pages hidden from the bottom nav (Screen simpleName list; empty default = all shown)
|
||||||
|
val hiddenScreens: List<String> = emptyList(),
|
||||||
|
// UI settings: page order (empty = default: Satellites/Passes/Radar/Map/Settings)
|
||||||
|
val screenOrder: List<String> = emptyList(),
|
||||||
|
// UI settings: More-menu order (empty = default: Mutual/Roaming/CW decode)
|
||||||
|
val subMenuOrder: List<String> = emptyList(),
|
||||||
|
// WaveLog logging (4.5.2): server config
|
||||||
|
val wavelogUrl: String = "",
|
||||||
|
val wavelogApiKey: String = "",
|
||||||
|
val wavelogStationId: String = "",
|
||||||
|
val wavelogAutoUpload: Boolean = false,
|
||||||
|
// Upstream radar compass offset (merged from rt-bishop)
|
||||||
|
val radarCompassOffset: Float = 0f,
|
||||||
|
val radarCompassOffsetElev: Float = 0f
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DataSourcesSettings(
|
data class DataSourcesSettings(
|
||||||
|
|||||||
@@ -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
|
if (sunrise == 0.0) sunrise = daynum
|
||||||
|
|
||||||
// Phase 4: fast-forward through the day until sun drops back below threshold
|
// Phase 4: fast-forward through the day until sun drops back below threshold.
|
||||||
daynum = sunrise
|
// 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))
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
guard = 0
|
guard = 0
|
||||||
while (sunPos.elevation > -threshold && guard++ < 500) {
|
while (sunPos.elevation > -threshold && guard++ < 500) {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/* QrzGridClient.kt - QRZ callsign grid scraper (4.5.5, pure JVM in domain).
|
||||||
|
* How it works (verified): GET https://www.qrz.com/db/{callsign} with the user's QRZ login Cookie,
|
||||||
|
* the Detail table on the page holds <td class="dh">Grid Square</td><td class="di">XXX</td>.
|
||||||
|
* Without cookies the Detail is unavailable (confirmed); if the other station has no grid, the row is absent (null).
|
||||||
|
* The Cookie is pasted by the user in settings (EditThisCookie JSON export or a raw cookie string),
|
||||||
|
* never built into the app.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.qrz
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
object QrzGridClient {
|
||||||
|
|
||||||
|
/** Parse the pasted Cookie (both formats): EditThisCookie JSON array or raw "k=v; k=v" string */
|
||||||
|
fun parseCookies(raw: String): String {
|
||||||
|
val text = raw.trim()
|
||||||
|
if (text.isEmpty()) return ""
|
||||||
|
// JSON array format: [{"name":"qz_userid","value":"1266043",...}, ...]
|
||||||
|
if (text.startsWith("[")) {
|
||||||
|
return try {
|
||||||
|
val arr = org.json.JSONArray(text)
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
val name = o.optString("name")
|
||||||
|
val value = o.optString("value")
|
||||||
|
if (name.isNotBlank()) parts.add("$name=$value")
|
||||||
|
}
|
||||||
|
parts.joinToString("; ")
|
||||||
|
} catch (_: Exception) { text }
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detect the callsign logged in with these cookies (fetch db home, extract the account menu callsign). Null on failure */
|
||||||
|
suspend fun fetchOwnCallsign(cookieHeader: String): String? = withContext(Dispatchers.IO) {
|
||||||
|
if (cookieHeader.isBlank()) return@withContext null
|
||||||
|
try {
|
||||||
|
val url = URL("https://www.qrz.com/db/")
|
||||||
|
val conn = url.openConnection()
|
||||||
|
conn.connectTimeout = 10_000
|
||||||
|
conn.readTimeout = 20_000
|
||||||
|
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat-Pro")
|
||||||
|
conn.setRequestProperty("Cookie", cookieHeader)
|
||||||
|
val html = conn.getInputStream().bufferedReader().use { it.readText() }
|
||||||
|
// Logged-in account menu (verified): <li class="leaf last" onclick="return true">BG7NTA <ul class="sub">
|
||||||
|
val pattern = Regex("<li class=\"leaf last\"[^>]*>\\s*([A-Z0-9/]+)\\s*<ul")
|
||||||
|
pattern.find(html)?.groupValues?.get(1)?.trim()
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up a callsign's grid. Null = not set / not found (silent, does not block logging) */
|
||||||
|
suspend fun lookupGrid(callsign: String, cookieHeader: String): String? = withContext(Dispatchers.IO) {
|
||||||
|
if (callsign.isBlank() || cookieHeader.isBlank()) return@withContext null
|
||||||
|
try {
|
||||||
|
val url = URL("https://www.qrz.com/db/${callsign.trim().uppercase()}")
|
||||||
|
val conn = url.openConnection()
|
||||||
|
conn.connectTimeout = 10_000
|
||||||
|
conn.readTimeout = 20_000
|
||||||
|
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat-Pro")
|
||||||
|
conn.setRequestProperty("Cookie", cookieHeader)
|
||||||
|
val html = conn.getInputStream().bufferedReader().use { it.readText() }
|
||||||
|
// Detail table Grid Square row (verified format)
|
||||||
|
val m = Regex("""<td class="dh">Grid Square</td><td class="di">([^<]+)</td>""")
|
||||||
|
.find(html)
|
||||||
|
m?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
|
|
||||||
|
/** AMSAT satellite status data source */
|
||||||
|
interface IAmSatRepository {
|
||||||
|
/** Fetch and parse the AMSAT status page; null on failure */
|
||||||
|
suspend fun fetchStatus(): SatStatusPage?
|
||||||
|
}
|
||||||
+8
@@ -30,10 +30,12 @@ interface IMainContainer {
|
|||||||
val selectionRepo: ISelectionRepo
|
val selectionRepo: ISelectionRepo
|
||||||
val satelliteRepo: ISatelliteRepo
|
val satelliteRepo: ISatelliteRepo
|
||||||
val databaseRepo: IDatabaseRepo
|
val databaseRepo: IDatabaseRepo
|
||||||
|
val amSatRepo: IAmSatRepository
|
||||||
val radioTrackingService: IRadioTrackingService
|
val radioTrackingService: IRadioTrackingService
|
||||||
val mutualPassData: StateFlow<MutualPassData>
|
val mutualPassData: StateFlow<MutualPassData>
|
||||||
fun setMutualPassData(data: MutualPassData)
|
fun setMutualPassData(data: MutualPassData)
|
||||||
fun provideAddToCalendar(): IAddToCalendar
|
fun provideAddToCalendar(): IAddToCalendar
|
||||||
|
fun providePairedBluetoothDevices(): List<Pair<String, String>>
|
||||||
fun provideShowToast(): IShowToast
|
fun provideShowToast(): IShowToast
|
||||||
fun provideBluetoothReporter(): IReporter
|
fun provideBluetoothReporter(): IReporter
|
||||||
fun provideNetworkReporter(): IReporter
|
fun provideNetworkReporter(): IReporter
|
||||||
@@ -41,7 +43,13 @@ interface IMainContainer {
|
|||||||
fun provideTxRadioController(): IRadioController
|
fun provideTxRadioController(): IRadioController
|
||||||
fun provideRxRadioController(): IRadioController
|
fun provideRxRadioController(): IRadioController
|
||||||
fun provideAudioCapture(): IAudioCapture
|
fun provideAudioCapture(): IAudioCapture
|
||||||
|
fun provideCwDecoder(): com.rtbishop.look4sat.core.domain.cw.ICwDecoder
|
||||||
fun provideSaveImage(): ISaveImage
|
fun provideSaveImage(): ISaveImage
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
data class MutualPassData(
|
data class MutualPassData(
|
||||||
|
|||||||
+1
-1
@@ -61,7 +61,7 @@ interface ISatelliteRepo {
|
|||||||
suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos>
|
suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos>
|
||||||
|
|
||||||
/** Get Doppler-shifted radio frequencies for a satellite at the given time. */
|
/** Get Doppler-shifted radio frequencies for a satellite at the given time. */
|
||||||
suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio>
|
suspend fun getRadios(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio>
|
||||||
|
|
||||||
/** Fetch radio transceivers for a satellite by its catalog number. */
|
/** Fetch radio transceivers for a satellite by its catalog number. */
|
||||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||||
|
|||||||
+3
-3
@@ -21,10 +21,10 @@ import com.rtbishop.look4sat.core.domain.model.SatItem
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface ISelectionRepo {
|
interface ISelectionRepo {
|
||||||
fun getCurrentTypes(): List<String>
|
fun getCurrentModes(): List<String>
|
||||||
fun getTypesList(): List<String>
|
fun getModesList(): List<String>
|
||||||
suspend fun getEntriesFlow(): Flow<List<SatItem>>
|
suspend fun getEntriesFlow(): Flow<List<SatItem>>
|
||||||
suspend fun setTypes(types: List<String>)
|
suspend fun setModes(modes: List<String>)
|
||||||
suspend fun setQuery(query: String)
|
suspend fun setQuery(query: String)
|
||||||
suspend fun setSelection(selectAll: Boolean)
|
suspend fun setSelection(selectAll: Boolean)
|
||||||
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
|
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
|
||||||
|
|||||||
+9
-3
@@ -32,9 +32,9 @@ interface ISettingsRepo {
|
|||||||
|
|
||||||
//region # Satellites selection settings
|
//region # Satellites selection settings
|
||||||
val selectedIds: StateFlow<List<Int>>
|
val selectedIds: StateFlow<List<Int>>
|
||||||
val selectedTypes: StateFlow<List<String>>
|
val selectedSatModes: StateFlow<List<String>>
|
||||||
fun setSelectedIds(ids: List<Int>)
|
fun setSelectedIds(ids: List<Int>)
|
||||||
fun setSelectedTypes(types: List<String>)
|
fun setSelectedSatModes(modes: List<String>)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Passes filter settings
|
//region # Passes filter settings
|
||||||
@@ -45,7 +45,8 @@ interface ISettingsRepo {
|
|||||||
//region # Station position settings
|
//region # Station position settings
|
||||||
val stationPosition: StateFlow<GeoPos>
|
val stationPosition: StateFlow<GeoPos>
|
||||||
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
|
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
|
||||||
fun setStationPosition(): Boolean
|
/** GPS fix (suspend): true only with a fix; false on missing permission/timeout/no signal */
|
||||||
|
suspend fun setStationPosition(): Boolean
|
||||||
fun setStationPosition(locator: String): Boolean
|
fun setStationPosition(locator: String): Boolean
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -77,4 +78,9 @@ interface ISettingsRepo {
|
|||||||
val radioControlSettings: StateFlow<RadioControlSettings>
|
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||||
fun updateRadioControlSettings(settings: RadioControlSettings)
|
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
//region # Per-satellite calculator offset settings
|
||||||
|
fun getSatelliteOffset(catnum: Int): String
|
||||||
|
fun setSatelliteOffset(catnum: Int, offset: String)
|
||||||
|
//endregion
|
||||||
}
|
}
|
||||||
@@ -22,4 +22,10 @@ import java.io.InputStream
|
|||||||
interface IRemoteSource {
|
interface IRemoteSource {
|
||||||
suspend fun getFileStream(uri: String): InputStream?
|
suspend fun getFileStream(uri: String): InputStream?
|
||||||
suspend fun getNetworkStream(url: String): InputStream?
|
suspend fun getNetworkStream(url: String): InputStream?
|
||||||
|
|
||||||
|
/** 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?
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,10 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.source
|
package com.rtbishop.look4sat.core.domain.source
|
||||||
|
|
||||||
object Sources {
|
object Sources {
|
||||||
|
// 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"
|
||||||
|
|
||||||
val satelliteDataUrls = mapOf(
|
val satelliteDataUrls = mapOf(
|
||||||
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||||
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
||||||
@@ -48,6 +52,15 @@ object Sources {
|
|||||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||||
"Other" to "" // key for sats filter
|
"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(
|
val transceiversDataUrls = mapOf(
|
||||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,4 +19,7 @@ package com.rtbishop.look4sat.core.domain.usecase
|
|||||||
|
|
||||||
interface IShowToast {
|
interface IShowToast {
|
||||||
operator fun invoke(message: String)
|
operator fun invoke(message: String)
|
||||||
|
|
||||||
|
/** Show by resource ID (four-language text) */
|
||||||
|
operator fun invoke(resId: Int)
|
||||||
}
|
}
|
||||||
@@ -70,8 +70,13 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
|||||||
val min = timestamp.substring(14, 16).toInt() * 60000
|
val min = timestamp.substring(14, 16).toInt() * 60000
|
||||||
val sec = timestamp.substring(17, 19).toInt() * 1000
|
val sec = timestamp.substring(17, 19).toInt() * 1000
|
||||||
val ms = timestamp.substring(20, 26).toInt() / 1000.0
|
val ms = timestamp.substring(20, 26).toInt() / 1000.0
|
||||||
val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1)
|
// Add the day fraction numerically. Building it by string surgery breaks
|
||||||
val epoch = "${year.substring(2)}$day$frac".toDouble()
|
// below 1e-3, where Double.toString() switches to scientific notation and
|
||||||
|
// dropping the first character removes a significant digit instead of the
|
||||||
|
// leading zero: 00:01:00 yielded "25001.944444444444445E-4" -> 2.50019,
|
||||||
|
// a silently valid epoch about 26 years off.
|
||||||
|
val dayFraction = (hour + min + sec + ms) / 86400000.0
|
||||||
|
val epoch = "${year.substring(2)}$day".toDouble() + dayFraction
|
||||||
OrbitalData(
|
OrbitalData(
|
||||||
name = name,
|
name = name,
|
||||||
epoch = epoch,
|
epoch = epoch,
|
||||||
@@ -91,7 +96,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
|||||||
val line1 = tle[1]
|
val line1 = tle[1]
|
||||||
val line2 = tle[2]
|
val line2 = tle[2]
|
||||||
OrbitalData(
|
OrbitalData(
|
||||||
name = tle[0].trim(),
|
name = tle[0].trim().removePrefix("0 "),
|
||||||
epoch = line1.substring(18, 32).toDouble(),
|
epoch = line1.substring(18, 32).toDouble(),
|
||||||
meanmo = line2.substring(52, 63).toDouble(),
|
meanmo = line2.substring(52, 63).toDouble(),
|
||||||
eccn = line2.substring(26, 33).toDouble() / 1e7,
|
eccn = line2.substring(26, 33).toDouble() / 1e7,
|
||||||
|
|||||||
+47
-2
@@ -1,4 +1,4 @@
|
|||||||
/*
|
/*
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
*
|
*
|
||||||
@@ -11,6 +11,7 @@ package com.rtbishop.look4sat.core.domain.utility
|
|||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
||||||
@@ -44,6 +45,9 @@ object DopplerFrequencyCalculator {
|
|||||||
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
|
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
|
||||||
* with an offset applied to the downlink (in Hz).
|
* with an offset applied to the downlink (in Hz).
|
||||||
* Returns null if the transponder is not a linear passband type.
|
* Returns null if the transponder is not a linear passband type.
|
||||||
|
*
|
||||||
|
* The user-entered downlink frequency already includes the offset, so subtract
|
||||||
|
* it before mapping the downlink passband position back to the uplink.
|
||||||
*/
|
*/
|
||||||
fun computeUplinkFromDownlinkWithOffset(
|
fun computeUplinkFromDownlinkWithOffset(
|
||||||
downlinkHz: Long,
|
downlinkHz: Long,
|
||||||
@@ -52,7 +56,7 @@ object DopplerFrequencyCalculator {
|
|||||||
offsetHz: Long
|
offsetHz: Long
|
||||||
): Long? {
|
): Long? {
|
||||||
if (!isLinearTransponder(transponder)) return null
|
if (!isLinearTransponder(transponder)) return null
|
||||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz + offsetHz, transponder) ?: return null
|
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
|
||||||
return orbitalPos.getUplinkFreq(baseUplink)
|
return orbitalPos.getUplinkFreq(baseUplink)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,4 +99,45 @@ object DopplerFrequencyCalculator {
|
|||||||
return upLow != null && upHigh != null && downLow != null && downHigh != null
|
return upLow != null && upHigh != null && downLow != null && downHigh != null
|
||||||
&& upLow != upHigh && downLow != downHigh
|
&& upLow != upHigh && downLow != downHigh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for the radio entry that should drive the standalone Calculator page.
|
||||||
|
*
|
||||||
|
* A frequency range alone is not enough: some non-user-facing or drifting data entries
|
||||||
|
* can also have low/high frequencies. The calculator is meant for the named linear
|
||||||
|
* transponder entry, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
|
||||||
|
*/
|
||||||
|
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
|
||||||
|
if (!isLinearTransponder(transponder)) return false
|
||||||
|
|
||||||
|
val info = transponder.info.lowercase(Locale.ENGLISH)
|
||||||
|
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
||||||
|
.joinToString(separator = " ")
|
||||||
|
.lowercase(Locale.ENGLISH)
|
||||||
|
val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
|
||||||
|
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
|
||||||
|
info.contains("xponder") || info.contains("xpdr")
|
||||||
|
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
|
||||||
|
|
||||||
|
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) ||
|
||||||
|
(hasLinearName && hasLinearMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes duplicate transponder entries that describe the same physical
|
||||||
|
* transponder with different mode labels (e.g. SatNOGS lists AO-7's Mode A
|
||||||
|
* as both "Lin SSB" and "Lin CW", and JO-97's U/V transponder as both
|
||||||
|
* "CW Transponder" and "SSB Transponder").
|
||||||
|
*
|
||||||
|
* Entries sharing the same uplink/downlink frequency range are considered
|
||||||
|
* the same transponder. The non-CW entry is preferred because its invert
|
||||||
|
* flag is more reliable (e.g. JO-97's CW entry wrongly has invert=false).
|
||||||
|
*/
|
||||||
|
fun deduplicateTransponders(radios: List<SatRadio>): List<SatRadio> {
|
||||||
|
return radios.groupBy { radio ->
|
||||||
|
listOf(radio.uplinkLow, radio.uplinkHigh, radio.downlinkLow, radio.downlinkHigh)
|
||||||
|
}.values.map { group ->
|
||||||
|
group.firstOrNull { it.downlinkMode?.equals("CW", ignoreCase = true) != true } ?: group.first()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,21 @@ fun Double.round(decimals: Int): Double {
|
|||||||
return kotlin.math.round(this * multiplier) / multiplier
|
return kotlin.math.round(this * multiplier) / multiplier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun String.aprsPasscode(): Int {
|
||||||
|
val callsign = this.trim().uppercase().substringBefore('-') // commonly strip SSID
|
||||||
|
var hash = 0x73E2
|
||||||
|
var i = 0
|
||||||
|
while (i < callsign.length) {
|
||||||
|
hash = hash xor (callsign[i].code shl 8)
|
||||||
|
i++
|
||||||
|
if (i < callsign.length) {
|
||||||
|
hash = hash xor callsign[i].code
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hash and 0x7FFF
|
||||||
|
}
|
||||||
|
|
||||||
//fun String.getHash(type: String = "SHA-256"): String {
|
//fun String.getHash(type: String = "SHA-256"): String {
|
||||||
// val hexChars = "0123456789ABCDEF"
|
// val hexChars = "0123456789ABCDEF"
|
||||||
// val bytes = MessageDigest.getInstance(type).digest(this.toByteArray())
|
// val bytes = MessageDigest.getInstance(type).digest(this.toByteArray())
|
||||||
|
|||||||
+11
-2
@@ -81,9 +81,18 @@ fun clipLat(latitude: Double): Double {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun clipLon(longitude: Double): Double {
|
fun clipLon(longitude: Double): Double {
|
||||||
|
// Reduce with a modulo so a single pass always terminates. The previous
|
||||||
|
// while-loop never returned for extreme inputs: Infinity stays Infinity
|
||||||
|
// after subtracting 360, so the loop ran forever, and a ~1e12 degree value
|
||||||
|
// took billions of iterations. NaN still passes through to clip() and is
|
||||||
|
// returned as NaN, which is the same behaviour as before.
|
||||||
|
if (!longitude.isFinite()) return longitude
|
||||||
var result = longitude
|
var result = longitude
|
||||||
while (result < MIN_LONGITUDE) result += 360.0
|
result = ((result + 180.0) % 360.0 + 360.0) % 360.0 - 180.0
|
||||||
while (result > MAX_LONGITUDE) result -= 360.0
|
// The closed interval [-180, 180] keeps +180 for a value that lands exactly
|
||||||
|
// on the positive boundary (old loop: 180 stays 180, only > 180 wraps);
|
||||||
|
// -180 is reserved for values that actually came from the west side.
|
||||||
|
if (result == -180.0 && longitude > 0.0) result = 180.0
|
||||||
return clip(result, MIN_LONGITUDE, MAX_LONGITUDE)
|
return clip(result, MIN_LONGITUDE, MAX_LONGITUDE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+116
-17
@@ -19,37 +19,136 @@ package com.rtbishop.look4sat.core.domain.utility
|
|||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a Maidenhead locator (QTH grid square) to a GeoPos.
|
||||||
|
* Supports 6-char (3 pair), 8-char (4 pair) and 10-char (5 pair) locators.
|
||||||
|
* The returned position is the center of the finest cell encoded by the locator:
|
||||||
|
* - 6 char: 5' lon x 2.5' lat cell center
|
||||||
|
* - 8 char: 30" lon x 15" lat cell center
|
||||||
|
* - 10 char: 1.25" lon x 0.625" lat cell center
|
||||||
|
*/
|
||||||
fun qthToPosition(locator: String): GeoPos? {
|
fun qthToPosition(locator: String): GeoPos? {
|
||||||
val trimmedQth = locator.take(6)
|
val trimmedQth = locator.trim().uppercase()
|
||||||
if (!isValidLocator(trimmedQth)) return null
|
if (!isValidLocator(trimmedQth)) return null
|
||||||
val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20
|
val lonFirst = (trimmedQth[0].code - 65) * 20
|
||||||
val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10
|
val latFirst = (trimmedQth[1].code - 65) * 10
|
||||||
val lonSecond = trimmedQth[2].toString().toInt() * 2
|
val lonSecond = trimmedQth[2].toString().toInt() * 2
|
||||||
val latSecond = trimmedQth[3].toString().toInt()
|
val latSecond = trimmedQth[3].toString().toInt()
|
||||||
val lonThird = (((trimmedQth[4].lowercaseChar().code - 97) / 12.0) + (1.0 / 24.0)) - 180
|
val lonThird = (trimmedQth[4].lowercaseChar().code - 97) / 12.0
|
||||||
val latThird = (((trimmedQth[5].lowercaseChar().code - 97) / 24.0) + (1.0 / 48.0)) - 90
|
val latThird = (trimmedQth[5].lowercaseChar().code - 97) / 24.0
|
||||||
val longitude = (lonFirst + lonSecond + lonThird).round(4)
|
var longitude = lonFirst + lonSecond + lonThird - 180
|
||||||
val latitude = (latFirst + latSecond + latThird).round(4)
|
var latitude = latFirst + latSecond + latThird - 90
|
||||||
return GeoPos(latitude, longitude)
|
// 8-char extension: 4th pair, digits, 30" lon x 15" lat cells
|
||||||
|
if (trimmedQth.length >= 8) {
|
||||||
|
longitude += trimmedQth[6].toString().toInt() / 120.0
|
||||||
|
latitude += trimmedQth[7].toString().toInt() / 240.0
|
||||||
|
}
|
||||||
|
// 10-char extension: 5th pair, letters, 1.25" lon x 0.625" lat cells
|
||||||
|
if (trimmedQth.length >= 10) {
|
||||||
|
longitude += (trimmedQth[8].lowercaseChar().code - 97) / 2880.0
|
||||||
|
latitude += (trimmedQth[9].lowercaseChar().code - 97) / 5760.0
|
||||||
|
}
|
||||||
|
// Offset to the center of the finest encoded cell
|
||||||
|
when (trimmedQth.length) {
|
||||||
|
8 -> {
|
||||||
|
longitude += 1.0 / 240.0
|
||||||
|
latitude += 1.0 / 480.0
|
||||||
|
}
|
||||||
|
10 -> {
|
||||||
|
longitude += 1.0 / 5760.0
|
||||||
|
latitude += 1.0 / 11520.0
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
longitude += 1.0 / 24.0
|
||||||
|
latitude += 1.0 / 48.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return GeoPos(latitude.round(6), longitude.round(6))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun positionToQth(latitude: Double, longitude: Double): String? {
|
/**
|
||||||
|
* Converts a GeoPos to a Maidenhead locator (QTH grid square).
|
||||||
|
* Default precision is 8 characters (4 pairs) giving 30" lon x 15" lat resolution,
|
||||||
|
* matching common 8-char grid square tools. Pass precision = 6 for the classic
|
||||||
|
* 5' x 2.5' resolution, or precision = 10 for the finest 1.25" x 0.625" resolution.
|
||||||
|
*/
|
||||||
|
fun positionToQth(latitude: Double, longitude: Double, precision: Int = 8): String? {
|
||||||
if (!isValidPosition(latitude, longitude)) return null
|
if (!isValidPosition(latitude, longitude)) return null
|
||||||
val newLongitude = if (longitude > 180.0) longitude else longitude + 180
|
// The grid spans [0, 360) lon and [0, 180) lat once shifted. Clamping the
|
||||||
val newLatitude = latitude + 90
|
// field index alone (coerceIn below) is not enough: at exactly +90 lat or
|
||||||
val lonFirst = (65 + (newLongitude / 20)).toInt().toChar()
|
// +180 lon the field saturates to R while the square/subsquare terms come
|
||||||
val latFirst = (65 + (newLatitude / 10)).toInt().toChar()
|
// from a modulo that has already wrapped to 0, so the encoded locator
|
||||||
val lonSecond = ((newLongitude / 2) % 10).toInt()
|
// decoded back 10 degrees of latitude / 20 degrees of longitude away.
|
||||||
|
// Nudge the upper bound into the last cell instead.
|
||||||
|
val newLongitude = (longitude + 180).coerceIn(0.0, 360.0 - 1e-9)
|
||||||
|
val newLatitude = (latitude + 90).coerceIn(0.0, 180.0 - 1e-9)
|
||||||
|
val lonFirst = (65 + (newLongitude / 20).toInt().coerceIn(0, 17)).toChar()
|
||||||
|
val latFirst = (65 + (newLatitude / 10).toInt().coerceIn(0, 17)).toChar()
|
||||||
|
val lonSecond = ((newLongitude % 20) / 2).toInt()
|
||||||
val latSecond = (newLatitude % 10).toInt()
|
val latSecond = (newLatitude % 10).toInt()
|
||||||
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar()
|
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar()
|
||||||
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar()
|
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar()
|
||||||
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
|
val qth = "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
|
||||||
|
if (precision < 8) return qth
|
||||||
|
val lonFourth = ((newLongitude % (1.0 / 12.0)) * 120).toInt()
|
||||||
|
val latFourth = ((newLatitude % (1.0 / 24.0)) * 240).toInt()
|
||||||
|
val qth8 = "$qth$lonFourth$latFourth"
|
||||||
|
if (precision < 10) return qth8
|
||||||
|
val lonFifth = (65 + (newLongitude % (1.0 / 120.0)) * 2880).toInt().toChar().lowercaseChar()
|
||||||
|
val latFifth = (65 + (newLatitude % (1.0 / 240.0)) * 5760).toInt().toChar().lowercaseChar()
|
||||||
|
return "$qth8$lonFifth$latFifth"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isValidPosition(lat: Double, lon: Double): Boolean {
|
private fun isValidPosition(lat: Double, lon: Double): Boolean {
|
||||||
return (lat >= -90.0 && lat <= 90.0) && (lon >= -180.0 && lon <= 360.0)
|
return lat in -90.0..90.0 && lon in -180.0..180.0
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isValidLocator(locator: String): Boolean {
|
private fun isValidLocator(locator: String): Boolean {
|
||||||
return locator.matches("[a-xA-X][a-xA-X]\\d\\d[a-xA-X][a-xA-X]".toRegex())
|
// Maidenhead fields are A-R (18 x 18). Subsquare letters are A-X (24).
|
||||||
|
// Accepting S-X in the first pair decodes to latitude >90 / longitude >180.
|
||||||
|
return locator.matches("[a-rA-R]{2}\\d{2}[a-xA-X]{2}(?:\\d{2}(?:[a-xA-X]{2})?)?".toRegex())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the 4-char field+square part of a locator, e.g. "OL42ih45" -> "OL42".
|
||||||
|
* A 4-char input is returned as-is when valid.
|
||||||
|
*/
|
||||||
|
fun qthToSquare(locator: String): String {
|
||||||
|
val upper = locator.trim().uppercase()
|
||||||
|
return when {
|
||||||
|
upper.length >= 4 && isValidLocator(upper) -> upper.take(4)
|
||||||
|
upper.length == 4 && upper.matches("[a-xA-X]{2}\\d{2}".toRegex()) -> upper
|
||||||
|
else -> "----"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the 3x3 grid of 4-char squares surrounding [square] (e.g. "OL42").
|
||||||
|
* Row 0 = north (lat +1), col 0 = west (lon -1). Handles field/square carry
|
||||||
|
* at boundaries (e.g. "AA00" wraps to "RR99" at the south-west corner).
|
||||||
|
* Mirrors the neighbor logic decompiled from the QTH Locator app.
|
||||||
|
*/
|
||||||
|
fun qthNeighbors(square: String): List<String> {
|
||||||
|
if (square.length != 4) return emptyList()
|
||||||
|
val lonField = (square[0].uppercaseChar().code - 65).coerceIn(0, 17)
|
||||||
|
val latField = (square[1].uppercaseChar().code - 65).coerceIn(0, 17)
|
||||||
|
val lonSquare = square[2].digitToCharOrNull() ?: return emptyList()
|
||||||
|
val latSquare = square[3].digitToCharOrNull() ?: return emptyList()
|
||||||
|
val result = mutableListOf<String>()
|
||||||
|
for (dLat in 1 downTo -1) { // north -> south
|
||||||
|
for (dLon in -1..1) { // west -> east
|
||||||
|
var lf = lonField
|
||||||
|
var tf = latField
|
||||||
|
var ls = lonSquare + dLon
|
||||||
|
var ts = latSquare + dLat
|
||||||
|
if (ls < 0) { lf -= 1; ls = 9 } else if (ls > 9) { lf += 1; ls = 0 }
|
||||||
|
if (ts < 0) { tf -= 1; ts = 9 } else if (ts > 9) { tf += 1; ts = 0 }
|
||||||
|
lf = (lf + 18) % 18
|
||||||
|
tf = (tf + 18) % 18
|
||||||
|
result += "${('A' + lf).toChar()}${('A' + tf).toChar()}$ls$ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Char.digitToCharOrNull(): Int? = digitToIntOrNull()
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
/* ILotwSatellitesRepo.kt - LoTW satellite list refresh interface (domain layer, 4.5.5).
|
||||||
|
* Impl: LotwSatellitesRepo in core/data (downloads ARRL config.tq6).
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.wavelog
|
||||||
|
|
||||||
|
interface ILotwSatellitesRepo {
|
||||||
|
sealed class RefreshResult {
|
||||||
|
data class Ok(val count: Int) : RefreshResult()
|
||||||
|
data class Error(val message: String) : RefreshResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restore()
|
||||||
|
|
||||||
|
suspend fun refresh(): RefreshResult
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/* LotwSatellites.kt - LoTW-supported satellite name list (112, statically embedded).
|
||||||
|
* Source: https://lotw.arrl.org/lotw/config.tq6 (gzip XML, official ARRL!)
|
||||||
|
* WaveLog updates its satellite table's lotw field from the same source (Update_model.php lotw_sats()).
|
||||||
|
* At runtime LotwSatellitesRepo can refresh it manually (settings WaveLog section button).
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.wavelog
|
||||||
|
|
||||||
|
object LotwSatellites {
|
||||||
|
private val staticNames: Set<String> = setOf("AISAT1", "AO-10", "AO-109", "AO-123", "AO-13", "AO-16", "AO-21", "AO-27", "AO-3", "AO-4", "AO-40", "AO-51", "AO-6", "AO-7", "AO-73", "AO-8", "AO-85", "AO-91", "AO-92", "ARISS", "Arsene", "BO-102", "BY70-1", "CAS-2T", "CAS-3H", "CAS-4A", "CAS-4B", "DO-64", "EO-79", "EO-88", "FO-118", "FO-12", "FO-20", "FO-29", "FO-99", "FS-3", "HO-107", "HO-113", "HO-119", "HO-68", "INSPR7", "IO-117", "IO-86", "JO-97", "KEDR", "LEDSAT", "LO-19", "LO-78", "LO-87", "LO-90", "MAYA-3", "MAYA-4", "MIREX", "MO-112", "MO-122", "NO-103", "NO-104", "NO-44", "NO-83", "NO-84", "PO-101", "QO-100", "RS-1", "RS-10", "RS-11", "RS-12", "RS-13", "RS-15", "RS-2", "RS-44", "RS-5", "RS-6", "RS-7", "RS-8", "SAREX", "SO-121", "SO-124", "SO-125", "SO-35", "SO-41", "SO-50", "SO-67", "SONATE", "TAURUS", "TEVEL1", "TEVEL2", "TEVEL3", "TEVEL4", "TEVEL5", "TEVEL6", "TEVEL7", "TEVEL8", "TO-108", "UKUBE1", "UO-14", "UVSQ", "VO-52", "XW-2A", "XW-2B", "XW-2C", "XW-2D", "XW-2E", "XW-2F", "TEV2-1", "TEV2-2", "TEV2-3", "TEV2-4", "TEV2-5", "TEV2-6", "TEV2-7", "TEV2-8", "TEV2-9")
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var dynamicNames: Set<String> = emptySet()
|
||||||
|
|
||||||
|
/** Effective satellite names: static embedded set union runtime updates */
|
||||||
|
val names: Set<String>
|
||||||
|
get() = if (dynamicNames.isEmpty()) staticNames else staticNames + dynamicNames
|
||||||
|
|
||||||
|
/** Runtime update (settings button -> called after LotwSatellitesRepo.refresh()) */
|
||||||
|
fun updateNames(newNames: Set<String>) {
|
||||||
|
if (newNames.isNotEmpty()) dynamicNames = newNames
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
/*
|
||||||
|
* WaveLogApi.kt - WaveLog log server API client (4.5.2 override fix 2).
|
||||||
|
*
|
||||||
|
* Supports both v1 and v2 (user's server only has v1 in practice; v2 returns 404):
|
||||||
|
* v2: POST {base}/api/v2/qso (Authorization: Bearer + JSON fields)
|
||||||
|
* v1: POST {base}/index.php/api/qso (key in JSON body + ADIF string)
|
||||||
|
* Strategy: try v2 first, auto-fallback to v1 on 404.
|
||||||
|
* Test connection: v2 GET api/v2/token; on 404 use v1 POST api/get_contacts_adif.
|
||||||
|
* Station grid: only v2 has GET api/v2/station/{id}; v1 lacks it -> fall back to user QTH.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.wavelog
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.OutputStreamWriter
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** Station info (GET /api/v2/station/{id} result) */
|
||||||
|
data class WavelogStation(
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
val callsign: String,
|
||||||
|
val gridsquare: String
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class WavelogResult {
|
||||||
|
data class Success(val message: String) : WavelogResult()
|
||||||
|
data class Failure(val message: String) : WavelogResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
object WaveLogApi {
|
||||||
|
|
||||||
|
private const val TIMEOUT_MS = 15000
|
||||||
|
|
||||||
|
/** Normalize server URL: strip trailing slash/index.php; prepend https:// when missing */
|
||||||
|
fun normalizeUrl(raw: String): String {
|
||||||
|
var u = raw.trim().trimEnd('/')
|
||||||
|
if (u.isBlank()) return ""
|
||||||
|
if (!u.startsWith("http://") && !u.startsWith("https://")) u = "https://$u"
|
||||||
|
if (u.endsWith("/index.php")) u = u.removeSuffix("/index.php")
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test connection: v2 GET api/v2/token; on 404 use v1 POST api/get_contacts_adif */
|
||||||
|
suspend fun testToken(url: String, apiKey: String, stationId: String = ""): WavelogResult = withContext(Dispatchers.IO) {
|
||||||
|
val base = normalizeUrl(url)
|
||||||
|
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
||||||
|
|
||||||
|
// v2: GET /index.php/api/v2/token
|
||||||
|
val v2 = httpRequest("$base/index.php/api/v2/token", "GET", apiKey, null)
|
||||||
|
if (v2.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v2)")
|
||||||
|
|
||||||
|
// v1: POST /index.php/api/get_contacts_adif (key in body)
|
||||||
|
if (stationId.isNotBlank()) {
|
||||||
|
val body = JSONObject().apply {
|
||||||
|
put("key", apiKey)
|
||||||
|
put("station_id", stationId)
|
||||||
|
put("fetchfromid", 0)
|
||||||
|
}.toString()
|
||||||
|
val v1 = httpRequest("$base/index.php/api/get_contacts_adif", "POST", apiKey, body)
|
||||||
|
if (v1.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
|
||||||
|
if (v1.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
|
||||||
|
}
|
||||||
|
// v1 attempt without index.php
|
||||||
|
val body = JSONObject().apply {
|
||||||
|
put("key", apiKey)
|
||||||
|
put("station_id", stationId)
|
||||||
|
put("fetchfromid", 0)
|
||||||
|
}.toString()
|
||||||
|
val v1b = httpRequest("$base/api/get_contacts_adif", "POST", apiKey, body)
|
||||||
|
if (v1b.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
|
||||||
|
if (v1b.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
|
||||||
|
|
||||||
|
WavelogResult.Failure("连接失败: v2 HTTP ${v2.first}, v1 HTTP ${v1b.first} — 请确认服务器地址/密钥正确")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Station info: v2 only; v1 lacks the endpoint (grid check falls back to user QTH) */
|
||||||
|
suspend fun getStation(url: String, apiKey: String, stationId: String): WavelogResult = withContext(Dispatchers.IO) {
|
||||||
|
val base = normalizeUrl(url)
|
||||||
|
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
||||||
|
val (code, resp) = httpRequest("$base/index.php/api/v2/station/$stationId", "GET", apiKey, null)
|
||||||
|
if (code in 200..299) {
|
||||||
|
return@withContext try {
|
||||||
|
val obj = JSONObject(resp)
|
||||||
|
val data = obj.optJSONObject("data") ?: obj
|
||||||
|
val station = WavelogStation(
|
||||||
|
id = data.optInt("id"),
|
||||||
|
name = data.optString("name"),
|
||||||
|
callsign = data.optString("callsign"),
|
||||||
|
gridsquare = data.optString("gridsquare")
|
||||||
|
)
|
||||||
|
WavelogResult.Success(JSONObject().apply {
|
||||||
|
put("id", station.id); put("name", station.name)
|
||||||
|
put("callsign", station.callsign); put("gridsquare", station.gridsquare)
|
||||||
|
}.toString())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
WavelogResult.Failure("解析失败: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// v1 has no station endpoint -> return empty Success (caller falls back to user QTH)
|
||||||
|
WavelogResult.Success("")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADIF band code from a frequency in Hz. "SAT" is NOT a legal ADIF band
|
||||||
|
* value (the Band enumeration is 160M/80M/.../2M/70CM/23CM...); a logger
|
||||||
|
* that fails to parse an illegal band falls back to a default such as
|
||||||
|
* 160m. Satellite QSOs must carry the real band of the TX frequency.
|
||||||
|
*/
|
||||||
|
fun bandFromHz(freqHz: Long): String = when {
|
||||||
|
freqHz >= 1240_000_000 -> "23CM"
|
||||||
|
freqHz >= 902_000_000 -> "33CM"
|
||||||
|
freqHz >= 420_000_000 -> "70CM"
|
||||||
|
freqHz >= 222_000_000 -> "1.25M"
|
||||||
|
freqHz >= 144_000_000 -> "2M"
|
||||||
|
freqHz >= 50_000_000 -> "6M"
|
||||||
|
freqHz >= 28_000_000 -> "10M"
|
||||||
|
freqHz >= 24_890_000 -> "12M"
|
||||||
|
freqHz >= 21_000_000 -> "15M"
|
||||||
|
freqHz >= 18_068_000 -> "17M"
|
||||||
|
freqHz >= 14_000_000 -> "20M"
|
||||||
|
freqHz >= 10_000_000 -> "30M"
|
||||||
|
freqHz >= 7_000_000 -> "40M"
|
||||||
|
freqHz >= 5_102_000 -> "60M"
|
||||||
|
freqHz >= 3_500_000 -> "80M"
|
||||||
|
freqHz >= 1_800_000 -> "160M"
|
||||||
|
else -> "160M"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Band class letter for satellite mode derivation: VHF=V, UHF=U, SHF=S. */
|
||||||
|
private fun bandLetter(freqHz: Long): String = when {
|
||||||
|
freqHz >= 1_240_000_000 -> "S"
|
||||||
|
freqHz >= 420_000_000 -> "U"
|
||||||
|
freqHz >= 144_000_000 -> "V"
|
||||||
|
else -> "V"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADIF SAT_MODE (free text, satellite convention): "V/U" = VHF up /
|
||||||
|
* UHF down, "U/V", "V/S", "U/S"... Derived from the actual TX/RX bands.
|
||||||
|
*/
|
||||||
|
fun satModeFrom(txFreqHz: Long, rxFreqHz: Long): String {
|
||||||
|
if (rxFreqHz <= 0) return ""
|
||||||
|
val up = bandLetter(txFreqHz)
|
||||||
|
val down = bandLetter(rxFreqHz)
|
||||||
|
return if (up == down) "" else "$up/$down"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LoTW-recognized satellite name: main name before parentheses, uppercased (ISS special case) */
|
||||||
|
fun normalizeSatName(raw: String): String {
|
||||||
|
val main = raw.substringBefore('(').trim()
|
||||||
|
.ifBlank { raw.trim() }
|
||||||
|
.uppercase(Locale.ENGLISH)
|
||||||
|
// Matching logic (mirrors WaveLog satellite table name/displayname matching + LoTW list):
|
||||||
|
// 1. Already in LoTW list (common TLE name == common name) -> return as-is
|
||||||
|
// 2. Not present -> check Celestrak alias map (SAUDISAT 1C -> SO-50 etc.); mapped name must be in LoTW list
|
||||||
|
// 3. Still unmatched -> return as-is (uploads are not blocked; QSO is still saved)
|
||||||
|
val commonName = mapOf(
|
||||||
|
"ZARYA" to "ARISS",
|
||||||
|
"ARISS" to "ARISS",
|
||||||
|
"FUNCUBE-1" to "AO-73",
|
||||||
|
"DIWATA-2B" to "PO-101",
|
||||||
|
"SAUDISAT-1C" to "SO-50",
|
||||||
|
"SAUDISAT 1C" to "SO-50",
|
||||||
|
"DIWATA-2A" to "PO-101"
|
||||||
|
)
|
||||||
|
val candidate = commonName[main] ?: main
|
||||||
|
return if (candidate in LotwSatellites.names) candidate else main
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create QSO: v2 first, fall back to v1 (ADIF) on 404 */
|
||||||
|
suspend fun postQso(
|
||||||
|
url: String,
|
||||||
|
apiKey: String,
|
||||||
|
stationProfileId: String,
|
||||||
|
qso: WavelogQso,
|
||||||
|
gridsquare: String
|
||||||
|
): WavelogResult = withContext(Dispatchers.IO) {
|
||||||
|
val base = normalizeUrl(url)
|
||||||
|
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
||||||
|
|
||||||
|
val satName = normalizeSatName(qso.satName)
|
||||||
|
|
||||||
|
// v2: POST /index.php/api/v2/qso (JSON fields)
|
||||||
|
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
|
||||||
|
val v2Body = JSONObject().apply {
|
||||||
|
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
|
||||||
|
put("call", qso.call)
|
||||||
|
put("band", bandFromHz(qso.freqTxHz))
|
||||||
|
put("mode", qso.mode)
|
||||||
|
put("qso_date", utcDate(qso.timeUtcMs))
|
||||||
|
put("time_on", utcTime(qso.timeUtcMs))
|
||||||
|
put("freq", String.format(Locale.ENGLISH, "%.6fM", qso.freqTxHz / 1_000_000.0))
|
||||||
|
put("freq_rx", String.format(Locale.ENGLISH, "%.6fM", qso.freqRxHz / 1_000_000.0))
|
||||||
|
put("gridsquare", gridsquare)
|
||||||
|
put("rst_sent", "59")
|
||||||
|
put("rst_rcvd", "59")
|
||||||
|
put("sat_name", satName)
|
||||||
|
if (satMode.isNotBlank()) put("sat_mode", satMode)
|
||||||
|
}
|
||||||
|
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
|
||||||
|
if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)")
|
||||||
|
if (code == 409) return@withContext WavelogResult.Success("重复(已存在)")
|
||||||
|
|
||||||
|
// v1: POST /index.php/api/qso (key in body + ADIF)
|
||||||
|
val v1Body = JSONObject().apply {
|
||||||
|
put("key", apiKey)
|
||||||
|
put("station_profile_id", stationProfileId)
|
||||||
|
put("type", "adif")
|
||||||
|
put("string", toAdif(qso, gridsquare, satName))
|
||||||
|
}
|
||||||
|
val (code1, resp1) = httpRequest("$base/index.php/api/qso", "POST", apiKey, v1Body.toString())
|
||||||
|
if (code1 in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
|
||||||
|
|
||||||
|
// v1 without index.php
|
||||||
|
val (code1b, resp1b) = httpRequest("$base/api/qso", "POST", apiKey, v1Body.toString())
|
||||||
|
if (code1b in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
|
||||||
|
|
||||||
|
WavelogResult.Failure("上传失败: v2 HTTP $code, v1 HTTP $code1 — ${shortError(resp1.ifBlank { resp1b })}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** v1 ADIF string (freq in MHz, length = UTF-8 byte count, sat_name normalized) */
|
||||||
|
internal fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String {
|
||||||
|
fun field(name: String, value: String): String {
|
||||||
|
val bytes = value.toByteArray(Charsets.UTF_8).size
|
||||||
|
return "<$name:$bytes>$value"
|
||||||
|
}
|
||||||
|
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
|
||||||
|
return buildString {
|
||||||
|
append(field("call", qso.call))
|
||||||
|
append(field("band", bandFromHz(qso.freqTxHz)))
|
||||||
|
append(field("mode", qso.mode))
|
||||||
|
append(field("freq", String.format(Locale.ENGLISH, "%.6f", qso.freqTxHz / 1_000_000.0)))
|
||||||
|
if (qso.freqRxHz > 0) {
|
||||||
|
append(field("freq_rx", String.format(Locale.ENGLISH, "%.6f", qso.freqRxHz / 1_000_000.0)))
|
||||||
|
}
|
||||||
|
append(field("qso_date", utcDateCompact(qso.timeUtcMs)))
|
||||||
|
append(field("time_on", utcTimeCompact(qso.timeUtcMs)))
|
||||||
|
append(field("rst_sent", "59"))
|
||||||
|
append(field("rst_rcvd", "59"))
|
||||||
|
// Send the grid at full precision. Truncating to 4 characters threw
|
||||||
|
// away the 6-character locator the QRZ lookup provides, coarsening the
|
||||||
|
// stored position from ~4.6 km to ~100 km and making a QSO logged via
|
||||||
|
// v1 disagree with the same QSO logged via v2 (which sends it whole).
|
||||||
|
if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare))
|
||||||
|
if (satName.isNotBlank()) {
|
||||||
|
append(field("sat_name", satName))
|
||||||
|
if (satMode.isNotBlank()) append(field("sat_mode", satMode))
|
||||||
|
append(field("prop_mode", "SAT"))
|
||||||
|
}
|
||||||
|
append("<eor>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generic HTTP request (returns code + body) */
|
||||||
|
private fun httpRequest(url: String, method: String, apiKey: String, jsonBody: String?): Pair<Int, String> {
|
||||||
|
return try {
|
||||||
|
val conn = URL(url).openConnection() as HttpURLConnection
|
||||||
|
conn.requestMethod = method
|
||||||
|
conn.connectTimeout = TIMEOUT_MS
|
||||||
|
conn.readTimeout = TIMEOUT_MS
|
||||||
|
if (apiKey.isNotBlank()) conn.setRequestProperty("Authorization", "Bearer $apiKey")
|
||||||
|
if (jsonBody != null) {
|
||||||
|
conn.doOutput = true
|
||||||
|
conn.setRequestProperty("Content-Type", "application/json")
|
||||||
|
conn.setRequestProperty("Accept", "application/json")
|
||||||
|
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(jsonBody) }
|
||||||
|
}
|
||||||
|
val code = conn.responseCode
|
||||||
|
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
|
||||||
|
val body = if (stream != null) {
|
||||||
|
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).use { it.readText() }
|
||||||
|
} else ""
|
||||||
|
code to body
|
||||||
|
} catch (e: Exception) {
|
||||||
|
-1 to (e.message ?: e.javaClass.simpleName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shortError(body: String): String {
|
||||||
|
if (body.startsWith("<")) return body.take(80) // HTML error page
|
||||||
|
return try {
|
||||||
|
val obj = JSONObject(body)
|
||||||
|
val err = obj.optJSONObject("error")
|
||||||
|
err?.optString("message")?.ifBlank { body.take(120) }
|
||||||
|
?: obj.optString("reason").ifBlank { obj.optString("message").ifBlank { body.take(120) } }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
body.take(120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun utcDate(ms: Long): String {
|
||||||
|
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||||
|
cal.timeInMillis = ms
|
||||||
|
return "%04d-%02d-%02d".format(
|
||||||
|
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
|
||||||
|
cal.get(java.util.Calendar.DAY_OF_MONTH)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun utcTime(ms: Long): String {
|
||||||
|
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||||
|
cal.timeInMillis = ms
|
||||||
|
return "%02d:%02d:%02d".format(
|
||||||
|
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
|
||||||
|
cal.get(java.util.Calendar.SECOND)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun utcDateCompact(ms: Long): String {
|
||||||
|
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||||
|
cal.timeInMillis = ms
|
||||||
|
return "%04d%02d%02d".format(
|
||||||
|
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
|
||||||
|
cal.get(java.util.Calendar.DAY_OF_MONTH)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun utcTimeCompact(ms: Long): String {
|
||||||
|
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||||
|
cal.timeInMillis = ms
|
||||||
|
return "%02d%02d%02d".format(
|
||||||
|
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
|
||||||
|
cal.get(java.util.Calendar.SECOND)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/*
|
||||||
|
* WavelogQueue.kt - WaveLog local log queue (4.5.2).
|
||||||
|
*
|
||||||
|
* Pure Kotlin (no Android deps): storage goes through the IWavelogQueueStore interface,
|
||||||
|
* implemented with SharedPreferences in core/data.
|
||||||
|
* Queue capped at 500 entries (oldest dropped beyond that).
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.wavelog
|
||||||
|
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/** Storage abstraction (SharedPreferences impl lives in core/data) */
|
||||||
|
interface IWavelogQueueStore {
|
||||||
|
fun load(): String
|
||||||
|
fun save(json: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** QSO entry awaiting upload (local queue element, mirrors POST /api/v2/qso fields) */
|
||||||
|
data class WavelogQso(
|
||||||
|
val id: String, // 本地唯一 id(UUID)
|
||||||
|
val timeUtcMs: Long, // 回车时刻 UTC 毫秒(本地显示 + 组装 qso_date/time_on)
|
||||||
|
val call: String,
|
||||||
|
val mode: String,
|
||||||
|
val freqTxHz: Long, // 上行(回车那一秒多普勒修正)
|
||||||
|
val freqRxHz: Long, // 下行
|
||||||
|
val satName: String,
|
||||||
|
val sessionId: String = "", // 场次 ID: 卫星名-AOS 时间戳(过境仰角 0 秒), 空=未分组(旧数据)
|
||||||
|
val gridsquare: String = "", // 对方网格(QRZ 爬虫填入, 4.5.5), 空=未查到
|
||||||
|
val uploaded: Boolean = false // 是否已成功上传(4.5.2 修复: 成功后保留标记, 表格打勾)
|
||||||
|
)
|
||||||
|
|
||||||
|
class WavelogQueue(private val store: IWavelogQueueStore) {
|
||||||
|
|
||||||
|
private val key = "wavelog_queue"
|
||||||
|
|
||||||
|
fun all(): List<WavelogQso> {
|
||||||
|
val raw = store.load()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(raw)
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
WavelogQso(
|
||||||
|
id = o.getString("id"),
|
||||||
|
timeUtcMs = o.getLong("timeUtcMs"),
|
||||||
|
call = o.optString("call"),
|
||||||
|
mode = o.optString("mode"),
|
||||||
|
freqTxHz = o.optLong("freqTxHz"),
|
||||||
|
freqRxHz = o.optLong("freqRxHz"),
|
||||||
|
satName = o.optString("satName"),
|
||||||
|
sessionId = o.optString("sessionId"),
|
||||||
|
gridsquare = o.optString("gridsquare"),
|
||||||
|
uploaded = o.optBoolean("uploaded", false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun add(qso: WavelogQso) {
|
||||||
|
val list = all().toMutableList()
|
||||||
|
list.add(0, qso) // 最新在前
|
||||||
|
if (list.size > 500) list.removeAt(list.size - 1)
|
||||||
|
save(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun remove(id: String) {
|
||||||
|
save(all().filter { it.id != id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun removeAll(ids: Set<String>) {
|
||||||
|
save(all().filter { it.id !in ids })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark as uploaded (kept in the queue; checkmark in the table) */
|
||||||
|
@Synchronized
|
||||||
|
fun markUploaded(id: String) {
|
||||||
|
save(all().map { if (it.id == id) it.copy(uploaded = true) else it })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a QSO's counterpart grid (async backfill from the QRZ scraper, 4.5.5) */
|
||||||
|
@Synchronized
|
||||||
|
fun updateGridsquare(id: String, grid: String) {
|
||||||
|
save(all().map { if (it.id == id) it.copy(gridsquare = grid) else it })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove all uploaded entries (optional; keeps the queue lean) */
|
||||||
|
@Synchronized
|
||||||
|
fun removeUploaded() {
|
||||||
|
save(all().filter { !it.uploaded })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun save(list: List<WavelogQso>) {
|
||||||
|
val arr = JSONArray()
|
||||||
|
list.forEach { q ->
|
||||||
|
arr.put(JSONObject().apply {
|
||||||
|
put("id", q.id); put("timeUtcMs", q.timeUtcMs); put("call", q.call)
|
||||||
|
put("mode", q.mode); put("freqTxHz", q.freqTxHz)
|
||||||
|
put("freqRxHz", q.freqRxHz); put("satName", q.satName)
|
||||||
|
put("sessionId", q.sessionId)
|
||||||
|
put("gridsquare", q.gridsquare)
|
||||||
|
put("uploaded", q.uploaded)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
store.save(arr.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
/*
|
||||||
|
* WavelogUploader.kt - WaveLog queue upload scheduler (4.5.2).
|
||||||
|
*
|
||||||
|
* Manual/periodic uploads share: per-batch grid check (user QTH first 4 chars vs station grid first 4 chars)
|
||||||
|
* -> POST /api/v2/qso -> success removes from the queue.
|
||||||
|
* Grid mismatch: returns NeedConfirm (UI dialog "Ignore and upload / Cancel"),
|
||||||
|
* retrying the batch with force=true once confirmed.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.wavelog
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
sealed class UploadOutcome {
|
||||||
|
data class NeedConfirm(val stationGrid: String, val userGrid: String) : UploadOutcome()
|
||||||
|
data class Done(
|
||||||
|
val successCount: Int,
|
||||||
|
val failedCount: Int,
|
||||||
|
val message: String,
|
||||||
|
val firstError: String = ""
|
||||||
|
) : UploadOutcome()
|
||||||
|
}
|
||||||
|
|
||||||
|
class WavelogUploader(
|
||||||
|
private val settingsRepo: ISettingsRepo,
|
||||||
|
private val queue: WavelogQueue
|
||||||
|
) {
|
||||||
|
|
||||||
|
// Station grid cache (refreshed before each upload; stale value kept on failure)
|
||||||
|
private var cachedStationGrid: String? = null
|
||||||
|
|
||||||
|
/** Upload the whole queue. force=true skips the grid confirm (user chose "Ignore and upload") */
|
||||||
|
suspend fun uploadQueue(force: Boolean = false): UploadOutcome {
|
||||||
|
val settings = settingsRepo.otherSettings.value
|
||||||
|
val url = settings.wavelogUrl
|
||||||
|
val apiKey = settings.wavelogApiKey
|
||||||
|
val stationId = settings.wavelogStationId
|
||||||
|
if (url.isBlank() || apiKey.isBlank() || stationId.isBlank()) {
|
||||||
|
return UploadOutcome.Done(0, queue.all().size, "未配置 WaveLog 服务器")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Fetch station info (station grid); fall back to user QTH when v1 lacks the endpoint
|
||||||
|
val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid()
|
||||||
|
if (stationGrid.isNullOrBlank()) {
|
||||||
|
return UploadOutcome.Done(0, queue.all().size, "无法获取站点信息(检查站点 ID/密钥权限)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Grid check: cloud station grid first 4 chars vs current station QTH first 4 chars
|
||||||
|
// (guards against a misconfigured station; unrelated to the QSO counterpart grid - per user)
|
||||||
|
if (!force) {
|
||||||
|
val userGrid = userQthGrid()
|
||||||
|
if (userGrid != null && stationGrid.take(4).lowercase() != userGrid.take(4).lowercase()) {
|
||||||
|
return UploadOutcome.NeedConfirm(stationGrid, userGrid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Upload one by one. ADIF gridsquare = counterpart grid (the QSO partner); blank until the scraper lands
|
||||||
|
val entries = queue.all()
|
||||||
|
var ok = 0
|
||||||
|
var fail = 0
|
||||||
|
var firstError = ""
|
||||||
|
for (qso in entries) {
|
||||||
|
if (qso.uploaded) { ok++; continue }
|
||||||
|
val result = WaveLogApi.postQso(url, apiKey, stationId, qso, qso.gridsquare)
|
||||||
|
if (result is WavelogResult.Success) {
|
||||||
|
ok++
|
||||||
|
queue.markUploaded(qso.id)
|
||||||
|
} else {
|
||||||
|
fail++
|
||||||
|
if (firstError.isBlank()) firstError = (result as? WavelogResult.Failure)?.message ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val message = if (fail == 0) "成功上传 $ok 条" else "成功 $ok 条, 失败 $fail 条(保留待重试)"
|
||||||
|
return UploadOutcome.Done(ok, fail, message, firstError)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getStationGrid(url: String, apiKey: String, stationId: String): String? {
|
||||||
|
val result = WaveLogApi.getStation(url, apiKey, stationId)
|
||||||
|
if (result is WavelogResult.Success) {
|
||||||
|
return try {
|
||||||
|
JSONObject(result.message).optString("gridsquare").takeIf { it.isNotBlank() }
|
||||||
|
?: cachedStationGrid
|
||||||
|
} catch (_: Exception) { cachedStationGrid }
|
||||||
|
}
|
||||||
|
return cachedStationGrid
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User's current QTH grid (first 4 chars; null when no QTH = check skipped) */
|
||||||
|
private fun userQthGrid(): String? {
|
||||||
|
return settingsRepo.stationPosition.value.qthLocator?.takeIf { it.length >= 4 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,9 +18,11 @@
|
|||||||
package com.rtbishop.look4sat.core.domain
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.aprsPasscode
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@ExperimentalCoroutinesApi
|
@ExperimentalCoroutinesApi
|
||||||
@@ -111,6 +113,47 @@ class DataParserTest {
|
|||||||
assert(dataParser.parseCSVStream(invalidCSVStream).isEmpty())
|
assert(dataParser.parseCSVStream(invalidCSVStream).isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun csvWithEpoch(epoch: String) = """
|
||||||
|
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,$epoch,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given CSV epoch one minute past midnight the day fraction is correct`() = runTest(testDispatcher) {
|
||||||
|
// Regression: the day fraction used to be built by string surgery
|
||||||
|
// (Double.toString().substring(1)), but toString switches to scientific
|
||||||
|
// notation below 1e-3, so the leading significant digit was truncated.
|
||||||
|
// 00:01:00 produced "25001.944444444444445E-4" -> 2.50019..., an epoch
|
||||||
|
// roughly 26 years off, with no exception to reveal it.
|
||||||
|
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:01:00.000000"))[0]
|
||||||
|
assertEquals(25001.0 + 60.0 / 86400.0, sat.epoch, 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given CSV epoch one second past midnight the day fraction is correct`() = runTest(testDispatcher) {
|
||||||
|
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:00:01.000000"))[0]
|
||||||
|
assertEquals(25001.0 + 1.0 / 86400.0, sat.epoch, 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given CSV epoch exactly at midnight the day fraction is zero`() = runTest(testDispatcher) {
|
||||||
|
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:00:00.000000"))[0]
|
||||||
|
assertEquals(25001.0, sat.epoch, 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given CSV epoch at midday the day fraction is one half`() = runTest(testDispatcher) {
|
||||||
|
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T12:00:00.000000"))[0]
|
||||||
|
assertEquals(25001.5, sat.epoch, 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given CSV epoch late in the day the day fraction stays below one`() = runTest(testDispatcher) {
|
||||||
|
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T23:59:59.999000"))[0]
|
||||||
|
assert(sat.epoch > 25001.999) { "expected almost a full day, got ${sat.epoch}" }
|
||||||
|
assert(sat.epoch < 25002.0) { "day fraction must not roll into the next day, got ${sat.epoch}" }
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) {
|
fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) {
|
||||||
val parsedList = dataParser.parseTLEStream(validTLEStream)
|
val parsedList = dataParser.parseTLEStream(validTLEStream)
|
||||||
@@ -239,4 +282,10 @@ class DataParserTest {
|
|||||||
// Matches the CSV test data epoch: 2021-11-16 → day 320
|
// Matches the CSV test data epoch: 2021-11-16 → day 320
|
||||||
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
|
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `check APRS passcode calculation`() {
|
||||||
|
assert("M7LNB".aprsPasscode() == 12443)
|
||||||
|
assert("N0CALL".aprsPasscode() == 13023)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+168
-15
@@ -3,22 +3,30 @@ package com.rtbishop.look4sat.core.domain
|
|||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||||
import org.junit.Assert.*
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class DopplerFrequencyCalculatorTest {
|
class DopplerFrequencyCalculatorTest {
|
||||||
|
|
||||||
private fun linearTransponder(
|
private fun linearTransponder(
|
||||||
|
uuid: String = "linear",
|
||||||
upLow: Long = 145_000_000L,
|
upLow: Long = 145_000_000L,
|
||||||
upHigh: Long = 145_500_000L,
|
upHigh: Long = 145_500_000L,
|
||||||
downLow: Long = 435_000_000L,
|
downLow: Long = 435_000_000L,
|
||||||
downHigh: Long? = 435_500_000L,
|
downHigh: Long? = 435_500_000L,
|
||||||
inverted: Boolean = false
|
inverted: Boolean = false,
|
||||||
|
info: String = "Linear Transponder",
|
||||||
|
downlinkMode: String? = "USB",
|
||||||
|
uplinkMode: String? = "LSB"
|
||||||
) = SatRadio(
|
) = SatRadio(
|
||||||
uuid = "linear", info = "Linear Transponder", isAlive = true,
|
uuid = uuid, info = info, isAlive = true,
|
||||||
downlinkLow = downLow, downlinkHigh = downHigh,
|
downlinkLow = downLow, downlinkHigh = downHigh,
|
||||||
downlinkMode = "USB", uplinkLow = upLow, uplinkHigh = upHigh,
|
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
|
||||||
uplinkMode = "LSB", isInverted = inverted, catnum = 12345
|
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun fmTransponder() = SatRadio(
|
private fun fmTransponder() = SatRadio(
|
||||||
@@ -48,14 +56,116 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
|
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsTrueForLinearTransponderName() {
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(linearTransponder()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsTrueForSsbTransponderName() {
|
||||||
|
val xpdr = linearTransponder(info = "Mode V/U SSB Transponder", downlinkMode = "USB", uplinkMode = "LSB")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(xpdr))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsFalseForRangeEntryWithoutTransponderName() {
|
||||||
|
val driftingRangeEntry = linearTransponder(info = "Upper side band (drifting)")
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsTrueForAbbreviatedLinName() {
|
||||||
|
// AO-7 style: "Mode V/A (A) Lin SSB" — "Lin" abbreviation, no "transponder" word
|
||||||
|
val ao7Entry = linearTransponder(info = "Mode V/A (A) Lin SSB", downlinkMode = "USB", uplinkMode = "USB")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7Entry))
|
||||||
|
val ao7CwEntry = linearTransponder(info = "Mode V/A (A) Lin CW", downlinkMode = "CW", uplinkMode = "CW")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7CwEntry))
|
||||||
|
val ao7ModeBEntry = linearTransponder(info = "Mode U/V (B) Lin", downlinkMode = "USB", uplinkMode = "LSB")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7ModeBEntry))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsTrueForLinearWithoutTransponderWord() {
|
||||||
|
// AO-73 style: "Mode U/V Linear" — has "Linear" but no "transponder"
|
||||||
|
val ao73Entry = linearTransponder(info = "Mode U/V Linear", downlinkMode = "USB", uplinkMode = "LSB")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao73Entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsFalseForDownlinkContainingLinInsideWord() {
|
||||||
|
// "Downlink" contains "lin" but is not a linear-transponder name
|
||||||
|
val downlinkEntry = linearTransponder(info = "Mode U Downlink", downlinkMode = "FM", uplinkMode = "FM")
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(downlinkEntry))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deduplicateTransponders_mergesSameFrequencyRange() {
|
||||||
|
// AO-7's Mode A: same range, SSB and CW entries
|
||||||
|
val ssb = linearTransponder(
|
||||||
|
uuid = "ssb-uuid", info = "Mode V/A (A) Lin SSB",
|
||||||
|
downlinkMode = "USB", uplinkMode = "USB"
|
||||||
|
)
|
||||||
|
val cw = linearTransponder(
|
||||||
|
uuid = "cw-uuid", info = "Mode V/A (A) Lin CW",
|
||||||
|
downlinkMode = "CW", uplinkMode = "CW"
|
||||||
|
)
|
||||||
|
val modeB = linearTransponder(
|
||||||
|
uuid = "modeb-uuid", info = "Mode U/V (B) Lin",
|
||||||
|
upLow = 432_125_000L, upHigh = 432_175_000L,
|
||||||
|
downLow = 145_925_000L, downHigh = 145_975_000L,
|
||||||
|
downlinkMode = "USB", uplinkMode = "LSB"
|
||||||
|
)
|
||||||
|
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(ssb, cw, modeB))
|
||||||
|
assertEquals(2, result.size)
|
||||||
|
// SSB entry should be preferred over CW (same range)
|
||||||
|
assertEquals("ssb-uuid", result[0].uuid)
|
||||||
|
assertEquals("modeb-uuid", result[1].uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deduplicateTransponders_prefersNonCwEntry() {
|
||||||
|
// JO-97: CW entry has invert=false (wrong), SSB has invert=true (correct)
|
||||||
|
val cw = linearTransponder(
|
||||||
|
uuid = "cw-uuid", info = "U/V CW Transponder",
|
||||||
|
downlinkMode = "CW", uplinkMode = "CW",
|
||||||
|
upLow = 435_100_000L, upHigh = 435_120_000L,
|
||||||
|
downLow = 145_855_000L, downHigh = 145_875_000L
|
||||||
|
)
|
||||||
|
val ssb = linearTransponder(
|
||||||
|
uuid = "ssb-uuid", info = "U/V SSB Transponder",
|
||||||
|
downlinkMode = "USB", uplinkMode = "LSB",
|
||||||
|
upLow = 435_100_000L, upHigh = 435_120_000L,
|
||||||
|
downLow = 145_855_000L, downHigh = 145_875_000L,
|
||||||
|
inverted = true
|
||||||
|
)
|
||||||
|
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(cw, ssb))
|
||||||
|
assertEquals(1, result.size)
|
||||||
|
assertEquals("ssb-uuid", result[0].uuid)
|
||||||
|
// Verify the correct invert flag is preserved
|
||||||
|
assertTrue(result[0].isInverted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deduplicateTransponders_preservesUniqueEntries() {
|
||||||
|
val t1 = linearTransponder(uuid = "t1", upLow = 145_000_000L, upHigh = 145_500_000L,
|
||||||
|
downLow = 435_000_000L, downHigh = 435_500_000L)
|
||||||
|
val t2 = linearTransponder(uuid = "t2", upLow = 435_000_000L, upHigh = 435_500_000L,
|
||||||
|
downLow = 145_000_000L, downHigh = 145_500_000L)
|
||||||
|
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(t1, t2))
|
||||||
|
assertEquals(2, result.size)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun computeUplinkFromDownlink_linear_noDoppler() {
|
fun computeUplinkFromDownlink_linear_noDoppler() {
|
||||||
val xpdr = linearTransponder()
|
val xpdr = linearTransponder()
|
||||||
val orbitalPos = pos(0.0)
|
val orbitalPos = pos(0.0)
|
||||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||||
assertNotNull(uplink)
|
assertNotNull(uplink)
|
||||||
assertTrue(uplink!! > 0)
|
|
||||||
// With zero Doppler, result equals mapDownlinkToUplink output
|
|
||||||
assertEquals(145_200_000L, uplink)
|
assertEquals(145_200_000L, uplink)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,42 +180,86 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() {
|
fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() {
|
||||||
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate
|
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate.
|
||||||
val xpdr = linearTransponder()
|
val xpdr = linearTransponder()
|
||||||
val orbitalPos = pos(7.0) // ~7 km/s receding
|
val orbitalPos = pos(7.0)
|
||||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||||
assertNotNull(uplink)
|
assertNotNull(uplink)
|
||||||
// Uplink freq should be Doppler shifted UP (compensating for receding)
|
|
||||||
assertTrue(uplink!! > 145_200_000L)
|
assertTrue(uplink!! > 145_200_000L)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun computeUplinkFromDownlink_fm_transponder_returnsNull() {
|
fun computeUplinkFromDownlink_fmTransponder_returnsNull() {
|
||||||
val orbitalPos = pos()
|
val orbitalPos = pos()
|
||||||
val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos)
|
val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos)
|
||||||
assertNull(result)
|
assertNull(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun computeDownlinkFromUplink_fm_transponder_returnsNull() {
|
fun computeDownlinkFromUplink_fmTransponder_returnsNull() {
|
||||||
val orbitalPos = pos()
|
val orbitalPos = pos()
|
||||||
val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos)
|
val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos)
|
||||||
assertNull(result)
|
assertNull(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeDownlinkFromUplink_withPositiveOffset_addsOffsetToDownlink() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||||
|
uplinkHz = 145_200_000L,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = 2_500L
|
||||||
|
)
|
||||||
|
assertEquals(435_202_500L, downlink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_withPositiveOffset_subtractsOffsetBeforeMapping() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||||
|
downlinkHz = 435_202_500L,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = 2_500L
|
||||||
|
)
|
||||||
|
assertEquals(145_200_000L, uplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeOffsetRoundTrip_handlesNegativeOffset() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||||
|
uplinkHz = 145_200_000L,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = -2_500L
|
||||||
|
)
|
||||||
|
assertEquals(435_197_500L, downlink)
|
||||||
|
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||||
|
downlinkHz = downlink!!,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = -2_500L
|
||||||
|
)
|
||||||
|
assertEquals(145_200_000L, uplink)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun computeUplinkFromDownlink_invertedTransponder() {
|
fun computeUplinkFromDownlink_invertedTransponder() {
|
||||||
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
|
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
|
||||||
val orbitalPos = pos(0.0)
|
val orbitalPos = pos(0.0)
|
||||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||||
assertNotNull(uplink)
|
assertNotNull(uplink)
|
||||||
// Inverted: offset from high end → maps to high end of uplink
|
|
||||||
assertEquals(145_300_000L, uplink)
|
assertEquals(145_300_000L, uplink)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun computeUplinkFromDownlink_roundTrip() {
|
fun computeUplinkFromDownlink_roundTrip() {
|
||||||
// downlink → uplink → downlink should round-trip
|
|
||||||
val xpdr = linearTransponder()
|
val xpdr = linearTransponder()
|
||||||
val orbitalPos = pos(3.5)
|
val orbitalPos = pos(3.5)
|
||||||
val originalDownlink = 435_250_000L
|
val originalDownlink = 435_250_000L
|
||||||
@@ -113,7 +267,6 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
assertNotNull(uplink)
|
assertNotNull(uplink)
|
||||||
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
|
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
|
||||||
assertNotNull(roundTripDownlink)
|
assertNotNull(roundTripDownlink)
|
||||||
// Doppler round-trip: small residual due to freq-dependent Doppler
|
|
||||||
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
|
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
|
||||||
assertTrue("Round-trip error too large: $error", error < 10000)
|
assertTrue("Round-trip error too large: $error", error < 10000)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
package com.rtbishop.look4sat.core.domain
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.qthNeighbors
|
||||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.qthToSquare
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class QthConverterTest {
|
class QthConverterTest {
|
||||||
@@ -26,21 +28,36 @@ class QthConverterTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `Given valid QTH returns correct POS`() {
|
fun `Given valid QTH returns correct POS`() {
|
||||||
var result = qthToPosition("io91VL39FX")
|
var result = qthToPosition("io91VL39FX")
|
||||||
assert(result?.latitude == 51.4792 && result.longitude == -0.2083)
|
assert(result?.latitude == 51.499913 && result.longitude == -0.22309)
|
||||||
result = qthToPosition("gf15vc")
|
result = qthToPosition("gf15vc")
|
||||||
assert(result?.latitude == -34.8958 && result.longitude == -56.2083)
|
assert(result?.latitude == -34.895833 && result.longitude == -56.208333)
|
||||||
|
// 8-char locators: finer 30" x 15" cell center
|
||||||
|
result = qthToPosition("io91vl47")
|
||||||
|
assert(result?.latitude == 51.489583 && result.longitude == -0.2125)
|
||||||
|
result = qthToPosition("jn58td25")
|
||||||
|
assert(result?.latitude == 48.147917 && result.longitude == 11.604167)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Given invalid QTH returns null`() {
|
fun `Given invalid QTH returns null`() {
|
||||||
assert(qthToPosition("ZZ00zz") == null)
|
assert(qthToPosition("ZZ00zz") == null)
|
||||||
assert(qthToPosition("JN58") == null)
|
assert(qthToPosition("JN58") == null)
|
||||||
|
assert(qthToPosition("io9") == null)
|
||||||
|
assert(qthToPosition("IO91VL7") == null)
|
||||||
|
assert(qthToPosition("IO91VL4X") == null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Given valid POS returns correct QTH`() {
|
fun `Given valid POS returns correct QTH`() {
|
||||||
assert(positionToQth(51.4878, -0.2146) == "IO91vl")
|
// default precision is 8 chars
|
||||||
assert(positionToQth(48.1466, 11.6083) == "JN58td")
|
assert(positionToQth(51.4878, -0.2146) == "IO91vl47")
|
||||||
|
assert(positionToQth(48.1466, 11.6083) == "JN58td25")
|
||||||
|
// 6-char precision still available for backwards compatibility
|
||||||
|
assert(positionToQth(51.4878, -0.2146, 6) == "IO91vl")
|
||||||
|
assert(positionToQth(48.1466, 11.6083, 6) == "JN58td")
|
||||||
|
// 10-char precision
|
||||||
|
assert(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb")
|
||||||
|
assert(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -48,4 +65,112 @@ class QthConverterTest {
|
|||||||
assert(positionToQth(91.0542, -170.1142) == null)
|
assert(positionToQth(91.0542, -170.1142) == null)
|
||||||
assert(positionToQth(89.0542, -240.1142) == null)
|
assert(positionToQth(89.0542, -240.1142) == null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given boundary POS stays in valid grid`() {
|
||||||
|
// antipodal / edge cases must not overflow the A-R / 0-9 / a-x alphabet
|
||||||
|
assert(positionToQth(-90.0, -180.0, 8) == "AA00aa00")
|
||||||
|
// Exact positive bounds belong to the final cell, not a modulo-wrapped
|
||||||
|
// R-field/0-square combination that decodes 10°/20° away.
|
||||||
|
assert(positionToQth(90.0, 180.0, 8) == "RR99xx99")
|
||||||
|
assert(positionToQth(0.0, 0.0, 8) == "JJ00aa00")
|
||||||
|
// roundtrip stability: 8-char roundtrip is stable across a sample of positions
|
||||||
|
val positions = listOf(
|
||||||
|
Pair(51.4878, -0.2146), Pair(48.1466, 11.6083), Pair(-33.8688, 151.2093),
|
||||||
|
Pair(39.9042, 116.4074), Pair(35.6895, 139.6917), Pair(41.714, -72.727)
|
||||||
|
)
|
||||||
|
positions.forEach { (lat, lon) ->
|
||||||
|
val qth = positionToQth(lat, lon, 8)
|
||||||
|
val pos = qthToPosition(qth!!)
|
||||||
|
val qth2 = positionToQth(pos!!.latitude, pos.longitude, 8)
|
||||||
|
assert(qth == qth2) { "Roundtrip failed for ($lat, $lon): $qth -> $qth2" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Encoded locator always decodes back within one cell`() {
|
||||||
|
// An 8-char cell is 30" lon x 15" lat, so a correct encode/decode pair
|
||||||
|
// can never differ by more than that. Field clamping used to break this
|
||||||
|
// near +90 / +180 and produced errors up to 10 deg lat / 20 deg lon.
|
||||||
|
var worstLat = 0.0
|
||||||
|
var worstLon = 0.0
|
||||||
|
var worst = ""
|
||||||
|
var lat = -90.0
|
||||||
|
while (lat <= 90.0) {
|
||||||
|
var lon = -180.0
|
||||||
|
while (lon <= 180.0) {
|
||||||
|
val qth = positionToQth(lat, lon, 8)
|
||||||
|
?: error("valid position rejected: ($lat, $lon)")
|
||||||
|
val pos = qthToPosition(qth) ?: error("own output rejected: $qth")
|
||||||
|
val dLat = kotlin.math.abs(pos.latitude - lat)
|
||||||
|
val dLon = kotlin.math.abs(pos.longitude - lon)
|
||||||
|
if (dLat > worstLat || dLon > worstLon) {
|
||||||
|
worstLat = maxOf(worstLat, dLat)
|
||||||
|
worstLon = maxOf(worstLon, dLon)
|
||||||
|
worst = "($lat, $lon) -> $qth -> (${pos.latitude}, ${pos.longitude})"
|
||||||
|
}
|
||||||
|
lon += 0.5
|
||||||
|
}
|
||||||
|
lat += 0.5
|
||||||
|
}
|
||||||
|
assert(worstLat <= 0.01 && worstLon <= 0.01) {
|
||||||
|
"roundtrip drifted by (${worstLat}, ${worstLon}) deg, worst: $worst"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given out of range longitude returns null`() {
|
||||||
|
// Maidenhead only covers -180..180; 181..360 used to be accepted and
|
||||||
|
// encoded into a plausible-looking locator 20-200 deg away.
|
||||||
|
assert(positionToQth(0.0, 181.0) == null)
|
||||||
|
assert(positionToQth(0.0, 270.0) == null)
|
||||||
|
assert(positionToQth(0.0, 360.0) == null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given locator with out of range field returns null`() {
|
||||||
|
// Fields run A-R; S-X in the first pair decoded past the poles.
|
||||||
|
assert(qthToPosition("SS00aa") == null)
|
||||||
|
assert(qthToPosition("XX99xx") == null)
|
||||||
|
assert(qthToPosition("AS00aa") == null)
|
||||||
|
assert(qthToPosition("AX99xx") == null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given square returns correct 3x3 neighbors`() {
|
||||||
|
// Reference grid from the QTH Locator screenshot: OL42
|
||||||
|
val neighbors = qthNeighbors("OL42")
|
||||||
|
assert(neighbors == listOf(
|
||||||
|
"OL33", "OL43", "OL53",
|
||||||
|
"OL32", "OL42", "OL52",
|
||||||
|
"OL31", "OL41", "OL51"
|
||||||
|
)) { "OL42 grid mismatch: $neighbors" }
|
||||||
|
// Center cell must be the input itself
|
||||||
|
assert(neighbors[4] == "OL42")
|
||||||
|
// 9 cells, all distinct
|
||||||
|
assert(neighbors.size == 9 && neighbors.toSet().size == 9)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given boundary square wraps fields correctly`() {
|
||||||
|
// South-west corner: AA00 neighbors wrap to RR99 / RA90 etc.
|
||||||
|
val sw = qthNeighbors("AA00")
|
||||||
|
assert(sw.size == 9 && sw.toSet().size == 9)
|
||||||
|
assert(sw[0] == "RA91" && sw[4] == "AA00" && sw[6] == "RR99" && sw[8] == "AR19")
|
||||||
|
// North-east corner: RR99 wraps to AA00
|
||||||
|
val ne = qthNeighbors("RR99")
|
||||||
|
assert(ne.size == 9 && ne.toSet().size == 9)
|
||||||
|
assert(ne[0] == "RA80" && ne[4] == "RR99" && ne[8] == "AR08")
|
||||||
|
// Field boundary: IO91's east neighbors cross into J field
|
||||||
|
val london = qthNeighbors("IO91")
|
||||||
|
assert(london[2] == "JO02" && london[5] == "JO01")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given full locator returns square part`() {
|
||||||
|
assert(qthToSquare("OL42ih45") == "OL42")
|
||||||
|
assert(qthToSquare("io91VL39FX") == "IO91")
|
||||||
|
assert(qthToSquare("JN58") == "JN58")
|
||||||
|
assert(qthToSquare("garbage!!") == "----")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.aprs
|
||||||
|
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APRS-IS is an ASCII line protocol. Formatting the position, altitude and
|
||||||
|
* course/speed extensions with the JVM default locale produced Eastern Arabic
|
||||||
|
* or Bengali digits on devices set to ar/fa/bn, and the server rejects those
|
||||||
|
* packets.
|
||||||
|
*
|
||||||
|
* Regression guard: every formatted field must stay ASCII regardless of the
|
||||||
|
* default locale.
|
||||||
|
*/
|
||||||
|
class AprsPacketLocaleTest {
|
||||||
|
|
||||||
|
private val original: Locale = Locale.getDefault()
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun restoreLocale() {
|
||||||
|
Locale.setDefault(original)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val asciiPacket = Regex("^[\\x20-\\x7E]*$")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun position_staysAsciiUnderArabicLocale() {
|
||||||
|
Locale.setDefault(Locale.forLanguageTag("ar-EG"))
|
||||||
|
|
||||||
|
val encoded = AprsPosition(39.9042, 116.4074, '/', '>').toUncompressedString()
|
||||||
|
|
||||||
|
assertTrue("not ASCII: $encoded", asciiPacket.matches(encoded))
|
||||||
|
assertEquals("3954.25N/11624.44E>", encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun position_staysAsciiUnderBengaliLocale() {
|
||||||
|
Locale.setDefault(Locale.forLanguageTag("bn-BD"))
|
||||||
|
|
||||||
|
val encoded = AprsPosition(-33.8688, 151.2093, '/', '>').toUncompressedString()
|
||||||
|
|
||||||
|
assertTrue("not ASCII: $encoded", asciiPacket.matches(encoded))
|
||||||
|
assertEquals("3352.13S/15112.56E>", encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun altitudeAndCourseSpeed_stayAsciiUnderPersianLocale() {
|
||||||
|
Locale.setDefault(Locale.forLanguageTag("fa-IR"))
|
||||||
|
|
||||||
|
val altitude = AprsPacket.formatAltitude(100.0)
|
||||||
|
val courseSpeed = AprsPacket.formatCourseSpeed(10.0, 90f)
|
||||||
|
val filter = AprsPacket.formatRangeFilter(39.9042, 116.4074, 100)
|
||||||
|
|
||||||
|
assertTrue("not ASCII: $altitude", asciiPacket.matches(altitude))
|
||||||
|
assertTrue("not ASCII: $courseSpeed", asciiPacket.matches(courseSpeed))
|
||||||
|
assertTrue("not ASCII: $filter", asciiPacket.matches(filter))
|
||||||
|
assertEquals("/A=000328", altitude)
|
||||||
|
assertEquals("/090/019", courseSpeed)
|
||||||
|
assertEquals("r/39.904/116.407/100", filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun altitude_clampsNegativeToKeepSixDigitField() {
|
||||||
|
// "%06d" of a negative value yields "/A=-00164": the '-' takes a digit
|
||||||
|
// slot, so the extension is no longer a valid fixed-width field.
|
||||||
|
assertEquals("/A=000000", AprsPacket.formatAltitude(-50.0))
|
||||||
|
assertEquals("/A=000000", AprsPacket.formatAltitude(-1.0))
|
||||||
|
assertEquals("/A=000328", AprsPacket.formatAltitude(100.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun courseSpeed_wrapsCourseIntoValidRange() {
|
||||||
|
assertEquals("/000/019", AprsPacket.formatCourseSpeed(10.0, 360f))
|
||||||
|
assertEquals("/359/019", AprsPacket.formatCourseSpeed(10.0, -1f))
|
||||||
|
assertEquals("/090/019", AprsPacket.formatCourseSpeed(10.0, 90f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ambiguousPosition_staysAsciiUnderArabicLocale() {
|
||||||
|
Locale.setDefault(Locale.forLanguageTag("ar-EG"))
|
||||||
|
|
||||||
|
for (ambiguity in 1..4) {
|
||||||
|
val encoded = AprsPosition(39.9042, 116.4074, '/', '>', ambiguity)
|
||||||
|
.toUncompressedString()
|
||||||
|
assertTrue("ambiguity=$ambiguity not ASCII: $encoded", asciiPacket.matches(encoded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
* 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 org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Greedy CTC collapse, matching the reference implementation's
|
||||||
|
* `greedy_ctc_decode`: drop blanks, then drop runs of the same label.
|
||||||
|
*
|
||||||
|
* Alphabet from `model.onnx.json` — 41 symbols plus blank at index 41.
|
||||||
|
*/
|
||||||
|
class CwCtcDecoderTest {
|
||||||
|
|
||||||
|
private val chars = listOf(
|
||||||
|
",", ".", "/", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "?",
|
||||||
|
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N",
|
||||||
|
"O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", " "
|
||||||
|
)
|
||||||
|
private val blank = 41
|
||||||
|
|
||||||
|
/** Build a `[1, T, 42]` log-prob tensor whose argmax follows [path]. */
|
||||||
|
private fun logits(path: IntArray): Array<Array<FloatArray>> {
|
||||||
|
val frames = Array(path.size) { t ->
|
||||||
|
FloatArray(42) { -10f }.also { it[path[t]] = 0f }
|
||||||
|
}
|
||||||
|
return arrayOf(frames)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun alphabetSizeMatchesModelMetadata() {
|
||||||
|
assertEquals("41 symbols + blank = 42 classes", 41, chars.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun greedy_dropsRunsOfTheSameLabel() {
|
||||||
|
assertEquals("A", CwCtcDecoder.greedy(logits(intArrayOf(14, 14, 14)), chars, blank))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun greedy_keepsRepeatsSeparatedByBlank() {
|
||||||
|
// A A <blank> A collapses to "AA": the blank breaks the run.
|
||||||
|
assertEquals("AA", CwCtcDecoder.greedy(logits(intArrayOf(14, 14, blank, 14)), chars, blank))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun greedy_allBlanksYieldEmptyString() {
|
||||||
|
assertEquals("", CwCtcDecoder.greedy(logits(intArrayOf(blank, blank, blank)), chars, blank))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun greedy_emptyInputYieldsEmptyString() {
|
||||||
|
assertEquals("", CwCtcDecoder.greedy(logits(intArrayOf()), chars, blank))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun greedy_decodesCallsignWithSpaceAndDigits() {
|
||||||
|
// "CQ BG7" — C=16 Q=30 space=40 B=15 G=20 7=10
|
||||||
|
val path = intArrayOf(
|
||||||
|
blank, 16, 16, blank, 30, blank, 40,
|
||||||
|
15, blank, 20, blank, 10, blank
|
||||||
|
)
|
||||||
|
assertEquals("CQ BG7", CwCtcDecoder.greedy(logits(path), chars, blank))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun greedy_picksHighestScoringClassPerFrame() {
|
||||||
|
// Frame favours S (32) over T (33); only S must survive.
|
||||||
|
val frame = FloatArray(42) { -10f }
|
||||||
|
frame[33] = -1f
|
||||||
|
frame[32] = -0.1f
|
||||||
|
assertEquals("S", CwCtcDecoder.greedy(arrayOf(arrayOf(frame)), chars, blank))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.cw
|
|
||||||
|
|
||||||
import org.junit.Assert.*
|
|
||||||
import org.junit.Test
|
|
||||||
import kotlin.math.PI
|
|
||||||
import kotlin.math.sin
|
|
||||||
|
|
||||||
class CwDecoderTest {
|
|
||||||
|
|
||||||
// --- Morse table ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun morseToChar_basicLetters() {
|
|
||||||
assertEquals('A', CwBayesianDecoder.morseToChar("01"))
|
|
||||||
assertEquals('S', CwBayesianDecoder.morseToChar("000"))
|
|
||||||
assertEquals('O', CwBayesianDecoder.morseToChar("111"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun morseToChar_numbers() {
|
|
||||||
assertEquals('1', CwBayesianDecoder.morseToChar("01111"))
|
|
||||||
assertEquals('0', CwBayesianDecoder.morseToChar("11111"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun morseToChar_unknown_returnsNull() {
|
|
||||||
assertNull(CwBayesianDecoder.morseToChar("......."))
|
|
||||||
assertNull(CwBayesianDecoder.morseToChar(""))
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- FFT ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fft_magnitudeSpectrum_detectsTone() {
|
|
||||||
val fft = CwFFT(256)
|
|
||||||
val sampleRate = 8000f
|
|
||||||
val freq = 700f
|
|
||||||
val buffer = FloatArray(256) { (sin(2.0 * PI * freq * it / sampleRate)).toFloat() }
|
|
||||||
val mag = fft.magnitudeSpectrum(buffer)
|
|
||||||
// Peak should be at bin around 700 * 256 / 8000 ≈ 22.4
|
|
||||||
var maxBin = 0
|
|
||||||
var maxVal = 0f
|
|
||||||
for (i in mag.indices) {
|
|
||||||
if (mag[i] > maxVal) { maxVal = mag[i]; maxBin = i }
|
|
||||||
}
|
|
||||||
assertTrue("Peak bin $maxBin should be near 22", maxBin in 18..26)
|
|
||||||
assertTrue("Peak value $maxVal should be positive", maxVal > 0.01f)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fft_magnitudeSpectrum_silence_isFlat() {
|
|
||||||
val fft = CwFFT(256)
|
|
||||||
val buffer = FloatArray(256) { 0f }
|
|
||||||
val mag = fft.magnitudeSpectrum(buffer)
|
|
||||||
for (v in mag) assertEquals("Silence spectrum should be 0, got $v", 0f, v, 1e-6f)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fft_rejectsWrongSize() {
|
|
||||||
assertThrows(IllegalArgumentException::class.java) { CwFFT(100) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Spectrogram ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun spectrogram_addSamples_updatesEnergy() {
|
|
||||||
val spec = CwSpectrogram(sampleRate = 8000)
|
|
||||||
val freq = 700f
|
|
||||||
// Feed multiple frames to stabilize energy normalization
|
|
||||||
for (i in 0..5) {
|
|
||||||
val buffer = FloatArray(256) { (sin(2.0 * PI * freq * it / 8000.0)).toFloat() }
|
|
||||||
spec.addSamples(buffer)
|
|
||||||
}
|
|
||||||
val col = spec.getCurrentColumn()
|
|
||||||
val peakBin = spec.findPeakBin()
|
|
||||||
assertTrue("Peak bin $peakBin should be >= 0", peakBin >= 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun spectrogram_findPeakBin_returnsValidBin() {
|
|
||||||
val spec = CwSpectrogram(sampleRate = 8000)
|
|
||||||
// Add multiple frames of 700 Hz tone
|
|
||||||
for (i in 0..5) {
|
|
||||||
val buffer = FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() }
|
|
||||||
spec.addSamples(buffer)
|
|
||||||
}
|
|
||||||
val peakBin = spec.findPeakBin()
|
|
||||||
assertTrue("Peak bin should be >= 0, got $peakBin", peakBin >= 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun spectrogram_freqToBin_roundtrip() {
|
|
||||||
val spec = CwSpectrogram(sampleRate = 8000)
|
|
||||||
val freq = 700f
|
|
||||||
val bin = spec.freqToBin(freq)
|
|
||||||
val backFreq = spec.binToFreq(bin)
|
|
||||||
assertTrue("Freq $freq → bin $bin → freq $backFreq", backFreq > 600f && backFreq < 800f)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun spectrogram_getBinEnergy_returnsCorrectLength() {
|
|
||||||
val spec = CwSpectrogram(sampleRate = 8000)
|
|
||||||
val energy = spec.getBinEnergy(0, 10)
|
|
||||||
assertEquals(10, energy.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun spectrogram_reset() {
|
|
||||||
val spec = CwSpectrogram(sampleRate = 8000)
|
|
||||||
spec.addSamples(FloatArray(256) { 1f })
|
|
||||||
spec.reset()
|
|
||||||
assertEquals(-1, spec.findPeakBin())
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Bayesian decoder ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun bayesian_processTone_dit() {
|
|
||||||
val decoder = CwBayesianDecoder()
|
|
||||||
// At 20 WPM, dot = 60 ms
|
|
||||||
val result = decoder.processTone(60f)
|
|
||||||
assertEquals('0', result.symbol)
|
|
||||||
assertTrue("Dit probability should be positive", result.probability > 0.1f)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun bayesian_processTone_dash() {
|
|
||||||
val decoder = CwBayesianDecoder()
|
|
||||||
// Dash = 3 * dot = 180 ms
|
|
||||||
val result = decoder.processTone(180f)
|
|
||||||
assertEquals('1', result.symbol)
|
|
||||||
assertTrue("Dash probability should be positive", result.probability > 0.1f)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun bayesian_processTone_unknown_returnsNull() {
|
|
||||||
val decoder = CwBayesianDecoder()
|
|
||||||
// Very long tone — low probability for both dit and dash
|
|
||||||
val result = decoder.processTone(5000f)
|
|
||||||
assertNull(result.symbol)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun bayesian_processGap_interChar_returnsChar() {
|
|
||||||
val decoder = CwBayesianDecoder()
|
|
||||||
decoder.processTone(60f) // dit
|
|
||||||
decoder.processTone(60f) // dit
|
|
||||||
decoder.processTone(60f) // dit
|
|
||||||
// 3 dots = "000" = 'S'
|
|
||||||
val char = decoder.processGap(180f) // 3 * dot = inter-char gap
|
|
||||||
assertEquals('S', char)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun bayesian_processGap_wordGap_addsSpace() {
|
|
||||||
val decoder = CwBayesianDecoder()
|
|
||||||
decoder.processTone(60f) // dit = 'E'
|
|
||||||
decoder.processGap(180f) // inter-char gap
|
|
||||||
// Now word gap
|
|
||||||
val space = decoder.processGap(420f) // 7 * dot
|
|
||||||
assertEquals(' ', space)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun bayesian_decodedText_accumulates() {
|
|
||||||
val decoder = CwBayesianDecoder()
|
|
||||||
decoder.processTone(60f) // dit = 'E'
|
|
||||||
decoder.processGap(180f) // inter-char
|
|
||||||
assertTrue(decoder.decodedText.isNotEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun bayesian_reset() {
|
|
||||||
val decoder = CwBayesianDecoder()
|
|
||||||
decoder.processTone(60f)
|
|
||||||
decoder.reset()
|
|
||||||
assertEquals("", decoder.decodedText)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun bayesian_getSpeed() {
|
|
||||||
val decoder = CwBayesianDecoder()
|
|
||||||
// Send 3 dits at 20 WPM (60 ms each)
|
|
||||||
decoder.processTone(60f)
|
|
||||||
decoder.processTone(60f)
|
|
||||||
decoder.processTone(60f)
|
|
||||||
val speed = decoder.getSpeed()
|
|
||||||
assertTrue("Speed should be ~20 WPM, got $speed", speed > 15f && speed < 30f)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Channel tracker ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun channelTracker_initialState() {
|
|
||||||
val spec = CwSpectrogram(sampleRate = 8000)
|
|
||||||
val tracker = CwChannelTracker(spec)
|
|
||||||
val channels = tracker.update()
|
|
||||||
assertTrue("No channels should be active initially", channels.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun channelTracker_detectsTone() {
|
|
||||||
val spec = CwSpectrogram(sampleRate = 8000)
|
|
||||||
// Feed a tone
|
|
||||||
for (i in 0..5) {
|
|
||||||
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
|
|
||||||
}
|
|
||||||
val tracker = CwChannelTracker(spec)
|
|
||||||
val channels = tracker.update()
|
|
||||||
assertTrue("Should detect at least 1 channel", channels.isNotEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun channelTracker_bestChannel() {
|
|
||||||
val spec = CwSpectrogram(sampleRate = 8000)
|
|
||||||
for (i in 0..5) {
|
|
||||||
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
|
|
||||||
}
|
|
||||||
val tracker = CwChannelTracker(spec)
|
|
||||||
tracker.update()
|
|
||||||
val best = tracker.getBestChannel()
|
|
||||||
assertNotNull("Best channel should exist", best)
|
|
||||||
if (best != null) assertTrue(best.frequency in 600f..800f)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun channelTracker_reset() {
|
|
||||||
val spec = CwSpectrogram(sampleRate = 8000)
|
|
||||||
for (i in 0..5) {
|
|
||||||
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
|
|
||||||
}
|
|
||||||
val tracker = CwChannelTracker(spec)
|
|
||||||
tracker.update()
|
|
||||||
tracker.reset()
|
|
||||||
assertNull(tracker.getBestChannel())
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Full decoder ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun decoder_initialState() {
|
|
||||||
val decoder = CwDecoder()
|
|
||||||
assertEquals("", decoder.decodedTextFlow.value)
|
|
||||||
assertEquals(0f, decoder.signalStrength.value, 0.001f)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun decoder_processSilence_doesNotCrash() {
|
|
||||||
val decoder = CwDecoder()
|
|
||||||
decoder.processBuffer(FloatArray(256) { 0f })
|
|
||||||
assertEquals("", decoder.decodedTextFlow.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun decoder_processNoise_doesNotCrash() {
|
|
||||||
val decoder = CwDecoder()
|
|
||||||
decoder.processBuffer(FloatArray(256) { (Math.random() * 2 - 1).toFloat() * 0.1f })
|
|
||||||
assertNotNull(decoder.decodedTextFlow.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun decoder_processTone_doesNotCrash() {
|
|
||||||
val decoder = CwDecoder()
|
|
||||||
for (i in 0..20) {
|
|
||||||
decoder.processBuffer(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
|
|
||||||
}
|
|
||||||
assertNotNull(decoder.decodedTextFlow.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun decoder_reset() {
|
|
||||||
val decoder = CwDecoder()
|
|
||||||
decoder.processBuffer(FloatArray(256) { 1f })
|
|
||||||
decoder.resetDecoder()
|
|
||||||
assertEquals("", decoder.decodedTextFlow.value)
|
|
||||||
assertEquals(0f, decoder.signalStrength.value, 0.001f)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun decoder_withFixedPitch() {
|
|
||||||
val decoder = CwDecoder(sampleRate = 8000, cwToneFreq = 700f)
|
|
||||||
assertEquals(700f, decoder.estimatedPitch.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/*
|
||||||
|
* 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 org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rolling audio buffer feeding DeepCW.
|
||||||
|
*
|
||||||
|
* DeepCW is a whole-segment CTC model, not a sample-by-sample decoder: it
|
||||||
|
* rewrites earlier output whenever more context arrives, so incremental
|
||||||
|
* stitching is impossible. Instead we keep a bounded window and re-decode all
|
||||||
|
* of it periodically, replacing the displayed text.
|
||||||
|
*/
|
||||||
|
class CwDeepBufferTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun capacityIsCappedAtMaxSeconds() {
|
||||||
|
val buffer = CwDeepBuffer(sampleRate = 3200, maxSeconds = 20.0)
|
||||||
|
repeat(30) { buffer.append(FloatArray(3200)) }
|
||||||
|
assertEquals(3200 * 20, buffer.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun oldestSamplesAreDiscardedFirst() {
|
||||||
|
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
|
||||||
|
buffer.append(floatArrayOf(1f, 2f, 3f))
|
||||||
|
buffer.append(floatArrayOf(4f, 5f))
|
||||||
|
assertArrayEquals(floatArrayOf(2f, 3f, 4f, 5f), buffer.snapshot(), 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun snapshotIsChronologicalAfterWrapAround() {
|
||||||
|
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
|
||||||
|
buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f))
|
||||||
|
assertArrayEquals(floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun appendLargerThanCapacityKeepsOnlyTheTail() {
|
||||||
|
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
|
||||||
|
buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f))
|
||||||
|
assertEquals(4, buffer.size)
|
||||||
|
assertArrayEquals(floatArrayOf(6f, 7f, 8f, 9f), buffer.snapshot(), 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun redecodeIsSignalledOncePerInterval() {
|
||||||
|
// 1.5 s at 3200 Hz is 4800 samples; 1600 samples is 0.5 s.
|
||||||
|
val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500)
|
||||||
|
assertFalse("1.0s elapsed: interval not reached", buffer.append(FloatArray(3200)))
|
||||||
|
assertTrue("1.5s elapsed: first trigger", buffer.append(FloatArray(1600)))
|
||||||
|
assertFalse("2.0s: only 0.5s since trigger", buffer.append(FloatArray(1600)))
|
||||||
|
assertFalse("2.5s: only 1.0s since trigger", buffer.append(FloatArray(1600)))
|
||||||
|
assertTrue("3.0s: 1.5s since trigger, fires again", buffer.append(FloatArray(1600)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun redecodeIntervalDoesNotDriftOverManyChunks() {
|
||||||
|
// 100 ms chunks, as AudioCapture emits them: exactly 15 chunks per
|
||||||
|
// 1.5 s interval, so 150 chunks must fire exactly 10 times.
|
||||||
|
val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500)
|
||||||
|
var fired = 0
|
||||||
|
repeat(150) { if (buffer.append(FloatArray(320))) fired++ }
|
||||||
|
assertEquals(10, fired)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun snapshotDoesNotAliasInternalStorage() {
|
||||||
|
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
|
||||||
|
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
|
||||||
|
buffer.snapshot()[0] = 99f
|
||||||
|
assertEquals("caller must not be able to mutate the buffer", 1f, buffer.snapshot()[0], 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resetClearsSamplesAndIntervalCounter() {
|
||||||
|
val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500)
|
||||||
|
buffer.append(FloatArray(3200))
|
||||||
|
buffer.reset()
|
||||||
|
assertEquals(0, buffer.size)
|
||||||
|
assertFalse("counter restarted, 1.0s must not trigger", buffer.append(FloatArray(3200)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hasEnoughAudioTracksTheModelMinimum() {
|
||||||
|
// compute() needs at least FFT_LENGTH samples to produce one frame.
|
||||||
|
val buffer = CwDeepBuffer(3200, 20.0)
|
||||||
|
buffer.append(FloatArray(100))
|
||||||
|
assertFalse(buffer.hasEnoughAudio)
|
||||||
|
buffer.append(FloatArray(200))
|
||||||
|
assertTrue(buffer.hasEnoughAudio)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun defaultsMatchTheMeasuredOptimum() {
|
||||||
|
// 20s / 1.5s were chosen from measurements: 20s is the smallest window
|
||||||
|
// reaching 0.0% CER, and keeps inference well inside real time.
|
||||||
|
val buffer = CwDeepBuffer()
|
||||||
|
assertEquals(CwDeepSpectrogram.SAMPLE_RATE * 20, buffer.capacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun overflowCollectsEvictedSamplesInOrder() {
|
||||||
|
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) // capacity 4
|
||||||
|
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
|
||||||
|
assertEquals("nothing evicted before the window is full", 0, buffer.overflowCount)
|
||||||
|
buffer.append(floatArrayOf(5f, 6f)) // overwrites 1, 2
|
||||||
|
assertArrayEquals("evicted samples, oldest first", floatArrayOf(1f, 2f), buffer.drainOverflow(), 0f)
|
||||||
|
assertArrayEquals("live window still correct", floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun drainOverflowClearsItself() {
|
||||||
|
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
|
||||||
|
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
|
||||||
|
buffer.append(floatArrayOf(5f))
|
||||||
|
assertEquals(1, buffer.overflowCount)
|
||||||
|
buffer.drainOverflow()
|
||||||
|
assertEquals(0, buffer.overflowCount)
|
||||||
|
assertArrayEquals(FloatArray(0), buffer.drainOverflow(), 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resetClearsOverflow() {
|
||||||
|
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
|
||||||
|
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
|
||||||
|
buffer.append(floatArrayOf(5f))
|
||||||
|
assertEquals(1, buffer.overflowCount)
|
||||||
|
buffer.reset()
|
||||||
|
assertEquals(0, buffer.overflowCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
/*
|
||||||
|
* 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 org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins the Kotlin front-end to the upstream Python reference implementation.
|
||||||
|
*
|
||||||
|
* `golden_spec.txt` was produced by deepcw-engine's own preprocessing code
|
||||||
|
* (numpy reflect padding, `np.hanning(N+1)[:-1]`, `np.fft.rfft`, `log1p`) over
|
||||||
|
* the audio [generateTestAudio] builds. Both sides synthesise the audio from
|
||||||
|
* the same deterministic formula, so only the spectrogram needs pinning.
|
||||||
|
*
|
||||||
|
* A mismatch here means the model would receive subtly wrong input and emit
|
||||||
|
* plausible-looking garbage, which is very hard to diagnose downstream — so
|
||||||
|
* this test guards the whole pipeline.
|
||||||
|
*
|
||||||
|
* Regenerate with `scripts/deepcw_gen_golden.py` from the skill library if the
|
||||||
|
* model metadata ever changes.
|
||||||
|
*/
|
||||||
|
class CwDeepGoldenVectorTest {
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SRC_RATE = 8000
|
||||||
|
const val TONE_HZ = 700.0
|
||||||
|
const val AMPLITUDE = 0.6
|
||||||
|
const val DOT_SAMPLES = 480 // 20 WPM at 8000 Hz: 1.2/20*8000
|
||||||
|
const val PATTERN = "-.-." // the letter C
|
||||||
|
const val TOLERANCE = 1e-4f
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Square-keyed 700 Hz tone: 4 dots of silence, "C", 4 dots of silence. */
|
||||||
|
private fun generateTestAudio(): FloatArray {
|
||||||
|
val keying = ArrayList<Int>()
|
||||||
|
repeat(4 * DOT_SAMPLES) { keying.add(0) }
|
||||||
|
for ((i, element) in PATTERN.withIndex()) {
|
||||||
|
val length = if (element == '-') 3 * DOT_SAMPLES else DOT_SAMPLES
|
||||||
|
repeat(length) { keying.add(1) }
|
||||||
|
if (i < PATTERN.length - 1) repeat(DOT_SAMPLES) { keying.add(0) }
|
||||||
|
}
|
||||||
|
repeat(4 * DOT_SAMPLES) { keying.add(0) }
|
||||||
|
|
||||||
|
return FloatArray(keying.size) { i ->
|
||||||
|
if (keying[i] == 1) {
|
||||||
|
(AMPLITUDE * sin(2.0 * PI * TONE_HZ * i / SRC_RATE)).toFloat()
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readGoldenSpectrogram(): Array<FloatArray> {
|
||||||
|
val stream = javaClass.classLoader?.getResourceAsStream("cw/golden_spec.txt")
|
||||||
|
?: throw IllegalStateException("cw/golden_spec.txt missing from test resources")
|
||||||
|
stream.bufferedReader().use { reader ->
|
||||||
|
val (frames, bins) = reader.readLine().trim().split(" ").map(String::toInt)
|
||||||
|
return Array(frames) {
|
||||||
|
val row = reader.readLine().trim().split(" ")
|
||||||
|
require(row.size == bins) { "expected $bins values, got ${row.size}" }
|
||||||
|
FloatArray(bins) { i -> row[i].toFloat() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun spectrogramMatchesPythonReferenceFrameByFrame() {
|
||||||
|
val expected = readGoldenSpectrogram()
|
||||||
|
val audio = CwDeepSpectrogram.resampleLinear(
|
||||||
|
generateTestAudio(), SRC_RATE, CwDeepSpectrogram.SAMPLE_RATE
|
||||||
|
)
|
||||||
|
val actual = CwDeepSpectrogram.compute(audio)
|
||||||
|
|
||||||
|
assertEquals("frame count", expected.size, actual.size)
|
||||||
|
assertEquals("bin count", expected[0].size, actual[0].size)
|
||||||
|
|
||||||
|
var worstDelta = 0f
|
||||||
|
var worstAt = ""
|
||||||
|
for (t in expected.indices) {
|
||||||
|
for (f in expected[t].indices) {
|
||||||
|
val delta = abs(expected[t][f] - actual[t][f])
|
||||||
|
if (delta > worstDelta) {
|
||||||
|
worstDelta = delta
|
||||||
|
worstAt = "frame $t bin $f: expected ${expected[t][f]}, got ${actual[t][f]}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(
|
||||||
|
"front-end diverges from the Python reference — worst delta $worstDelta at $worstAt",
|
||||||
|
worstDelta <= TOLERANCE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resampledLengthMatchesReference() {
|
||||||
|
val audio = generateTestAudio()
|
||||||
|
assertEquals("source audio length", 9120, audio.size)
|
||||||
|
val resampled = CwDeepSpectrogram.resampleLinear(
|
||||||
|
audio, SRC_RATE, CwDeepSpectrogram.SAMPLE_RATE
|
||||||
|
)
|
||||||
|
assertEquals("resampled length", 3648, resampled.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun goldenVectorHasExpectedShape() {
|
||||||
|
val golden = readGoldenSpectrogram()
|
||||||
|
assertEquals("frames", 77, golden.size)
|
||||||
|
assertEquals("bins", CwDeepSpectrogram.FREQUENCY_BINS, golden[0].size)
|
||||||
|
}
|
||||||
|
}
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
/*
|
||||||
|
* 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 org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the DeepCW front-end against the upstream Python reference
|
||||||
|
* implementation (deepcw-engine examples/python/decode_morse.py).
|
||||||
|
*
|
||||||
|
* Model metadata: sampleRate 3200, fftLength 256, hopLength 48,
|
||||||
|
* 400-1200 Hz -> 65 bins, log1p normalization.
|
||||||
|
*/
|
||||||
|
class CwDeepSpectrogramTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun frequencyBinRange_matchesModelMetadata() {
|
||||||
|
// binHz = 3200/256 = 12.5; start = ceil(400/12.5) = 32; stop = floor(1200/12.5)+1 = 97
|
||||||
|
val (start, stop) = CwDeepSpectrogram.frequencyBinRange(3200, 256, 400.0, 1200.0)
|
||||||
|
assertEquals(32, start)
|
||||||
|
assertEquals(97, stop)
|
||||||
|
assertEquals("metadata declares 65 frequency bins", 65, stop - start)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun compute_producesTimeBy65Matrix() {
|
||||||
|
// 1 second at 3200 Hz. Reflect padding adds fft/2 on both sides,
|
||||||
|
// so frames = 1 + (3200 + 256 - 256)/48 = 1 + 66 = 67
|
||||||
|
val spec = CwDeepSpectrogram.compute(FloatArray(3200))
|
||||||
|
assertEquals(67, spec.size)
|
||||||
|
assertEquals(65, spec[0].size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun compute_toneLandsInExpectedBin() {
|
||||||
|
// 700 Hz -> absolute bin 700/12.5 = 56 -> relative index 56 - 32 = 24
|
||||||
|
val audio = FloatArray(3200) { (0.6 * sin(2.0 * PI * 700.0 * it / 3200.0)).toFloat() }
|
||||||
|
val spec = CwDeepSpectrogram.compute(audio)
|
||||||
|
val middle = spec[spec.size / 2]
|
||||||
|
val peak = middle.indices.maxByOrNull { middle[it] } ?: -1
|
||||||
|
assertTrue("peak at index $peak, expected near 24", abs(peak - 24) <= 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun compute_appliesLog1pSoValuesAreNonNegative() {
|
||||||
|
val audio = FloatArray(3200) { (0.6 * sin(2.0 * PI * 700.0 * it / 3200.0)).toFloat() }
|
||||||
|
val spec = CwDeepSpectrogram.compute(audio)
|
||||||
|
for (frame in spec) {
|
||||||
|
for (v in frame) {
|
||||||
|
assertTrue("log1p of a magnitude must be >= 0, got $v", v >= 0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resampleLinear_convertsRateAndLength() {
|
||||||
|
assertEquals(3200, CwDeepSpectrogram.resampleLinear(FloatArray(8000), 8000, 3200).size)
|
||||||
|
assertEquals(3200, CwDeepSpectrogram.resampleLinear(FloatArray(44100), 44100, 3200).size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resampleLinear_sameRateIsIdentity() {
|
||||||
|
val input = floatArrayOf(0.1f, 0.2f, 0.3f)
|
||||||
|
val out = CwDeepSpectrogram.resampleLinear(input, 3200, 3200)
|
||||||
|
assertEquals(3, out.size)
|
||||||
|
assertEquals(0.2f, out[1], 1e-6f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resampleLinear_preservesToneFrequency() {
|
||||||
|
// A 700 Hz tone sampled at 8000 Hz must still peak at bin 24 after
|
||||||
|
// resampling to 3200 Hz — this is the path real microphone audio takes.
|
||||||
|
val at8k = FloatArray(8000) { (0.6 * sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() }
|
||||||
|
val at3200 = CwDeepSpectrogram.resampleLinear(at8k, 8000, 3200)
|
||||||
|
val spec = CwDeepSpectrogram.compute(at3200)
|
||||||
|
val middle = spec[spec.size / 2]
|
||||||
|
val peak = middle.indices.maxByOrNull { middle[it] } ?: -1
|
||||||
|
assertTrue("resampled tone peak at $peak, expected near 24", abs(peak - 24) <= 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun compute_rejectsAudioShorterThanFftLength() {
|
||||||
|
try {
|
||||||
|
CwDeepSpectrogram.compute(FloatArray(100))
|
||||||
|
throw AssertionError("expected an exception for audio shorter than fftLength")
|
||||||
|
} catch (expected: IllegalArgumentException) {
|
||||||
|
// desired path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.navigation
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menu layout rules. Every case here is a bug that shipped at least once, so
|
||||||
|
* treat a failure as a regression rather than a spec question.
|
||||||
|
*/
|
||||||
|
class MenuLayoutTest {
|
||||||
|
|
||||||
|
private val all = listOf(
|
||||||
|
"Satellites", "Passes", "Radar", "Mutual", "Roaming",
|
||||||
|
"CwDecode", "WavelogLog", "AMSAT", "Map", "Settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun layout(
|
||||||
|
screenOrder: List<String> = emptyList(),
|
||||||
|
subMenuOrder: List<String> = emptyList(),
|
||||||
|
hidden: List<String> = emptyList()
|
||||||
|
) = MenuLayout.resolve(all, screenOrder, subMenuOrder, hidden)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun defaultsPutFivePagesOnTheBarAndTheRestBehindMore() {
|
||||||
|
val l = layout()
|
||||||
|
assertEquals(listOf("Satellites", "Passes", "Radar", "Map", "Settings"), l.mainIds)
|
||||||
|
assertEquals(listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT"), l.moreIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsIsAlwaysReachable() {
|
||||||
|
// Moving any page onto the bar used to push Settings out of BOTH menus,
|
||||||
|
// permanently locking the user out of the settings page.
|
||||||
|
for (page in listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT")) {
|
||||||
|
val moved = MenuLayout.moveToMain(page, all, emptyList(), emptyList())
|
||||||
|
val l = layout(moved.screenOrder, moved.subMenuOrder)
|
||||||
|
assertTrue(
|
||||||
|
"moving $page hid Settings: main=${l.mainIds} more=${l.moreIds}",
|
||||||
|
"Settings" in l.mainIds || "Settings" in l.moreIds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun movingAmsatToMainActuallyTakesEffect() {
|
||||||
|
// The legacy migration re-appended AMSAT to the sub-menu unconditionally,
|
||||||
|
// silently undoing the user's choice.
|
||||||
|
val moved = MenuLayout.moveToMain("AMSAT", all, emptyList(), emptyList())
|
||||||
|
val l = layout(moved.screenOrder, moved.subMenuOrder)
|
||||||
|
assertTrue("AMSAT missing from the bar: ${l.mainIds}", "AMSAT" in l.mainIds)
|
||||||
|
assertTrue("AMSAT still behind More: ${l.moreIds}", "AMSAT" !in l.moreIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun movingWavelogLogToMainActuallyTakesEffect() {
|
||||||
|
val moved = MenuLayout.moveToMain("WavelogLog", all, emptyList(), emptyList())
|
||||||
|
val l = layout(moved.screenOrder, moved.subMenuOrder)
|
||||||
|
assertTrue("WavelogLog missing from the bar: ${l.mainIds}", "WavelogLog" in l.mainIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun newPagesUnknownToPersistedOrderDefaultToTheMoreMenu() {
|
||||||
|
// Upgrading from a build that predates AMSAT and WavelogLog: neither list
|
||||||
|
// mentions them, so both must land behind More rather than vanishing.
|
||||||
|
val l = layout(
|
||||||
|
screenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings"),
|
||||||
|
subMenuOrder = listOf("Mutual", "Roaming", "CwDecode")
|
||||||
|
)
|
||||||
|
assertTrue("AMSAT should land behind More", "AMSAT" in l.moreIds)
|
||||||
|
assertTrue("WavelogLog should land behind More", "WavelogLog" in l.moreIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun everyVisiblePageIsReachableFromSomeMenu() {
|
||||||
|
// Pages beyond the five slots used to vanish instead of overflowing.
|
||||||
|
val l = layout(
|
||||||
|
screenOrder = listOf("Satellites", "Passes", "Radar", "Mutual", "Roaming", "Map", "Settings"),
|
||||||
|
subMenuOrder = listOf("CwDecode", "WavelogLog", "AMSAT")
|
||||||
|
)
|
||||||
|
assertEquals("every page must be reachable", all.toSet(), (l.mainIds + l.moreIds).toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hiddenPagesAppearInNeitherMenu() {
|
||||||
|
val l = layout(hidden = listOf("Radar", "Map"))
|
||||||
|
assertTrue("Radar" !in l.mainIds && "Radar" !in l.moreIds)
|
||||||
|
assertTrue("Map" !in l.mainIds && "Map" !in l.moreIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsCannotBeHidden() {
|
||||||
|
val l = layout(hidden = listOf("Settings"))
|
||||||
|
assertTrue("Settings" in l.mainIds || "Settings" in l.moreIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theBarNeverExceedsFiveSlots() {
|
||||||
|
val l = layout(screenOrder = all)
|
||||||
|
assertTrue("bar had ${l.mainIds.size} slots: ${l.mainIds}", l.mainIds.size <= MenuLayout.MAIN_SLOTS)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun movingAPageOutOfTheBarPutsItBehindMore() {
|
||||||
|
val moved = MenuLayout.moveToMore("Radar", all, emptyList(), emptyList())
|
||||||
|
val l = layout(moved.screenOrder, moved.subMenuOrder)
|
||||||
|
assertTrue("Radar" in l.moreIds)
|
||||||
|
assertTrue("Radar" !in l.mainIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsCannotBeMovedOffTheBar() {
|
||||||
|
val moved = MenuLayout.moveToMore("Settings", all, emptyList(), emptyList())
|
||||||
|
val l = layout(moved.screenOrder, moved.subMenuOrder)
|
||||||
|
assertTrue("Settings" in l.mainIds || "Settings" in l.moreIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolveIsStableWhenAppliedTwice() {
|
||||||
|
// Persisting what resolve() produced must not change the outcome, or the
|
||||||
|
// settings list and the bar drift apart on the next recomposition.
|
||||||
|
val first = layout()
|
||||||
|
val second = layout(first.mainIds, first.moreIds)
|
||||||
|
assertEquals(first.mainIds, second.mainIds)
|
||||||
|
assertEquals(first.moreIds, second.moreIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun movingAPageOntoAFullBarEvictsAnotherPageIntoMore() {
|
||||||
|
// The bar starts full (5 including Settings), so making room must push an
|
||||||
|
// existing page into More instead of dropping it.
|
||||||
|
val moved = MenuLayout.moveToMain("CwDecode", all, emptyList(), emptyList())
|
||||||
|
val l = layout(moved.screenOrder, moved.subMenuOrder)
|
||||||
|
assertTrue("CwDecode" in l.mainIds)
|
||||||
|
assertEquals("nothing may be lost", all.toSet(), (l.mainIds + l.moreIds).toSet())
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
/*
|
||||||
|
* 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.predict
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.time.Instant
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
class CelestialComputerTest {
|
||||||
|
|
||||||
|
private val observer = GeoPos(22.314066, 108.706575)
|
||||||
|
|
||||||
|
private fun subLunarLongitude(gha: Double) = if (gha <= 180.0) -gha else 360.0 - gha
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `moon hour angle stays within one revolution`() {
|
||||||
|
// Sample a full synodic month at 37-minute steps so the sweep crosses
|
||||||
|
// every hour-angle quadrant many times over.
|
||||||
|
var timeMillis = 1786060800_000L // 2026-08-07T00:00:00Z
|
||||||
|
val stepMillis = 37 * 60 * 1000L
|
||||||
|
val endMillis = timeMillis + 30L * 86400_000L
|
||||||
|
var samples = 0
|
||||||
|
while (timeMillis < endMillis) {
|
||||||
|
val gha = CelestialComputer.getMoonPosition(observer, timeMillis).gha
|
||||||
|
assertTrue("gha=$gha out of 0..360 at $timeMillis", gha >= 0.0 && gha < 360.0)
|
||||||
|
val longitude = subLunarLongitude(gha)
|
||||||
|
assertTrue(
|
||||||
|
"sub-lunar longitude=$longitude out of -180..180 at $timeMillis",
|
||||||
|
longitude >= -180.0 && longitude <= 180.0
|
||||||
|
)
|
||||||
|
samples++
|
||||||
|
timeMillis += stepMillis
|
||||||
|
}
|
||||||
|
assertTrue("expected a meaningful sweep, got $samples samples", samples > 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class RiseSetCase(
|
||||||
|
val name: String,
|
||||||
|
val observer: GeoPos,
|
||||||
|
val startIso: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `findSunRiseSet returns distinct sunrise and sunset for representative locations`() {
|
||||||
|
val cases = listOf(
|
||||||
|
RiseSetCase("Equator at March equinox", GeoPos(0.0, 0.0), "2026-03-20T00:00:00Z"),
|
||||||
|
RiseSetCase("Equator at September equinox", GeoPos(0.0, 0.0), "2026-09-23T00:00:00Z"),
|
||||||
|
RiseSetCase("Sydney winter", GeoPos(-33.8688, 151.2093), "2026-06-21T00:00:00Z"),
|
||||||
|
RiseSetCase("Buenos Aires winter", GeoPos(-34.6037, -58.3816), "2026-06-21T00:00:00Z"),
|
||||||
|
RiseSetCase("Cape Town winter", GeoPos(-33.9249, 18.4241), "2026-06-21T00:00:00Z"),
|
||||||
|
RiseSetCase("London summer", GeoPos(51.5074, -0.1278), "2026-06-21T00:00:00Z")
|
||||||
|
)
|
||||||
|
|
||||||
|
cases.forEach { testCase ->
|
||||||
|
val result = CelestialComputer.findSunRiseSet(testCase.observer, testCase.startIso.toMillis())
|
||||||
|
val daylightDuration = result.setTimeMillis - result.riseTimeMillis
|
||||||
|
|
||||||
|
assertTrue("${testCase.name}: sunrise should be non-zero", result.riseTimeMillis > 0L)
|
||||||
|
assertTrue("${testCase.name}: sunset should be non-zero", result.setTimeMillis > 0L)
|
||||||
|
assertTrue("${testCase.name}: sunset should be after sunrise", result.setTimeMillis > result.riseTimeMillis)
|
||||||
|
assertTrue("${testCase.name}: daylight duration should be longer than 1 hour", daylightDuration > HOUR_MILLIS)
|
||||||
|
assertTrue("${testCase.name}: daylight duration should be shorter than 24 hours", daylightDuration < DAY_MILLIS)
|
||||||
|
|
||||||
|
val riseElevation = CelestialComputer.getSunPosition(testCase.observer, result.riseTimeMillis).elevation
|
||||||
|
val setElevation = CelestialComputer.getSunPosition(testCase.observer, result.setTimeMillis).elevation
|
||||||
|
assertEquals("${testCase.name}: sunrise should converge near the standard threshold", SUNRISE_SET_THRESHOLD, riseElevation, 0.02)
|
||||||
|
assertEquals("${testCase.name}: sunset should converge near the standard threshold", SUNRISE_SET_THRESHOLD, setElevation, 0.02)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `findSunRiseSet does not return the same instant for equinox regression cases`() {
|
||||||
|
listOf("2026-03-20T00:00:00Z", "2026-09-23T00:00:00Z").forEach { startIso ->
|
||||||
|
val result = CelestialComputer.findSunRiseSet(GeoPos(0.0, 0.0), startIso.toMillis())
|
||||||
|
val separationMillis = abs(result.setTimeMillis - result.riseTimeMillis)
|
||||||
|
|
||||||
|
assertTrue("$startIso: sunrise and sunset should be separated", separationMillis > HOUR_MILLIS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toMillis(): Long = Instant.parse(this).toEpochMilli()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val SUNRISE_SET_THRESHOLD = -0.8333
|
||||||
|
private const val HOUR_MILLIS = 60L * 60L * 1000L
|
||||||
|
private const val DAY_MILLIS = 24L * HOUR_MILLIS
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.utility
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* clipLon must reduce any longitude into [-180, 180] in bounded time. The old
|
||||||
|
* while-loop never returned for extreme inputs: Infinity stays Infinity after
|
||||||
|
* subtracting 360 (infinite loop), and ~1e12 degree values took billions of
|
||||||
|
* iterations. The modulo rewrite must be bit-equivalent for all finite values.
|
||||||
|
*/
|
||||||
|
class ClipLonTest {
|
||||||
|
|
||||||
|
private fun reduced(v: Double) = ((v + 180.0) % 360.0 + 360.0) % 360.0 - 180.0
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `finite values match the closed interval semantics`() {
|
||||||
|
// The old loop: subtract/add 360 until within (-180, 180] is not quite
|
||||||
|
// it either - 180 stays 180, -180 stays -180. So the interval is closed
|
||||||
|
// on both ends with +180 for positive-boundary hits.
|
||||||
|
assertEquals(-180.0, clipLon(-180.0), 1e-12)
|
||||||
|
assertEquals(180.0, clipLon(180.0), 1e-12)
|
||||||
|
assertEquals(0.0, clipLon(360.0), 1e-12)
|
||||||
|
assertEquals(180.0, clipLon(540.0), 1e-12)
|
||||||
|
assertEquals(-180.0, clipLon(-540.0), 1e-12)
|
||||||
|
assertEquals(-179.999, clipLon(180.001), 1e-9)
|
||||||
|
assertEquals(179.999, clipLon(-180.001), 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `modulo rewrite is equivalent to the loop across the domain`() {
|
||||||
|
// Sweep the same range the old implementation covered in a probe:
|
||||||
|
// every 0.01 degree from -10000 to 10000 must agree with the reference
|
||||||
|
// reduction to within 1e-9.
|
||||||
|
var v = -10000.0
|
||||||
|
while (v <= 10000.0) {
|
||||||
|
val reference = clipLonByLoop(v)
|
||||||
|
assertEquals(reference, clipLon(v), 1e-9)
|
||||||
|
v += 0.01
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extreme and non-finite inputs return immediately`() {
|
||||||
|
// These used to hang the calling thread (Infinity loop) or take seconds.
|
||||||
|
assertEquals(Double.POSITIVE_INFINITY, clipLon(Double.POSITIVE_INFINITY), 0.0)
|
||||||
|
assertEquals(Double.NEGATIVE_INFINITY, clipLon(Double.NEGATIVE_INFINITY), 0.0)
|
||||||
|
assertTrue(clipLon(Double.NaN).isNaN())
|
||||||
|
assertFalse(clipLon(1e15).isNaN())
|
||||||
|
assertTrue(clipLon(1e15) in -180.0..180.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The old while-loop implementation, kept as the reference for equivalence. */
|
||||||
|
private fun clipLonByLoop(longitude: Double): Double {
|
||||||
|
var result = longitude
|
||||||
|
while (result < -180.0) result += 360.0
|
||||||
|
while (result > 180.0) result -= 360.0
|
||||||
|
return minOf(maxOf(result, -180.0), 180.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.wavelog
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the WaveLog upload payload frequency/band fields.
|
||||||
|
*
|
||||||
|
* Regression: user reported QSOs landing in the 160m band. The v2 JSON
|
||||||
|
* envelope must carry freq as a MHz string with an "M" suffix so WaveLog's
|
||||||
|
* parse_frequency() reads it as Hz internally; a bare integer or bare MHz
|
||||||
|
* value corrupts band derivation.
|
||||||
|
*/
|
||||||
|
class WaveLogApiPayloadTest {
|
||||||
|
|
||||||
|
// SO-50: uplink 145.850 MHz, downlink 436.795 MHz
|
||||||
|
private val uplinkHz = 145_850_000L
|
||||||
|
private val downlinkHz = 436_795_000L
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun v2_freq_usesMhzStringWithMSuffix() {
|
||||||
|
val freq = String.format(Locale.ENGLISH, "%.6fM", uplinkHz / 1_000_000.0)
|
||||||
|
val freqRx = String.format(Locale.ENGLISH, "%.6fM", downlinkHz / 1_000_000.0)
|
||||||
|
assertEquals("145.850000M", freq)
|
||||||
|
assertEquals("436.795000M", freqRx)
|
||||||
|
// WaveLog parse_frequency: "145.850000M" -> 145850000 Hz
|
||||||
|
val parsedHz = parseLikeWaveLog(freq)
|
||||||
|
assertEquals(uplinkHz, parsedHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun v1_adif_freq_isBareMhzNumber() {
|
||||||
|
// v1 ADIF <FREQ> is a bare MHz number per ADIF spec (no unit suffix)
|
||||||
|
val freq = String.format(Locale.ENGLISH, "%.6f", uplinkHz / 1_000_000.0)
|
||||||
|
assertEquals("145.850000", freq)
|
||||||
|
val adifFreq = freq.toDouble() * 1_000_000
|
||||||
|
assertEquals(uplinkHz.toDouble(), adifFreq, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun v1_adif_preservesSixCharacterGrid() {
|
||||||
|
val qso = WavelogQso(
|
||||||
|
id = "test",
|
||||||
|
timeUtcMs = 0L,
|
||||||
|
call = "BG7NTA",
|
||||||
|
mode = "FM",
|
||||||
|
freqTxHz = uplinkHz,
|
||||||
|
freqRxHz = downlinkHz,
|
||||||
|
satName = "SO-50"
|
||||||
|
)
|
||||||
|
|
||||||
|
val adif = WaveLogApi.toAdif(qso, "OM89ab", "SO-50")
|
||||||
|
|
||||||
|
assertTrue(adif.contains("<gridsquare:6>OM89ab"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors WaveLog Logbook_model::parse_frequency: int = Hz, "12.3M" suffix = MHz. */
|
||||||
|
private fun parseLikeWaveLog(raw: String): Long {
|
||||||
|
val s = raw.trim()
|
||||||
|
return if (s.endsWith("M", ignoreCase = true)) {
|
||||||
|
(s.dropLast(1).toDouble() * 1_000_000).toLong()
|
||||||
|
} else if (s.endsWith("k", ignoreCase = true)) {
|
||||||
|
(s.dropLast(1).toDouble() * 1_000).toLong()
|
||||||
|
} else {
|
||||||
|
s.toLong()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun qsoFreqs_stayInSatelliteBands_afterDoppler() {
|
||||||
|
// Doppler-corrected values must remain near the base frequency.
|
||||||
|
// SPEED_OF_LIGHT = 299792458 m/s; distanceRate is km/s (x1000 -> m/s).
|
||||||
|
val dopplerRate = 7.0 // km/s approaching
|
||||||
|
val corrected = uplinkHz * (299_792_458.0 + dopplerRate * 1000.0) / 299_792_458.0
|
||||||
|
assertTrue(
|
||||||
|
"corrected within +-20kHz, got ${corrected - uplinkHz} Hz",
|
||||||
|
kotlin.math.abs(corrected - uplinkHz) < 20_000
|
||||||
|
)
|
||||||
|
// band derivation: 145.x MHz -> 2m, never 160m (1.8-2.0 MHz)
|
||||||
|
val mhz = corrected / 1_000_000.0
|
||||||
|
assertTrue("145.x MHz stays in 2m, got $mhz MHz", mhz in 144.0..148.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun band_isRealBand_notSAT() {
|
||||||
|
// SO-50: TX 145.850 MHz (VHF) -> band 2M, sat mode V/U
|
||||||
|
assertEquals("2M", WaveLogApi.bandFromHz(145_850_000))
|
||||||
|
assertEquals("V/U", WaveLogApi.satModeFrom(145_850_000, 436_795_000))
|
||||||
|
// AO-73: TX 435.150 MHz (UHF up), RX 145.950 MHz (VHF down) -> band 70CM, U/V
|
||||||
|
assertEquals("70CM", WaveLogApi.bandFromHz(435_150_000))
|
||||||
|
assertEquals("U/V", WaveLogApi.satModeFrom(435_150_000, 145_950_000))
|
||||||
|
// Same-band (e.g. simplex) -> empty sat mode
|
||||||
|
assertEquals("", WaveLogApi.satModeFrom(145_850_000, 145_950_000))
|
||||||
|
// Never 160m for satellite frequencies
|
||||||
|
assertTrue(WaveLogApi.bandFromHz(145_850_000) != "160M")
|
||||||
|
assertTrue(WaveLogApi.bandFromHz(436_795_000) != "160M")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun adif_containsRealBandAndSatMode() {
|
||||||
|
// v2 payload fields (mirror postQso construction)
|
||||||
|
val band = WaveLogApi.bandFromHz(uplinkHz)
|
||||||
|
val satMode = WaveLogApi.satModeFrom(uplinkHz, downlinkHz)
|
||||||
|
assertEquals("2M", band)
|
||||||
|
assertEquals("V/U", satMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
77 65
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0.07889045 0.08153424 0.08419035 0.08701893 0.09030894 0.09436007 0.09932096 0.1050817 0.1113084 0.1176305 0.1239188 0.1305645 0.1386389 0.1497532 0.1654913 0.1866336 0.2127252 0.2422527 0.2731474 0.3032433 0.3305584 0.3534314 0.3705729 0.3810754 0.3844073 0.3804049 0.3692688 0.3515675 0.3282437 0.3006178 0.2703726 0.239492 0.2101091 0.1842086 0.1631677 0.1472881 0.1356851 0.126759 0.1189501 0.1112676 0.1034146 0.09563871 0.0884626 0.08238604 0.07764256 0.07410739 0.07139879 0.06908784 0.06687377 0.06464756 0.06245377 0.06040035 0.05856907 0.05696608 0.05552753 0.05416456 0.05281367 0.05146397 0.05015182 0.0489313 0.04783952 0.04687586 0.04600478 0.04517753 0.04435825
|
||||||
|
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
|
||||||
|
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
|
||||||
|
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
|
||||||
|
0.07889044 0.08153418 0.08419043 0.08701882 0.09030894 0.09435995 0.09932118 0.1050815 0.1113084 0.1176306 0.1239187 0.1305645 0.1386389 0.1497532 0.1654913 0.1866337 0.2127252 0.2422527 0.2731474 0.3032434 0.3305583 0.3534314 0.3705729 3.007106 3.643108 3.00695 0.3692689 0.3515675 0.3282436 0.300618 0.2703725 0.239492 0.210109 0.1842086 0.1631676 0.1472882 0.1356852 0.1267589 0.1189501 0.3679682 0.5516769 0.2577798 0.08846281 0.08238595 0.07764257 0.07410715 0.07139895 0.06908786 0.06687377 0.06464756 0.06245382 0.06040037 0.05856909 0.05696621 0.05552716 0.0541646 0.05281365 0.05146411 0.05015175 0.04893123 0.04783951 0.04687591 0.0460047 0.04517756 0.04435825
|
||||||
|
1.564492e-07 1.122884e-07 8.888702e-08 1.762498e-07 8.600421e-09 1.938033e-07 2.387622e-07 2.871272e-07 2.21893e-07 1.239545e-07 8.498516e-08 5.176826e-08 1.897711e-07 8.593484e-08 4.070753e-08 7.40209e-08 2.278317e-07 8.807671e-08 9.716474e-08 1.372644e-07 1.501705e-07 2.285518e-08 9.67386e-08 2.987679 3.6553 2.987679 1.266364e-07 1.456021e-08 5.034151e-08 2.597979e-07 1.838301e-07 6.343754e-08 8.150528e-08 5.003271e-09 9.525829e-08 2.182818e-07 1.843608e-07 1.385002e-08 6.420205e-09 0.3078006 0.542824 0.3078008 2.254644e-07 1.478402e-07 1.590096e-07 3.480243e-07 1.698739e-07 5.310058e-08 4.452434e-08 4.024338e-09 5.150024e-08 2.914611e-08 1.276424e-07 1.826463e-07 3.983407e-07 5.450444e-08 2.266521e-07 1.942693e-07 7.255422e-08 1.167992e-07 1.510635e-07 6.353517e-08 7.878904e-08 3.480655e-08 4.585462e-09
|
||||||
|
1.564492e-07 1.122884e-07 8.888694e-08 1.762498e-07 8.600284e-09 1.938033e-07 2.387623e-07 2.871272e-07 2.21893e-07 1.239545e-07 8.498505e-08 5.176823e-08 1.897709e-07 8.593484e-08 4.070745e-08 7.402096e-08 2.278317e-07 8.807665e-08 9.716481e-08 1.372644e-07 1.501703e-07 2.285522e-08 9.673848e-08 2.987679 3.6553 2.987679 1.266363e-07 1.456018e-08 5.034165e-08 2.597979e-07 1.8383e-07 6.34376e-08 8.150528e-08 5.003333e-09 9.525822e-08 2.182818e-07 1.843609e-07 1.385006e-08 6.420322e-09 0.3078006 0.542824 0.3078008 2.254643e-07 1.478403e-07 1.590095e-07 3.480243e-07 1.698739e-07 5.310051e-08 4.452434e-08 4.0244e-09 5.150032e-08 2.914612e-08 1.276426e-07 1.826462e-07 3.983408e-07 5.450447e-08 2.266521e-07 1.942694e-07 7.255434e-08 1.167993e-07 1.510637e-07 6.353518e-08 7.878896e-08 3.480649e-08 4.585462e-09
|
||||||
|
1.564492e-07 1.122884e-07 8.888694e-08 1.762499e-07 8.600289e-09 1.938033e-07 2.387623e-07 2.871272e-07 2.21893e-07 1.239545e-07 8.498504e-08 5.176823e-08 1.897709e-07 8.593484e-08 4.070745e-08 7.402096e-08 2.278317e-07 8.807665e-08 9.716482e-08 1.372644e-07 1.501703e-07 2.285522e-08 9.673848e-08 2.987679 3.6553 2.987679 1.266363e-07 1.456017e-08 5.034164e-08 2.597979e-07 1.8383e-07 6.34376e-08 8.150528e-08 5.003331e-09 9.525821e-08 2.182818e-07 1.843609e-07 1.385006e-08 6.420325e-09 0.3078006 0.542824 0.3078008 2.254643e-07 1.478403e-07 1.590095e-07 3.480243e-07 1.698739e-07 5.310052e-08 4.452434e-08 4.024398e-09 5.150032e-08 2.914612e-08 1.276426e-07 1.826462e-07 3.983408e-07 5.450447e-08 2.266521e-07 1.942694e-07 7.255434e-08 1.167993e-07 1.510637e-07 6.353518e-08 7.878896e-08 3.480649e-08 4.585462e-09
|
||||||
|
1.564492e-07 1.122884e-07 8.888702e-08 1.762499e-07 8.600421e-09 1.938034e-07 2.387622e-07 2.871271e-07 2.21893e-07 1.239546e-07 8.498516e-08 5.176833e-08 1.897711e-07 8.593477e-08 4.070753e-08 7.402097e-08 2.278317e-07 8.807664e-08 9.716474e-08 1.372644e-07 1.501705e-07 2.285512e-08 9.67386e-08 2.987679 3.6553 2.987679 1.266364e-07 1.456028e-08 5.034151e-08 2.59798e-07 1.838301e-07 6.343761e-08 8.150528e-08 5.003339e-09 9.525829e-08 2.182819e-07 1.843608e-07 1.384995e-08 6.420205e-09 0.3078006 0.542824 0.3078008 2.254644e-07 1.478402e-07 1.590096e-07 3.480242e-07 1.698739e-07 5.310051e-08 4.452434e-08 4.024406e-09 5.150024e-08 2.914618e-08 1.276424e-07 1.826463e-07 3.983407e-07 5.450437e-08 2.266521e-07 1.942693e-07 7.255422e-08 1.167992e-07 1.510635e-07 6.353524e-08 7.878904e-08 3.480648e-08 4.585462e-09
|
||||||
|
1.564492e-07 1.122885e-07 8.888715e-08 1.762501e-07 8.600158e-09 1.938033e-07 2.387623e-07 2.871272e-07 2.21893e-07 1.239545e-07 8.498502e-08 5.176819e-08 1.897708e-07 8.593463e-08 4.070766e-08 7.402083e-08 2.278317e-07 8.807678e-08 9.716461e-08 1.372642e-07 1.501702e-07 2.285526e-08 9.673845e-08 2.987679 3.6553 2.987679 1.266363e-07 1.456013e-08 5.034177e-08 2.597981e-07 1.838302e-07 6.343747e-08 8.150528e-08 5.003199e-09 9.525842e-08 2.18282e-07 1.843611e-07 1.38501e-08 6.420349e-09 0.3078006 0.542824 0.3078008 2.254643e-07 1.478403e-07 1.590094e-07 3.480241e-07 1.698737e-07 5.310065e-08 4.452434e-08 4.024266e-09 5.150011e-08 2.914633e-08 1.276427e-07 1.826462e-07 3.983409e-07 5.45045e-08 2.266521e-07 1.942694e-07 7.255437e-08 1.167993e-07 1.510638e-07 6.353538e-08 7.878917e-08 3.480662e-08 4.585462e-09
|
||||||
|
1.564492e-07 1.122885e-07 8.888713e-08 1.762501e-07 8.600153e-09 1.938034e-07 2.387623e-07 2.871272e-07 2.21893e-07 1.239545e-07 8.498504e-08 5.176827e-08 1.897708e-07 8.593455e-08 4.070764e-08 7.402088e-08 2.278317e-07 8.807672e-08 9.716463e-08 1.372642e-07 1.501702e-07 2.285517e-08 9.673848e-08 2.987679 3.6553 2.987679 1.266363e-07 1.456022e-08 5.034178e-08 2.597982e-07 1.838302e-07 6.343753e-08 8.150528e-08 5.003255e-09 9.52584e-08 2.182821e-07 1.843611e-07 1.385001e-08 6.420325e-09 0.3078006 0.542824 0.3078008 2.254643e-07 1.478402e-07 1.590094e-07 3.48024e-07 1.698737e-07 5.310059e-08 4.452434e-08 4.024322e-09 5.150013e-08 2.91464e-08 1.276427e-07 1.826463e-07 3.983408e-07 5.450445e-08 2.266521e-07 1.942693e-07 7.255434e-08 1.167992e-07 1.510638e-07 6.353546e-08 7.878916e-08 3.480656e-08 4.585462e-09
|
||||||
|
1.564492e-07 1.122884e-07 8.888702e-08 1.762501e-07 8.600201e-09 1.938033e-07 2.387621e-07 2.871272e-07 2.21893e-07 1.239545e-07 8.49852e-08 5.176818e-08 1.897709e-07 8.59346e-08 4.070753e-08 7.402099e-08 2.278317e-07 8.807662e-08 9.716474e-08 1.372642e-07 1.501702e-07 2.285526e-08 9.673864e-08 2.987679 3.6553 2.987679 1.266365e-07 1.456013e-08 5.034173e-08 2.597982e-07 1.838301e-07 6.343763e-08 8.150528e-08 5.00336e-09 9.525829e-08 2.182821e-07 1.84361e-07 1.38501e-08 6.420163e-09 0.3078006 0.542824 0.3078008 2.254645e-07 1.478403e-07 1.590094e-07 3.480241e-07 1.698739e-07 5.310049e-08 4.452434e-08 4.024427e-09 5.150024e-08 2.914636e-08 1.276426e-07 1.826462e-07 3.983407e-07 5.450442e-08 2.266521e-07 1.942693e-07 7.255419e-08 1.167993e-07 1.510638e-07 6.353542e-08 7.878904e-08 3.480646e-08 4.585462e-09
|
||||||
|
0.07889044 0.08153418 0.08419043 0.08701882 0.09030894 0.09435995 0.09932118 0.1050815 0.1113084 0.1176306 0.1239187 0.1305645 0.1386389 0.1497532 0.1654913 0.1866337 0.2127252 0.2422527 0.2731474 0.3032434 0.3305583 0.3534314 0.3705729 3.007106 3.643108 3.00695 0.3692689 0.3515675 0.3282436 0.300618 0.2703725 0.239492 0.210109 0.1842086 0.1631676 0.1472882 0.1356852 0.1267589 0.1189501 0.3679682 0.5516769 0.2577798 0.08846281 0.08238595 0.07764257 0.07410715 0.07139895 0.06908786 0.06687377 0.06464756 0.06245382 0.06040037 0.05856909 0.05696621 0.05552716 0.0541646 0.05281365 0.05146411 0.05015175 0.04893123 0.04783951 0.04687591 0.0460047 0.04517756 0.04435825
|
||||||
|
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
|
||||||
|
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
|
||||||
|
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
|
||||||
|
0.0133671 0.103174 0.161102 0.1321454 0.01981753 0.1171069 0.1886141 0.1606799 0.03154613 0.1368369 0.230862 0.208004 0.05647711 0.1650847 0.3020293 0.298915 0.1252058 0.1979204 0.4401451 0.5311838 0.4472875 0.1536819 0.2990841 0.5738236 0.6613865 0.5702119 0.2925259 0.1586363 0.4468553 0.5274072 0.4351405 0.1946639 0.1232734 0.2943546 0.2986827 0.1671092 0.04655298 0.1966405 0.2236971 0.1398044 0.01435895 0.1384342 0.1696025 0.109466 0.009560954 0.1101514 0.1376043 0.08825757 0.01127213 0.09668601 0.1209162 0.07821064 0.008807627 0.0850097 0.1078746 0.07068323 0.006762736 0.07595089 0.09776526 0.06483996 0.005235632 0.0689388 0.08987227 0.0602516 0.004088645
|
||||||
|
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
|
||||||
|
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
|
||||||
|
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
|
||||||
|
0.01336695 0.1031739 0.161102 0.1321453 0.01981753 0.1171067 0.1886142 0.1606797 0.03154635 0.1368371 0.2308619 0.2080039 0.05647729 0.1650848 0.3020293 0.298915 0.1252055 0.1979204 0.440145 0.531184 0.4472874 0.1536818 0.299084 3.026 3.630765 3.02569 0.2925259 0.1586363 0.4468552 0.5274073 0.4351404 0.1946639 0.1232734 0.2943546 0.2986827 0.1671094 0.04655316 0.1966404 0.2236971 0.4124265 0.5511931 0.1922355 0.1696027 0.1094658 0.009561111 0.110151 0.1376044 0.08825763 0.01127217 0.09668601 0.1209163 0.07821067 0.008807753 0.08500987 0.1078743 0.07068328 0.00676251 0.07595107 0.0977652 0.06483985 0.005235482 0.06893886 0.0898722 0.06025163 0.004088641
|
||||||
|
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
|
||||||
|
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
|
||||||
|
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
|
||||||
|
0.0133671 0.103174 0.161102 0.1321454 0.01981753 0.1171069 0.1886141 0.1606799 0.03154613 0.1368369 0.230862 0.208004 0.05647711 0.1650847 0.3020293 0.298915 0.1252058 0.1979204 0.4401451 0.5311838 0.4472875 0.1536819 0.2990841 0.5738236 0.6613865 0.5702119 0.2925259 0.1586363 0.4468553 0.5274072 0.4351405 0.1946639 0.1232734 0.2943546 0.2986827 0.1671092 0.04655298 0.1966405 0.2236971 0.1398044 0.01435895 0.1384342 0.1696025 0.109466 0.009560954 0.1101514 0.1376043 0.08825757 0.01127213 0.09668601 0.1209162 0.07821064 0.008807627 0.0850097 0.1078746 0.07068323 0.006762736 0.07595089 0.09776526 0.06483996 0.005235632 0.0689388 0.08987227 0.0602516 0.004088645
|
||||||
|
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
|
||||||
|
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
|
||||||
|
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
|
||||||
|
0.07889044 0.08153418 0.08419043 0.08701882 0.09030894 0.09435995 0.09932118 0.1050815 0.1113084 0.1176306 0.1239187 0.1305645 0.1386389 0.1497532 0.1654913 0.1866337 0.2127252 0.2422527 0.2731474 0.3032434 0.3305583 0.3534314 0.3705729 3.007106 3.643108 3.00695 0.3692689 0.3515675 0.3282436 0.300618 0.2703725 0.239492 0.210109 0.1842086 0.1631676 0.1472882 0.1356852 0.1267589 0.1189501 0.3679682 0.5516769 0.2577798 0.08846281 0.08238595 0.07764257 0.07410715 0.07139895 0.06908786 0.06687377 0.06464756 0.06245382 0.06040037 0.05856909 0.05696621 0.05552716 0.0541646 0.05281365 0.05146411 0.05015175 0.04893123 0.04783951 0.04687591 0.0460047 0.04517756 0.04435825
|
||||||
|
1.564492e-07 1.122885e-07 8.888702e-08 1.762501e-07 8.600116e-09 1.938031e-07 2.387619e-07 2.871269e-07 2.21893e-07 1.239548e-07 8.498547e-08 5.176805e-08 1.897708e-07 8.59346e-08 4.070753e-08 7.402088e-08 2.278317e-07 8.807672e-08 9.716474e-08 1.372642e-07 1.501702e-07 2.28554e-08 9.673891e-08 2.987679 3.6553 2.987679 1.266367e-07 1.456e-08 5.034181e-08 2.597982e-07 1.838301e-07 6.343753e-08 8.150528e-08 5.003254e-09 9.525829e-08 2.182821e-07 1.843611e-07 1.385024e-08 6.419893e-09 0.3078006 0.542824 0.3078008 2.254647e-07 1.478404e-07 1.590093e-07 3.480241e-07 1.698739e-07 5.31006e-08 4.452434e-08 4.024321e-09 5.150024e-08 2.914635e-08 1.276427e-07 1.82646e-07 3.983404e-07 5.450418e-08 2.266521e-07 1.942691e-07 7.255392e-08 1.167995e-07 1.510639e-07 6.353541e-08 7.878904e-08 3.480657e-08 4.585462e-09
|
||||||
|
1.564492e-07 1.122886e-07 8.888708e-08 1.762499e-07 8.600304e-09 1.938035e-07 2.387625e-07 2.871271e-07 2.21893e-07 1.239546e-07 8.49849e-08 5.176842e-08 1.89771e-07 8.593476e-08 4.070759e-08 7.402079e-08 2.278317e-07 8.807682e-08 9.716468e-08 1.372644e-07 1.501704e-07 2.285502e-08 9.673834e-08 2.987679 3.6553 2.987679 1.266362e-07 1.456037e-08 5.034163e-08 2.59798e-07 1.838302e-07 6.343743e-08 8.150528e-08 5.003165e-09 9.525835e-08 2.182819e-07 1.843609e-07 1.384986e-08 6.420458e-09 0.3078006 0.542824 0.3078008 2.254642e-07 1.478401e-07 1.590095e-07 3.480242e-07 1.698738e-07 5.310068e-08 4.452434e-08 4.024231e-09 5.150018e-08 2.914619e-08 1.276425e-07 1.826464e-07 3.98341e-07 5.45043e-08 2.266521e-07 1.942692e-07 7.255448e-08 1.167991e-07 1.510637e-07 6.353525e-08 7.87891e-08 3.480666e-08 4.585462e-09
|
||||||
|
1.564492e-07 1.122883e-07 8.888687e-08 1.762498e-07 8.600378e-09 1.938033e-07 2.387621e-07 2.87127e-07 2.21893e-07 1.239547e-07 8.498525e-08 5.176826e-08 1.89771e-07 8.593487e-08 4.070738e-08 7.402101e-08 2.278317e-07 8.80766e-08 9.716489e-08 1.372645e-07 1.501704e-07 2.285518e-08 9.673868e-08 2.987679 3.6553 2.987679 1.266365e-07 1.456021e-08 5.034155e-08 2.597979e-07 1.8383e-07 6.343765e-08 8.150528e-08 5.003383e-09 9.525814e-08 2.182818e-07 1.843608e-07 1.385002e-08 6.420119e-09 0.3078006 0.542824 0.3078008 2.254645e-07 1.478402e-07 1.590096e-07 3.480243e-07 1.69874e-07 5.310046e-08 4.452434e-08 4.02445e-09 5.150039e-08 2.914608e-08 1.276425e-07 1.826463e-07 3.983406e-07 5.450425e-08 2.266521e-07 1.942691e-07 7.255414e-08 1.167992e-07 1.510636e-07 6.353514e-08 7.878889e-08 3.480644e-08 4.585462e-09
|
||||||
|
1.564492e-07 1.122883e-07 8.888674e-08 1.762497e-07 8.60019e-09 1.938031e-07 2.387623e-07 2.871273e-07 2.21893e-07 1.239544e-07 8.498511e-08 5.176805e-08 1.897708e-07 8.593494e-08 4.070725e-08 7.402105e-08 2.278317e-07 8.807655e-08 9.716502e-08 1.372645e-07 1.501702e-07 2.285539e-08 9.673855e-08 2.987679 3.6553 2.987679 1.266364e-07 1.456e-08 5.034174e-08 2.597978e-07 1.838298e-07 6.343769e-08 8.150528e-08 5.00342e-09 9.525801e-08 2.182817e-07 1.84361e-07 1.385023e-08 6.420252e-09 0.3078006 0.542824 0.3078008 2.254644e-07 1.478404e-07 1.590094e-07 3.480244e-07 1.698741e-07 5.310043e-08 4.452434e-08 4.024487e-09 5.150052e-08 2.914602e-08 1.276427e-07 1.82646e-07 3.983407e-07 5.450456e-08 2.266521e-07 1.942695e-07 7.255427e-08 1.167994e-07 1.510638e-07 6.353508e-08 7.878877e-08 3.48064e-08 4.585462e-09
|
||||||
|
1.564492e-07 1.122884e-07 8.888679e-08 1.762497e-07 8.600452e-09 1.938035e-07 2.387627e-07 2.871276e-07 2.21893e-07 1.239541e-07 8.49847e-08 5.176846e-08 1.897711e-07 8.593501e-08 4.07073e-08 7.402098e-08 2.278317e-07 8.807663e-08 9.716497e-08 1.372646e-07 1.501705e-07 2.285498e-08 9.673813e-08 2.987679 3.6553 2.987679 1.266359e-07 1.456041e-08 5.034148e-08 2.597978e-07 1.838299e-07 6.343762e-08 8.150528e-08 5.003356e-09 9.525806e-08 2.182816e-07 1.843608e-07 1.384982e-08 6.42067e-09 0.3078006 0.542824 0.3078008 2.254639e-07 1.4784e-07 1.590097e-07 3.480245e-07 1.698741e-07 5.310049e-08 4.452434e-08 4.024423e-09 5.150047e-08 2.914594e-08 1.276424e-07 1.826465e-07 3.983412e-07 5.450482e-08 2.266521e-07 1.942697e-07 7.255469e-08 1.16799e-07 1.510635e-07 6.3535e-08 7.878881e-08 3.480647e-08 4.585462e-09
|
||||||
|
1.564492e-07 1.122884e-07 8.888694e-08 1.762497e-07 8.600463e-09 1.938032e-07 2.387621e-07 2.871272e-07 2.21893e-07 1.239545e-07 8.498531e-08 5.17681e-08 1.897711e-07 8.593498e-08 4.070744e-08 7.402095e-08 2.278317e-07 8.807665e-08 9.716483e-08 1.372646e-07 1.501705e-07 2.285535e-08 9.673875e-08 2.987679 3.6553 2.987679 1.266366e-07 1.456004e-08 5.034147e-08 2.597978e-07 1.8383e-07 6.34376e-08 8.150528e-08 5.003324e-09 9.52582e-08 2.182817e-07 1.843607e-07 1.385019e-08 6.420059e-09 0.3078006 0.542824 0.3078008 2.254646e-07 1.478404e-07 1.590097e-07 3.480245e-07 1.698739e-07 5.310052e-08 4.452434e-08 4.024391e-09 5.150033e-08 2.914598e-08 1.276424e-07 1.826461e-07 3.983406e-07 5.450446e-08 2.266521e-07 1.942694e-07 7.255408e-08 1.167994e-07 1.510635e-07 6.353503e-08 7.878896e-08 3.48065e-08 4.585462e-09
|
||||||
|
1.564492e-07 1.122886e-07 8.888708e-08 1.762499e-07 8.600294e-09 1.938035e-07 2.387625e-07 2.871271e-07 2.21893e-07 1.239546e-07 8.498491e-08 5.176843e-08 1.89771e-07 8.593477e-08 4.070759e-08 7.40208e-08 2.278317e-07 8.807681e-08 9.716467e-08 1.372644e-07 1.501703e-07 2.285501e-08 9.673835e-08 2.987679 3.6553 2.987679 1.266362e-07 1.456038e-08 5.034164e-08 2.59798e-07 1.838302e-07 6.343744e-08 8.150528e-08 5.003168e-09 9.525836e-08 2.182819e-07 1.843609e-07 1.384985e-08 6.420451e-09 0.3078006 0.542824 0.3078008 2.254642e-07 1.478401e-07 1.590095e-07 3.480242e-07 1.698738e-07 5.310068e-08 4.452434e-08 4.024235e-09 5.150017e-08 2.914619e-08 1.276426e-07 1.826464e-07 3.983409e-07 5.450431e-08 2.266521e-07 1.942692e-07 7.255447e-08 1.167991e-07 1.510637e-07 6.353525e-08 7.878911e-08 3.480665e-08 4.585462e-09
|
||||||
|
0.07889044 0.08153418 0.08419043 0.08701882 0.09030894 0.09435995 0.09932118 0.1050815 0.1113084 0.1176306 0.1239187 0.1305645 0.1386389 0.1497532 0.1654913 0.1866337 0.2127252 0.2422527 0.2731474 0.3032434 0.3305583 0.3534314 0.3705729 3.007106 3.643108 3.00695 0.3692689 0.3515675 0.3282436 0.300618 0.2703725 0.239492 0.210109 0.1842086 0.1631676 0.1472882 0.1356852 0.1267589 0.1189501 0.3679682 0.5516769 0.2577798 0.08846281 0.08238595 0.07764257 0.07410715 0.07139895 0.06908786 0.06687377 0.06464756 0.06245382 0.06040037 0.05856909 0.05696621 0.05552716 0.0541646 0.05281365 0.05146411 0.05015175 0.04893123 0.04783951 0.04687591 0.0460047 0.04517756 0.04435825
|
||||||
|
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
|
||||||
|
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
|
||||||
|
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
|
||||||
|
0.0133671 0.103174 0.161102 0.1321454 0.01981753 0.1171069 0.1886141 0.1606799 0.03154613 0.1368369 0.230862 0.208004 0.05647711 0.1650847 0.3020293 0.298915 0.1252058 0.1979204 0.4401451 0.5311838 0.4472875 0.1536819 0.2990841 0.5738236 0.6613865 0.5702119 0.2925259 0.1586363 0.4468553 0.5274072 0.4351405 0.1946639 0.1232734 0.2943546 0.2986827 0.1671092 0.04655298 0.1966405 0.2236971 0.1398044 0.01435895 0.1384342 0.1696025 0.109466 0.009560954 0.1101514 0.1376043 0.08825757 0.01127213 0.09668601 0.1209162 0.07821064 0.008807627 0.0850097 0.1078746 0.07068323 0.006762736 0.07595089 0.09776526 0.06483996 0.005235632 0.0689388 0.08987227 0.0602516 0.004088645
|
||||||
|
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
|
||||||
|
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
|
||||||
|
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
|
||||||
|
0.01336695 0.1031739 0.161102 0.1321453 0.01981753 0.1171067 0.1886142 0.1606797 0.03154635 0.1368371 0.2308619 0.2080039 0.05647729 0.1650848 0.3020293 0.298915 0.1252055 0.1979204 0.440145 0.531184 0.4472874 0.1536818 0.299084 3.026 3.630765 3.02569 0.2925259 0.1586363 0.4468552 0.5274073 0.4351404 0.1946639 0.1232734 0.2943546 0.2986827 0.1671094 0.04655316 0.1966404 0.2236971 0.4124265 0.5511931 0.1922355 0.1696027 0.1094658 0.009561111 0.110151 0.1376044 0.08825763 0.01127217 0.09668601 0.1209163 0.07821067 0.008807753 0.08500987 0.1078743 0.07068328 0.00676251 0.07595107 0.0977652 0.06483985 0.005235482 0.06893886 0.0898722 0.06025163 0.004088641
|
||||||
|
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
|
||||||
|
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
|
||||||
|
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
|
||||||
|
0.07889045 0.08153424 0.08419035 0.08701893 0.09030894 0.09436007 0.09932096 0.1050817 0.1113084 0.1176305 0.1239188 0.1305645 0.1386389 0.1497532 0.1654913 0.1866336 0.2127252 0.2422527 0.2731474 0.3032433 0.3305584 0.3534314 0.3705729 0.3810754 0.3844073 0.3804049 0.3692688 0.3515675 0.3282437 0.3006178 0.2703726 0.239492 0.2101091 0.1842086 0.1631677 0.1472881 0.1356851 0.126759 0.1189501 0.1112676 0.1034146 0.09563871 0.0884626 0.08238604 0.07764256 0.07410739 0.07139879 0.06908784 0.06687377 0.06464756 0.06245377 0.06040035 0.05856907 0.05696608 0.05552753 0.05416456 0.05281367 0.05146397 0.05015182 0.0489313 0.04783952 0.04687586 0.04600478 0.04517753 0.04435825
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||||
+105
-6
@@ -27,13 +27,18 @@ import androidx.compose.foundation.layout.PaddingValues
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -71,6 +76,8 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.TextUnit
|
import androidx.compose.ui.unit.TextUnit
|
||||||
@@ -143,7 +150,7 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
}
|
}
|
||||||
val sdfTime = remember(isUtc) {
|
val sdfTime = remember(isUtc) {
|
||||||
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
|
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
|
||||||
}
|
}
|
||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -209,6 +216,11 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun displayLocale(): Locale {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
@@ -224,8 +236,13 @@ fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier)
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier, enabled: Boolean = true) {
|
fun IconCard(
|
||||||
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action) {
|
action: () -> Unit, resId: Int, modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true, containerColor: Color = Color.Unspecified
|
||||||
|
) {
|
||||||
|
val colors = if (containerColor == Color.Unspecified) CardDefaults.elevatedCardColors()
|
||||||
|
else CardDefaults.elevatedCardColors(containerColor = containerColor)
|
||||||
|
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action, colors = colors) {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
|
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
|
||||||
}
|
}
|
||||||
@@ -280,6 +297,35 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass(
|
|||||||
altitude = 0, maxElevation = 0.0, orbitalObject = NearEarthObject(defaultOrbitalData), progress = 0f
|
altitude = 0, maxElevation = 0.0, orbitalObject = NearEarthObject(defaultOrbitalData), progress = 0f
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun InfoDialog(
|
||||||
|
title: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onAccept: () -> Unit,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
DialogShell(onDismissRequest = onDismiss) { padding ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = padding, top = padding, end = padding)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SharedDialog(
|
fun SharedDialog(
|
||||||
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
||||||
@@ -329,6 +375,56 @@ fun SharedDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ConfirmDialog(
|
||||||
|
title: String,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onAccept: () -> Unit,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
DialogShell(onDismissRequest = onCancel) { padding ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = padding, top = padding, end = padding)
|
||||||
|
) {
|
||||||
|
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = padding)
|
||||||
|
)
|
||||||
|
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WhatsNewDialog(onDismiss: () -> Unit) {
|
||||||
|
InfoDialog(
|
||||||
|
title = stringResource(R.string.pass_whatsnew_title),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
onAccept = onDismiss
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.pass_whatsnew_message),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(0.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun DialogShell(
|
private fun DialogShell(
|
||||||
@@ -337,6 +433,9 @@ private fun DialogShell(
|
|||||||
) {
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
val padding = LocalSpacing.current.large
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||||
|
val containerHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
|
||||||
|
val maxSheetHeight = containerHeight - statusBarHeight
|
||||||
val stopSheetFling = remember {
|
val stopSheetFling = remember {
|
||||||
object : NestedScrollConnection {
|
object : NestedScrollConnection {
|
||||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||||
@@ -354,7 +453,7 @@ private fun DialogShell(
|
|||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
dragHandle = null,
|
dragHandle = null,
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
|
||||||
scrimColor = Color.Black.copy(alpha = 0.64f)
|
scrimColor = Color.Black.copy(alpha = 0.64f)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -362,6 +461,7 @@ private fun DialogShell(
|
|||||||
verticalArrangement = Arrangement.spacedBy(padding),
|
verticalArrangement = Arrangement.spacedBy(padding),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = maxSheetHeight)
|
||||||
.nestedScroll(stopSheetFling)
|
.nestedScroll(stopSheetFling)
|
||||||
) {
|
) {
|
||||||
content(padding)
|
content(padding)
|
||||||
@@ -518,6 +618,5 @@ fun formatFrequency(frequencyHz: Long): String {
|
|||||||
if (frequencyHz <= 0) return "---"
|
if (frequencyHz <= 0) return "---"
|
||||||
val mhz = frequencyHz / 1_000_000
|
val mhz = frequencyHz / 1_000_000
|
||||||
val khz = (frequencyHz % 1_000_000) / 1_000
|
val khz = (frequencyHz % 1_000_000) / 1_000
|
||||||
val hz = frequencyHz % 1_000
|
return String.format(Locale.ENGLISH, "%d.%03d", mhz, khz)
|
||||||
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
|
|
||||||
}
|
}
|
||||||
+8
-4
@@ -79,6 +79,10 @@ private val lightScheme = lightColorScheme(
|
|||||||
onSecondary = Color(0xFFFFFFFF),
|
onSecondary = Color(0xFFFFFFFF),
|
||||||
secondaryContainer = Color(0xFFF1E1BB),
|
secondaryContainer = Color(0xFFF1E1BB),
|
||||||
onSecondaryContainer = Color(0xFF221B04),
|
onSecondaryContainer = Color(0xFF221B04),
|
||||||
|
tertiary = Color(0xFF3C6FE0), // AMSAT Active (darker for light theme)
|
||||||
|
onTertiary = Color(0xFFFFFFFF),
|
||||||
|
tertiaryContainer = Color(0xFFE09800), // AMSAT Telemetry (darker)
|
||||||
|
onTertiaryContainer = Color(0xFF000000),
|
||||||
background = Color(0xFFFFF8F0),
|
background = Color(0xFFFFF8F0),
|
||||||
onBackground = Color(0xFF1E1B13),
|
onBackground = Color(0xFF1E1B13),
|
||||||
surface = Color(0xFFFFF8F0),
|
surface = Color(0xFFFFF8F0),
|
||||||
@@ -101,10 +105,10 @@ private val darkScheme = darkColorScheme(
|
|||||||
onSecondary = Color(0xFF000000),
|
onSecondary = Color(0xFF000000),
|
||||||
secondaryContainer = Color(0xFF404040), // navBar indicator,
|
secondaryContainer = Color(0xFF404040), // navBar indicator,
|
||||||
onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon
|
onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon
|
||||||
// tertiary = Color(0xFF121212),
|
tertiary = Color(0xFF648FFF), // AMSAT Active (from amsat.org/status)
|
||||||
// onTertiary = Color(0xFF121212),
|
onTertiary = Color(0xFF000000),
|
||||||
// tertiaryContainer = Color(0xFF121212),
|
tertiaryContainer = Color(0xFFFFB000), // AMSAT Telemetry
|
||||||
// onTertiaryContainer = Color(0xFF121212),
|
onTertiaryContainer = Color(0xFF000000),
|
||||||
background = Color(0xFF121212),
|
background = Color(0xFF121212),
|
||||||
onBackground = Color(0xFFE0E0E0),
|
onBackground = Color(0xFFE0E0E0),
|
||||||
surface = Color(0xFF202020), // card background
|
surface = Color(0xFF202020), // card background
|
||||||
|
|||||||
+20
-8
@@ -21,25 +21,37 @@ import androidx.navigation3.runtime.NavKey
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: String) : NavKey {
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat)
|
data object Satellites : Screen(R.drawable.ic_sputnik, R.string.nav_sat, "Satellites")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass, "Passes")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
|
data object Radar : Screen(R.drawable.ic_satellite, R.string.nav_radar, "Radar")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
data object Map : Screen(R.drawable.ic_map, R.string.nav_map, "Map")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Mutual : Screen(R.drawable.ic_radar, R.string.nav_mutual)
|
data object Mutual : Screen(R.drawable.ic_match, R.string.nav_mutual, "Mutual")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
data object Roaming : Screen(R.drawable.ic_roaming, R.string.nav_roaming, "Roaming")
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object CwDecode : Screen(R.drawable.ic_cw, R.string.nav_cw, "CwDecode")
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object WavelogLog : Screen(R.drawable.ic_log, R.string.nav_log, "WavelogLog")
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object AmSat : Screen(R.drawable.ic_satellite, R.string.nav_amsat, "AMSAT")
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -70,4 +82,4 @@ class DeeplinkResolver(private val fallbackDestination: NavKey = Screen.Passes)
|
|||||||
matchers.forEach { it.match(deeplink)?.let { match -> return match } }
|
matchers.forEach { it.match(deeplink)?.let { match -> return match } }
|
||||||
return fallbackDestination
|
return fallbackDestination
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" />
|
||||||
|
</vector>
|
||||||
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user