Compare commits
275
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abb73ffef7 | ||
|
|
cc4f156c83 | ||
|
|
3810e76ea2 | ||
|
|
5ed76dba31 | ||
|
|
78a6f270bf | ||
|
|
8445c17033 | ||
|
|
e315c87f05 | ||
|
|
e27d692e8f | ||
|
|
6c67aa2718 | ||
|
|
cb3ebe7870 | ||
|
|
e2263668ce | ||
|
|
91263674a9 | ||
|
|
9576607fc6 | ||
|
|
00b7b25cc1 | ||
|
|
a3e3932f73 | ||
|
|
3d3db784a3 | ||
|
|
08f9106749 | ||
|
|
6db10b5b72 | ||
|
|
cd70e3654c | ||
|
|
a25f0fe2cf | ||
|
|
b1dce9c518 | ||
|
|
13c5fc9584 | ||
|
|
e8e67b74cd | ||
|
|
758dc6d567 | ||
|
|
4567f46867 | ||
|
|
fe6d0af8b7 | ||
|
|
3a8086eb05 | ||
|
|
e8ad51fd1b | ||
|
|
6859c825d7 | ||
|
|
271488a43e | ||
|
|
321cd8f2fa | ||
|
|
0208a577c4 | ||
|
|
eb66a77cae | ||
|
|
0a67f74369 | ||
|
|
e0900778f0 | ||
|
|
2ff8643988 | ||
|
|
b19c78441c | ||
|
|
262ae45432 | ||
|
|
7ac54f0a37 | ||
|
|
ce68f48765 | ||
|
|
38f939bd49 | ||
|
|
bdc6db5aff | ||
|
|
92499b1cf1 | ||
|
|
828fd0fb6f | ||
|
|
07df22d287 | ||
|
|
ac45ed0efb | ||
|
|
96bbb022e8 | ||
|
|
b6753a4fa6 | ||
|
|
0889a3bd88 | ||
|
|
10c415fabd | ||
|
|
984a139a81 | ||
|
|
4cb03111bc | ||
|
|
23f47d9122 | ||
|
|
5a45aab2b1 | ||
|
|
9367878702 | ||
|
|
8fbc639a82 | ||
|
|
fa73328936 | ||
|
|
50a644f417 | ||
|
|
7a2bbb8701 | ||
|
|
018a3afd2b | ||
|
|
3612e662e7 | ||
|
|
79215e7623 | ||
|
|
8f646d76f9 | ||
|
|
ea125d7db4 | ||
|
|
fdb44af9ff | ||
|
|
f6db55b35c | ||
|
|
1b8f8c46f6 | ||
|
|
9798107d37 | ||
|
|
e3d7238721 | ||
|
|
40ba3fecdf | ||
|
|
57d6f9d7ed | ||
|
|
a654735337 | ||
|
|
a42a5f1f0d | ||
|
|
c547a126ee | ||
|
|
16c746f552 | ||
|
|
8f56ea05ec | ||
|
|
b749733289 | ||
|
|
b4cfb16159 | ||
|
|
7319cf8f5b | ||
|
|
baf2a7022d | ||
|
|
b95a86c97f | ||
|
|
ed1fe66892 | ||
|
|
1e24673632 | ||
|
|
ed0f5678b3 | ||
|
|
aac1fa0da5 | ||
|
|
c7bb253981 | ||
|
|
af96fe1cf0 | ||
|
|
27d41eb2ee | ||
|
|
2bac6655f3 | ||
|
|
2da7127fd3 | ||
|
|
fed9fe188e | ||
|
|
19ca5205fb | ||
|
|
a7a6d70d40 | ||
|
|
d5230b3bc6 | ||
|
|
7d7abeb31e | ||
|
|
828f720955 | ||
|
|
5f1f90067f | ||
|
|
f4e188261d | ||
|
|
e1233dceaa | ||
|
|
09e728fa1b | ||
|
|
c1895506e0 | ||
|
|
066fafbb81 | ||
|
|
aedb3fee19 | ||
|
|
77314e824e | ||
|
|
74902cddce | ||
|
|
8324903104 | ||
|
|
a757474b06 | ||
|
|
9cfa238e5c | ||
|
|
29ca4c29bb | ||
|
|
82c8112575 | ||
|
|
20e5b2f617 | ||
|
|
aa10d4170c | ||
|
|
aac3aee4bb | ||
|
|
d749c5ab48 | ||
|
|
082e0c5bff | ||
|
|
e69d6dee4a | ||
|
|
cf93ca9f71 | ||
|
|
7cadded6ca | ||
|
|
6fc2f560b7 | ||
|
|
8adf861ed7 | ||
|
|
f4f6ec7db5 | ||
|
|
5d89190348 | ||
|
|
f9fd7a2cfa | ||
|
|
21f14848da | ||
|
|
d1668f1c27 | ||
|
|
d6b61eaa4b | ||
|
|
26c714fc24 | ||
|
|
2e1d8b9c00 | ||
|
|
7cdc2952dc | ||
|
|
f42d1f6c57 | ||
|
|
3e25e1aa2f | ||
|
|
e0405b541f | ||
|
|
c374058e1d | ||
|
|
ac5cbbc49a | ||
|
|
bd51044690 | ||
|
|
cf49dcfa01 | ||
|
|
1982c2d3dc | ||
|
|
5f297f9233 | ||
|
|
37300fb2a6 | ||
|
|
fc8096bdd5 | ||
|
|
d93e3b4028 | ||
|
|
9b0d543f12 | ||
|
|
57762613a8 | ||
|
|
13ef70810c | ||
|
|
6a62f951ad | ||
|
|
0d3e02c596 | ||
|
|
7c22bf6ac1 | ||
|
|
b8113fc757 | ||
|
|
86ab1b26b7 | ||
|
|
de6c964136 | ||
|
|
e477851adf | ||
|
|
d2d4a6fe86 | ||
|
|
2f4e3f5802 | ||
|
|
24eebdef74 | ||
|
|
3f5b48f270 | ||
|
|
060fa2dfcd | ||
|
|
c42e1d7b4e | ||
|
|
09ebf1f39a | ||
|
|
8b5960282f | ||
|
|
ec40f29f28 | ||
|
|
5dd7a35a23 | ||
|
|
4b835bac5c | ||
|
|
10eb84690e | ||
|
|
bf25292bf8 | ||
|
|
284183836e | ||
|
|
de85aa1e8b | ||
|
|
fb014a1183 | ||
|
|
78049a29d9 | ||
|
|
8fbfcb3712 | ||
|
|
3818c58eff | ||
|
|
4c62b2dfb2 | ||
|
|
d8e1ee3266 | ||
|
|
90bb584813 | ||
|
|
f789a15338 | ||
|
|
6221516893 | ||
|
|
c9cab8457d | ||
|
|
19926c58ac | ||
|
|
955871c27b | ||
|
|
bfcef27b90 | ||
|
|
449d1a9eec | ||
|
|
fe97339ece | ||
|
|
4d5a413fa8 | ||
|
|
25078b3121 | ||
|
|
96a4d15424 | ||
|
|
bf4cd5c07c | ||
|
|
84d11f289f | ||
|
|
f4a22d9779 | ||
|
|
0e767f4250 | ||
|
|
2c26272e0b | ||
|
|
fc1bec1915 | ||
|
|
7dd256f65d | ||
|
|
4d7952d293 | ||
|
|
8a2b0a9aa3 | ||
|
|
c9d87be289 | ||
|
|
012ea1eeb6 | ||
|
|
920af7bde2 | ||
|
|
88a8e795d1 | ||
|
|
44ae5cef5d | ||
|
|
518d9a4d22 | ||
|
|
602b1553a2 | ||
|
|
bd0881fb4b | ||
|
|
72f507163a | ||
|
|
f7a3dbaa2d | ||
|
|
c525f3a11f | ||
|
|
2777607ab5 | ||
|
|
a222cbb839 | ||
|
|
a9dd3e6e55 | ||
|
|
905995ab44 | ||
|
|
0ef8f05dc4 | ||
|
|
f7402a3531 | ||
|
|
dd378990fe | ||
|
|
a23bf34ccf | ||
|
|
a724c0823d | ||
|
|
cda6cf8583 | ||
|
|
c09e87fa4e | ||
|
|
0bcf73e1f9 | ||
|
|
ab1a0c0891 | ||
|
|
1a55241791 | ||
|
|
9d437a90c9 | ||
|
|
5351afe508 | ||
|
|
916a0d0f0a | ||
|
|
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 | ||
|
|
88d1498eac | ||
|
|
31ae04329d | ||
|
|
eae6304124 | ||
|
|
97c6d609ac | ||
|
|
44c442ddd3 | ||
|
|
f9250ec1f1 | ||
|
|
bff3e1af09 | ||
|
|
6dbe30b3f6 | ||
|
|
8a920acead | ||
|
|
929f652b8e | ||
|
|
610768501e | ||
|
|
427e6e1862 | ||
|
|
bfa5e5f474 | ||
|
|
213425379c | ||
|
|
c3444aed24 | ||
|
|
368bd199e5 | ||
|
|
7bb0bbbb46 | ||
|
|
deec5581ae | ||
|
|
1de5632428 | ||
|
|
2dfd76825b | ||
|
|
06fc8bc2fb | ||
|
|
75322390b8 | ||
|
|
d1312f1f09 | ||
|
|
75acbbd633 | ||
|
|
a37fb8f773 | ||
|
|
9d0d971500 | ||
|
|
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"
|
||||||
+8
-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,6 @@ fastlane/readme.md
|
|||||||
/app/release/output-metadata.json
|
/app/release/output-metadata.json
|
||||||
/app/release/
|
/app/release/
|
||||||
/.kotlin/sessions/
|
/.kotlin/sessions/
|
||||||
|
# Kotlin build caches in any module, not only the root - build-logic produces one too.
|
||||||
|
.kotlin/
|
||||||
|
.hermes/
|
||||||
@@ -8,16 +8,15 @@ All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) po
|
|||||||
## Project Overview
|
## 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,13 @@ 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.
|
- AMSAT status colours are ARGB literals in `core:data` (`AmSatRepository.statusColorOf`) and duplicated in
|
||||||
|
`core:presentation/MainTheme.kt`, so the data layer currently decides how the UI looks. Known debt, left as
|
||||||
|
upstream shipped it: the fix is a status enum in `core:domain` with the colour mapping in `core:presentation`.
|
||||||
|
Anything needing themeable, dark-mode-aware or colour-blind-safe status colours has to do that first.
|
||||||
|
|
||||||
## Copilot Working Mode: Code-Only
|
## 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>
|
||||||
+40
-2
@@ -1,12 +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 {
|
android {
|
||||||
// PR #1 CW decoder uses the Morse Expert native decoder, which ships only armeabi-v7a.
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
|
// ONNX Runtime 的 AAR 自带 4 个架构共 115MB 原生库(arm64 28M / armv7 20M /
|
||||||
|
// x86 33M / x86_64 34M)。x86 系列只有模拟器用得到, 全打包会让 APK 从 8MB
|
||||||
|
// 涨到 135MB。仅保留真机需要的两个 ABI。
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("armeabi-v7a")
|
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,25 @@
|
|||||||
<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" />
|
||||||
|
<!--
|
||||||
|
dataSync rather than location. The location type is refused outright unless a location
|
||||||
|
runtime permission has already been granted - startForeground throws SecurityException -
|
||||||
|
and this service reads the station position the operator typed into settings, so demanding
|
||||||
|
location access to beacon a fixed QTH is both wrong and a way to fail silently for anyone
|
||||||
|
who declined it. The dataSync six-hour cap that prompted the earlier switch applies only
|
||||||
|
when targetSdk is 35 or higher, which this project does not declare.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<!--
|
||||||
|
Doze suspends network access and ignores wake locks even for a foreground service, so a
|
||||||
|
coroutine delay wakes up on time and then cannot reach the network. An exact alarm with
|
||||||
|
setExactAndAllowWhileIdle is the only scheduling that survives Doze, and at a five-minute
|
||||||
|
floor the system's one-alarm-per-nine-minutes throttle is not a problem.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<application
|
||||||
android:name=".MainApplication"
|
android:name=".MainApplication"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
@@ -43,7 +61,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,287 @@
|
|||||||
|
package com.rtbishop.look4sat.app
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
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.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.IBinder
|
||||||
|
import com.rtbishop.look4sat.BuildConfig
|
||||||
|
import com.rtbishop.look4sat.MainApplication
|
||||||
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
|
||||||
|
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
|
||||||
|
|
||||||
|
/** Alarm-driven tick, kept separate so a manual report stays distinguishable. */
|
||||||
|
const val ACTION_ALARM_TICK = "com.rtbishop.look4sat.APRS_ALARM_TICK"
|
||||||
|
|
||||||
|
private const val ALARM_REQUEST = 4101
|
||||||
|
}
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var reporter: AprsReporter? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler on the main looper, for anything that must not run on the reporter's IO thread.
|
||||||
|
*
|
||||||
|
* onReport is invoked from AprsReporter's Dispatchers.IO scope, and Toast construction there
|
||||||
|
* throws because that thread has no Looper - an exception the surrounding runCatching then
|
||||||
|
* swallowed, so every report notice was silently discarded. The messages existed and no
|
||||||
|
* operator ever saw one.
|
||||||
|
*/
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private var lastState: AprsState = AprsState.Idle
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_STOP -> stopReporting()
|
||||||
|
ACTION_ALARM_TICK -> {
|
||||||
|
// Woken by the exact alarm. Reporting once and then booking the next tick, rather
|
||||||
|
// than using a repeating alarm, means a changed interval takes effect at once.
|
||||||
|
if (reporter == null) startReporting() else reporter?.reportNow()
|
||||||
|
scheduleNextTick()
|
||||||
|
}
|
||||||
|
ACTION_REPORT_NOW -> {
|
||||||
|
if (reporter == null) {
|
||||||
|
// 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) },
|
||||||
|
// The real version, so the login line cannot drift from the build again.
|
||||||
|
appVersion = BuildConfig.VERSION_NAME,
|
||||||
|
positionProvider = { stationPosition() },
|
||||||
|
onState = { lastState = it },
|
||||||
|
onReport = { report ->
|
||||||
|
AprsStore.saveLastReport(this, report.ok, report.detail)
|
||||||
|
// Derived from this report rather than read from lastState: onState fires AFTER
|
||||||
|
// onReport, so the notification was being rebuilt from the previous cycle's
|
||||||
|
// verdict. For an alarm-driven report at 03:00 the notification is the only
|
||||||
|
// surface that survives, and it was showing the wrong one.
|
||||||
|
lastState = if (report.ok) AprsState.Connected else AprsState.Error
|
||||||
|
updateNotification(cfg)
|
||||||
|
// Report result always surfaces: success = short Toast, failure = long Toast + reason
|
||||||
|
// An unverified login needs its own message: the write succeeded, so a bare
|
||||||
|
// failure notice would send the operator looking at their network when the
|
||||||
|
// problem is the passcode - and APRS-IS is dropping every packet meanwhile.
|
||||||
|
// No receive-only notice. APRS-IS lets an unverified station connect and then
|
||||||
|
// discards its packets, but this app only reports its own position - there is no
|
||||||
|
// receiving side to it - so telling the operator they are "in receive-only mode"
|
||||||
|
// named a state that does not exist here. Without a passcode the packet does not
|
||||||
|
// arrive, and that is what the failure notice says.
|
||||||
|
val msg = when {
|
||||||
|
report.ok -> getString(R.string.aprs_toast_ok)
|
||||||
|
!report.verified -> getString(R.string.aprs_toast_unverified)
|
||||||
|
else -> getString(R.string.aprs_toast_fail, report.detail)
|
||||||
|
}
|
||||||
|
mainHandler.post {
|
||||||
|
Toast.makeText(this, msg,
|
||||||
|
if (report.ok) Toast.LENGTH_SHORT else Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
reporter = rep
|
||||||
|
rep.start()
|
||||||
|
// The reporter beacons once on start; the alarm carries every one after that.
|
||||||
|
scheduleNextTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopReporting() {
|
||||||
|
cancelTicks()
|
||||||
|
reporter?.stop()
|
||||||
|
reporter = null
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startForegroundWithNotification(cfg: AprsConfig) {
|
||||||
|
try {
|
||||||
|
val notif = buildNotification(cfg)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
// Must match the manifest attribute exactly: AOSP checks the passed type is a
|
||||||
|
// subset of the declared one and throws otherwise, which the catch below turns
|
||||||
|
// into a silent stopSelf(). Declaring location instead would additionally require
|
||||||
|
// a granted location permission before this call, and the settings card asks only
|
||||||
|
// for notifications - so that combination fails silently too.
|
||||||
|
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIF_ID, notif)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Book the next beacon with an exact alarm.
|
||||||
|
*
|
||||||
|
* A coroutine delay was used before, which Doze defeats: the timer fires but network access is
|
||||||
|
* suspended and wake locks are ignored, even inside a foreground service. Only
|
||||||
|
* setExactAndAllowWhileIdle survives that, and the five-minute floor keeps this well clear of
|
||||||
|
* the system's throttle on how often such an alarm may repeat.
|
||||||
|
*/
|
||||||
|
private fun scheduleNextTick() {
|
||||||
|
val cfg = AprsStore.loadConfig(this)
|
||||||
|
if (!cfg.enabled) return
|
||||||
|
val alarms = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||||
|
val minutes = cfg.intervalMin.coerceAtLeast(AprsReporter.MIN_INTERVAL_MIN)
|
||||||
|
val at = System.currentTimeMillis() + minutes * 60_000L
|
||||||
|
runCatching {
|
||||||
|
val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()
|
||||||
|
if (exact) {
|
||||||
|
alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, tickIntent())
|
||||||
|
} else {
|
||||||
|
// The operator revoked exact alarms. An inexact one still beacons, just whenever
|
||||||
|
// the system decides, which beats not beaconing at all.
|
||||||
|
alarms.set(AlarmManager.RTC_WAKEUP, at, tickIntent())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelTicks() {
|
||||||
|
val alarms = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||||
|
runCatching { alarms.cancel(tickIntent()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tickIntent(): PendingIntent = PendingIntent.getService(
|
||||||
|
this,
|
||||||
|
ALARM_REQUEST,
|
||||||
|
Intent(this, AprsForegroundService::class.java).setAction(ACTION_ALARM_TICK),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
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
|
||||||
@@ -73,6 +82,7 @@ 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.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
|
||||||
@@ -80,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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,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,
|
||||||
@@ -150,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
|
||||||
@@ -181,101 +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.setMutualPassData(MutualPassData())
|
|
||||||
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 ->
|
|
||||||
container.setMutualPassData(pass ?: MutualPassData())
|
|
||||||
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.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 = "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)
|
||||||
|
|||||||
+7
-1
@@ -57,11 +57,17 @@ 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()
|
||||||
}
|
}
|
||||||
|
// The APRS login line reports the app version to every station on the network. It used
|
||||||
|
// to be a literal in core:data and drifted twice, so the app module now reads
|
||||||
|
// BuildConfig.VERSION_NAME - which AGP 8 only generates when asked.
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
applicationIdSuffix = ".debug"
|
applicationIdSuffix = ".debug"
|
||||||
|
|||||||
@@ -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,244 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.aprs
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.aprs.AprsLogin
|
||||||
|
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.OutputStreamWriter
|
||||||
|
import java.io.PrintWriter
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APRS-IS TCP client. Plain text: the server greets, the client logs in, then one packet per line.
|
||||||
|
*
|
||||||
|
* Two things here decide whether the operator can trust the app at all. A packet sent on a dead
|
||||||
|
* socket must not report success, and a login the server refused to verify must not look like a
|
||||||
|
* working connection - an unverified client stays connected while the server silently drops
|
||||||
|
* everything it sends.
|
||||||
|
*/
|
||||||
|
class AprsIsClient(
|
||||||
|
private val host: String,
|
||||||
|
private val port: Int,
|
||||||
|
private val callsign: String,
|
||||||
|
private val ssid: String,
|
||||||
|
private val passcode: Int,
|
||||||
|
private val softwareName: String,
|
||||||
|
private val version: String,
|
||||||
|
private val filter: String = "",
|
||||||
|
private val timeoutSec: Int = 120
|
||||||
|
) {
|
||||||
|
private var socket: Socket? = null
|
||||||
|
private var writer: PrintWriter? = null
|
||||||
|
private var reader: BufferedReader? = null
|
||||||
|
private val lock = Any()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the server said about this login, or null before a login has been attempted.
|
||||||
|
*
|
||||||
|
* Kept as state rather than only thrown, because [AprsLogin.Outcome.Unverified] is not a
|
||||||
|
* connection error: the socket is up and writes succeed. The operator has to be told, or
|
||||||
|
* they will watch reports "succeed" for hours while nothing reaches the network.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
var loginOutcome: AprsLogin.Outcome? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
val isConnected: Boolean
|
||||||
|
get() = synchronized(lock) { socket?.isConnected == true && socket?.isClosed == false }
|
||||||
|
|
||||||
|
/** True when the server verified the passcode, so packets from this client are accepted. */
|
||||||
|
val isVerified: Boolean get() = loginOutcome is AprsLogin.Outcome.Verified
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True only when the server told us it did NOT verify the login.
|
||||||
|
*
|
||||||
|
* Distinct from `!isVerified` on purpose. [AprsLogin.Outcome.Unverified] means the server
|
||||||
|
* said so and really is discarding our packets. [AprsLogin.Outcome.Unknown] means we could
|
||||||
|
* not recognise its answer - the packets may well be landing - so blaming the operator's
|
||||||
|
* passcode for that would send them to fix something that is not broken.
|
||||||
|
*/
|
||||||
|
val isRefusedByServer: Boolean get() = loginOutcome is AprsLogin.Outcome.Unverified
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect and log in. Blocking; call from a background thread.
|
||||||
|
*
|
||||||
|
* Throws when the connection cannot be made or the server rejected the login outright.
|
||||||
|
* A login the server accepted but did not verify returns normally and leaves
|
||||||
|
* [loginOutcome] as [AprsLogin.Outcome.Unverified] for the caller to surface.
|
||||||
|
*/
|
||||||
|
@Throws(Exception::class)
|
||||||
|
fun connect() {
|
||||||
|
disconnect()
|
||||||
|
loginOutcome = null
|
||||||
|
val s = Socket()
|
||||||
|
try {
|
||||||
|
s.connect(InetSocketAddress(host, port), CONNECT_MS)
|
||||||
|
s.tcpNoDelay = true
|
||||||
|
synchronized(lock) {
|
||||||
|
socket = s
|
||||||
|
writer = PrintWriter(OutputStreamWriter(s.getOutputStream(), Charsets.ISO_8859_1), true)
|
||||||
|
reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.ISO_8859_1), 256)
|
||||||
|
}
|
||||||
|
s.soTimeout = LOGIN_MS
|
||||||
|
// The spec has the client log in AFTER the server's identification line, so read the
|
||||||
|
// greeting first. Anything starting with # is a comment and may be skipped.
|
||||||
|
readGreeting(s)?.let { refusal ->
|
||||||
|
// The server refused before we even logged in. Previously this verdict was
|
||||||
|
// computed and then overwritten by readLoginResponse, so the branch was a lie.
|
||||||
|
loginOutcome = refusal
|
||||||
|
throw IllegalArgumentException(refusal.detail)
|
||||||
|
}
|
||||||
|
val login = AprsLogin.line(callsign, ssid, passcode, softwareName, version, filter)
|
||||||
|
writer?.print(login)
|
||||||
|
writer?.print(CRLF)
|
||||||
|
writer?.flush()
|
||||||
|
loginOutcome = readLoginResponse()
|
||||||
|
s.soTimeout = timeoutSec * 1000
|
||||||
|
val outcome = loginOutcome
|
||||||
|
if (outcome is AprsLogin.Outcome.Rejected) throw IllegalArgumentException(outcome.detail)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Close the local socket before re-throwing so it does not leak when the failure
|
||||||
|
// lands after connect() but before the field assignment - otherwise periodic
|
||||||
|
// reconnects accumulate file descriptors until no more can be opened.
|
||||||
|
runCatching { s.close() }
|
||||||
|
synchronized(lock) {
|
||||||
|
writer = null
|
||||||
|
reader = null
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consume the server's greeting comment, returning a refusal when it is not one.
|
||||||
|
*
|
||||||
|
* Absence is tolerated because some servers send none, but on a short probe window rather
|
||||||
|
* than the full login timeout: waiting LOGIN_MS for a greeting that will never come cost
|
||||||
|
* eight seconds on every single connect to such a server.
|
||||||
|
*/
|
||||||
|
private fun readGreeting(socket: Socket): AprsLogin.Outcome.Rejected? {
|
||||||
|
val previous = socket.soTimeout
|
||||||
|
return try {
|
||||||
|
socket.soTimeout = GREETING_MS
|
||||||
|
val line = reader?.readLine() ?: return null
|
||||||
|
// A server that opens with anything but a comment is refusing us.
|
||||||
|
if (line.startsWith("#")) null else AprsLogin.Outcome.Rejected(line.trim())
|
||||||
|
} catch (ignored: IOException) {
|
||||||
|
null
|
||||||
|
} finally {
|
||||||
|
runCatching { socket.soTimeout = previous }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read lines until the login verdict arrives, skipping keepalive comments.
|
||||||
|
*
|
||||||
|
* Bounded by the read timeout, so an unresponsive server cannot hang the caller.
|
||||||
|
*/
|
||||||
|
private fun readLoginResponse(): AprsLogin.Outcome {
|
||||||
|
// Bounded by a deadline, not a line count: comments are free to skip, and a chatty
|
||||||
|
// server that sent six of them before its verdict used to exhaust a fixed budget and
|
||||||
|
// turn an accepted login into Unknown - telling the operator their passcode was wrong
|
||||||
|
// when it had just been accepted.
|
||||||
|
val deadline = System.currentTimeMillis() + LOGIN_MS
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
val line = try {
|
||||||
|
reader?.readLine()
|
||||||
|
} catch (timeout: SocketTimeoutException) {
|
||||||
|
return AprsLogin.Outcome.Unknown("no response within ${LOGIN_MS}ms")
|
||||||
|
} catch (failure: IOException) {
|
||||||
|
return AprsLogin.Outcome.Rejected(failure.message ?: "login read failed")
|
||||||
|
} ?: return AprsLogin.Outcome.Rejected("connection closed during login")
|
||||||
|
AprsLogin.parse(line)?.let { return it }
|
||||||
|
}
|
||||||
|
return AprsLogin.Outcome.Unknown("no login response recognised")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send one packet and report what happened.
|
||||||
|
*
|
||||||
|
* Returns null when there is no connection to write to. Otherwise a pair of whether the
|
||||||
|
* packet went out and a detail string for the operator.
|
||||||
|
*/
|
||||||
|
fun sendPacket(packetLine: String): Pair<Boolean, String>? {
|
||||||
|
synchronized(lock) {
|
||||||
|
val w = writer ?: return null
|
||||||
|
// Reading the response inside the same lock: disconnect() may run concurrently and
|
||||||
|
// null these fields, and reading outside the lock raced with that - the read could
|
||||||
|
// hit a just-closed socket and be reported as a successful send.
|
||||||
|
// CRLF explicitly rather than println: the spec requires "TNC2 format terminated by
|
||||||
|
// a carriage return, line feed sequence", and println emits the platform separator,
|
||||||
|
// a bare LF on Android. An earlier draft of this method sent no terminator at all,
|
||||||
|
// which leaves the server's line reader waiting forever while every send reports
|
||||||
|
// success - exactly the failure this class exists to prevent.
|
||||||
|
w.print(packetLine)
|
||||||
|
w.print(CRLF)
|
||||||
|
w.flush()
|
||||||
|
if (w.checkError()) return Pair(false, "write failed")
|
||||||
|
val s = socket ?: return Pair(false, "not connected")
|
||||||
|
val previousTimeout = s.soTimeout
|
||||||
|
return try {
|
||||||
|
s.soTimeout = ACK_MS
|
||||||
|
classifyAck(reader?.readLine())
|
||||||
|
} catch (timeout: SocketTimeoutException) {
|
||||||
|
// Silence is the normal case: APRS-IS does not acknowledge a position report, so
|
||||||
|
// nothing arriving means the line went out and the server had nothing to say.
|
||||||
|
// Telling this apart from a broken connection is the point of this method - the
|
||||||
|
// previous version treated EVERY exception as success, so a dead socket reported
|
||||||
|
// "sent OK" and made every real failure invisible, including a rejected login.
|
||||||
|
Pair(true, "sent")
|
||||||
|
} catch (failure: IOException) {
|
||||||
|
Pair(false, failure.message ?: "read failed")
|
||||||
|
} finally {
|
||||||
|
runCatching { s.soTimeout = previousTimeout }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpret whatever the server sent back after a packet.
|
||||||
|
*
|
||||||
|
* Shares AprsLogin's judgement rather than keeping its own, because the two disagreed in a way
|
||||||
|
* that mattered: treating any leading `#` as harmless meant `# Port full` and `# Login by user
|
||||||
|
* not allowed` - both of which mean the server is about to drop us - were reported as a
|
||||||
|
* successful send. APRS-IS does not acknowledge position reports, so a harmless comment still
|
||||||
|
* counts as sent; anything the server says that is not harmless does not.
|
||||||
|
*/
|
||||||
|
private fun classifyAck(response: String?): Pair<Boolean, String> {
|
||||||
|
if (response == null) return Pair(false, "connection closed by server")
|
||||||
|
return when (val verdict = AprsLogin.parse(response)) {
|
||||||
|
// A keepalive or identification comment: no verdict, so the write stands.
|
||||||
|
null -> Pair(true, "sent")
|
||||||
|
is AprsLogin.Outcome.Rejected -> Pair(false, verdict.detail)
|
||||||
|
// A late login verdict is not about this packet, and loginOutcome already carries it.
|
||||||
|
else -> Pair(true, "sent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
synchronized(lock) {
|
||||||
|
runCatching { writer?.close() }
|
||||||
|
runCatching { reader?.close() }
|
||||||
|
runCatching { socket?.close() }
|
||||||
|
writer = null
|
||||||
|
reader = null
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Line terminator the protocol requires, independent of the platform's own. */
|
||||||
|
const val CRLF = "\r\n"
|
||||||
|
|
||||||
|
const val CONNECT_MS = 30_000
|
||||||
|
const val LOGIN_MS = 8_000
|
||||||
|
const val ACK_MS = 3_000
|
||||||
|
|
||||||
|
/** Probe window for the greeting, short because its absence is legitimate. */
|
||||||
|
const val GREETING_MS = 2_000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.aprs
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.aprs.AprsBeacon
|
||||||
|
import com.rtbishop.look4sat.core.domain.aprs.AprsPasscode
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.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,
|
||||||
|
/**
|
||||||
|
* False only when the server told us it did not verify the login.
|
||||||
|
*
|
||||||
|
* Carried separately from [ok] because the two are independent: a refused client's writes
|
||||||
|
* still succeed, so the packet leaves the phone and looks sent, while aprsc discards every
|
||||||
|
* one of them. Without surfacing it the operator can watch reports succeed for hours with
|
||||||
|
* nothing reaching the network. A login whose response we simply could not parse leaves this
|
||||||
|
* true, since the packets may be landing and the passcode is not at fault.
|
||||||
|
*/
|
||||||
|
val verified: Boolean = true,
|
||||||
|
/**
|
||||||
|
* True when the operator asked for a receive-only connection.
|
||||||
|
*
|
||||||
|
* Distinguished from a refused login because both log in with -1 and the server answers
|
||||||
|
* "unverified" to each: without this, deliberately choosing receive-only - the one way to
|
||||||
|
* test a setup without putting anything on the network - was reported as a wrong passcode
|
||||||
|
* and sent the operator to fix something they had set on purpose.
|
||||||
|
*/
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Report scheduler (periodic + manual trigger); connection management lives in the foreground service */
|
||||||
|
class AprsReporter(
|
||||||
|
private val configProvider: () -> AprsConfig,
|
||||||
|
/**
|
||||||
|
* The app's own version, reported to APRS-IS in the login line.
|
||||||
|
*
|
||||||
|
* Passed in because core:data has no BuildConfig. It used to be a literal here and drifted
|
||||||
|
* exactly as predicted: it still read 4.6.0 two releases later, so every station on the
|
||||||
|
* network was told the wrong version. A caller in the app module can read the real one.
|
||||||
|
*/
|
||||||
|
private val appVersion: String,
|
||||||
|
private val positionProvider: () -> Pair<Double, Double>? = { null },
|
||||||
|
private val onState: (AprsState) -> Unit = {},
|
||||||
|
private val onReport: (AprsReport) -> Unit = {}
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// One report now; the service's exact alarm drives every one after this. The loop that
|
||||||
|
// used to live here relied on a coroutine delay, which Doze defeats - the timer fires on
|
||||||
|
// schedule and then finds network access suspended, so the beacon stopped whenever the
|
||||||
|
// screen locked while the notification still claimed it was running.
|
||||||
|
job = scope.launch { reportOnce() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
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 {
|
||||||
|
// The packet is built BEFORE connecting: there is no reason to open a session and log
|
||||||
|
// in only to discover there is nothing to send, which happened every five minutes for
|
||||||
|
// an operator whose QTH was unset.
|
||||||
|
val pos = positionProvider()
|
||||||
|
val beacon = AprsBeacon.build(
|
||||||
|
callsign = cfg.callsign,
|
||||||
|
ssid = cfg.ssid,
|
||||||
|
latitude = pos?.first,
|
||||||
|
longitude = pos?.second,
|
||||||
|
symbolTable = cfg.symbolTable,
|
||||||
|
symbolCode = cfg.symbolCode,
|
||||||
|
comment = cfg.statusText
|
||||||
|
)
|
||||||
|
// Nothing goes out without a position. Substituting 0,0 put this station in the Gulf
|
||||||
|
// of Guinea on the global network, under the operator's own callsign.
|
||||||
|
if (beacon is AprsBeacon.Result.Blocked) {
|
||||||
|
onState(AprsState.Error)
|
||||||
|
onReport(
|
||||||
|
AprsReport(System.currentTimeMillis(), "", false, refusalDetail(beacon.refusal))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val packetLine = (beacon as AprsBeacon.Result.Line).text
|
||||||
|
|
||||||
|
val c = client ?: AprsIsClient(
|
||||||
|
host = cfg.server,
|
||||||
|
port = cfg.port,
|
||||||
|
callsign = cfg.callsign,
|
||||||
|
ssid = cfg.ssid,
|
||||||
|
// Never derives one: a blank or wrong entry logs in receive-only rather than
|
||||||
|
// transmitting under a passcode the app invented for an unchecked licence.
|
||||||
|
passcode = AprsPasscode.loginValue(cfg.callsign, cfg.passcode),
|
||||||
|
// Two fields, because APRS-IS wants `vers <name> <version>` as separate tokens.
|
||||||
|
softwareName = "Look4Sat",
|
||||||
|
version = appVersion
|
||||||
|
).also { client = it }
|
||||||
|
if (!c.isConnected) c.connect()
|
||||||
|
onState(AprsState.Connected)
|
||||||
|
val result = c.sendPacket(packetLine)
|
||||||
|
val sent = result?.first == true
|
||||||
|
val detail = result?.second ?: "no connection"
|
||||||
|
// A write that succeeded on a login the server refused to verify is not a delivered
|
||||||
|
// packet: aprsc takes it and drops it, which is what let every real failure hide.
|
||||||
|
// Only an explicit refusal counts against us though - a login whose response we
|
||||||
|
// could not parse may be working fine, and blaming the passcode for that would send
|
||||||
|
// the operator to fix something that is not broken.
|
||||||
|
val refused = c.isRefusedByServer
|
||||||
|
val ok = sent && !refused
|
||||||
|
// "sent" is the write's own verdict and reads as nonsense next to a failure - the card
|
||||||
|
// showed "failed - sent" for a refused login. When the refusal is what failed the
|
||||||
|
// report, say that instead.
|
||||||
|
val reported = when {
|
||||||
|
ok -> detail
|
||||||
|
refused -> "login not verified"
|
||||||
|
else -> detail
|
||||||
|
}
|
||||||
|
onReport(
|
||||||
|
AprsReport(
|
||||||
|
System.currentTimeMillis(), packetLine, ok, reported,
|
||||||
|
verified = !refused
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (ok) onState(AprsState.Connected) else onState(AprsState.Error)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
runCatching { client?.disconnect() }
|
||||||
|
client = null
|
||||||
|
onState(AprsState.Error)
|
||||||
|
onReport(AprsReport(System.currentTimeMillis(), "", false, e.message ?: "error", false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** A short reason for a refusal, for the operator's last-report line. */
|
||||||
|
private fun refusalDetail(refusal: AprsBeacon.Refusal): String = when (refusal) {
|
||||||
|
AprsBeacon.Refusal.NoPosition -> "no position yet"
|
||||||
|
AprsBeacon.Refusal.NoCallsign -> "no callsign set"
|
||||||
|
is AprsBeacon.Refusal.ImpossiblePosition -> "position out of range"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
/** Floor for the reporting interval, in minutes. */
|
||||||
|
const val MIN_INTERVAL_MIN = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A house, not a car.
|
||||||
|
*
|
||||||
|
* Only fresh installs see this: saveConfig writes every key unconditionally and the enable
|
||||||
|
* switch calls it, so anyone who has ever turned APRS on has both symbol keys on disk and this
|
||||||
|
* fallback cannot reach them.
|
||||||
|
*/
|
||||||
|
private const val DEFAULT_SYMBOL_CODE = "-"
|
||||||
|
private const val KEY_LAST_TIME = "last_report_time"
|
||||||
|
private const val KEY_LAST_OK = "last_report_ok"
|
||||||
|
private const val KEY_LAST_DETAIL = "last_report_detail"
|
||||||
|
|
||||||
|
/** Read config (saved as filled; no need to re-enter each time) */
|
||||||
|
fun loadConfig(context: Context): AprsConfig {
|
||||||
|
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
return AprsConfig(
|
||||||
|
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, DEFAULT_SYMBOL_CODE) ?: DEFAULT_SYMBOL_CODE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last report result (shown on the settings card) */
|
||||||
|
data class LastReport(val time: Long = 0L, val ok: Boolean = false, val detail: String = "")
|
||||||
|
|
||||||
|
fun loadLastReport(context: Context): LastReport {
|
||||||
|
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,771 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.cw
|
||||||
|
|
||||||
|
import ai.onnxruntime.OnnxTensor
|
||||||
|
import ai.onnxruntime.OrtEnvironment
|
||||||
|
import ai.onnxruntime.OrtSession
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import com.rtbishop.look4sat.core.domain.cw.CwCtcDecoder
|
||||||
|
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
|
||||||
|
import com.rtbishop.look4sat.core.domain.cw.CwAntiAlias
|
||||||
|
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
|
||||||
|
import com.rtbishop.look4sat.core.domain.cw.CwDetectionPool
|
||||||
|
import com.rtbishop.look4sat.core.domain.cw.CwShiftDecider
|
||||||
|
import com.rtbishop.look4sat.core.domain.cw.CwToneShifter
|
||||||
|
import com.rtbishop.look4sat.core.domain.cw.ICwDecoder
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.nio.FloatBuffer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CW decoder backed by the DeepCW neural network (AGPL-3.0, see
|
||||||
|
* `feature/cw/licenses/NOTICE.md`).
|
||||||
|
*
|
||||||
|
* The model classifies a whole audio segment at once rather than streaming
|
||||||
|
* sample by sample, and it revises earlier characters once more context
|
||||||
|
* arrives. Incremental stitching therefore produces duplicated callsigns —
|
||||||
|
* measured character error rates of 67-294% against 0% for whole-segment
|
||||||
|
* decoding. Instead a [CwDeepBuffer] holds the last 20 seconds and the whole
|
||||||
|
* window is re-decoded every 1.5 seconds, replacing [decodedText] outright.
|
||||||
|
*
|
||||||
|
* The model's fixed 400-1200 Hz analysis window means pitch detection is built
|
||||||
|
* in; no spectral peak tracking or squelch gating is needed. A tone outside that
|
||||||
|
* window is invisible to the model, so [CwToneShifter] can optionally move it in —
|
||||||
|
* see [isToneShiftEnabled].
|
||||||
|
*
|
||||||
|
* @param isToneShiftEnabled read on every chunk so toggling the setting takes effect
|
||||||
|
* without rebuilding the decoder. Defaults to disabled: with it off the audio path
|
||||||
|
* is byte-for-byte what it was before the feature existed.
|
||||||
|
*/
|
||||||
|
class CwDeepDecoder(
|
||||||
|
context: Context,
|
||||||
|
private val isToneShiftEnabled: () -> Boolean = { false }
|
||||||
|
) : ICwDecoder {
|
||||||
|
|
||||||
|
// internal, not private: the archive timing is user-visible behaviour and its test asserts
|
||||||
|
// against these constants directly rather than a copy that could silently drift.
|
||||||
|
internal companion object {
|
||||||
|
const val TAG = "CwDeepDecoder"
|
||||||
|
const val MODEL_ASSET = "deepcw/model.onnx"
|
||||||
|
const val METADATA_ASSET = "deepcw/model.onnx.json"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evicted audio is decoded into permanent history once this much accumulates.
|
||||||
|
*
|
||||||
|
* This is the delay before decoded text reaches the record, and it is additive with
|
||||||
|
* the 20 s live window: at the old 15 s the record showed nothing for the first 35 s
|
||||||
|
* of a session, and thereafter text that had scrolled out of the live window sat
|
||||||
|
* invisible for up to 15 s before landing - the record appeared to stall and, when
|
||||||
|
* it was still concatenating the live window, to delete what it had just shown.
|
||||||
|
*
|
||||||
|
* Each batch is one full inference, so this trades CPU for latency. 4 s holds the gap
|
||||||
|
* under the ~4.7 s a seven-character call sign takes at 18 WPM - the record must not
|
||||||
|
* stall for longer than the one thing an operator most needs to read back - while the
|
||||||
|
* archive path still fires less than half as often as the 1.5 s live redecode cycle.
|
||||||
|
*/
|
||||||
|
const val ARCHIVE_SECONDS = 4.0
|
||||||
|
val ARCHIVE_THRESHOLD: Int = (CwDeepSpectrogram.SAMPLE_RATE * ARCHIVE_SECONDS).toInt()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Samples the detector needs for a usable estimate: 0.4 s at 3200 Hz, giving
|
||||||
|
* ~12.5 Hz resolution.
|
||||||
|
*
|
||||||
|
* A capture chunk is ~100 ms, which is 4410 samples at the 44.1 kHz capture
|
||||||
|
* rate but only 320 after resampling to 3200 Hz. Gating on a single chunk
|
||||||
|
* reaching this size would therefore never fire, so chunks are accumulated in
|
||||||
|
* [detectionPool] until enough audio is available.
|
||||||
|
*/
|
||||||
|
const val DETECT_MIN_SAMPLES = 1280
|
||||||
|
|
||||||
|
/** Detection cadence; re-running it on every 100 ms chunk would be wasteful. */
|
||||||
|
const val DETECT_INTERVAL_MS = 2000
|
||||||
|
|
||||||
|
/** Silence after which a tone reading is treated as stale. See runDetection. */
|
||||||
|
const val TONE_EXPIRY_MS = 10_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum change in the required shift before the window is re-shifted.
|
||||||
|
*
|
||||||
|
* Two scan bins (12.5 Hz each) plus margin. Re-shifting drops the 20 s decode
|
||||||
|
* window, so a tone drifting slightly - or the estimate hopping to an adjacent
|
||||||
|
* bin - must not keep wiping context that is still perfectly decodable.
|
||||||
|
*/
|
||||||
|
const val SHIFT_HYSTERESIS_HZ = 40f
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _decodedText = MutableStateFlow("")
|
||||||
|
override val decodedText: StateFlow<String> = _decodedText.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archived text: only appended to, so a pane bound to it never loses what it showed.
|
||||||
|
*
|
||||||
|
* This once carried a provisional tail meant to cover the gap while audio waited to be
|
||||||
|
* archived, but the decode feeding that tail was never wired up, so the tail was always
|
||||||
|
* empty and the gap stayed. It is closed instead by archiving in [ARCHIVE_SECONDS]
|
||||||
|
* batches, which no longer concatenate anything that gets rewritten.
|
||||||
|
*/
|
||||||
|
private val _historyText = MutableStateFlow("")
|
||||||
|
override val historyText: StateFlow<String> = _historyText.asStateFlow()
|
||||||
|
|
||||||
|
/** Permanently archived text; the provisional tail is appended to this for display. */
|
||||||
|
private var committedText = ""
|
||||||
|
|
||||||
|
private val _estimatedPitch = MutableStateFlow<Float?>(null)
|
||||||
|
override val estimatedPitch: StateFlow<Float?> = _estimatedPitch.asStateFlow()
|
||||||
|
|
||||||
|
private val _detectedToneHz = MutableStateFlow<Float?>(null)
|
||||||
|
override val detectedToneHz: StateFlow<Float?> = _detectedToneHz.asStateFlow()
|
||||||
|
|
||||||
|
private val _activeShiftHz = MutableStateFlow(0f)
|
||||||
|
override val activeShiftHz: StateFlow<Float> = _activeShiftHz.asStateFlow()
|
||||||
|
|
||||||
|
private val _signalStrength = MutableStateFlow(0f)
|
||||||
|
override val signalStrength: StateFlow<Float> = _signalStrength.asStateFlow()
|
||||||
|
|
||||||
|
private val _lastInferenceMs = MutableStateFlow(0)
|
||||||
|
override val lastInferenceMs: StateFlow<Int> = _lastInferenceMs.asStateFlow()
|
||||||
|
|
||||||
|
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||||
|
override val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
||||||
|
|
||||||
|
private val buffer = CwDeepBuffer()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evicted audio accumulates here until it reaches [ARCHIVE_SECONDS], then is decoded
|
||||||
|
* once and appended to [historyText]. The batch stays short enough that the record does
|
||||||
|
* not visibly stall, and accuracy barely suffers: this content has already been through
|
||||||
|
* the 20 s window many times, so the archive decode is a confirmation, not a first look.
|
||||||
|
*
|
||||||
|
* Sized to the full window rather than the batch: [flush] hands over whatever the live
|
||||||
|
* window holds, which can be the whole 20 s.
|
||||||
|
*/
|
||||||
|
private val archiveBuffer = FloatArray(CwDeepBuffer.DEFAULT_MAX_SECONDS.toInt() * CwDeepSpectrogram.SAMPLE_RATE)
|
||||||
|
private var archiveSize = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Window contents retired by a change of shift, waiting to be archived.
|
||||||
|
*
|
||||||
|
* The live window is the only route into the archive - audio gets there by being pushed
|
||||||
|
* out - so clearing the window used to mean its audio was never decoded at all. When the
|
||||||
|
* shift changed more often than the window took to fill, that was every sample: modelled
|
||||||
|
* at 18 WPM with a drift every 20 s, five minutes of listening archived nothing whatever.
|
||||||
|
* Synchronised on itself: written from the capture path and drained from both there and
|
||||||
|
* [flush], which run on different coroutines. Capped, because a signal drifting on every
|
||||||
|
* detection scan would otherwise queue windows faster than they can be decoded and grow
|
||||||
|
* without bound; past the cap the oldest goes, since newer audio is what is being read.
|
||||||
|
*/
|
||||||
|
private val retiredAudio = ArrayDeque<FloatArray>()
|
||||||
|
|
||||||
|
/** Windows held awaiting archival before the oldest is dropped. */
|
||||||
|
private val retiredAudioLimit = 4
|
||||||
|
|
||||||
|
/** Held while inference runs so slow devices skip work instead of queuing it. */
|
||||||
|
private val inferenceLock = Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialises the archive path, which mutates state the capture coroutine also touches.
|
||||||
|
*
|
||||||
|
* [flush] runs on a different coroutine from [processBuffer] - on pause, and from the app
|
||||||
|
* scope as the screen leaves - and both append to [committedText], which is a read, an
|
||||||
|
* inference lasting hundreds of milliseconds, and only then a write. Interleaved, the
|
||||||
|
* later write wins and a whole batch of text is lost, precisely at the moment the
|
||||||
|
* operator stops listening and starts reading. They also both touch [archiveBuffer] and
|
||||||
|
* [archiveSize]: a reset of the index under a snapshot that already covered those slots
|
||||||
|
* makes the same audio decode twice.
|
||||||
|
*
|
||||||
|
* Not [inferenceLock]: that one is a tryLock, dropping work when contended, which is
|
||||||
|
* right for the live window (another decode is 1.5 s away) and wrong here (dropping an
|
||||||
|
* archive batch discards the audio for good).
|
||||||
|
*/
|
||||||
|
private val archiveLock = Mutex()
|
||||||
|
|
||||||
|
/** Decides what shift to apply from successive tone estimates. */
|
||||||
|
private val shiftDecider = CwShiftDecider(SHIFT_HYSTERESIS_HZ)
|
||||||
|
|
||||||
|
/** Wall clock of the last scan that actually found a tone, for [TONE_EXPIRY_MS]. */
|
||||||
|
private var lastToneAtMs = 0L
|
||||||
|
|
||||||
|
/** Wall clock of the last detection scan, throttling it to [DETECT_INTERVAL_MS]. */
|
||||||
|
private var lastDetectAtMs = 0L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pools resampled chunks until [DETECT_MIN_SAMPLES] is reached. A single capture
|
||||||
|
* chunk is only 320 samples once resampled, so detection has to pool several.
|
||||||
|
*/
|
||||||
|
private val detectionPool = CwDetectionPool(DETECT_MIN_SAMPLES)
|
||||||
|
|
||||||
|
/** Carries Hilbert filter history and mixer phase across capture chunks. */
|
||||||
|
private val streamingShifter = CwToneShifter.Streaming()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anti-alias filter for the decimation to [CwDeepSpectrogram.SAMPLE_RATE].
|
||||||
|
*
|
||||||
|
* Built on the first chunk because the capture rate is not known until then. Without
|
||||||
|
* it everything above 1600 Hz folds into the window: a 3000 Hz tone reappeared at
|
||||||
|
* 200 Hz at 119 times the spectral mean, and the whole 1600-22050 Hz band of hiss
|
||||||
|
* folded down on top of the signal.
|
||||||
|
*/
|
||||||
|
private var antiAlias: CwAntiAlias.Streaming? = null
|
||||||
|
private var antiAliasRate = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Previous value of the setting, so a toggle can invalidate buffered audio.
|
||||||
|
* Null until the first chunk: a decoder created while the setting is already on
|
||||||
|
* must not treat that as a change and wipe an empty buffer.
|
||||||
|
*/
|
||||||
|
private var toneShiftWasEnabled: Boolean? = null
|
||||||
|
|
||||||
|
private var environment: OrtEnvironment? = null
|
||||||
|
private var session: OrtSession? = null
|
||||||
|
private var chars: List<String> = emptyList()
|
||||||
|
private var blankIndex = 41
|
||||||
|
private var inputName = "spectrogram"
|
||||||
|
private var outputName = "log_probs"
|
||||||
|
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
private var loadAttempted = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
CwProbe.init(appContext)
|
||||||
|
CwProbe.step("decoder_constructed")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads metadata and the ONNX session on first use.
|
||||||
|
*
|
||||||
|
* Deliberately not done in `init`: loading pulls in ONNX Runtime's native
|
||||||
|
* library, and a failure there surfaces as [UnsatisfiedLinkError]. Thrown
|
||||||
|
* from a constructor it would take down the whole composable that created
|
||||||
|
* the decoder, so the work happens here where it can be reported through
|
||||||
|
* [errorMessage] instead.
|
||||||
|
*
|
||||||
|
* @return true when the session is ready to run.
|
||||||
|
*/
|
||||||
|
private fun ensureLoaded(): Boolean {
|
||||||
|
if (session != null) return true
|
||||||
|
if (loadAttempted) return false
|
||||||
|
loadAttempted = true
|
||||||
|
CwProbe.step("load_begin")
|
||||||
|
try {
|
||||||
|
val metadata = JSONObject(
|
||||||
|
appContext.assets.open(METADATA_ASSET).bufferedReader().use { it.readText() }
|
||||||
|
)
|
||||||
|
val charArray = metadata.getJSONArray("chars")
|
||||||
|
chars = List(charArray.length()) { charArray.getString(it) }
|
||||||
|
blankIndex = metadata.getInt("blank_index")
|
||||||
|
inputName = metadata.getString("onnx_input_name")
|
||||||
|
outputName = metadata.getString("onnx_output_name")
|
||||||
|
|
||||||
|
val modelBytes = appContext.assets.open(MODEL_ASSET).use { it.readBytes() }
|
||||||
|
val env = OrtEnvironment.getEnvironment()
|
||||||
|
environment = env
|
||||||
|
val options = OrtSession.SessionOptions().apply {
|
||||||
|
// Keep a core free for audio capture and the UI; the default
|
||||||
|
// would spread inference across every core on the device.
|
||||||
|
val threads = (Runtime.getRuntime().availableProcessors() - 1).coerceIn(1, 4)
|
||||||
|
setIntraOpNumThreads(threads)
|
||||||
|
}
|
||||||
|
session = env.createSession(modelBytes, options)
|
||||||
|
CwProbe.step("load_session_ok")
|
||||||
|
Log.i(TAG, "DeepCW ready: ${modelBytes.size} bytes, ${chars.size} classes")
|
||||||
|
return true
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
// Catches UnsatisfiedLinkError (missing/mismatched .so) as well as
|
||||||
|
// asset and session failures.
|
||||||
|
CwProbe.step("load_failed:${t.javaClass.simpleName}")
|
||||||
|
Log.e(TAG, "DeepCW model failed to load", t)
|
||||||
|
_errorMessage.value =
|
||||||
|
"CW model failed to load: ${t.message ?: t.javaClass.simpleName}"
|
||||||
|
// Persist the failure for devices without logcat access.
|
||||||
|
runCatching {
|
||||||
|
val sw = java.io.StringWriter()
|
||||||
|
t.printStackTrace(java.io.PrintWriter(sw))
|
||||||
|
java.io.File(appContext.filesDir, "deepcw_load_error.txt")
|
||||||
|
.writeText("${t.javaClass.name}: ${t.message}\n${sw}\n")
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun processBuffer(samples: FloatArray, sampleRate: Int) {
|
||||||
|
if (samples.isEmpty()) return
|
||||||
|
if (!ensureLoaded()) return
|
||||||
|
|
||||||
|
// Filter before decimating. resampleLinear interpolates without removing anything
|
||||||
|
// above the new Nyquist, so this has to happen first or the fold is already baked in.
|
||||||
|
if (antiAlias == null || antiAliasRate != sampleRate) {
|
||||||
|
antiAlias = CwAntiAlias.Streaming(sampleRate, CwDeepSpectrogram.SAMPLE_RATE)
|
||||||
|
antiAliasRate = sampleRate
|
||||||
|
}
|
||||||
|
val bandLimited = antiAlias?.process(samples) ?: samples
|
||||||
|
// The filter holds back its group delay, so the first call returns nothing.
|
||||||
|
if (bandLimited.isEmpty()) return
|
||||||
|
|
||||||
|
val resampled = CwDeepSpectrogram.resampleLinear(
|
||||||
|
bandLimited, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
||||||
|
)
|
||||||
|
val prepared = applyToneShift(resampled)
|
||||||
|
|
||||||
|
// Anything applyToneShift just retired from the window is older than what follows, so
|
||||||
|
// it is archived before the new audio is buffered - otherwise the record comes out
|
||||||
|
// with its text transposed. Archived whole rather than accumulated: it is already a
|
||||||
|
// full window's worth, and holding it back would only expose it to the next retirement.
|
||||||
|
val retired = drainRetiredAudio()
|
||||||
|
for (batch in retired) {
|
||||||
|
try {
|
||||||
|
archiveDecode(batch)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
if (t is CancellationException) throw t
|
||||||
|
Log.e(TAG, "retired audio decode failed", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val shouldRedecode = buffer.append(prepared)
|
||||||
|
|
||||||
|
// Archive audio that scrolled out of the live window. It is decoded once
|
||||||
|
// when a full archive chunk has accumulated, so old text does not vanish.
|
||||||
|
val overflow = buffer.drainOverflow()
|
||||||
|
if (overflow.isNotEmpty()) {
|
||||||
|
for (v in overflow) {
|
||||||
|
// Flush before appending when the buffer is full, so large batches
|
||||||
|
// (e.g. 47999 samples already accumulated + 64000 new overflow)
|
||||||
|
// do not silently drop audio that scrolled out of the live window.
|
||||||
|
if (archiveSize >= archiveBuffer.size) {
|
||||||
|
val audio = archiveBuffer.copyOf(archiveSize)
|
||||||
|
archiveSize = 0
|
||||||
|
try {
|
||||||
|
archiveDecode(audio)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
if (t is CancellationException) throw t
|
||||||
|
Log.e(TAG, "archive decode failed", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
archiveBuffer[archiveSize++] = v
|
||||||
|
}
|
||||||
|
// Final flush when threshold is reached (e.g. exactly 48000 accumulated).
|
||||||
|
if (archiveSize >= ARCHIVE_THRESHOLD) {
|
||||||
|
val audio = archiveBuffer.copyOf(archiveSize)
|
||||||
|
archiveSize = 0
|
||||||
|
try {
|
||||||
|
archiveDecode(audio)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
if (t is CancellationException) throw t
|
||||||
|
Log.e(TAG, "archive decode failed", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldRedecode || !buffer.hasEnoughAudio) return
|
||||||
|
|
||||||
|
// Drop this cycle rather than queue when the previous run is still going.
|
||||||
|
if (!inferenceLock.tryLock()) {
|
||||||
|
Log.d(TAG, "inference still running, skipping this interval")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
decodeWindow(buffer.snapshot())
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
// Cancellation is normal when the user pauses: the capture coroutine
|
||||||
|
// is cancelled while an inference is in flight. Never swallow it as
|
||||||
|
// a decode error — rethrow so the coroutine machinery works, and do
|
||||||
|
// not flash a spurious "decode failed" banner.
|
||||||
|
if (t is CancellationException) throw t
|
||||||
|
Log.e(TAG, "inference failed", t)
|
||||||
|
_errorMessage.value = "CW decode failed: ${t.message ?: t.javaClass.simpleName}"
|
||||||
|
runCatching {
|
||||||
|
val sw = java.io.StringWriter()
|
||||||
|
t.printStackTrace(java.io.PrintWriter(sw))
|
||||||
|
java.io.File(appContext.filesDir, "deepcw_infer_error.txt")
|
||||||
|
.writeText("${t.javaClass.name}: ${t.message}\n${sw}\n")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
inferenceLock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move an out-of-window tone into the model's analysis window when the user has
|
||||||
|
* enabled it.
|
||||||
|
*
|
||||||
|
* The detection scan is a bin-by-bin DFT, so it runs at most every
|
||||||
|
* [DETECT_INTERVAL_MS] rather than on every ~100 ms capture chunk; the decision it
|
||||||
|
* produces is cached in [_activeShiftHz] and applied to the chunks in between. A
|
||||||
|
* tone already inside the window yields a zero shift, and then this returns the
|
||||||
|
* caller's array untouched.
|
||||||
|
*
|
||||||
|
* @return the audio to buffer: [resampled] itself whenever no shift applies.
|
||||||
|
*/
|
||||||
|
private fun applyToneShift(resampled: FloatArray): FloatArray {
|
||||||
|
val enabled = isToneShiftEnabled()
|
||||||
|
|
||||||
|
// A toggle invalidates whatever is already buffered: those samples were moved by
|
||||||
|
// the old setting and cannot be un-shifted, so the 20 s window would keep
|
||||||
|
// decoding them - and the pitch readout would correct them by the wrong amount -
|
||||||
|
// for up to 20 s after the user acted. Seeded from the current setting on the
|
||||||
|
// first chunk so starting up with it already on is not treated as a change.
|
||||||
|
val previousEnabled = toneShiftWasEnabled ?: enabled
|
||||||
|
toneShiftWasEnabled = enabled
|
||||||
|
if (enabled != previousEnabled) {
|
||||||
|
Log.i(TAG, "toneShift: setting changed to $enabled, dropping buffered audio")
|
||||||
|
dropBufferedAudio()
|
||||||
|
_activeShiftHz.value = 0f
|
||||||
|
_detectedToneHz.value = null
|
||||||
|
lastToneAtMs = 0L
|
||||||
|
shiftDecider.reset()
|
||||||
|
lastDetectAtMs = 0L
|
||||||
|
detectionPool.clear()
|
||||||
|
streamingShifter.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detection runs whether or not shifting is enabled. It is the only measurement
|
||||||
|
// that can see past the model's window, so with it skipped an out-of-window tone
|
||||||
|
// left the UI with nothing truthful to show: the spectrogram's own pitch readout
|
||||||
|
// is arithmetically confined to the window and reports the leakage piled against
|
||||||
|
// the nearest edge, so a 1500 Hz tone published "1200 Hz" and a healthy signal
|
||||||
|
// level while decoding nothing at all.
|
||||||
|
detectionPool.add(resampled)
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val elapsed = now - lastDetectAtMs
|
||||||
|
if (detectionPool.isReady && elapsed >= DETECT_INTERVAL_MS) {
|
||||||
|
lastDetectAtMs = now
|
||||||
|
runDetection(detectionPool.drain(), shiftEnabled = enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enabled) return resampled
|
||||||
|
|
||||||
|
// Streaming keeps the Hilbert filter history and mixer phase across chunks;
|
||||||
|
// shifting each chunk in isolation distorted the 62 samples at its edges.
|
||||||
|
return streamingShifter.process(resampled, _activeShiftHz.value, CwDeepSpectrogram.SAMPLE_RATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discard buffered audio that was shifted by a now-stale amount.
|
||||||
|
*
|
||||||
|
* The live window and the pending archive chunk both hold shifted samples that
|
||||||
|
* cannot be un-shifted, so they are dropped rather than decoded against the new
|
||||||
|
* shift. Text already committed to [historyText] stays: it was correct when decoded.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Drop audio that was shifted by a setting no longer in force - but keep what can be kept.
|
||||||
|
*
|
||||||
|
* The live window has to go: it holds samples moved by two different amounts, and one
|
||||||
|
* spectrogram over both smears the tone. The pending archive batch does not. Those samples
|
||||||
|
* already left the window, they were shifted consistently, and they are complete, so
|
||||||
|
* discarding them threw away decodable audio for no reason. They are left in place here to
|
||||||
|
* be archived by the normal path, which keeps this function non-suspending: its two
|
||||||
|
* callers sit on the synchronous capture path, and making them suspend to run an inference
|
||||||
|
* here would put a decode inside the tone-detection scan.
|
||||||
|
*
|
||||||
|
* It mattered because this runs on every change of shift, which tracks the detected tone,
|
||||||
|
* which drifts across a pass. Modelled at 18 WPM, a drift every 20 s left the record
|
||||||
|
* permanently empty however long the operator listened: the window was wiped before any
|
||||||
|
* batch could complete, so nothing was ever committed. With the record still concatenating
|
||||||
|
* the live decode at the time, each wipe visibly cut the transcript short as well - text
|
||||||
|
* going backwards, then never accumulating at all.
|
||||||
|
*/
|
||||||
|
/** Take every retired window, oldest first, leaving the queue empty. */
|
||||||
|
private fun drainRetiredAudio(): List<FloatArray> = synchronized(retiredAudio) {
|
||||||
|
if (retiredAudio.isEmpty()) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
retiredAudio.toList().also { retiredAudio.clear() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dropBufferedAudio() {
|
||||||
|
// Retired, not discarded. The samples cannot stay in the window - mixing two shifts
|
||||||
|
// in one spectrogram smears the tone - but they are internally consistent and
|
||||||
|
// complete, so they decode fine on their own. Handed to the archive path rather than
|
||||||
|
// decoded here, because both callers sit on the synchronous capture path.
|
||||||
|
val retiring = buffer.snapshot()
|
||||||
|
if (retiring.isNotEmpty()) {
|
||||||
|
synchronized(retiredAudio) {
|
||||||
|
while (retiredAudio.size >= retiredAudioLimit) {
|
||||||
|
Log.w(TAG, "retired audio queue full, dropping the oldest window")
|
||||||
|
retiredAudio.removeFirst()
|
||||||
|
}
|
||||||
|
retiredAudio.addLast(retiring)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buffer.reset()
|
||||||
|
// Committed text stays: it was correct for audio that really was archived.
|
||||||
|
_historyText.value = committedText
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feed one detection to [shiftDecider] and log what it decided.
|
||||||
|
*
|
||||||
|
* The rule itself lives in core:domain so it can be tested directly; keeping it here
|
||||||
|
* meant tests could only restate it, and a restated rule cannot fail when the real
|
||||||
|
* one is wrong - four injected defects once left the whole suite green.
|
||||||
|
*/
|
||||||
|
private fun runDetection(sample: FloatArray, shiftEnabled: Boolean) {
|
||||||
|
val analysis = CwToneShifter.analyse(sample, CwDeepSpectrogram.SAMPLE_RATE)
|
||||||
|
|
||||||
|
// Published either way: the UI needs the real pitch to say why nothing decodes
|
||||||
|
// when shifting is off and the tone is out of range. Held through silences for
|
||||||
|
// the same reason the shift is - CW is gaps, and a gap is not a retune - but not
|
||||||
|
// indefinitely: without an expiry the last out-of-band reading survived every
|
||||||
|
// silent scan, so after retuning into the band the hint kept naming a frequency
|
||||||
|
// the operator had left. Ten seconds clears comfortably any real gap, the longest
|
||||||
|
// being about 1.7 s at 5 WPM between words plus a few seconds of thinking.
|
||||||
|
val tone = analysis.toneHz
|
||||||
|
if (tone != null) {
|
||||||
|
_detectedToneHz.value = tone
|
||||||
|
lastToneAtMs = System.currentTimeMillis()
|
||||||
|
} else if (System.currentTimeMillis() - lastToneAtMs > TONE_EXPIRY_MS) {
|
||||||
|
_detectedToneHz.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shiftEnabled) return
|
||||||
|
|
||||||
|
val decision = shiftDecider.accept(analysis)
|
||||||
|
_activeShiftHz.value = decision.shiftHz
|
||||||
|
|
||||||
|
when (decision.outcome) {
|
||||||
|
CwShiftDecider.Outcome.NO_TONE -> Log.d(
|
||||||
|
TAG,
|
||||||
|
"toneShift: no tone in ${sample.size} samples, keeping shift=${decision.shiftHz}Hz"
|
||||||
|
)
|
||||||
|
|
||||||
|
CwShiftDecider.Outcome.WITHIN_HYSTERESIS -> Log.d(
|
||||||
|
TAG,
|
||||||
|
"toneShift: tone=${decision.toneHz}Hz within ${CwShiftDecider.DEFAULT_HYSTERESIS_HZ}Hz " +
|
||||||
|
"of anchor ${shiftDecider.anchorToneHz}Hz, keeping shift=${decision.shiftHz}Hz"
|
||||||
|
)
|
||||||
|
|
||||||
|
CwShiftDecider.Outcome.NO_SHIFT_NEEDED -> Log.d(
|
||||||
|
TAG,
|
||||||
|
"toneShift: tone=${decision.toneHz}Hz inside " +
|
||||||
|
"${CwDeepSpectrogram.MIN_FREQ_HZ}-${CwDeepSpectrogram.MAX_FREQ_HZ}Hz, no shift"
|
||||||
|
)
|
||||||
|
|
||||||
|
CwShiftDecider.Outcome.SHIFTED -> Log.i(
|
||||||
|
TAG,
|
||||||
|
"toneShift: tone=${decision.toneHz}Hz outside window, " +
|
||||||
|
"shifting ${decision.shiftHz}Hz to ${CwToneShifter.TARGET_HZ}Hz"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision.changed) {
|
||||||
|
// The window still holds audio moved by the old amount. Mixing two shifts in
|
||||||
|
// one spectrogram smears the tone, and the pitch readout could only be right
|
||||||
|
// for one of them, so rebuild the window from the new shift.
|
||||||
|
Log.i(TAG, "toneShift: shift changed, dropping buffered audio")
|
||||||
|
dropBufferedAudio()
|
||||||
|
streamingShifter.reset()
|
||||||
|
CwProbe.step("tone_shift tone=${decision.toneHz} shift=${decision.shiftHz}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun decodeWindow(window: FloatArray) = withContext(Dispatchers.Default) {
|
||||||
|
val activeSession = session ?: return@withContext
|
||||||
|
val activeEnvironment = environment ?: return@withContext
|
||||||
|
CwProbe.step("infer_begin frames=${window.size}")
|
||||||
|
|
||||||
|
val spectrogram = CwDeepSpectrogram.compute(window)
|
||||||
|
val text = runInference(activeSession, activeEnvironment, spectrogram)
|
||||||
|
|
||||||
|
// Replace, never append: the model rewrites earlier characters as more
|
||||||
|
// context arrives, so appending would leave stale guesses on screen.
|
||||||
|
_decodedText.value = text
|
||||||
|
|
||||||
|
updateSignalMetrics(spectrogram)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive whatever audio is still in the pipeline, so stopping does not discard it.
|
||||||
|
*
|
||||||
|
* Two places hold audio that would otherwise never be decoded into the record: the
|
||||||
|
* batch accumulating towards [ARCHIVE_THRESHOLD], and the live window itself, whose
|
||||||
|
* contents only ever reach the archive by being pushed out by newer audio. Together
|
||||||
|
* that is the last [CwDeepBuffer.DEFAULT_MAX_SECONDS] + [ARCHIVE_SECONDS] of a session
|
||||||
|
* - which includes the end of every transmission, the part with the call sign in it.
|
||||||
|
*
|
||||||
|
* Order matters: the pending batch left the window before anything still in it, so it
|
||||||
|
* has to be archived first or the record comes out with its text transposed.
|
||||||
|
*/
|
||||||
|
override suspend fun flush() = archiveLock.withLock {
|
||||||
|
// Oldest first, all the way down: retired window contents, then the batch accumulating
|
||||||
|
// towards the threshold, then what is still live.
|
||||||
|
for (batch in drainRetiredAudio()) {
|
||||||
|
runCatching { archiveDecodeLocked(batch) }
|
||||||
|
.onFailure { if (it is CancellationException) throw it }
|
||||||
|
}
|
||||||
|
if (archiveSize > 0) {
|
||||||
|
val pending = archiveBuffer.copyOf(archiveSize)
|
||||||
|
archiveSize = 0
|
||||||
|
runCatching { archiveDecodeLocked(pending) }
|
||||||
|
.onFailure { if (it is CancellationException) throw it }
|
||||||
|
}
|
||||||
|
// Draining the window empties it, so a second flush cannot double-archive the tail.
|
||||||
|
val window = buffer.snapshot()
|
||||||
|
if (window.isNotEmpty()) {
|
||||||
|
buffer.reset()
|
||||||
|
runCatching { archiveDecodeLocked(window) }
|
||||||
|
.onFailure { if (it is CancellationException) throw it }
|
||||||
|
}
|
||||||
|
// The live line described audio that is now in the record; leaving it would show the
|
||||||
|
// same characters twice, in two places, one of them stale.
|
||||||
|
_decodedText.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a chunk of audio that has scrolled out of the live window and
|
||||||
|
* append it to [historyText]. Unlike the live window this never replaces —
|
||||||
|
* the archived audio is final, so its text is permanent.
|
||||||
|
*/
|
||||||
|
private suspend fun archiveDecode(audio: FloatArray) = archiveLock.withLock {
|
||||||
|
archiveDecodeLocked(audio)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [archiveDecode] without the lock, for callers already holding [archiveLock]. */
|
||||||
|
private suspend fun archiveDecodeLocked(audio: FloatArray) = withContext(Dispatchers.Default) {
|
||||||
|
val activeSession = session ?: return@withContext
|
||||||
|
val activeEnvironment = environment ?: return@withContext
|
||||||
|
if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext
|
||||||
|
val spectrogram = CwDeepSpectrogram.compute(audio)
|
||||||
|
val text = runInference(activeSession, activeEnvironment, spectrogram)
|
||||||
|
committedText += text
|
||||||
|
_historyText.value = committedText
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run the ONNX model over a pre-computed spectrogram and return the decoded text. */
|
||||||
|
private fun runInference(
|
||||||
|
activeSession: OrtSession,
|
||||||
|
activeEnvironment: OrtEnvironment,
|
||||||
|
spectrogram: Array<FloatArray>
|
||||||
|
): String {
|
||||||
|
val frames = spectrogram.size
|
||||||
|
val bins = CwDeepSpectrogram.FREQUENCY_BINS
|
||||||
|
|
||||||
|
val flat = FloatBuffer.allocate(frames * bins)
|
||||||
|
for (frame in spectrogram) flat.put(frame)
|
||||||
|
flat.rewind()
|
||||||
|
|
||||||
|
val shape = longArrayOf(1, 1, frames.toLong(), bins.toLong())
|
||||||
|
val startedAt = System.currentTimeMillis()
|
||||||
|
val text: String
|
||||||
|
OnnxTensor.createTensor(activeEnvironment, flat, shape).use { input ->
|
||||||
|
activeSession.run(mapOf(inputName to input)).use { result ->
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val logits = result[outputName].get().value as Array<Array<FloatArray>>
|
||||||
|
text = CwCtcDecoder.greedy(logits, chars, blankIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_lastInferenceMs.value = (System.currentTimeMillis() - startedAt).toInt()
|
||||||
|
CwProbe.step("infer_done ms=${_lastInferenceMs.value}")
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report the loudest bin as the tone pitch and its prominence over the
|
||||||
|
* window mean as a 0..1 strength, purely for the UI readout.
|
||||||
|
*/
|
||||||
|
private fun updateSignalMetrics(spectrogram: Array<FloatArray>) {
|
||||||
|
if (spectrogram.isEmpty()) return
|
||||||
|
var bestBin = 0
|
||||||
|
var bestValue = 0f
|
||||||
|
var total = 0f
|
||||||
|
var count = 0
|
||||||
|
for (frame in spectrogram) {
|
||||||
|
for (bin in frame.indices) {
|
||||||
|
val value = frame[bin]
|
||||||
|
total += value
|
||||||
|
count++
|
||||||
|
if (value > bestValue) {
|
||||||
|
bestValue = value
|
||||||
|
bestBin = bin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count == 0 || bestValue <= 0f) return
|
||||||
|
|
||||||
|
val binHz = CwDeepSpectrogram.SAMPLE_RATE.toDouble() / CwDeepSpectrogram.FFT_LENGTH
|
||||||
|
// Relative bin 0 is 400 Hz; absolute bin index is 32 + bestBin.
|
||||||
|
val absoluteBin = 32 + bestBin
|
||||||
|
// Undo the shift before reporting: the spectrogram sees the moved tone, but
|
||||||
|
// the readout must show the pitch the operator actually hears on the radio.
|
||||||
|
_estimatedPitch.value = (absoluteBin * binHz - _activeShiftHz.value).toFloat()
|
||||||
|
|
||||||
|
val mean = total / count
|
||||||
|
val prominence = ((bestValue - mean) / bestValue).coerceIn(0f, 1f)
|
||||||
|
|
||||||
|
// The meter claims something decodable is present, so it needs a tone the scan has
|
||||||
|
// actually confirmed inside the window - not merely the absence of a confirmed
|
||||||
|
// out-of-window one. Requiring the confirmation is what covers the intermittent
|
||||||
|
// case: a slow fist out of band at 15% duty scores 2.5 against MIN_PROMINENCE 4.5,
|
||||||
|
// so no tone is reported, and a condition keyed on "confirmed outside" stayed false
|
||||||
|
// and let the meter read half scale on window-edge leakage beside an empty
|
||||||
|
// transcript - the exact reading this gate exists to suppress.
|
||||||
|
val confirmed = _detectedToneHz.value
|
||||||
|
val decodable = confirmed != null &&
|
||||||
|
(_activeShiftHz.value != 0f || CwToneShifter.isInsideWindow(confirmed))
|
||||||
|
_signalStrength.value = if (decodable) prominence else 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear everything. Not serialised against [archiveLock]: an archive decode already in
|
||||||
|
* flight can land its batch after this returns, leaving a few characters behind. The
|
||||||
|
* operator asked to clear and can ask again; making this suspend to close that window
|
||||||
|
* would push it onto every caller, including a synchronous button handler.
|
||||||
|
*/
|
||||||
|
override fun reset() {
|
||||||
|
antiAlias?.reset()
|
||||||
|
buffer.reset()
|
||||||
|
synchronized(retiredAudio) { retiredAudio.clear() }
|
||||||
|
_decodedText.value = ""
|
||||||
|
_historyText.value = ""
|
||||||
|
committedText = ""
|
||||||
|
archiveSize = 0
|
||||||
|
_estimatedPitch.value = null
|
||||||
|
_detectedToneHz.value = null
|
||||||
|
lastToneAtMs = 0L
|
||||||
|
_signalStrength.value = 0f
|
||||||
|
_lastInferenceMs.value = 0
|
||||||
|
// Re-detect from scratch: the operator may have retuned before resetting.
|
||||||
|
_activeShiftHz.value = 0f
|
||||||
|
shiftDecider.reset()
|
||||||
|
lastDetectAtMs = 0L
|
||||||
|
detectionPool.clear()
|
||||||
|
streamingShifter.reset()
|
||||||
|
// Leave toneShiftWasEnabled unset so the next chunk re-seeds it from the
|
||||||
|
// current setting instead of reporting a spurious change.
|
||||||
|
toneShiftWasEnabled = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
try {
|
||||||
|
session?.close()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.w(TAG, "session close failed", t)
|
||||||
|
}
|
||||||
|
session = null
|
||||||
|
environment = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.cw
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal crash-probe logger for diagnosing crashes that produce no Java
|
||||||
|
* stack trace (native faults, low-memory kills). Each step appends one line
|
||||||
|
* to `files/probe_cw.txt`; if the process dies mid-way the last line shows
|
||||||
|
* exactly where. No adb or logcat required.
|
||||||
|
*/
|
||||||
|
internal object CwProbe {
|
||||||
|
|
||||||
|
/** Keep the diagnostic file bounded: the decoder writes two lines per
|
||||||
|
* 1.5 s inference tick (~170 KB/hour), so without a cap it grows without
|
||||||
|
* limit on every release build. Truncate instead of deleting so the
|
||||||
|
* probe keeps the last diagnostics before a crash. */
|
||||||
|
private const val MAX_FILE_BYTES = 1_048_576L // 1 MiB
|
||||||
|
|
||||||
|
private var dir: java.io.File? = null
|
||||||
|
|
||||||
|
fun init(context: Context) {
|
||||||
|
dir = context.filesDir
|
||||||
|
}
|
||||||
|
|
||||||
|
fun step(label: String) {
|
||||||
|
val target = dir ?: return
|
||||||
|
runCatching {
|
||||||
|
val line = "${System.currentTimeMillis()} $label"
|
||||||
|
val file = java.io.File(target, "probe_cw.txt")
|
||||||
|
if (file.length() > MAX_FILE_BYTES) file.delete()
|
||||||
|
file.appendText("$line\n")
|
||||||
|
Log.i("CwProbe", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,12 @@ interface Look4SatDao {
|
|||||||
@Query("DELETE FROM entries")
|
@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
|
||||||
@@ -65,17 +69,22 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
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 com.rtbishop.look4sat.core.data.qrz.QrzGridLookup
|
||||||
|
import com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup
|
||||||
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 +99,59 @@ 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) {
|
||||||
|
// Read per chunk so toggling the setting applies without restarting capture.
|
||||||
|
settingsRepo.otherSettings.value.cwToneShiftEnabled
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QRZ grid lookup. Holds the cookie read so no composable has to: the log screen used to pull
|
||||||
|
* it out of SharedPreferences through LocalContext, putting disk access inside composition.
|
||||||
|
*/
|
||||||
|
private val qrzGridLookup: QrzGridLookup by lazy {
|
||||||
|
QrzGridLookup(
|
||||||
|
context.getSharedPreferences(QrzGridLookup.PREFS_NAME, Context.MODE_PRIVATE),
|
||||||
|
OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
.readTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun provideQrzGridLookup(): IQrzGridLookup = qrzGridLookup
|
||||||
|
|
||||||
override fun provideBluetoothReporter(): IReporter {
|
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 +205,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 +215,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 +238,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.qrz
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup
|
||||||
|
import com.rtbishop.look4sat.core.domain.qrz.QrzGrid
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grid lookup backed by the cookie the operator pasted into settings.
|
||||||
|
*
|
||||||
|
* Owns the cookie read so the log screen does not: a composable used to pull it out of
|
||||||
|
* SharedPreferences through LocalContext on every submission, which put disk access inside
|
||||||
|
* composition and went around the repository layer.
|
||||||
|
*/
|
||||||
|
class QrzGridLookup(
|
||||||
|
private val preferences: SharedPreferences,
|
||||||
|
httpClient: OkHttpClient,
|
||||||
|
dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||||
|
) : IQrzGridLookup {
|
||||||
|
|
||||||
|
private val source = QrzGridSource(httpClient, dispatcher)
|
||||||
|
|
||||||
|
override suspend fun lookup(callsign: String): QrzGrid {
|
||||||
|
val cookie = preferences.getString(COOKIE_KEY, "").orEmpty()
|
||||||
|
// No cookie and an expired one call for the same thing from the operator, so they report
|
||||||
|
// the same way rather than adding a fourth outcome nobody could act on differently.
|
||||||
|
if (cookie.isBlank()) return QrzGrid.SignedOut
|
||||||
|
return source.lookupGrid(callsign, cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun signedInAs(): String? {
|
||||||
|
val cookie = preferences.getString(COOKIE_KEY, "").orEmpty()
|
||||||
|
if (cookie.isBlank()) return null
|
||||||
|
return source.lookupOwnCallsign(cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Where the settings screen stores what the operator pasted. */
|
||||||
|
const val PREFS_NAME = "qrz_cookie"
|
||||||
|
const val COOKIE_KEY = "cookie"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.qrz
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.qrz.QrzGrid
|
||||||
|
import com.rtbishop.look4sat.core.domain.qrz.QrzGridParser
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a station's Maidenhead locator off its QRZ.com page.
|
||||||
|
*
|
||||||
|
* QRZ has no free lookup API for this, so the page is fetched with the operator's own session
|
||||||
|
* cookie and parsed. The cookie is pasted by the operator in settings and never built into the
|
||||||
|
* app. Parsing lives in [QrzGridParser] so it can be tested without a network; this class only
|
||||||
|
* fetches and retries.
|
||||||
|
*/
|
||||||
|
class QrzGridSource(
|
||||||
|
private val httpClient: OkHttpClient,
|
||||||
|
private val dispatcher: CoroutineDispatcher
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up [callsign]'s locator.
|
||||||
|
*
|
||||||
|
* Retried because this runs on a phone, mid-pass, often on mobile data - a single timeout
|
||||||
|
* used to mean the QSO was logged without a grid and the operator was never told. Retries
|
||||||
|
* are bounded and backed off so a genuinely unreachable QRZ costs at most a few seconds:
|
||||||
|
* only transport failures are retried, since a page that loaded and parsed will not parse
|
||||||
|
* differently on a second attempt.
|
||||||
|
*/
|
||||||
|
suspend fun lookupGrid(callsign: String, cookieHeader: String): QrzGrid =
|
||||||
|
withContext(dispatcher) {
|
||||||
|
if (callsign.isBlank() || cookieHeader.isBlank()) return@withContext QrzGrid.SignedOut
|
||||||
|
val url = "$DB_URL${callsign.trim().uppercase()}"
|
||||||
|
fetchWithRetry(url, cookieHeader)?.let(QrzGridParser::parseGrid)
|
||||||
|
?: QrzGrid.Unreachable(MAX_ATTEMPTS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The callsign the pasted cookie is signed in as, so settings can show the operator whose
|
||||||
|
* account it belongs to rather than just claiming success.
|
||||||
|
*/
|
||||||
|
suspend fun lookupOwnCallsign(cookieHeader: String): String? = withContext(dispatcher) {
|
||||||
|
if (cookieHeader.isBlank()) return@withContext null
|
||||||
|
fetchWithRetry(DB_URL, cookieHeader)?.let(QrzGridParser::parseOwnCallsign)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch [url], retrying transport failures with backoff. Null when every attempt failed. */
|
||||||
|
/**
|
||||||
|
* Normalise whatever the operator pasted into a Cookie header value.
|
||||||
|
*
|
||||||
|
* They paste either a raw `k=v; k=v` header or the JSON array a cookie-export extension
|
||||||
|
* produces. The old client normalised this and the rewrite dropped it, so a JSON export that
|
||||||
|
* used to work went out as a literal JSON blob, QRZ served its signed-out page, and the app
|
||||||
|
* told the operator their cookie had expired when it was perfectly good.
|
||||||
|
*/
|
||||||
|
private fun normalise(raw: String): String = QrzGridParser.cookieHeader(raw)
|
||||||
|
|
||||||
|
private suspend fun fetchWithRetry(url: String, rawCookie: String): String? {
|
||||||
|
val cookieHeader = normalise(rawCookie)
|
||||||
|
if (cookieHeader.isBlank()) return null
|
||||||
|
repeat(MAX_ATTEMPTS) { attempt ->
|
||||||
|
try {
|
||||||
|
val request = Request.Builder().url(url)
|
||||||
|
.header("User-Agent", USER_AGENT)
|
||||||
|
.header("Cookie", cookieHeader)
|
||||||
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
if (response.isSuccessful) return response.body.string()
|
||||||
|
// A 4xx will repeat identically, so only server-side faults are worth retrying.
|
||||||
|
if (response.code < 500) return null
|
||||||
|
}
|
||||||
|
} catch (exception: CancellationException) {
|
||||||
|
throw exception
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
println("QrzGridSource attempt ${attempt + 1} failed: $exception")
|
||||||
|
}
|
||||||
|
if (attempt < MAX_ATTEMPTS - 1) delay(BACKOFF_MS[attempt])
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Bare form is the signed-in home page; a callsign appended is that station's page. */
|
||||||
|
const val DB_URL = "https://www.qrz.com/db/"
|
||||||
|
const val USER_AGENT = "Mozilla/5.0 (Linux; Android 13) Look4Sat"
|
||||||
|
const val MAX_ATTEMPTS = 3
|
||||||
|
|
||||||
|
/** Waits before the second and third attempt. Short enough to finish inside a pass. */
|
||||||
|
val BACKOFF_MS = longArrayOf(700L, 2_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
+253
@@ -0,0 +1,253 @@
|
|||||||
|
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).
|
||||||
|
*
|
||||||
|
* Internal rather than private so [AmSatRepository.buildStatuses] can be unit-tested:
|
||||||
|
* the JSON parsing around it needs Android's JSONObject, which is a stub on the JVM.
|
||||||
|
*/
|
||||||
|
internal data class ApiReport(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val callsign: String,
|
||||||
|
val report: String,
|
||||||
|
val gridSquare: String,
|
||||||
|
val reportedTimeUtcSec: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
|
||||||
|
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
|
||||||
|
|
||||||
|
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||||
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
|
||||||
|
val nowSec = System.currentTimeMillis() / 1000
|
||||||
|
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
|
||||||
|
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
|
||||||
|
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
|
||||||
|
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
|
||||||
|
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return@withContext null
|
||||||
|
val names = parseCatalog(catalogJson)
|
||||||
|
val reports = parseReports(reportsJson)
|
||||||
|
|
||||||
|
if (names.isEmpty() && reports.isEmpty()) return@withContext null
|
||||||
|
|
||||||
|
val statuses = buildStatuses(names, reports, nowSec)
|
||||||
|
val reportMap = reports.associate { it.id to toSatReport(it) }
|
||||||
|
|
||||||
|
// The summary endpoint tells us how many reports each satellite actually has,
|
||||||
|
// independent of the 500-record cap. Mark any satellite whose global pull is
|
||||||
|
// incomplete so the UI can show a data-coverage note.
|
||||||
|
val summaryJson = remoteSource.getAmSatSummary(hours = 72)
|
||||||
|
val expectedCounts = parseSummary(summaryJson)
|
||||||
|
val marked = statuses.map { status ->
|
||||||
|
val expected = expectedCounts[status.name]
|
||||||
|
val actual = status.days.sumOf { day -> day.slots.sumOf { it.count } }
|
||||||
|
if (expected != null && expected > actual) status.copy(summaryCount = expected)
|
||||||
|
else status
|
||||||
|
}
|
||||||
|
|
||||||
|
SatStatusPage(System.currentTimeMillis(), marked, reportMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse catalog JSON to list of satellite names */
|
||||||
|
private fun parseCatalog(json: String): List<String> {
|
||||||
|
return try {
|
||||||
|
val arr = JSONObject(json).getJSONArray("data")
|
||||||
|
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse reports JSON to list of ApiReport domain objects */
|
||||||
|
private fun parseReports(json: String): List<ApiReport> {
|
||||||
|
return try {
|
||||||
|
val arr = JSONObject(json).getJSONArray("data")
|
||||||
|
(0 until arr.length()).mapNotNull { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
val iso = o.optString("reported_time", "")
|
||||||
|
if (iso.isEmpty()) null else ApiReport(
|
||||||
|
id = o.optString("id", ""),
|
||||||
|
name = o.optString("name", ""),
|
||||||
|
callsign = o.optString("callsign", ""),
|
||||||
|
report = o.optString("report", ""),
|
||||||
|
gridSquare = o.optString("grid_square", ""),
|
||||||
|
reportedTimeUtcSec = parseIsoUtcSec(iso)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse summary JSON to per-satellite report counts.
|
||||||
|
*
|
||||||
|
* The summary aggregates across all statuses, so a satellite with both "heard" and
|
||||||
|
* "not heard" entries appears once; we sum its report_count across all its rows.
|
||||||
|
* Returns an empty map (not null) on failure so the caller can just check for
|
||||||
|
* missing keys — a failed summary call degrades gracefully to "no coverage marker".
|
||||||
|
*/
|
||||||
|
private fun parseSummary(json: String?): Map<String, Int> {
|
||||||
|
if (json == null) return emptyMap()
|
||||||
|
return try {
|
||||||
|
val arr = JSONObject(json).getJSONArray("data")
|
||||||
|
val out = mutableMapOf<String, Int>()
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
val name = o.optString("name", "")
|
||||||
|
val count = o.optInt("report_count", 0)
|
||||||
|
if (name.isNotEmpty() && count > 0) {
|
||||||
|
out[name] = (out[name] ?: 0) + count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */
|
||||||
|
private fun parseIsoUtcSec(iso: String): Long {
|
||||||
|
return try {
|
||||||
|
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
|
||||||
|
} catch (_: Exception) {
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build one SatStatus (3 days x 12 two-hour slots) per catalog satellite.
|
||||||
|
*
|
||||||
|
* Days are UTC calendar days and slots are fixed UTC bands, matching amsat.org: day 0
|
||||||
|
* is today, its slot 0 covers 22:00-24:00 UTC and slot 11 covers 00:00-02:00, so both
|
||||||
|
* the day list and the slots inside it read newest-first.
|
||||||
|
*
|
||||||
|
* A rolling window anchored on "now" was wrong: fetching at 06:07 UTC put 17.9 hours
|
||||||
|
* of yesterday into the cell labelled today. Checked against a live amsat.org page of
|
||||||
|
* 1021 reports, 73% landed in the wrong day column.
|
||||||
|
*/
|
||||||
|
internal fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||||
|
val byName = reports.groupBy { it.name }
|
||||||
|
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||||
|
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
|
|
||||||
|
// Midnight UTC today, the anchor every slot boundary is derived from.
|
||||||
|
utc.timeInMillis = nowSec * 1000
|
||||||
|
utc.set(Calendar.HOUR_OF_DAY, 0)
|
||||||
|
utc.set(Calendar.MINUTE, 0)
|
||||||
|
utc.set(Calendar.SECOND, 0)
|
||||||
|
utc.set(Calendar.MILLISECOND, 0)
|
||||||
|
val todayMidnightSec = utc.timeInMillis / 1000
|
||||||
|
|
||||||
|
// Reuses the same Calendar, which is safe only because each pass assigns
|
||||||
|
// timeInMillis outright rather than adjusting fields. After this loop it points at
|
||||||
|
// the oldest day, so anything added below must set the time again before reading.
|
||||||
|
val labels = (0 until 3).map { d ->
|
||||||
|
utc.timeInMillis = (todayMidnightSec - d * 86400L) * 1000
|
||||||
|
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
|
||||||
|
}
|
||||||
|
// Oldest report across the whole response, marking how far back the data reaches.
|
||||||
|
// Taken globally rather than per satellite: a quiet satellite has no reports of its
|
||||||
|
// own, but the slots it shares with the rest of the response were still covered.
|
||||||
|
//
|
||||||
|
// Timestamps of zero are excluded: parseIsoUtcSec returns 0 when a reported_time
|
||||||
|
// fails to parse, and a single such record would drag this back to 1970 and mark
|
||||||
|
// nothing as uncovered, silently reverting the distinction.
|
||||||
|
val dataFromSec = reports.asSequence()
|
||||||
|
.map { it.reportedTimeUtcSec }
|
||||||
|
.filter { it > 0L }
|
||||||
|
.minOrNull()
|
||||||
|
?: todayMidnightSec
|
||||||
|
|
||||||
|
return names.map { name ->
|
||||||
|
val satReports = byName[name].orEmpty()
|
||||||
|
val days = (0 until 3).map { dayIdx ->
|
||||||
|
val dayStart = todayMidnightSec - dayIdx * 86400L
|
||||||
|
val slots = (0 until 12).map { slotIdx ->
|
||||||
|
// Slot 0 is the last band of the day, so the day reads newest-first.
|
||||||
|
val slotStart = dayStart + (11 - slotIdx) * 7200L
|
||||||
|
val slotEnd = slotStart + 7200L
|
||||||
|
val inSlot = satReports.filter { it.reportedTimeUtcSec in slotStart until slotEnd }
|
||||||
|
if (inSlot.isEmpty()) {
|
||||||
|
// A slot entirely before the data starts is unknown, not silent.
|
||||||
|
val colour = if (slotEnd <= dataFromSec) NO_DATA_GRAY else NO_REPORT_GRAY
|
||||||
|
SatSlot(statusColor = colour, count = 0)
|
||||||
|
} else {
|
||||||
|
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
|
||||||
|
SatSlot(
|
||||||
|
statusColor = statusColorOf(newest.report),
|
||||||
|
count = inSlot.size,
|
||||||
|
reportIds = inSlot.map { it.id }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SatDay(dateLabel = labels[dayIdx], slots = slots)
|
||||||
|
}
|
||||||
|
SatStatus(name = name, days = days)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toSatReport(r: ApiReport): SatReport {
|
||||||
|
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
|
cal.timeInMillis = r.reportedTimeUtcSec * 1000
|
||||||
|
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
|
||||||
|
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
|
||||||
|
val y = cal.get(Calendar.YEAR)
|
||||||
|
val mo = (cal.get(Calendar.MONTH) + 1).toString().padStart(2, '0')
|
||||||
|
val d = cal.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0')
|
||||||
|
return SatReport(
|
||||||
|
id = r.id,
|
||||||
|
statusText = r.report,
|
||||||
|
call = r.callsign,
|
||||||
|
grid = r.gridSquare,
|
||||||
|
dateUtc = "$y-$mo-$d",
|
||||||
|
timeUtc = "$hh:$mm UTC"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map status text to color value (for UI rendering). */
|
||||||
|
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
|
||||||
|
"heard", "crew active" -> ACTIVE_BLUE
|
||||||
|
"telemetry only" -> TLM_ORANGE
|
||||||
|
"not heard" -> NOT_HEARD_PINK
|
||||||
|
else -> CONFLICT_DEEP_ORANGE
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// AMSAT official status colors (from amsat.org/status)
|
||||||
|
private const val ACTIVE_BLUE = 0xFF648FFF
|
||||||
|
private const val TLM_ORANGE = 0xFFFFB000
|
||||||
|
private const val NOT_HEARD_PINK = 0xFFDC267F
|
||||||
|
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
|
||||||
|
private const val NO_REPORT_GRAY = 0xFFC0C0C0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slots older than the data we actually received.
|
||||||
|
*
|
||||||
|
* The API caps at 500 records however many hours are requested. Measured live: a
|
||||||
|
* 72-hour request returned 500 reports spanning only 49 hours, leaving the oldest
|
||||||
|
* 9.5 hours of the third day with no data at all. Painting those the same grey as
|
||||||
|
* "nobody reported" claimed knowledge we do not have, so they get a lighter shade.
|
||||||
|
*/
|
||||||
|
private const val NO_DATA_GRAY = 0xFFE8E8E8
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
-10
@@ -42,6 +42,17 @@ class DatabaseRepo(
|
|||||||
|
|
||||||
private val customSourceType = "Other"
|
private val customSourceType = "Other"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type key for satellites fetched from a custom URL.
|
||||||
|
*
|
||||||
|
* Separate from customSourceType because setSatelliteTypeIds overwrites rather than merges, so
|
||||||
|
* sharing "Other" with manual file import meant each wiped the other's type index. The
|
||||||
|
* satellites stayed in the database and stayed selectable either way - only their grouping in
|
||||||
|
* the type filter was lost - but the two sources are different things and deserve different
|
||||||
|
* keys.
|
||||||
|
*/
|
||||||
|
private val customUrlType = "Custom"
|
||||||
|
|
||||||
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
||||||
var importedCount = 0
|
var importedCount = 0
|
||||||
remoteSource.getFileStream(uri)?.let { stream ->
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
@@ -67,25 +78,55 @@ class DatabaseRepo(
|
|||||||
|
|
||||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||||
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||||
val tleUrls = buildMap {
|
// A custom URL REPLACES the built-in sources rather than joining them. The previous map
|
||||||
putAll(Sources.satelliteDataUrls)
|
// overwrote only the "All" value and still fetched the other 26, so switching this on meant
|
||||||
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
|
// "my source AND yours" - which defeats the reasons for setting one: a mirror, a filtered
|
||||||
}.filterValues { it.isNotBlank() }
|
// subset, an offline server, or a network where Celestrak is unreachable. On a blocked link
|
||||||
val radioUrls = buildMap {
|
// the real behaviour was 26 failing requests.
|
||||||
putAll(Sources.transceiversDataUrls)
|
//
|
||||||
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
|
// Satellites already stored do not disappear: insertEntries is OnConflictStrategy.REPLACE
|
||||||
}.filterValues { it.isNotBlank() }
|
// and nothing is deleted before the insert, so rows the new source does not mention survive.
|
||||||
|
// The type index for the skipped keys goes stale rather than empty, which is the honest
|
||||||
|
// outcome - it is the last known membership, not a claim about this fetch.
|
||||||
|
//
|
||||||
|
// The key is customUrlType, not "All": setSatelliteTypeIds early-returns on "All", so
|
||||||
|
// indexing under it was always a no-op and satellites from a custom URL were never
|
||||||
|
// reachable by the type filter at all. They now are.
|
||||||
|
val tleUrls = if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank()) {
|
||||||
|
mapOf(customUrlType to dataSourcesSettings.tleUrl)
|
||||||
|
} else {
|
||||||
|
Sources.satelliteDataUrls.filterValues { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
val radioUrls = if (dataSourcesSettings.useCustomTransceivers &&
|
||||||
|
dataSourcesSettings.transceiversUrl.isNotBlank()
|
||||||
|
) {
|
||||||
|
mapOf("SatNOGS" to dataSourcesSettings.transceiversUrl)
|
||||||
|
} else {
|
||||||
|
Sources.transceiversDataUrls.filterValues { it.isNotBlank() }
|
||||||
|
}
|
||||||
// launch all network requests concurrently
|
// 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) } }
|
||||||
|
val tleResults = tleJobs.awaitAll()
|
||||||
|
val radioResults = radioJobs.awaitAll()
|
||||||
|
// Orbital elements are counted on their own. A combined count let a successful transceivers
|
||||||
|
// fetch stand in for a failed orbital one: with a custom TLE URL there are two requests
|
||||||
|
// rather than 28, so if that URL was down and SatNOGS answered, the total was 1, no
|
||||||
|
// exception was raised, and setUpdateSuccessful stamped a fresh timestamp for an update
|
||||||
|
// that refreshed no orbital data at all - which also suppressed the 48-hour auto-update
|
||||||
|
// retry that keys off that timestamp. The failure existed before but 26 other sources hid
|
||||||
|
// it; replacing them made it easy to hit.
|
||||||
|
if (tleResults.none { it.second != null }) {
|
||||||
|
throw java.io.IOException("No orbital data source could be downloaded")
|
||||||
|
}
|
||||||
// parse fetched data concurrently and associate with types
|
// 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) {
|
||||||
|
|||||||
+167
-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,23 @@ 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 keyCwToneShiftEnabled = "cwToneShiftEnabled"
|
||||||
|
private val keyAmsatDayStripes = "amsatDayStripes"
|
||||||
|
|
||||||
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 +122,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 +134,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 +152,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 +162,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 +185,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 +247,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 +269,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 +333,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 +346,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 +355,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 +367,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 +398,18 @@ 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)
|
||||||
|
putBoolean(keyCwToneShiftEnabled, new.cwToneShiftEnabled)
|
||||||
|
putBoolean(keyAmsatDayStripes, new.amsatDayStripes)
|
||||||
|
|
||||||
}
|
}
|
||||||
new
|
new
|
||||||
}
|
}
|
||||||
@@ -371,8 +425,19 @@ 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),
|
||||||
|
cwToneShiftEnabled = preferences.getBoolean(keyCwToneShiftEnabled, false),
|
||||||
|
amsatDayStripes = preferences.getBoolean(keyAmsatDayStripes, true)
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -390,12 +455,47 @@ class SettingsRepo(
|
|||||||
_dataSourcesSettings.value = settings
|
_dataSourcesSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
/** Placeholders a 4.4.7-era build could persist. Neither is a reachable address. */
|
||||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
|
private val placeholderTleUrl = "https://example.com/tle.txt"
|
||||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
|
private val placeholderRadioUrl = "https://example.com/radio.json"
|
||||||
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
|
private val keyPlaceholderUrlsMigrated = "placeholderUrlsMigrated"
|
||||||
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
|
|
||||||
)
|
/**
|
||||||
|
* Replace the example.com placeholders an old build could store.
|
||||||
|
*
|
||||||
|
* Runs once, following the pattern of migrateRCFormats. This used to be a rewrite applied on
|
||||||
|
* every read, so the stored value and the returned value disagreed indefinitely and nothing
|
||||||
|
* ever settled the difference.
|
||||||
|
*/
|
||||||
|
private fun migratePlaceholderUrls() {
|
||||||
|
if (preferences.getBoolean(keyPlaceholderUrlsMigrated, false)) return
|
||||||
|
preferences.edit {
|
||||||
|
if (preferences.getString(keyTleUrl, null) == placeholderTleUrl) {
|
||||||
|
putString(keyTleUrl, Sources.defaultTleUrl)
|
||||||
|
putBoolean(keyUseCustomTle, false)
|
||||||
|
}
|
||||||
|
if (preferences.getString(keyTransceiversUrl, null) == placeholderRadioUrl) {
|
||||||
|
putString(keyTransceiversUrl, Sources.defaultTransceiversUrl)
|
||||||
|
putBoolean(keyUseCustomTransceivers, false)
|
||||||
|
}
|
||||||
|
putBoolean(keyPlaceholderUrlsMigrated, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDataSourcesSettings(): DataSourcesSettings {
|
||||||
|
migratePlaceholderUrls()
|
||||||
|
// The switch is reported as the operator set it. It used to be ANDed with
|
||||||
|
// `url != default`, so typing the default URL by hand switched custom sources off by
|
||||||
|
// itself and the settings screen showed a state nobody had chosen.
|
||||||
|
return DataSourcesSettings(
|
||||||
|
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
|
||||||
|
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
|
||||||
|
tleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl,
|
||||||
|
transceiversUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl)
|
||||||
|
?: Sources.defaultTransceiversUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Radio control settings
|
//region # Radio control settings
|
||||||
@@ -435,5 +535,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,75 @@ 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.7")
|
||||||
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@use null
|
||||||
|
response.body?.string()
|
||||||
|
}
|
||||||
|
} catch (exception: CancellationException) {
|
||||||
|
throw exception
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
println("RemoteSource amsat catalog exception: $exception")
|
||||||
|
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = withContext(dispatcher) {
|
||||||
|
try {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit")
|
||||||
|
.header("User-Agent", "Look4Sat/4.5.7")
|
||||||
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@use null
|
||||||
|
response.body?.string()
|
||||||
|
}
|
||||||
|
} catch (exception: CancellationException) {
|
||||||
|
throw exception
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
println("RemoteSource amsat reports exception: $exception")
|
||||||
|
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAmSatSummary(hours: Int): String? = withContext(dispatcher) {
|
||||||
|
try {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://www.amsat.org/status/api/v1/summary.php?hours=$hours")
|
||||||
|
.header("User-Agent", "Look4Sat/4.5.7")
|
||||||
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@use null
|
||||||
|
response.body?.string()
|
||||||
|
}
|
||||||
|
} catch (exception: CancellationException) {
|
||||||
|
throw exception
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
println("RemoteSource amsat summary exception: $exception")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -56,8 +56,14 @@ class AudioCapture : IAudioCapture {
|
|||||||
if (read > 0) emit(if (read == chunkSize) buffer.copyOf() else buffer.copyOfRange(0, read))
|
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,12 @@ 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun invoke(resId: Int, vararg formatArgs: Any) {
|
||||||
|
invoke(context.getString(resId, *formatArgs))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/* LotwSatellitesRepo.kt - runtime refresh of the LoTW satellite list (4.5.5).
|
||||||
|
* Downloads ARRL's official config.tq6 (gzip XML), parses <satellite name="...">,
|
||||||
|
* updates the LotwSatellites dynamic set + persists to SharedPreferences (survives restarts).
|
||||||
|
* Trigger: the "Update sats" button in the settings WaveLog section (user decided: manual).
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.wavelog
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.LotwSatellites
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.zip.GZIPInputStream
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
class LotwSatellitesRepo(private val context: Context) : ILotwSatellitesRepo {
|
||||||
|
|
||||||
|
private val prefs = context.getSharedPreferences("lotw_satellites", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
/** Restore the last downloaded list from SharedPreferences (call at process start) */
|
||||||
|
override fun restore() {
|
||||||
|
val saved = prefs.getString(KEY_NAMES, null)
|
||||||
|
if (!saved.isNullOrBlank()) {
|
||||||
|
LotwSatellites.updateNames(saved.split(",").filter { it.isNotBlank() }.toSet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download config.tq6 -> parse -> update memory + persist. Result is surfaced in the UI */
|
||||||
|
override suspend fun refresh(): ILotwSatellitesRepo.RefreshResult = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val url = URL("https://lotw.arrl.org/lotw/config.tq6")
|
||||||
|
val conn = url.openConnection()
|
||||||
|
conn.connectTimeout = 10_000
|
||||||
|
conn.readTimeout = 20_000
|
||||||
|
conn.setRequestProperty("User-Agent", "Look4Sat-Pro/4.5.5")
|
||||||
|
val raw = conn.getInputStream()
|
||||||
|
val reader = BufferedReader(InputStreamReader(GZIPInputStream(raw), Charsets.UTF_8))
|
||||||
|
val text = reader.use { it.readText() }
|
||||||
|
// Parse <satellite name="XXX" ...> - fixed format (official ARRL XML)
|
||||||
|
val names = Regex("""<satellite name="([^"]+)""").findAll(text)
|
||||||
|
.map { it.groupValues[1].uppercase() }
|
||||||
|
.toSet()
|
||||||
|
if (names.isEmpty()) return@withContext ILotwSatellitesRepo.RefreshResult.Error("列表为空(响应异常)")
|
||||||
|
LotwSatellites.updateNames(names)
|
||||||
|
prefs.edit()
|
||||||
|
.putString(KEY_NAMES, names.joinToString(","))
|
||||||
|
.putLong(KEY_UPDATED, System.currentTimeMillis())
|
||||||
|
.apply()
|
||||||
|
ILotwSatellitesRepo.RefreshResult.Ok(names.size)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ILotwSatellitesRepo.RefreshResult.Error(e.message ?: "未知错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val KEY_NAMES = "names"
|
||||||
|
private const val KEY_UPDATED = "updated"
|
||||||
|
}
|
||||||
|
}
|
||||||
+307
@@ -0,0 +1,307 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.aprs
|
||||||
|
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.PrintWriter
|
||||||
|
import java.net.ServerSocket
|
||||||
|
import java.net.Socket
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Socket-level tests against a stand-in APRS-IS server.
|
||||||
|
*
|
||||||
|
* These exist because the pure-logic tests could not catch the failures that matter here. A draft
|
||||||
|
* of [AprsIsClient.sendPacket] once wrote the packet with no line terminator at all: every send
|
||||||
|
* reported success, the server received a single unterminated stream, and nothing anywhere went
|
||||||
|
* red. APRS-IS is a line protocol and does not acknowledge position reports, so silence is the
|
||||||
|
* normal case - which means only a test that reads the bytes off a real socket can tell a
|
||||||
|
* delivered packet from a lost one.
|
||||||
|
*/
|
||||||
|
class AprsIsClientSocketTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minimal APRS-IS server. Greets, answers the login as instructed, then records whatever
|
||||||
|
* lines arrive without acknowledging them, which is what the real network does.
|
||||||
|
*/
|
||||||
|
private class FakeServer(
|
||||||
|
private val greeting: String? = "# aprsc 2.1.19-g730c5c0",
|
||||||
|
private val loginResponse: String? = "# logresp TEST verified, server FAKE",
|
||||||
|
private val chatter: List<String> = emptyList(),
|
||||||
|
/** Sent right after the first packet arrives, to exercise the ack read. */
|
||||||
|
private val afterPacket: String? = null
|
||||||
|
) : AutoCloseable {
|
||||||
|
private val server = ServerSocket(0)
|
||||||
|
private val ready = CountDownLatch(1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The accepted connection. Held because closing the ServerSocket only stops it listening
|
||||||
|
* - an established connection survives, so a test that wants a dead peer has to close
|
||||||
|
* this one. Getting that wrong made a correct implementation look broken.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
private var peer: Socket? = null
|
||||||
|
|
||||||
|
val port: Int get() = server.localPort
|
||||||
|
|
||||||
|
/** Every complete line the client sent after logging in. */
|
||||||
|
val received = mutableListOf<String>()
|
||||||
|
|
||||||
|
/** Raw bytes of the client's traffic, so a missing terminator is visible. */
|
||||||
|
val rawAfterLogin = StringBuilder()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var loginLine: String? = null
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
thread(isDaemon = true) {
|
||||||
|
runCatching {
|
||||||
|
server.accept().use { client ->
|
||||||
|
peer = client
|
||||||
|
val out = PrintWriter(client.getOutputStream(), true)
|
||||||
|
val input = BufferedReader(InputStreamReader(client.getInputStream()))
|
||||||
|
greeting?.let { out.print(it + "\r\n"); out.flush() }
|
||||||
|
loginLine = input.readLine()
|
||||||
|
chatter.forEach { out.print(it + "\r\n"); out.flush() }
|
||||||
|
loginResponse?.let { out.print(it + "\r\n"); out.flush() }
|
||||||
|
ready.countDown()
|
||||||
|
// Read lines but never acknowledge, exactly as APRS-IS treats positions.
|
||||||
|
var firstPacket = true
|
||||||
|
while (true) {
|
||||||
|
val line = input.readLine() ?: break
|
||||||
|
synchronized(received) {
|
||||||
|
received += line
|
||||||
|
rawAfterLogin.append(line)
|
||||||
|
}
|
||||||
|
if (firstPacket) {
|
||||||
|
firstPacket = false
|
||||||
|
afterPacket?.let { out.print(it + "\r\n"); out.flush() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ready.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun awaitLogin(): Boolean = ready.await(5, TimeUnit.SECONDS)
|
||||||
|
|
||||||
|
fun lines(): List<String> = synchronized(received) { received.toList() }
|
||||||
|
|
||||||
|
/** Close the established connection, so the client is talking to a dead peer. */
|
||||||
|
fun dropClient() {
|
||||||
|
runCatching { peer?.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
runCatching { server.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun client(port: Int, passcode: Int = 12345) = AprsIsClient(
|
||||||
|
host = "127.0.0.1",
|
||||||
|
port = port,
|
||||||
|
callsign = "TEST",
|
||||||
|
ssid = "",
|
||||||
|
passcode = passcode,
|
||||||
|
softwareName = "Look4Sat",
|
||||||
|
version = "test"
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The regression that motivated this file. Two packets must arrive as two lines; without a
|
||||||
|
* terminator they concatenate into one stream the server can never parse, while both sends
|
||||||
|
* report success.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `each packet arrives as its own line`() {
|
||||||
|
FakeServer().use { server ->
|
||||||
|
server.start()
|
||||||
|
val c = client(server.port)
|
||||||
|
c.connect()
|
||||||
|
assertTrue(server.awaitLogin())
|
||||||
|
val first = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>one")
|
||||||
|
val second = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>two")
|
||||||
|
Thread.sleep(300)
|
||||||
|
c.disconnect()
|
||||||
|
|
||||||
|
assertEquals(true, first?.first)
|
||||||
|
assertEquals(true, second?.first)
|
||||||
|
val lines = server.lines()
|
||||||
|
assertEquals("both packets must reach the server as separate lines", 2, lines.size)
|
||||||
|
assertTrue(lines[0].endsWith(">one"))
|
||||||
|
assertTrue(lines[1].endsWith(">two"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The login line has to be terminated too, or the server never reads it. */
|
||||||
|
@Test
|
||||||
|
fun `the server receives a complete login line`() {
|
||||||
|
FakeServer().use { server ->
|
||||||
|
server.start()
|
||||||
|
val c = client(server.port)
|
||||||
|
c.connect()
|
||||||
|
assertTrue(server.awaitLogin())
|
||||||
|
c.disconnect()
|
||||||
|
assertEquals("user TEST pass 12345 vers Look4Sat test", server.loginLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A verified login is recognised and lets reports count as delivered. */
|
||||||
|
@Test
|
||||||
|
fun `a verified login is not reported as refused`() {
|
||||||
|
FakeServer().use { server ->
|
||||||
|
server.start()
|
||||||
|
val c = client(server.port)
|
||||||
|
c.connect()
|
||||||
|
assertTrue(server.awaitLogin())
|
||||||
|
assertTrue(c.isVerified)
|
||||||
|
assertFalse(c.isRefusedByServer)
|
||||||
|
c.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The case the rewrite exists for: the server accepts the connection, the write succeeds,
|
||||||
|
* and every packet is discarded. The client must say so rather than report success.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `an unverified login is flagged while the connection stays up`() {
|
||||||
|
FakeServer(loginResponse = "# logresp TEST unverified, server FAKE").use { server ->
|
||||||
|
server.start()
|
||||||
|
val c = client(server.port, passcode = -1)
|
||||||
|
c.connect()
|
||||||
|
assertTrue(server.awaitLogin())
|
||||||
|
assertFalse("unverified must not read as verified", c.isVerified)
|
||||||
|
assertTrue("the server explicitly refused", c.isRefusedByServer)
|
||||||
|
// Not a connection error: a receive-only login is legitimate and stays connected.
|
||||||
|
assertTrue(c.isConnected)
|
||||||
|
c.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A chatty server used to exhaust a fixed line budget, turning an accepted login into
|
||||||
|
* Unknown and telling the operator their passcode was wrong when it had been accepted.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `keepalive chatter before the verdict does not hide it`() {
|
||||||
|
// The real keepalive repeats the server identification with a timestamp, captured from
|
||||||
|
// euro.aprs2.net. A made-up "# keepalive N" would now read as a refusal, correctly - only
|
||||||
|
// greetings and verdicts are treated as harmless.
|
||||||
|
val chatter = List(8) { "# aprsc 2.1.21-gbfc2090 25 Aug 2026 16:41:0$it GMT T2UK 1.2.3.4:14580" }
|
||||||
|
FakeServer(chatter = chatter).use { server ->
|
||||||
|
server.start()
|
||||||
|
val c = client(server.port)
|
||||||
|
c.connect()
|
||||||
|
assertTrue(server.awaitLogin())
|
||||||
|
assertTrue("the verdict must be found past the comments", c.isVerified)
|
||||||
|
c.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A server that sends no greeting is legitimate, and must not cost the full login window on
|
||||||
|
* every connect - that was eight seconds per attempt.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a server without a greeting connects promptly`() {
|
||||||
|
FakeServer(greeting = null).use { server ->
|
||||||
|
server.start()
|
||||||
|
val c = client(server.port)
|
||||||
|
val started = System.currentTimeMillis()
|
||||||
|
c.connect()
|
||||||
|
assertTrue(server.awaitLogin())
|
||||||
|
val elapsed = System.currentTimeMillis() - started
|
||||||
|
c.disconnect()
|
||||||
|
assertTrue("connect took ${elapsed}ms, expected well under the login window",
|
||||||
|
elapsed < 6_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The failure a live server actually produced, and the one that mattered most.
|
||||||
|
*
|
||||||
|
* aprsc answers `# Invalid login: ...` and closes. That is a comment but not a logresp, so it
|
||||||
|
* was skipped as chatter, the login timed out into Unknown - treated as "may be working" - and
|
||||||
|
* every send afterwards reported success. Measured against euro.aprs2.net before the fix:
|
||||||
|
* loginOutcome=Unknown, isRefusedByServer=false, sendPacket=(true, "sent").
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a refused login is not reported as a successful send`() {
|
||||||
|
FakeServer(loginResponse = "# Invalid login: bad software version").use { server ->
|
||||||
|
server.start()
|
||||||
|
val c = client(server.port)
|
||||||
|
// An outright refusal throws from connect(), which is the correct outcome.
|
||||||
|
val threw = runCatching { c.connect() }.exceptionOrNull()
|
||||||
|
assertTrue(server.awaitLogin())
|
||||||
|
assertFalse("a refused login must not read as verified", c.isVerified)
|
||||||
|
val sentOk = runCatching {
|
||||||
|
c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>x")?.first
|
||||||
|
}.getOrNull()
|
||||||
|
assertTrue(
|
||||||
|
"the refusal must surface: threw=$threw sentOk=$sentOk",
|
||||||
|
threw != null || sentOk != true
|
||||||
|
)
|
||||||
|
c.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A server saying it is about to drop us must not read as a successful send.
|
||||||
|
*
|
||||||
|
* The ack read used to treat any leading `#` as harmless chatter, so `# Port full` - which
|
||||||
|
* means the server is closing the connection - was reported as sent. It now shares the login
|
||||||
|
* parser's judgement, so only a greeting or keepalive counts as harmless.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a server refusal after the write is not reported as sent`() {
|
||||||
|
FakeServer(afterPacket = "# Port full").use { server ->
|
||||||
|
server.start()
|
||||||
|
val c = client(server.port)
|
||||||
|
c.connect()
|
||||||
|
assertTrue(server.awaitLogin())
|
||||||
|
val result = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>x")
|
||||||
|
c.disconnect()
|
||||||
|
assertEquals("a server refusal must fail the report", false, result?.first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The real keepalive must still count as sent, since APRS-IS never acknowledges a position. */
|
||||||
|
@Test
|
||||||
|
fun `a keepalive after the write still counts as sent`() {
|
||||||
|
val keepalive = "# aprsc 2.1.21-gbfc2090 25 Aug 2026 16:41:07 GMT T2UK 1.2.3.4:14580"
|
||||||
|
FakeServer(afterPacket = keepalive).use { server ->
|
||||||
|
server.start()
|
||||||
|
val c = client(server.port)
|
||||||
|
c.connect()
|
||||||
|
assertTrue(server.awaitLogin())
|
||||||
|
val result = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>x")
|
||||||
|
c.disconnect()
|
||||||
|
assertEquals("a keepalive must not fail the report", true, result?.first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sending after the server has gone must report failure, not success. */
|
||||||
|
@Test
|
||||||
|
fun `a send after the server closes is reported as failed`() {
|
||||||
|
val server = FakeServer()
|
||||||
|
server.start()
|
||||||
|
val c = client(server.port)
|
||||||
|
c.connect()
|
||||||
|
assertTrue(server.awaitLogin())
|
||||||
|
// Closing the ServerSocket alone would leave this connection alive.
|
||||||
|
server.dropClient()
|
||||||
|
Thread.sleep(200)
|
||||||
|
// The first write may still land in the socket buffer; by the second the loss is certain.
|
||||||
|
c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>one")
|
||||||
|
val second = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>two")
|
||||||
|
c.disconnect()
|
||||||
|
assertEquals("a send on a dead connection must not report success", false, second?.first)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.cw
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
|
||||||
|
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.math.floor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long audio waits before its text can reach the record pane.
|
||||||
|
*
|
||||||
|
* The record binds to archived text only. The live decode is rewritten from scratch every
|
||||||
|
* cycle, so a pane that concatenated it lost characters the operator had already read - the
|
||||||
|
* decoder appeared to delete its own output while still running. Binding to archived text
|
||||||
|
* makes the pane monotonic, and the cost is latency, which is additive: audio must first be
|
||||||
|
* pushed out of the live window, then accumulate into a full archive batch.
|
||||||
|
*
|
||||||
|
* [CwDeepDecoder] needs a Context and a loaded ONNX model, so it cannot be constructed here.
|
||||||
|
* These tests read its real constants rather than copies, so retuning one without
|
||||||
|
* reconsidering the user-visible delay fails here.
|
||||||
|
*/
|
||||||
|
class CwArchiveTimingTest {
|
||||||
|
|
||||||
|
/** Sending speed for the character counts, typical for satellite CW. */
|
||||||
|
private val wpm = 18.0
|
||||||
|
|
||||||
|
/** PARIS standard: one word is five characters. */
|
||||||
|
private val charsPerSecond = wpm * 5 / 60.0
|
||||||
|
|
||||||
|
private val window = CwDeepBuffer.DEFAULT_MAX_SECONDS
|
||||||
|
private val batch = CwDeepDecoder.ARCHIVE_SECONDS
|
||||||
|
|
||||||
|
/** Seconds of audio that have reached the archive after listening for [elapsed]. */
|
||||||
|
private fun archivedSeconds(elapsed: Double): Double {
|
||||||
|
val evicted = elapsed - window
|
||||||
|
if (evicted <= 0.0) return 0.0
|
||||||
|
return floor(evicted / batch) * batch
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thresholdMatchesTheDeclaredBatchLength() {
|
||||||
|
assertEquals(
|
||||||
|
"threshold must be the batch length in samples",
|
||||||
|
(CwDeepSpectrogram.SAMPLE_RATE * batch).toInt(),
|
||||||
|
CwDeepDecoder.ARCHIVE_THRESHOLD
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun archiveBatchIsShorterThanACallSign() {
|
||||||
|
// A seven-character call sign at 18 WPM takes about 4.7 s. A batch longer than that
|
||||||
|
// means the record can stall for longer than the single most important thing being
|
||||||
|
// sent, which is what made the stall read as deletion.
|
||||||
|
val callSignSeconds = 7 / charsPerSecond
|
||||||
|
assertTrue(
|
||||||
|
"batch $batch s must not exceed a call sign at $wpm WPM " +
|
||||||
|
"(${"%.1f".format(callSignSeconds)} s)",
|
||||||
|
batch <= callSignSeconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun firstTextReachesTheRecordWithinHalfAMinute() {
|
||||||
|
// At the previous 15 s batch this was 35 s, so a short exchange ended with the record
|
||||||
|
// still completely empty: every decoded character had only ever been in the live line,
|
||||||
|
// which shows 64 characters and overwrites them.
|
||||||
|
val firstArchive = window + batch
|
||||||
|
assertTrue("first archived text must appear within 30 s, got $firstArchive s", firstArchive <= 30.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aThirtySecondSessionStillProducesARecord() {
|
||||||
|
val archived = archivedSeconds(30.0)
|
||||||
|
assertTrue("30 s of listening must archive something, got $archived s", archived > 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theRecordNeverStallsForLongerThanOneBatch() {
|
||||||
|
var longestStall = 0.0
|
||||||
|
var lastGrowthAt = window
|
||||||
|
var previous = 0.0
|
||||||
|
var t = window
|
||||||
|
while (t <= 600.0) {
|
||||||
|
val archived = archivedSeconds(t)
|
||||||
|
if (archived > previous) {
|
||||||
|
longestStall = maxOf(longestStall, t - lastGrowthAt)
|
||||||
|
lastGrowthAt = t
|
||||||
|
previous = archived
|
||||||
|
}
|
||||||
|
t += 0.1
|
||||||
|
}
|
||||||
|
assertTrue(
|
||||||
|
"record stalled ${"%.1f".format(longestStall)} s, one batch is $batch s",
|
||||||
|
longestStall <= batch + 0.11
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun audioInFlightWhenCaptureStopsWouldLoseTheEndOfTheTransmission() {
|
||||||
|
// Why flush() exists. Neither holding place drains on its own: the live window only
|
||||||
|
// reaches the archive by being pushed out by newer audio, and the pending batch only
|
||||||
|
// by filling up. Both hold the end of the transmission, where the call sign is.
|
||||||
|
val worstCase = window + batch
|
||||||
|
val lostCharacters = worstCase * charsPerSecond
|
||||||
|
assertTrue(
|
||||||
|
"flush() must exist: ${"%.0f".format(lostCharacters)} characters would be lost",
|
||||||
|
lostCharacters > 20
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun archivingStaysRarerThanTheLiveDecode() {
|
||||||
|
// Each batch is one inference. Shortening the batch trades CPU for latency, so it must
|
||||||
|
// stay rarer than the live redecode or the archive path becomes the dominant cost.
|
||||||
|
val liveIntervalSeconds = CwDeepBuffer.DEFAULT_REDECODE_INTERVAL_MS / 1000.0
|
||||||
|
assertTrue(
|
||||||
|
"batch $batch s must stay longer than the live cycle $liveIntervalSeconds s",
|
||||||
|
batch > liveIntervalSeconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBatchIsLongEnoughToDecode() {
|
||||||
|
// compute() rejects audio shorter than one FFT frame, so a batch below that would be
|
||||||
|
// silently dropped by archiveDecode's size guard and its text lost outright.
|
||||||
|
assertTrue(
|
||||||
|
"batch of ${CwDeepDecoder.ARCHIVE_THRESHOLD} samples must exceed " +
|
||||||
|
"FFT_LENGTH ${CwDeepSpectrogram.FFT_LENGTH}",
|
||||||
|
CwDeepDecoder.ARCHIVE_THRESHOLD > CwDeepSpectrogram.FFT_LENGTH
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.cw
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
|
||||||
|
import com.rtbishop.look4sat.core.domain.cw.CwToneShifter
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gating contract the decoder relies on: shift only when the user opted in AND the
|
||||||
|
* tone is outside the model window.
|
||||||
|
*
|
||||||
|
* [CwDeepDecoder] needs a Context and a loaded ONNX model, so it cannot be constructed
|
||||||
|
* here. What these tests do exercise is the real decision function the decoder calls -
|
||||||
|
* [CwToneShifter.analyse] - rather than a copy of it, so a wrong verdict fails here.
|
||||||
|
* The decoder's own sample accumulation and throttling are covered by the streaming
|
||||||
|
* tests in core:domain.
|
||||||
|
*/
|
||||||
|
class CwToneShiftGateTest {
|
||||||
|
|
||||||
|
private val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
|
||||||
|
|
||||||
|
private fun tone(hz: Double, samples: Int = 1600): FloatArray = FloatArray(samples) { i ->
|
||||||
|
sin(2.0 * PI * hz * i / sampleRate).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The enabled/disabled gate as [CwDeepDecoder.applyToneShift] applies it: when off
|
||||||
|
* the audio is returned as-is, when on the verdict comes from the real analyser.
|
||||||
|
*/
|
||||||
|
private fun gate(audio: FloatArray, enabled: Boolean): FloatArray {
|
||||||
|
if (!enabled) return audio
|
||||||
|
val analysis = CwToneShifter.analyse(audio, sampleRate)
|
||||||
|
if (!analysis.needsShift) return audio
|
||||||
|
return CwToneShifter.shift(audio, analysis.shiftHz, sampleRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disabled leaves every tone untouched`() {
|
||||||
|
for (hz in listOf(150.0, 300.0, 800.0, 1200.0, 1500.0)) {
|
||||||
|
val audio = tone(hz)
|
||||||
|
assertSame(
|
||||||
|
"$hz Hz must pass through unchanged while the setting is off",
|
||||||
|
audio, gate(audio, enabled = false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `enabled still leaves in-window tones untouched`() {
|
||||||
|
for (hz in listOf(400.0, 600.0, 800.0, 1000.0, 1200.0)) {
|
||||||
|
val audio = tone(hz)
|
||||||
|
assertSame(
|
||||||
|
"$hz Hz is inside the window; enabling the setting must not alter it",
|
||||||
|
audio, gate(audio, enabled = true)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `enabled shifts only out-of-window tones`() {
|
||||||
|
for (hz in listOf(200.0, 300.0, 1300.0, 1500.0)) {
|
||||||
|
val audio = tone(hz)
|
||||||
|
val result = gate(audio, enabled = true)
|
||||||
|
assertFalse("$hz Hz should have been shifted", result === audio)
|
||||||
|
assertEquals("shift must preserve length", audio.size, result.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `window edges count as inside`() {
|
||||||
|
val analysisLow = CwToneShifter.analyse(tone(CwDeepSpectrogram.MIN_FREQ_HZ), sampleRate)
|
||||||
|
val analysisHigh = CwToneShifter.analyse(tone(CwDeepSpectrogram.MAX_FREQ_HZ), sampleRate)
|
||||||
|
assertFalse("400 Hz is the lower edge, inside", analysisLow.needsShift)
|
||||||
|
assertFalse("1200 Hz is the upper edge, inside", analysisHigh.needsShift)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `shift target is inside the window`() {
|
||||||
|
assertTrue(
|
||||||
|
"the target must be a pitch the model can see",
|
||||||
|
CwToneShifter.isInsideWindow(CwToneShifter.TARGET_HZ.toFloat())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression guard for the defect that made the whole feature dead on arrival:
|
||||||
|
* the decoder gated detection on a single chunk reaching DETECT_MIN_SAMPLES, but
|
||||||
|
* AudioCapture delivers 4410 samples at 44.1 kHz, which is only 320 after
|
||||||
|
* resampling to 3200 Hz. Detection could never run.
|
||||||
|
*
|
||||||
|
* The decoder now pools chunks, so what matters is that the pooled size is
|
||||||
|
* reachable: a handful of real-sized chunks must add up to enough audio.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `pooled capture chunks reach the detection threshold`() {
|
||||||
|
val captureRate = 44100
|
||||||
|
val captureChunk = captureRate / 10 // AudioCapture's ~100 ms read
|
||||||
|
val resampledChunk = captureChunk * CwDeepSpectrogram.SAMPLE_RATE / captureRate
|
||||||
|
assertEquals(
|
||||||
|
"a capture chunk resamples to 320 samples; if this changes revisit pooling",
|
||||||
|
320, resampledChunk
|
||||||
|
)
|
||||||
|
|
||||||
|
val threshold = 1280 // CwDeepDecoder.DETECT_MIN_SAMPLES
|
||||||
|
val chunksNeeded = (threshold + resampledChunk - 1) / resampledChunk
|
||||||
|
assertTrue(
|
||||||
|
"a single chunk ($resampledChunk) must not be expected to reach $threshold",
|
||||||
|
resampledChunk < threshold
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"pooling must reach the threshold within a second of audio, needs $chunksNeeded chunks",
|
||||||
|
chunksNeeded in 2..10
|
||||||
|
)
|
||||||
|
|
||||||
|
// And that much audio must actually be enough for the detector to work.
|
||||||
|
val pooled = tone(1500.0, samples = threshold)
|
||||||
|
val detected = CwToneShifter.detectToneHz(pooled, sampleRate)
|
||||||
|
assertEquals(
|
||||||
|
"the pooled window must be long enough to detect a tone",
|
||||||
|
1500.0, detected!!.toDouble(), 25.0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.GregorianCalendar
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADVERSARIAL AUDIT SCRATCH FILE - delete when the audit report is written.
|
||||||
|
* Probes buildStatuses for aliasing, midnight arithmetic and boundary defects.
|
||||||
|
*/
|
||||||
|
class AmSatAuditTest {
|
||||||
|
|
||||||
|
private object UnusedSource : IRemoteSource {
|
||||||
|
override suspend fun getFileStream(uri: String): InputStream? = null
|
||||||
|
override suspend fun getNetworkStream(url: String): InputStream? = null
|
||||||
|
override suspend fun getAmSatCatalog(): String? = null
|
||||||
|
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||||
|
override suspend fun getAmSatSummary(hours: Int): String? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private val repo = AmSatRepository(UnusedSource)
|
||||||
|
|
||||||
|
private fun utc(y: Int, mo: Int, d: Int, h: Int, mi: Int = 0, s: Int = 0): Long {
|
||||||
|
val c = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
|
c.clear(); c.set(y, mo - 1, d, h, mi, s)
|
||||||
|
return c.timeInMillis / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rep(name: String, at: Long, id: String, status: String = "heard") =
|
||||||
|
ApiReport(id, name, "T", status, "AA00", at)
|
||||||
|
|
||||||
|
private fun labelsAt(now: Long) =
|
||||||
|
repo.buildStatuses(listOf("X"), emptyList(), now).single().days.map { it.dateLabel }
|
||||||
|
|
||||||
|
/** Reference: the label a UTC instant's day should carry. */
|
||||||
|
private fun expectLabel(y: Int, mo: Int, d: Int): String {
|
||||||
|
val mn = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug",
|
||||||
|
"Sep", "Oct", "Nov", "Dec")
|
||||||
|
return "${mn[mo - 1]} $d"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 1. midnight arithmetic under hostile inputs ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun auditMidnightExactlyAtMidnight() {
|
||||||
|
assertEquals(
|
||||||
|
listOf(expectLabel(2026, 8, 22), expectLabel(2026, 8, 21), expectLabel(2026, 8, 20)),
|
||||||
|
labelsAt(utc(2026, 8, 22, 0, 0, 0))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun auditMidnightOneSecondBeforeAndAfter() {
|
||||||
|
assertEquals(
|
||||||
|
"23:59:59 on Aug 21 must still be Aug 21",
|
||||||
|
listOf("Aug 21", "Aug 20", "Aug 19"),
|
||||||
|
labelsAt(utc(2026, 8, 21, 23, 59, 59))
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"00:00:01 on Aug 22 must already be Aug 22",
|
||||||
|
listOf("Aug 22", "Aug 21", "Aug 20"),
|
||||||
|
labelsAt(utc(2026, 8, 22, 0, 0, 1))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun auditLeapDay2028() {
|
||||||
|
assertEquals(
|
||||||
|
"Feb 29 2028 back to Feb 27",
|
||||||
|
listOf("Feb 29", "Feb 28", "Feb 27"),
|
||||||
|
labelsAt(utc(2028, 2, 29, 12))
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"Mar 1 2028 must reach back through the leap day",
|
||||||
|
listOf("Mar 1", "Feb 29", "Feb 28"),
|
||||||
|
labelsAt(utc(2028, 3, 1, 0, 0, 0))
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"Mar 1 2027 (no leap day) must skip straight to Feb 27",
|
||||||
|
listOf("Mar 1", "Feb 28", "Feb 27"),
|
||||||
|
labelsAt(utc(2027, 3, 1, 12))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun auditYearBoundary() {
|
||||||
|
assertEquals(
|
||||||
|
listOf("Jan 1", "Dec 31", "Dec 30"),
|
||||||
|
labelsAt(utc(2027, 1, 1, 0, 0, 0))
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf("Jan 2", "Jan 1", "Dec 31"),
|
||||||
|
labelsAt(utc(2027, 1, 2, 23, 59, 59))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun auditMonthBoundariesEveryMonth() {
|
||||||
|
// First of every month in a leap and a non-leap year.
|
||||||
|
for (year in listOf(2027, 2028)) {
|
||||||
|
for (mo in 1..12) {
|
||||||
|
val now = utc(year, mo, 1, 0, 0, 0)
|
||||||
|
val got = labelsAt(now)
|
||||||
|
val ref = GregorianCalendar(TimeZone.getTimeZone("UTC"))
|
||||||
|
ref.timeInMillis = now * 1000
|
||||||
|
val want = (0 until 3).map {
|
||||||
|
val c = ref.clone() as Calendar
|
||||||
|
c.add(Calendar.DAY_OF_MONTH, -it)
|
||||||
|
expectLabel(c.get(Calendar.YEAR), c.get(Calendar.MONTH) + 1,
|
||||||
|
c.get(Calendar.DAY_OF_MONTH))
|
||||||
|
}
|
||||||
|
assertEquals("$year-$mo-01", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The load-bearing claim: subtracting 86400 equals Calendar day arithmetic in UTC.
|
||||||
|
* Proven exhaustively over 20 years of days rather than argued.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun auditSubtracting86400EqualsCalendarDayArithmeticForTwentyYears() {
|
||||||
|
val ref = GregorianCalendar(TimeZone.getTimeZone("UTC"))
|
||||||
|
var now = utc(2020, 1, 1, 12)
|
||||||
|
val end = utc(2040, 1, 1, 12)
|
||||||
|
var checked = 0
|
||||||
|
while (now < end) {
|
||||||
|
val got = labelsAt(now)
|
||||||
|
ref.timeInMillis = now * 1000
|
||||||
|
val want = (0 until 3).map {
|
||||||
|
val c = ref.clone() as Calendar
|
||||||
|
c.add(Calendar.DAY_OF_MONTH, -it)
|
||||||
|
expectLabel(c.get(Calendar.YEAR), c.get(Calendar.MONTH) + 1,
|
||||||
|
c.get(Calendar.DAY_OF_MONTH))
|
||||||
|
}
|
||||||
|
assertEquals("at epoch $now", want, got)
|
||||||
|
now += 86400
|
||||||
|
checked++
|
||||||
|
}
|
||||||
|
assertTrue("must have checked >7000 days, got $checked", checked > 7000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The device default zone must not reach the computation. Run the whole build under
|
||||||
|
* hostile default zones including ones with DST and half-hour offsets, and under the
|
||||||
|
* DST transition instants of those zones.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun auditDefaultTimeZoneCannotInfluenceTheGrid() {
|
||||||
|
val original = TimeZone.getDefault()
|
||||||
|
try {
|
||||||
|
val zones = listOf(
|
||||||
|
"UTC", "America/New_York", "Europe/Berlin", "Australia/Lord_Howe",
|
||||||
|
"Asia/Kolkata", "Pacific/Kiritimati", "Pacific/Niue", "Pacific/Chatham",
|
||||||
|
"America/Sao_Paulo", "Asia/Kathmandu"
|
||||||
|
)
|
||||||
|
// Instants that are DST transitions in at least one zone above.
|
||||||
|
val instants = listOf(
|
||||||
|
utc(2026, 3, 8, 7), utc(2026, 11, 1, 6), utc(2026, 3, 29, 1),
|
||||||
|
utc(2026, 10, 25, 1), utc(2026, 4, 5, 16), utc(2026, 10, 4, 16),
|
||||||
|
utc(2026, 8, 22, 0, 0, 0), utc(2026, 8, 22, 23, 59, 59),
|
||||||
|
utc(2027, 1, 1, 0, 0, 0), utc(2028, 2, 29, 0, 0, 0)
|
||||||
|
)
|
||||||
|
val baseline = HashMap<Long, List<String>>()
|
||||||
|
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
|
||||||
|
for (i in instants) baseline[i] = labelsAt(i)
|
||||||
|
|
||||||
|
for (z in zones) {
|
||||||
|
TimeZone.setDefault(TimeZone.getTimeZone(z))
|
||||||
|
for (i in instants) {
|
||||||
|
assertEquals("zone $z at $i", baseline[i], labelsAt(i))
|
||||||
|
// and the placement of a report must not move either
|
||||||
|
val s = repo.buildStatuses(
|
||||||
|
listOf("X"), listOf(rep("X", i - 3600, "r")), i
|
||||||
|
).single()
|
||||||
|
val cell = s.days.withIndex().flatMap { (d, day) ->
|
||||||
|
day.slots.withIndex().filter { "r" in it.value.reportIds }
|
||||||
|
.map { d to it.index }
|
||||||
|
}
|
||||||
|
assertEquals("zone $z placement at $i", 1, cell.size)
|
||||||
|
baseline["p$i".hashCode().toLong()]?.let { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
TimeZone.setDefault(original)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locale can swap the calendar system out from under Calendar.getInstance. */
|
||||||
|
@Test
|
||||||
|
fun auditDefaultLocaleCannotInfluenceTheGrid() {
|
||||||
|
val original = Locale.getDefault()
|
||||||
|
try {
|
||||||
|
val want = run {
|
||||||
|
Locale.setDefault(Locale.US)
|
||||||
|
labelsAt(utc(2026, 8, 22, 12))
|
||||||
|
}
|
||||||
|
for (l in listOf(
|
||||||
|
Locale("th", "TH", "TH"), Locale("ja", "JP", "JP"),
|
||||||
|
Locale("ar", "SA"), Locale.forLanguageTag("th-TH-u-ca-buddhist")
|
||||||
|
)) {
|
||||||
|
Locale.setDefault(l)
|
||||||
|
assertEquals("locale $l", want, labelsAt(utc(2026, 8, 22, 12)))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Locale.setDefault(original)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- aliasing / shared Calendar state leak ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared Calendar is mutated by the labels loop after todayMidnightSec is read.
|
||||||
|
* If any later step re-read it, day 0 would inherit day 2's date. Prove day 0's
|
||||||
|
* slots are anchored on today, not on the last value the Calendar held.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun auditSharedCalendarIsNotReReadAfterTheLabelsLoop() {
|
||||||
|
val now = utc(2026, 8, 22, 12)
|
||||||
|
// A report at today 12:30 must be in day 0. If the anchor had leaked to Aug 20
|
||||||
|
// it would fall outside the grid entirely.
|
||||||
|
val s = repo.buildStatuses(
|
||||||
|
listOf("X"), listOf(rep("X", utc(2026, 8, 22, 12, 30), "r")), now
|
||||||
|
).single()
|
||||||
|
assertEquals("Aug 22", s.days[0].dateLabel)
|
||||||
|
assertTrue("today's report must be in day 0 slot 5", "r" in s.days[0].slots[5].reportIds)
|
||||||
|
assertTrue(
|
||||||
|
"no other day may hold it",
|
||||||
|
s.days.drop(1).all { d -> d.slots.all { it.count == 0 } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two consecutive calls on the same repository must be identical (no instance state). */
|
||||||
|
@Test
|
||||||
|
fun auditRepeatedCallsAreIdempotent() {
|
||||||
|
val now = utc(2026, 8, 22, 12)
|
||||||
|
val reports = listOf(
|
||||||
|
rep("X", utc(2026, 8, 22, 1), "a"), rep("X", utc(2026, 8, 21, 23), "b"),
|
||||||
|
rep("X", utc(2026, 8, 20, 0, 0, 0), "c")
|
||||||
|
)
|
||||||
|
fun shape() = repo.buildStatuses(listOf("X"), reports, now).single()
|
||||||
|
.days.map { d -> d.dateLabel to d.slots.map { it.reportIds } }
|
||||||
|
val first = shape()
|
||||||
|
repeat(5) { assertEquals("call must not drift", first, shape()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- slot boundary exactness ----------
|
||||||
|
|
||||||
|
/** No report may appear in two cells, and none inside the window may vanish. */
|
||||||
|
@Test
|
||||||
|
fun auditEveryBoundaryInstantLandsInExactlyOneCell() {
|
||||||
|
val now = utc(2026, 8, 22, 12)
|
||||||
|
val mid = utc(2026, 8, 22, 0, 0, 0)
|
||||||
|
// every slot edge of all three days, and one second either side of each
|
||||||
|
val probes = ArrayList<Long>()
|
||||||
|
for (d in 0 until 3) for (s in 0..12) {
|
||||||
|
val edge = mid - d * 86400L + s * 7200L
|
||||||
|
probes.add(edge - 1); probes.add(edge); probes.add(edge + 1)
|
||||||
|
}
|
||||||
|
for (t in probes.distinct()) {
|
||||||
|
val s = repo.buildStatuses(listOf("X"), listOf(rep("X", t, "r")), now).single()
|
||||||
|
val hits = s.days.withIndex().flatMap { (di, day) ->
|
||||||
|
day.slots.withIndex().filter { "r" in it.value.reportIds }.map { di to it.index }
|
||||||
|
}
|
||||||
|
val inWindow = t >= mid - 2 * 86400L && t < mid + 86400L
|
||||||
|
if (inWindow) {
|
||||||
|
assertEquals("epoch $t must occupy exactly one cell, got $hits", 1, hits.size)
|
||||||
|
// and the cell's day must match the report's UTC date
|
||||||
|
val c = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
|
c.timeInMillis = t * 1000
|
||||||
|
val want = expectLabel(c.get(Calendar.YEAR), c.get(Calendar.MONTH) + 1,
|
||||||
|
c.get(Calendar.DAY_OF_MONTH))
|
||||||
|
assertEquals("epoch $t day label", want, s.days[hits[0].first].dateLabel)
|
||||||
|
// slot index must invert the hour band
|
||||||
|
assertEquals("epoch $t slot", 11 - c.get(Calendar.HOUR_OF_DAY) / 2, hits[0].second)
|
||||||
|
} else {
|
||||||
|
assertEquals("epoch $t is outside the window", 0, hits.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Counts must sum to the number of in-window reports: nothing dropped, nothing doubled. */
|
||||||
|
@Test
|
||||||
|
fun auditCountsConserveReports() {
|
||||||
|
val now = utc(2026, 8, 22, 12)
|
||||||
|
val mid = utc(2026, 8, 22, 0, 0, 0)
|
||||||
|
val reports = ArrayList<ApiReport>()
|
||||||
|
var i = 0
|
||||||
|
var t = mid - 2 * 86400L
|
||||||
|
while (t < mid + 86400L) { reports.add(rep("X", t, "r${i++}")); t += 1801 }
|
||||||
|
val s = repo.buildStatuses(listOf("X"), reports, now).single()
|
||||||
|
val total = s.days.sumOf { d -> d.slots.sumOf { it.count } }
|
||||||
|
val ids = s.days.flatMap { d -> d.slots.flatMap { it.reportIds } }
|
||||||
|
assertEquals("every in-window report must be counted once", reports.size, total)
|
||||||
|
assertEquals("no id may repeat", ids.size, ids.toSet().size)
|
||||||
|
assertEquals("id set must be complete", reports.map { it.id }.toSet(), ids.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- duplicate catalogue names ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun auditDuplicateCatalogueNamesProduceDuplicateRows() {
|
||||||
|
val s = repo.buildStatuses(
|
||||||
|
listOf("DUP", "DUP", "OTHER"),
|
||||||
|
listOf(rep("DUP", utc(2026, 8, 22, 11), "r")),
|
||||||
|
utc(2026, 8, 22, 12)
|
||||||
|
)
|
||||||
|
assertEquals("a duplicated catalogue name yields a duplicated row", 3, s.size)
|
||||||
|
assertEquals(2, s.count { it.name == "DUP" })
|
||||||
|
// both duplicated rows carry the same report -> the tap dialog double lists it
|
||||||
|
assertEquals(
|
||||||
|
listOf(1, 1),
|
||||||
|
s.filter { it.name == "DUP" }.map { it.days[0].slots[6].count }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun auditReportsForNamesAbsentFromCatalogueAreSilentlyDropped() {
|
||||||
|
val s = repo.buildStatuses(
|
||||||
|
listOf("IN-CATALOG"),
|
||||||
|
listOf(rep("NOT-IN-CATALOG", utc(2026, 8, 22, 11), "ghost")),
|
||||||
|
utc(2026, 8, 22, 12)
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"a report whose satellite is not in the catalogue never renders",
|
||||||
|
s.single().days.all { d -> d.slots.all { it.count == 0 } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- unparsable timestamps ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun auditZeroTimestampFromFailedParseIsDroppedNotShownAsEpoch() {
|
||||||
|
val s = repo.buildStatuses(
|
||||||
|
listOf("X"), listOf(rep("X", 0L, "unparsable")), utc(2026, 8, 22, 12)
|
||||||
|
).single()
|
||||||
|
assertTrue(
|
||||||
|
"a 0L timestamp (parse failure) must not render",
|
||||||
|
s.days.all { d -> d.slots.all { it.count == 0 } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- future reports ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun auditFutureReportsLaterTodayStillRender() {
|
||||||
|
// Fetched at 07:00; a report stamped 23:00 today lands in slot 0 of today.
|
||||||
|
val s = repo.buildStatuses(
|
||||||
|
listOf("X"), listOf(rep("X", utc(2026, 8, 22, 23), "later")), utc(2026, 8, 22, 7)
|
||||||
|
).single()
|
||||||
|
assertTrue("today's later bands are pre-drawn", "later" in s.days[0].slots[0].reportIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- complexity ----------
|
||||||
|
|
||||||
|
/** One pass per slot over the satellite's own reports; not O(all reports x slots). */
|
||||||
|
@Test
|
||||||
|
fun auditBuildIsLinearInReportsNotQuadratic() {
|
||||||
|
fun timeFor(nSats: Int, nReports: Int): Long {
|
||||||
|
val names = (0 until nSats).map { "S$it" }
|
||||||
|
val now = utc(2026, 8, 22, 12)
|
||||||
|
val mid = utc(2026, 8, 22, 0, 0, 0)
|
||||||
|
val reports = (0 until nReports).map {
|
||||||
|
rep(names[it % nSats], mid - (it % 172800).toLong(), "r$it")
|
||||||
|
}
|
||||||
|
repo.buildStatuses(names, reports, now) // warm
|
||||||
|
val t0 = System.nanoTime()
|
||||||
|
repeat(3) { repo.buildStatuses(names, reports, now) }
|
||||||
|
return System.nanoTime() - t0
|
||||||
|
}
|
||||||
|
val small = timeFor(88, 500)
|
||||||
|
val big = timeFor(88, 5000)
|
||||||
|
val ratio = big.toDouble() / small
|
||||||
|
println("AUDIT complexity: 500 reports=${small / 1_000_000}ms 5000=${big / 1_000_000}ms ratio=$ratio")
|
||||||
|
assertNotNull(ratio)
|
||||||
|
assertTrue("10x the reports must not cost >40x the time (ratio=$ratio)", ratio < 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** toSatReport's YEAR is locale sensitive: proves whether the dialog date corrupts. */
|
||||||
|
@Test
|
||||||
|
fun auditReportDialogDateUnderThaiLocale() {
|
||||||
|
val original = Locale.getDefault()
|
||||||
|
try {
|
||||||
|
val c = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
|
Locale.setDefault(Locale.US)
|
||||||
|
c.timeInMillis = utc(2026, 8, 22, 11) * 1000
|
||||||
|
val gregorianYear = c.get(Calendar.YEAR)
|
||||||
|
Locale.setDefault(Locale("th", "TH", "TH"))
|
||||||
|
val c2 = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
|
c2.timeInMillis = utc(2026, 8, 22, 11) * 1000
|
||||||
|
val thaiYear = c2.get(Calendar.YEAR)
|
||||||
|
println("AUDIT locale year: gregorian=$gregorianYear thai=$thaiYear class=${c2.javaClass.name}")
|
||||||
|
assertEquals(
|
||||||
|
"if these differ, toSatReport prints a Buddhist year in the dialog",
|
||||||
|
gregorianYear, thaiYear
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
Locale.setDefault(original)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+366
@@ -0,0 +1,366 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins the grid the AMSAT status page draws.
|
||||||
|
*
|
||||||
|
* Two contracts matter. The day cell renders one stripe per slot, so "every day has
|
||||||
|
* exactly 12 slots, newest first" became load-bearing. And the day columns are UTC
|
||||||
|
* calendar days, so a report must land in the cell whose label matches its UTC date - an
|
||||||
|
* earlier rolling window anchored on "now" put 17.9 hours of yesterday into the cell
|
||||||
|
* labelled today, and 73% of a live 1021-report page landed in the wrong column.
|
||||||
|
*
|
||||||
|
* This drives [AmSatRepository.buildStatuses] directly rather than `fetchStatus`, because
|
||||||
|
* the parsing around it uses Android's `JSONObject`, a stub on the JVM: a `fetchStatus`
|
||||||
|
* test returns null for every input and proves nothing.
|
||||||
|
*/
|
||||||
|
class AmSatSlotBuildTest {
|
||||||
|
|
||||||
|
private object UnusedSource : IRemoteSource {
|
||||||
|
override suspend fun getFileStream(uri: String): InputStream? = null
|
||||||
|
override suspend fun getNetworkStream(url: String): InputStream? = null
|
||||||
|
override suspend fun getAmSatCatalog(): String? = null
|
||||||
|
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||||
|
override suspend fun getAmSatSummary(hours: Int): String? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private val repo = AmSatRepository(UnusedSource)
|
||||||
|
|
||||||
|
/** Epoch seconds for a UTC wall-clock instant, so every case reads unambiguously. */
|
||||||
|
private fun utc(year: Int, month: Int, day: Int, hour: Int, minute: Int = 0): Long {
|
||||||
|
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
|
cal.clear()
|
||||||
|
cal.set(year, month - 1, day, hour, minute, 0)
|
||||||
|
return cal.timeInMillis / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Midday, so "today" has hours on both sides of the fetch. */
|
||||||
|
private val nowSec = utc(2026, 8, 22, 12)
|
||||||
|
|
||||||
|
private fun report(name: String, status: String, at: Long, id: String = "r-$name-$at") =
|
||||||
|
ApiReport(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
callsign = "TEST",
|
||||||
|
report = status,
|
||||||
|
gridSquare = "AA00",
|
||||||
|
reportedTimeUtcSec = at
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun build(names: List<String>, reports: List<ApiReport>) =
|
||||||
|
repo.buildStatuses(names, reports, nowSec)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every day carries exactly twelve slots`() {
|
||||||
|
val statuses = build(
|
||||||
|
listOf("AO-91", "SO-50", "ISS"),
|
||||||
|
listOf(report("AO-91", "heard", utc(2026, 8, 22, 11)))
|
||||||
|
)
|
||||||
|
assertEquals(3, statuses.size)
|
||||||
|
for (status in statuses) {
|
||||||
|
assertEquals("${status.name} must have 3 days", 3, status.days.size)
|
||||||
|
for (day in status.days) {
|
||||||
|
assertEquals(
|
||||||
|
"${status.name} ${day.dateLabel} must have 12 slots for the stripe renderer",
|
||||||
|
12, day.slots.size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a satellite nobody reported still gets twelve slots per day`() {
|
||||||
|
// The renderer must never receive an empty list, which would draw nothing at all.
|
||||||
|
val status = build(listOf("QUIET-1"), emptyList()).single()
|
||||||
|
assertEquals(3, status.days.size)
|
||||||
|
status.days.forEach { assertEquals(12, it.slots.size) }
|
||||||
|
assertTrue(
|
||||||
|
"a silent satellite must be all no-report slots",
|
||||||
|
status.days.all { day -> day.slots.all { it.count == 0 } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `days are labelled with UTC calendar dates`() {
|
||||||
|
val status = build(listOf("AO-91"), emptyList()).single()
|
||||||
|
assertEquals("today", "Aug 22", status.days[0].dateLabel)
|
||||||
|
assertEquals("yesterday", "Aug 21", status.days[1].dateLabel)
|
||||||
|
assertEquals("the day before", "Aug 20", status.days[2].dateLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the label does not drift with the time of day`() {
|
||||||
|
// The old rolling window relabelled the same data depending on when it was
|
||||||
|
// fetched. A calendar day must not care.
|
||||||
|
for (hour in listOf(0, 6, 12, 18, 23)) {
|
||||||
|
val labels = repo.buildStatuses(listOf("AO-91"), emptyList(), utc(2026, 8, 22, hour))
|
||||||
|
.single().days.map { it.dateLabel }
|
||||||
|
assertEquals("fetched at ${hour}:00 UTC", listOf("Aug 22", "Aug 21", "Aug 20"), labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `slots cover fixed UTC bands, newest first`() {
|
||||||
|
// Slot 0 is 22:00-24:00 and slot 11 is 00:00-02:00, matching amsat.org.
|
||||||
|
val status = build(
|
||||||
|
listOf("AO-91"),
|
||||||
|
listOf(
|
||||||
|
report("AO-91", "heard", utc(2026, 8, 22, 23), id = "lateToday"),
|
||||||
|
report("AO-91", "not heard", utc(2026, 8, 22, 1), id = "earlyToday")
|
||||||
|
)
|
||||||
|
).single()
|
||||||
|
val today = status.days[0]
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
"23:00 belongs in slot 0, the day's last band",
|
||||||
|
"lateToday" in today.slots[0].reportIds
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"01:00 belongs in slot 11, the day's first band",
|
||||||
|
"earlyToday" in today.slots[11].reportIds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a report lands in the day matching its UTC date`() {
|
||||||
|
val status = build(
|
||||||
|
listOf("AO-91"),
|
||||||
|
listOf(
|
||||||
|
report("AO-91", "heard", utc(2026, 8, 22, 11), id = "today"),
|
||||||
|
report("AO-91", "heard", utc(2026, 8, 21, 15), id = "yesterday"),
|
||||||
|
report("AO-91", "heard", utc(2026, 8, 20, 5), id = "dayBefore")
|
||||||
|
)
|
||||||
|
).single()
|
||||||
|
|
||||||
|
// Positions computed from the UTC bands: 11:00 -> slot 6, 15:00 -> slot 4,
|
||||||
|
// 05:00 -> slot 9.
|
||||||
|
assertTrue("today's report", "today" in status.days[0].slots[6].reportIds)
|
||||||
|
assertTrue("yesterday's report", "yesterday" in status.days[1].slots[4].reportIds)
|
||||||
|
assertTrue("the day before", "dayBefore" in status.days[2].slots[9].reportIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a report just after midnight stays in the new day`() {
|
||||||
|
// The boundary the rolling window got wrong: 00:30 today must not appear as
|
||||||
|
// yesterday.
|
||||||
|
val status = build(
|
||||||
|
listOf("AO-91"),
|
||||||
|
listOf(report("AO-91", "heard", utc(2026, 8, 22, 0, 30), id = "justAfterMidnight"))
|
||||||
|
).single()
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
"00:30 belongs to today's first band",
|
||||||
|
"justAfterMidnight" in status.days[0].slots[11].reportIds
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"yesterday must stay empty",
|
||||||
|
status.days[1].slots.all { it.count == 0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `each status maps to its own colour`() {
|
||||||
|
// The stripes are now the only carrier of status, so distinct states must stay
|
||||||
|
// distinct all the way out of the repository.
|
||||||
|
val at = utc(2026, 8, 22, 11)
|
||||||
|
val statuses = build(
|
||||||
|
listOf("A", "B", "C", "D"),
|
||||||
|
listOf(
|
||||||
|
report("A", "heard", at),
|
||||||
|
report("B", "telemetry only", at),
|
||||||
|
report("C", "not heard", at),
|
||||||
|
report("D", "something the api invented", at)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val colours = statuses.map { status -> status.days[0].slots[6].statusColor }
|
||||||
|
assertTrue("no state may be colourless", colours.none { it == 0L })
|
||||||
|
assertEquals(
|
||||||
|
"heard, telemetry and not heard must be visually distinct",
|
||||||
|
3, colours.take(3).toSet().size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a slot keeps every report it contains`() {
|
||||||
|
// The tap dialog lists reports from the slots, so none may be dropped when several
|
||||||
|
// land in the same two-hour window. 10:00-12:00 is slot 6.
|
||||||
|
val status = build(
|
||||||
|
listOf("AO-91"),
|
||||||
|
listOf(
|
||||||
|
report("AO-91", "heard", utc(2026, 8, 22, 10, 15), id = "a"),
|
||||||
|
report("AO-91", "heard", utc(2026, 8, 22, 11, 0), id = "b"),
|
||||||
|
report("AO-91", "not heard", utc(2026, 8, 22, 11, 45), id = "c")
|
||||||
|
)
|
||||||
|
).single()
|
||||||
|
val slot = status.days[0].slots[6]
|
||||||
|
assertEquals("all three reports fall in the same band", 3, slot.count)
|
||||||
|
assertEquals(setOf("a", "b", "c"), slot.reportIds.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a slot shows the newest status when reports disagree`() {
|
||||||
|
// Within one band the most recent observation wins; anything else would keep
|
||||||
|
// showing a failure after the satellite recovered.
|
||||||
|
fun colourFor(firstStatus: String, secondStatus: String): Long = build(
|
||||||
|
listOf("AO-91"),
|
||||||
|
listOf(
|
||||||
|
report("AO-91", firstStatus, utc(2026, 8, 22, 10, 15), id = "older"),
|
||||||
|
report("AO-91", secondStatus, utc(2026, 8, 22, 11, 45), id = "newer")
|
||||||
|
)
|
||||||
|
).single().days[0].slots[6].statusColor
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
"the slot colour must follow the newest report, not the first",
|
||||||
|
colourFor("not heard", "heard") != colourFor("heard", "not heard")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reports outside the three-day window are ignored`() {
|
||||||
|
val status = build(
|
||||||
|
listOf("AO-91"),
|
||||||
|
listOf(
|
||||||
|
report("AO-91", "heard", utc(2026, 8, 18, 12), id = "tooOld"),
|
||||||
|
report("AO-91", "heard", utc(2026, 8, 23, 12), id = "future")
|
||||||
|
)
|
||||||
|
).single()
|
||||||
|
assertTrue(
|
||||||
|
"nothing outside the window may appear",
|
||||||
|
status.days.all { day -> day.slots.all { it.count == 0 } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reports for other satellites do not leak between rows`() {
|
||||||
|
val statuses = build(
|
||||||
|
listOf("AO-91", "SO-50"),
|
||||||
|
listOf(report("AO-91", "heard", utc(2026, 8, 22, 11), id = "onlyAo91"))
|
||||||
|
)
|
||||||
|
val ao91 = statuses.first { it.name == "AO-91" }
|
||||||
|
val so50 = statuses.first { it.name == "SO-50" }
|
||||||
|
|
||||||
|
assertEquals("AO-91 has its report", 1, ao91.days[0].slots[6].count)
|
||||||
|
assertTrue(
|
||||||
|
"SO-50 must stay empty",
|
||||||
|
so50.days.all { day -> day.slots.all { it.count == 0 } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty catalog yields no rows rather than a malformed grid`() {
|
||||||
|
assertTrue(build(emptyList(), emptyList()).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slots older than the data we received must not claim nobody was listening.
|
||||||
|
*
|
||||||
|
* The API caps at 500 records however many hours are asked for. Measured live, a
|
||||||
|
* 72-hour request returned 500 reports covering only 49 hours, so the oldest 9.5 hours
|
||||||
|
* of the third day had no data at all - 352 of 3168 cells were painting "nobody heard
|
||||||
|
* it" over "we never looked".
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `slots before the data starts are marked no-data, not no-report`() {
|
||||||
|
// The only report is midday yesterday, so nothing older than that was covered.
|
||||||
|
val oldestReport = utc(2026, 8, 21, 12)
|
||||||
|
val status = build(
|
||||||
|
listOf("AO-91"),
|
||||||
|
listOf(report("AO-91", "heard", oldestReport, id = "only"))
|
||||||
|
).single()
|
||||||
|
|
||||||
|
val noReport = 0xFFC0C0C0
|
||||||
|
val noData = 0xFFE8E8E8
|
||||||
|
|
||||||
|
// The day before yesterday is entirely before the data begins.
|
||||||
|
assertTrue(
|
||||||
|
"every slot older than the data must read as no-data",
|
||||||
|
status.days[2].slots.all { it.statusColor == noData }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Yesterday straddles it: bands after midday are covered, bands before are not.
|
||||||
|
val yesterday = status.days[1]
|
||||||
|
assertEquals("the report's own band", 1, yesterday.slots[5].count)
|
||||||
|
assertTrue(
|
||||||
|
"bands after the oldest report are covered, so silence there is real",
|
||||||
|
yesterday.slots.take(6).all { it.statusColor != noData }
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"the earliest band of yesterday is before any data",
|
||||||
|
yesterday.slots[11].statusColor == noData
|
||||||
|
)
|
||||||
|
|
||||||
|
// Today is entirely after the data starts, so its silence is genuine.
|
||||||
|
assertTrue(
|
||||||
|
"today's empty slots mean nobody reported",
|
||||||
|
status.days[0].slots.all { it.statusColor == noReport }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `coverage is judged from all reports, not one satellite's`() {
|
||||||
|
// A satellite nobody reported must not show as no-data for the whole grid: the
|
||||||
|
// slots were covered, that satellite simply was not heard.
|
||||||
|
val statuses = build(
|
||||||
|
listOf("LOUD", "QUIET"),
|
||||||
|
listOf(report("LOUD", "heard", utc(2026, 8, 20, 1), id = "early"))
|
||||||
|
)
|
||||||
|
val quiet = statuses.first { it.name == "QUIET" }
|
||||||
|
val noData = 0xFFE8E8E8
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
"coverage reaches back to the earliest report of any satellite",
|
||||||
|
quiet.days.all { day -> day.slots.none { it.statusColor == noData } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A report whose timestamp failed to parse must not disable the distinction.
|
||||||
|
*
|
||||||
|
* parseIsoUtcSec returns 0 for an unparseable reported_time, and coverage is the
|
||||||
|
* minimum timestamp in the response - so one such record would put the coverage
|
||||||
|
* boundary in 1970 and mark every slot as reported-on. Measured on a grid that should
|
||||||
|
* have had 18 no-data cells, a single zero timestamp took it to none.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a report with an unparseable timestamp does not disable the no-data marking`() {
|
||||||
|
val noData = 0xFFE8E8E8
|
||||||
|
val realReport = report("AO-91", "heard", utc(2026, 8, 21, 12), id = "real")
|
||||||
|
val brokenTimestamp = ApiReport(
|
||||||
|
id = "broken",
|
||||||
|
name = "AO-91",
|
||||||
|
callsign = "TEST",
|
||||||
|
report = "heard",
|
||||||
|
gridSquare = "AA00",
|
||||||
|
reportedTimeUtcSec = 0L
|
||||||
|
)
|
||||||
|
|
||||||
|
val withoutBroken = build(listOf("AO-91"), listOf(realReport))
|
||||||
|
.single().days.sumOf { day -> day.slots.count { it.statusColor == noData } }
|
||||||
|
val withBroken = build(listOf("AO-91"), listOf(realReport, brokenTimestamp))
|
||||||
|
.single().days.sumOf { day -> day.slots.count { it.statusColor == noData } }
|
||||||
|
|
||||||
|
assertTrue("the baseline must have uncovered slots to compare", withoutBroken > 0)
|
||||||
|
assertEquals(
|
||||||
|
"a zero timestamp must not change what counts as covered",
|
||||||
|
withoutBroken, withBroken
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty response marks nothing as covered`() {
|
||||||
|
// With no reports at all there is no evidence about any slot.
|
||||||
|
val status = build(listOf("AO-91"), emptyList()).single()
|
||||||
|
val noData = 0xFFE8E8E8
|
||||||
|
assertTrue(
|
||||||
|
"yesterday and earlier cannot be claimed as silent",
|
||||||
|
status.days.drop(1).all { day -> day.slots.all { it.statusColor == noData } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+113
-7
@@ -18,6 +18,7 @@
|
|||||||
package com.rtbishop.look4sat.core.data.repository
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
@@ -102,7 +103,96 @@ 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"])
|
// A custom URL replaces the built-in TLE sources: none of them is requested. The
|
||||||
|
// transceivers group is separate and its own switch is off here, so it still fetches.
|
||||||
|
val builtInTle = Sources.satelliteDataUrls.values.filter { it.isNotBlank() }
|
||||||
|
assertTrue(
|
||||||
|
"no built-in TLE source may be fetched, got " + remoteSource.requestedUrls,
|
||||||
|
builtInTle.none { it in remoteSource.requestedUrls }
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"the operator's URL must be fetched",
|
||||||
|
customCsvUrl in remoteSource.requestedUrls
|
||||||
|
)
|
||||||
|
// Indexed under "Custom". It used to go under "All", where setSatelliteTypeIds
|
||||||
|
// early-returns, so the type filter never saw these satellites and the old assertion here
|
||||||
|
// was checking a no-op.
|
||||||
|
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Custom"])
|
||||||
|
assertEquals(null, settingsRepo.satelliteTypeIdsByType["All"])
|
||||||
|
// NOT "Other": that key belongs to manual file import, and setSatelliteTypeIds overwrites
|
||||||
|
// rather than merges, so sharing it would have each source wipe the other's index.
|
||||||
|
assertEquals(null, settingsRepo.satelliteTypeIdsByType["Other"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The switch-off path must be untouched: all built-in sources, exactly as before. */
|
||||||
|
@Test
|
||||||
|
fun `without a custom source every built-in source is fetched`() = runTest(dispatcher) {
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
// Every built-in TLE source has to answer, or updateFromRemote throws because all of
|
||||||
|
// them failed, which would mask what this test checks. The transceivers group is left
|
||||||
|
// unanswered on purpose: org.json is compileOnly in core:domain, so DataParser cannot
|
||||||
|
// parse a radio payload on the JVM anyway.
|
||||||
|
Sources.satelliteDataUrls.values.filter { it.isNotBlank() }
|
||||||
|
.forEach { networkStreams[it] = { validCsvStream() } }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo(
|
||||||
|
dataSources = DataSourcesSettings(
|
||||||
|
useCustomTLE = false,
|
||||||
|
useCustomTransceivers = false,
|
||||||
|
tleUrl = "https://example.com/ignored.csv",
|
||||||
|
transceiversUrl = ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
|
||||||
|
repository.updateFromRemote()
|
||||||
|
|
||||||
|
val expected = Sources.satelliteDataUrls.values.filter { it.isNotBlank() }
|
||||||
|
assertTrue(
|
||||||
|
"expected all built-in sources, got " + remoteSource.requestedUrls.size,
|
||||||
|
expected.all { it in remoteSource.requestedUrls }
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"the custom URL must not be fetched when the switch is off",
|
||||||
|
"https://example.com/ignored.csv" !in remoteSource.requestedUrls
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dead custom URL must fail the update even when the transceivers source answers.
|
||||||
|
*
|
||||||
|
* The counts used to be added together, so one transceivers success covered a total orbital
|
||||||
|
* failure: no exception, and a fresh "updated successfully" timestamp for an update that
|
||||||
|
* refreshed nothing. Replacing the built-in sources shrank the denominator from 28 to 2 and
|
||||||
|
* made that easy to hit.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a dead custom url fails the update even if transceivers succeed`() = runTest(dispatcher) {
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
Sources.transceiversDataUrls.values.filter { it.isNotBlank() }
|
||||||
|
.forEach { networkStreams[it] = { "[]".byteInputStream() } }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo(
|
||||||
|
dataSources = DataSourcesSettings(
|
||||||
|
useCustomTLE = true,
|
||||||
|
useCustomTransceivers = false,
|
||||||
|
tleUrl = "https://example.com/dead.csv",
|
||||||
|
transceiversUrl = ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
|
||||||
|
var threw = false
|
||||||
|
try {
|
||||||
|
repository.updateFromRemote()
|
||||||
|
} catch (_: java.io.IOException) {
|
||||||
|
threw = true
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue("a total orbital failure must raise", threw)
|
||||||
|
assertTrue("no entries may be inserted", localSource.insertedEntries.isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validCsvStream(): InputStream = """
|
private fun validCsvStream(): InputStream = """
|
||||||
@@ -121,9 +211,21 @@ private class FakeRemoteSource : IRemoteSource {
|
|||||||
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||||
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||||
|
|
||||||
|
/** Every URL asked for, so a test can assert WHICH sources were fetched, not just the result. */
|
||||||
|
val requestedUrls = mutableListOf<String>()
|
||||||
|
|
||||||
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? {
|
||||||
|
requestedUrls += url
|
||||||
|
return networkStreams[url]?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAmSatCatalog(): String? = null
|
||||||
|
|
||||||
|
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||||
|
|
||||||
|
override suspend fun getAmSatSummary(hours: Int): String? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeLocalSource : ILocalSource {
|
private class FakeLocalSource : ILocalSource {
|
||||||
@@ -165,10 +267,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 +278,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 +295,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 +324,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,156 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.aprs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the position line this station puts on APRS-IS, or refuses to.
|
||||||
|
*
|
||||||
|
* Separated from the reporter so the packet can be tested without a socket. Every rule here
|
||||||
|
* comes from aprs-is.net/connecting.aspx, and each of them was being broken:
|
||||||
|
*
|
||||||
|
* - the path must be exactly `TCPIP*`, and was absent entirely
|
||||||
|
* - the line must not exceed 512 bytes including CRLF, and had no cap
|
||||||
|
* - the comment must not contain a line break, or it injects a second packet
|
||||||
|
* - a station with no position must not transmit, where 0.0 was substituted so 0 degrees north,
|
||||||
|
* 0 degrees east - a point in the Gulf of Guinea - went out under the operator's callsign
|
||||||
|
*/
|
||||||
|
object AprsBeacon {
|
||||||
|
|
||||||
|
/** The only path a client-originated packet may carry. */
|
||||||
|
const val PATH = "TCPIP*"
|
||||||
|
|
||||||
|
/** Destination for a position report with no addressee. */
|
||||||
|
const val DESTINATION = "APRS"
|
||||||
|
|
||||||
|
/** Maximum line length including the CRLF the caller appends. */
|
||||||
|
const val MAX_LINE_BYTES = 512
|
||||||
|
|
||||||
|
/** Comment limit for this position format, per the APRS specification. */
|
||||||
|
const val MAX_COMMENT = 43
|
||||||
|
|
||||||
|
/** Why a beacon could not be built. */
|
||||||
|
sealed interface Refusal {
|
||||||
|
|
||||||
|
/** No position was available. Transmitting 0,0 would claim the Gulf of Guinea. */
|
||||||
|
data object NoPosition : Refusal
|
||||||
|
|
||||||
|
/** The callsign is missing, so the packet would have no valid source. */
|
||||||
|
data object NoCallsign : Refusal
|
||||||
|
|
||||||
|
/** Latitude or longitude outside the possible range. */
|
||||||
|
data class ImpossiblePosition(val latitude: Double, val longitude: Double) : Refusal
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Either a line ready to send, or the reason there is none. */
|
||||||
|
sealed interface Result {
|
||||||
|
data class Line(val text: String) : Result
|
||||||
|
data class Blocked(val refusal: Refusal) : Result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the position line.
|
||||||
|
*
|
||||||
|
* Returns [Result.Blocked] rather than a placeholder: a beacon is a claim about where the
|
||||||
|
* operator is, and there is no honest default for "nowhere".
|
||||||
|
*/
|
||||||
|
fun build(
|
||||||
|
callsign: String,
|
||||||
|
ssid: String,
|
||||||
|
latitude: Double?,
|
||||||
|
longitude: Double?,
|
||||||
|
symbolTable: String,
|
||||||
|
symbolCode: String,
|
||||||
|
comment: String
|
||||||
|
): Result {
|
||||||
|
if (callsign.isBlank()) return Result.Blocked(Refusal.NoCallsign)
|
||||||
|
if (latitude == null || longitude == null) return Result.Blocked(Refusal.NoPosition)
|
||||||
|
if (latitude !in -90.0..90.0 || longitude !in -180.0..180.0) {
|
||||||
|
return Result.Blocked(Refusal.ImpossiblePosition(latitude, longitude))
|
||||||
|
}
|
||||||
|
val source = AprsPacket.formatCallSsid(callsign.trim().uppercase(), ssid.trim())
|
||||||
|
val position = AprsPosition(
|
||||||
|
latitude = latitude,
|
||||||
|
longitude = longitude,
|
||||||
|
symbolTable = tableOf(symbolTable),
|
||||||
|
symbolCode = codeOf(symbolCode)
|
||||||
|
)
|
||||||
|
val header = "$source>$DESTINATION,$PATH:="
|
||||||
|
val body = position.toUncompressedString()
|
||||||
|
val room = MAX_LINE_BYTES - CRLF_BYTES - header.toByteArray().size - body.toByteArray().size
|
||||||
|
return Result.Line(header + body + sanitiseComment(comment, room))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip anything that would break the line, then trim to fit.
|
||||||
|
*
|
||||||
|
* A newline typed into the comment field used to end the packet early and start a second one
|
||||||
|
* from the remaining text - an injection the operator could trigger by accident.
|
||||||
|
*/
|
||||||
|
fun sanitiseComment(comment: String, room: Int = MAX_COMMENT): String {
|
||||||
|
if (room <= 0) return ""
|
||||||
|
val cleaned = comment.asSequence()
|
||||||
|
// Printable ASCII only: line breaks split the packet, and control characters have no
|
||||||
|
// meaning in a comment while being able to confuse a parser.
|
||||||
|
.filter { it.code in 0x20..0x7E }
|
||||||
|
.joinToString("")
|
||||||
|
.trim()
|
||||||
|
val limit = minOf(MAX_COMMENT, room)
|
||||||
|
return if (cleaned.length <= limit) cleaned else cleaned.take(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The symbol table byte, defaulting to the primary table.
|
||||||
|
*
|
||||||
|
* Must be `/`, `\` or an overlay character. It was previously whatever the operator typed
|
||||||
|
* first - any character at all, including one that breaks the fixed-width parse. aprs.fi
|
||||||
|
* names symbol misconfiguration as the most common reason a station never appears on the map.
|
||||||
|
*/
|
||||||
|
fun tableOf(entry: String): Char {
|
||||||
|
val candidate = entry.trim().firstOrNull() ?: return TABLE_PRIMARY
|
||||||
|
return when {
|
||||||
|
candidate == TABLE_PRIMARY || candidate == TABLE_ALTERNATE -> candidate
|
||||||
|
candidate.isDigit() -> candidate
|
||||||
|
candidate in 'A'..'Z' -> candidate
|
||||||
|
else -> TABLE_PRIMARY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The symbol byte. Any printable character is a valid symbol; anything else is not. */
|
||||||
|
fun codeOf(entry: String): Char {
|
||||||
|
val candidate = entry.trim().firstOrNull() ?: return DEFAULT_SYMBOL
|
||||||
|
return if (candidate.code in 0x21..0x7E) candidate else DEFAULT_SYMBOL
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TABLE_PRIMARY = '/'
|
||||||
|
private const val TABLE_ALTERNATE = '\\'
|
||||||
|
|
||||||
|
/** Bytes the caller adds after the line. */
|
||||||
|
private const val CRLF_BYTES = 2
|
||||||
|
|
||||||
|
/** Fallback symbol. `>` is a car on the primary table - a reasonable stand-in for a phone. */
|
||||||
|
/**
|
||||||
|
* Substituted when the stored code is unusable.
|
||||||
|
*
|
||||||
|
* A house, not a car. The old default was '>' (CAR) with a comment conceding it was "a
|
||||||
|
* reasonable stand-in for a phone" - but a station beaconing from a handset showed up as a
|
||||||
|
* vehicle for every operator who was not driving, and aprs.fi names transmit-side symbol
|
||||||
|
* misconfiguration among the first things to check when a station looks wrong. A house is
|
||||||
|
* correct for most users and obviously wrong rather than misleading for the rest.
|
||||||
|
*/
|
||||||
|
private const val DEFAULT_SYMBOL = '-'
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.aprs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The APRS-IS login line and the verdict the server returns for it.
|
||||||
|
*
|
||||||
|
* Kept apart from the socket so it can be tested: whether a login was verified decides whether
|
||||||
|
* anything this app sends reaches the network, and that distinction used to be made by a
|
||||||
|
* substring check inside a runCatching whose result was discarded, so it could not fail loudly.
|
||||||
|
*
|
||||||
|
* Format per aprs-is.net/connecting.aspx:
|
||||||
|
* `user mycall[-ss] pass passcode [vers softwarename softwarevers [filter ...]]`
|
||||||
|
*/
|
||||||
|
object AprsLogin {
|
||||||
|
|
||||||
|
/** What the server decided about a login attempt. */
|
||||||
|
sealed interface Outcome {
|
||||||
|
|
||||||
|
/** The passcode matched the callsign. Packets from this client are accepted. */
|
||||||
|
data class Verified(val callsign: String) : Outcome
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server accepted the connection but did not verify the login.
|
||||||
|
*
|
||||||
|
* Not an error at the socket level, which is exactly why it needs surfacing: writes keep
|
||||||
|
* succeeding while the server discards every packet. A receive-only login (passcode -1)
|
||||||
|
* lands here legitimately.
|
||||||
|
*/
|
||||||
|
data class Unverified(val callsign: String) : Outcome
|
||||||
|
|
||||||
|
/** The server refused the login outright. */
|
||||||
|
data class Rejected(val detail: String) : Outcome
|
||||||
|
|
||||||
|
/** Nothing recognisable arrived. The connection may still work; we simply do not know. */
|
||||||
|
data class Unknown(val detail: String) : Outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Any run of whitespace, collapsed to a hyphen inside a single token. */
|
||||||
|
private val WHITESPACE = Regex("""\s+""")
|
||||||
|
|
||||||
|
/** Passcode value that asks for a receive-only connection. */
|
||||||
|
const val RECEIVE_ONLY_PASSCODE = -1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the login line.
|
||||||
|
*
|
||||||
|
* `vers` takes TWO tokens - a software name and a version, separated by a space. An earlier
|
||||||
|
* version of this replaced that space with a hyphen, reading the rule "softwarename must not
|
||||||
|
* contain a space" as "the field must be a single token". Live aprsc 2.1.21 rejects the result:
|
||||||
|
*
|
||||||
|
* sent: user N0CALL pass -1 vers Look4Sat-4.5.4
|
||||||
|
* got: # Invalid login: software name and version are not separated by a space
|
||||||
|
*
|
||||||
|
* So name and version stay apart, and whitespace is collapsed WITHIN each of them instead.
|
||||||
|
*/
|
||||||
|
fun line(
|
||||||
|
callsign: String,
|
||||||
|
ssid: String,
|
||||||
|
passcode: Int,
|
||||||
|
name: String,
|
||||||
|
version: String,
|
||||||
|
filter: String = ""
|
||||||
|
): String {
|
||||||
|
val callSsid = AprsPacket.formatCallSsid(callsign, ssid)
|
||||||
|
val safeName = name.trim().replace(WHITESPACE, "-").ifEmpty { "Look4Sat" }
|
||||||
|
val safeVersion = version.trim().replace(WHITESPACE, "-").ifEmpty { "0" }
|
||||||
|
val base = "user $callSsid pass $passcode vers $safeName $safeVersion"
|
||||||
|
val trimmedFilter = filter.trim()
|
||||||
|
return if (trimmedFilter.isEmpty()) base else "$base $trimmedFilter"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpret one line of server output, or null when it carries no verdict.
|
||||||
|
*
|
||||||
|
* Classified by what is KNOWN HARMLESS rather than by a list of known refusals, because that
|
||||||
|
* list was incomplete and the failure is silent. aprsc refuses with `# Invalid login: ...` but
|
||||||
|
* also `# Login by user not allowed` - observed live on rotate.aprs2.net - and `# Port full`
|
||||||
|
* and `# Server full`. Each was skipped as chatter, the login timed out into Unknown, Unknown
|
||||||
|
* is deliberately read as "may be working", and every send afterwards reported success to an
|
||||||
|
* operator the server had refused.
|
||||||
|
*
|
||||||
|
* So identification and keepalive comments return null, a logresp is parsed, and anything else
|
||||||
|
* the server bothers to say during login counts as it objecting.
|
||||||
|
*
|
||||||
|
* Note that "unverified" contains "verified", so the negative is tested first - a naive
|
||||||
|
* contains("verified") reports every refusal as acceptance.
|
||||||
|
*/
|
||||||
|
fun parse(line: String): Outcome? {
|
||||||
|
val trimmed = line.trim()
|
||||||
|
if (trimmed.isEmpty()) return null
|
||||||
|
if (!trimmed.startsWith("#")) {
|
||||||
|
// A non-comment line during login is the server objecting in plain text.
|
||||||
|
return Outcome.Rejected(trimmed)
|
||||||
|
}
|
||||||
|
val lower = trimmed.lowercase()
|
||||||
|
if (lower.contains("logresp")) {
|
||||||
|
val callsign = callsignFrom(trimmed)
|
||||||
|
return when {
|
||||||
|
lower.contains("unverified") -> Outcome.Unverified(callsign)
|
||||||
|
lower.contains("verified") -> Outcome.Verified(callsign)
|
||||||
|
else -> Outcome.Unknown(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Identification and keepalives are the only comments that mean "keep reading".
|
||||||
|
if (HARMLESS.any { lower.startsWith(it) }) return null
|
||||||
|
return Outcome.Rejected(trimmed.removePrefix("#").trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comment prefixes that carry no verdict.
|
||||||
|
*
|
||||||
|
* Matching a prefix rather than searching for refusal words means a refusal nobody anticipated
|
||||||
|
* is treated as a refusal instead of being ignored.
|
||||||
|
*/
|
||||||
|
private val HARMLESS = listOf("# aprsc", "# javaprssrvr", "# aprsis", "# filter")
|
||||||
|
|
||||||
|
/** The callsign token in `# logresp CALL verified, ...`, or empty when absent. */
|
||||||
|
private fun callsignFrom(response: String): String {
|
||||||
|
val tokens = response.removePrefix("#").trim().split(Regex("\\s+"))
|
||||||
|
val index = tokens.indexOfFirst { it.equals("logresp", ignoreCase = true) }
|
||||||
|
if (index < 0) return ""
|
||||||
|
return tokens.getOrNull(index + 1)?.trimEnd(',') ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.aprs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides what passcode to present to APRS-IS, and whether the operator's entry is usable.
|
||||||
|
*
|
||||||
|
* The app must not derive a transmit passcode for the operator. APRS-IS states that supplying
|
||||||
|
* the correct passcode to a user is the software author's responsibility, and the passcode
|
||||||
|
* exists as a licence check - deriving it in-app and shipping the algorithm defeats the point.
|
||||||
|
* APRSdroid has the same algorithm in the same file and deliberately does not use it for this
|
||||||
|
* reason, validating the operator's entry instead and linking out to request one.
|
||||||
|
*
|
||||||
|
* So this validates. [AprsPacket.passcode] stays, because checking an entry means recomputing
|
||||||
|
* the expected value, but nothing here substitutes a derived code for a missing one.
|
||||||
|
*/
|
||||||
|
object AprsPasscode {
|
||||||
|
|
||||||
|
/** Value that asks APRS-IS for a receive-only connection. Always legitimate. */
|
||||||
|
const val RECEIVE_ONLY = -1
|
||||||
|
|
||||||
|
/** What the operator's passcode entry amounts to. */
|
||||||
|
sealed interface Entry {
|
||||||
|
|
||||||
|
/** A passcode that matches the callsign. Reports will be forwarded. */
|
||||||
|
data class Transmit(val passcode: Int) : Entry
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An explicit -1, or a blank entry.
|
||||||
|
*
|
||||||
|
* A blank entry lands here rather than being filled in with a derived code: connecting
|
||||||
|
* receive-only is honest about what an operator without a passcode can do, where a
|
||||||
|
* derived code silently claims a licence check that was never performed.
|
||||||
|
*/
|
||||||
|
data object ReceiveOnly : Entry
|
||||||
|
|
||||||
|
/** Something was typed but it is not this callsign's passcode. */
|
||||||
|
data class Mismatch(val expectedFor: String) : Entry
|
||||||
|
|
||||||
|
/** Something was typed that is not a number at all. */
|
||||||
|
data object NotANumber : Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify what the operator typed.
|
||||||
|
*
|
||||||
|
* A mismatch is reported rather than corrected, so the UI can refuse to save and say why.
|
||||||
|
* Silently swapping in a derived code is how an operator ends up believing they are
|
||||||
|
* transmitting under a passcode they never obtained.
|
||||||
|
*/
|
||||||
|
fun classify(callsign: String, entry: String): Entry {
|
||||||
|
val trimmed = entry.trim()
|
||||||
|
if (trimmed.isEmpty()) return Entry.ReceiveOnly
|
||||||
|
val value = trimmed.toIntOrNull() ?: return Entry.NotANumber
|
||||||
|
// Checked before the callsign comparison: -1 is the documented receive-only value and
|
||||||
|
// is never anyone's passcode, so comparing it would report a deliberate choice as a typo.
|
||||||
|
if (value == RECEIVE_ONLY) return Entry.ReceiveOnly
|
||||||
|
val call = callsign.trim()
|
||||||
|
if (call.isEmpty()) return Entry.Mismatch("")
|
||||||
|
return if (value == AprsPacket.passcode(call)) {
|
||||||
|
Entry.Transmit(value)
|
||||||
|
} else {
|
||||||
|
Entry.Mismatch(call.uppercase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number to send in the login line for this entry.
|
||||||
|
*
|
||||||
|
* Anything not usable becomes [RECEIVE_ONLY]: the connection still works, the operator is
|
||||||
|
* told separately that their reports are not being forwarded, and no packet goes out under
|
||||||
|
* a passcode the app invented. The previous code sent a derived transmit passcode here,
|
||||||
|
* and `takeIf { it >= 0 }` additionally made an explicit -1 impossible to use - which also
|
||||||
|
* blocked the one safe way to test a setup, since a receive-only login is how you confirm
|
||||||
|
* the connection works without putting anything on the network.
|
||||||
|
*/
|
||||||
|
fun loginValue(callsign: String, entry: String): Int =
|
||||||
|
when (val classified = classify(callsign, entry)) {
|
||||||
|
is Entry.Transmit -> classified.passcode
|
||||||
|
else -> RECEIVE_ONLY
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when this entry lets the operator's reports reach the network. */
|
||||||
|
fun canTransmit(callsign: String, entry: String): Boolean =
|
||||||
|
classify(callsign, entry) is Entry.Transmit
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.aprs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One APRS symbol an operator might plausibly want.
|
||||||
|
*
|
||||||
|
* [table] and [code] are the two characters that go into the packet. [descriptionKey] names a
|
||||||
|
* string resource rather than holding text, because core:domain has no access to resources and
|
||||||
|
* hardcoding English here would put wording outside the locale files.
|
||||||
|
*/
|
||||||
|
data class AprsSymbol(val table: Char, val code: Char, val descriptionKey: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A short list of symbols worth offering, instead of two free-text fields.
|
||||||
|
*
|
||||||
|
* The fields accepted anything and used only the first character, so typing "satellite" into the
|
||||||
|
* table field persisted the whole word and beaconed as `/` - the field lied about what it did.
|
||||||
|
* aprs.fi's own troubleshooting guidance puts transmit-side symbol misconfiguration among the first
|
||||||
|
* things to check when a station does not appear as expected.
|
||||||
|
*
|
||||||
|
* The strongest single argument for a list: `\S` is Satellite/Pacsat but `/S` is SHUTTLE. One
|
||||||
|
* keystroke apart, and both look correct to someone typing from memory.
|
||||||
|
*
|
||||||
|
* Renderings are from aprs.org/symbols/symbolsX.txt (WB4APR, 25 Nov 2015). The list is deliberately
|
||||||
|
* short - it covers fixed, portable, vehicle and satellite postures, not all 400-odd symbols.
|
||||||
|
*/
|
||||||
|
object AprsSymbols {
|
||||||
|
|
||||||
|
/** Fixed home station. Correct for most users, and wrong in an obvious way for the rest. */
|
||||||
|
val HOUSE = AprsSymbol('/', '-', "aprs_symbol_house")
|
||||||
|
|
||||||
|
val curated = listOf(
|
||||||
|
HOUSE,
|
||||||
|
AprsSymbol('\\', '-', "aprs_symbol_house_alt"),
|
||||||
|
AprsSymbol('/', '[', "aprs_symbol_person"),
|
||||||
|
AprsSymbol('/', 'y', "aprs_symbol_yagi"),
|
||||||
|
// Alternate table. /S is SHUTTLE, which is not what anyone means here.
|
||||||
|
AprsSymbol('\\', 'S', "aprs_symbol_satellite"),
|
||||||
|
AprsSymbol('/', ';', "aprs_symbol_portable"),
|
||||||
|
AprsSymbol('/', '$', "aprs_symbol_phone"),
|
||||||
|
AprsSymbol('/', 'I', "aprs_symbol_tcpip"),
|
||||||
|
AprsSymbol('\\', 'K', "aprs_symbol_ht"),
|
||||||
|
AprsSymbol('/', '>', "aprs_symbol_car"),
|
||||||
|
AprsSymbol('/', 'k', "aprs_symbol_truck"),
|
||||||
|
AprsSymbol('/', 'v', "aprs_symbol_van"),
|
||||||
|
AprsSymbol('/', 'R', "aprs_symbol_rv"),
|
||||||
|
AprsSymbol('/', 'b', "aprs_symbol_bike")
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the curated entry matching a stored pair, or null when it is not on the list.
|
||||||
|
*
|
||||||
|
* Null matters: an operator may have set a symbol this list does not offer, and the picker must
|
||||||
|
* show it as-is rather than silently substituting the nearest entry.
|
||||||
|
*/
|
||||||
|
fun find(table: Char, code: Char): AprsSymbol? =
|
||||||
|
curated.firstOrNull { it.table == table && it.code == code }
|
||||||
|
|
||||||
|
/** Same, from whatever strings the settings screen holds. Blank means the shipped default. */
|
||||||
|
fun find(table: String, code: String): AprsSymbol? {
|
||||||
|
val t = table.firstOrNull() ?: HOUSE.table
|
||||||
|
val c = code.firstOrNull() ?: HOUSE.code
|
||||||
|
return find(t, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.cw
|
||||||
|
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Low-pass filter applied before decimating to the model's sample rate.
|
||||||
|
*
|
||||||
|
* [CwDeepSpectrogram.resampleLinear] drops from the capture rate to 3200 Hz by
|
||||||
|
* interpolating between samples, with nothing removing the content above the new
|
||||||
|
* Nyquist of 1600 Hz first. Everything higher folds back into the audible window,
|
||||||
|
* which is not a subtle degradation - measured on 44100 Hz input a 3000 Hz tone
|
||||||
|
* reappears at 200 Hz at 119 times the spectral mean, indistinguishable from a real
|
||||||
|
* signal, and 1800 Hz lands on 1400 Hz. Worse for copy, the whole 1600-22050 Hz band
|
||||||
|
* of hiss folds down on top of the signal and lifts the noise floor across the entire
|
||||||
|
* display.
|
||||||
|
*
|
||||||
|
* The resampler itself is deliberately left alone: its comment notes it matches the
|
||||||
|
* reference implementation the model was trained against, so changing its arithmetic
|
||||||
|
* would move the spectrogram away from what DeepCW expects. Filtering first fixes the
|
||||||
|
* aliasing without touching that contract.
|
||||||
|
*
|
||||||
|
* A windowed-sinc FIR rather than a biquad cascade: the transition band has to be
|
||||||
|
* steep to keep 1600 Hz while rejecting 1800 Hz, and a linear-phase FIR does not
|
||||||
|
* smear the keying envelope the way a high-order IIR would.
|
||||||
|
*/
|
||||||
|
object CwAntiAlias {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cut-off as a fraction of the target Nyquist.
|
||||||
|
*
|
||||||
|
* Below 1.0 so the transition band lands inside the discarded region rather than
|
||||||
|
* straddling it. At 0.92 the response is flat to 1470 Hz, which still covers the
|
||||||
|
* model's 1200 Hz window and the shifter's detection range with room to spare.
|
||||||
|
*/
|
||||||
|
private const val CUTOFF_FRACTION = 0.92
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter length. Odd so the group delay is a whole number of samples.
|
||||||
|
*
|
||||||
|
* 127 taps at 44100 Hz gives roughly a 700 Hz transition width - enough to put
|
||||||
|
* 1800 Hz down by more than 40 dB while passing 1470 Hz unattenuated. Longer would
|
||||||
|
* be sharper and slower; this runs on a phone during a pass.
|
||||||
|
*/
|
||||||
|
private const val TAPS = 127
|
||||||
|
|
||||||
|
/** Delay introduced by [TAPS], for callers that need to align another path. */
|
||||||
|
const val GROUP_DELAY_SAMPLES = TAPS / 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter [audio] so that decimating to [targetRate] cannot alias.
|
||||||
|
*
|
||||||
|
* A no-op when [sourceRate] is at or below [targetRate], since there is nothing
|
||||||
|
* above the target Nyquist to remove. Returns a new array; [audio] is unchanged.
|
||||||
|
*/
|
||||||
|
fun prepareForDecimation(audio: FloatArray, sourceRate: Int, targetRate: Int): FloatArray {
|
||||||
|
if (audio.isEmpty() || sourceRate <= targetRate) return audio
|
||||||
|
val cutoffHz = targetRate / 2.0 * CUTOFF_FRACTION
|
||||||
|
return applyFir(audio, kernelFor(cutoffHz, sourceRate))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windowed-sinc low-pass kernel, normalised to unity gain at DC.
|
||||||
|
*
|
||||||
|
* Blackman window: its sidelobes are around -58 dB against the Hamming window's
|
||||||
|
* -41 dB, and sidelobe level is exactly what decides how much of the folded band
|
||||||
|
* survives.
|
||||||
|
*/
|
||||||
|
private fun kernelFor(cutoffHz: Double, sampleRate: Int): FloatArray {
|
||||||
|
val normalised = cutoffHz / sampleRate
|
||||||
|
val half = TAPS / 2
|
||||||
|
val raw = DoubleArray(TAPS) { i ->
|
||||||
|
val n = i - half
|
||||||
|
val sinc = if (n == 0) {
|
||||||
|
2.0 * normalised
|
||||||
|
} else {
|
||||||
|
sin(2.0 * PI * normalised * n) / (PI * n)
|
||||||
|
}
|
||||||
|
val window = 0.42 -
|
||||||
|
0.5 * cos(2.0 * PI * i / (TAPS - 1)) +
|
||||||
|
0.08 * cos(4.0 * PI * i / (TAPS - 1))
|
||||||
|
sinc * window
|
||||||
|
}
|
||||||
|
val sum = raw.sum()
|
||||||
|
// Unity DC gain, so filtering does not change the level the model was trained on.
|
||||||
|
return FloatArray(TAPS) { i -> (raw[i] / sum).toFloat() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convolve, compensating for the filter's own delay so the output lines up with
|
||||||
|
* the input. Edge taps that fall outside the buffer see zeros, which costs the
|
||||||
|
* first and last [GROUP_DELAY_SAMPLES] samples of an isolated buffer.
|
||||||
|
*/
|
||||||
|
private fun applyFir(audio: FloatArray, kernel: FloatArray): FloatArray {
|
||||||
|
val out = FloatArray(audio.size)
|
||||||
|
for (i in audio.indices) {
|
||||||
|
var sum = 0f
|
||||||
|
for (k in kernel.indices) {
|
||||||
|
val j = i - k + GROUP_DELAY_SAMPLES
|
||||||
|
if (j >= 0 && j < audio.size) sum += kernel[k] * audio[j]
|
||||||
|
}
|
||||||
|
out[i] = sum
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chunk-by-chunk filter that carries the state [prepareForDecimation] cannot.
|
||||||
|
*
|
||||||
|
* Two things are needed for concatenated chunks to match a whole-buffer filter.
|
||||||
|
* History is the obvious one: the FIR spans [TAPS] samples, so a chunk's first
|
||||||
|
* outputs need the tail of the one before it.
|
||||||
|
*
|
||||||
|
* The second is less obvious and was measured rather than reasoned about. A
|
||||||
|
* linear-phase FIR is centred, so output sample `i` needs input up to
|
||||||
|
* `i + GROUP_DELAY_SAMPLES` - samples that have not been captured yet when the
|
||||||
|
* chunk arrives. A first attempt let those taps fall off the end of the buffer and
|
||||||
|
* read as zeros; against a whole-buffer filter that diverged by 0.134 across the
|
||||||
|
* last 44 samples of every chunk, which is a click at each boundary rather than a
|
||||||
|
* rounding difference.
|
||||||
|
*
|
||||||
|
* So output is held back by [GROUP_DELAY_SAMPLES] samples: each call emits the
|
||||||
|
* samples whose lookahead has now arrived, and keeps the rest until the next chunk
|
||||||
|
* completes them. The cost is a fixed 63-sample delay, about 1.4 ms at 44100 Hz,
|
||||||
|
* against a 20 WPM dot of roughly 60 ms.
|
||||||
|
*
|
||||||
|
* Not thread-safe: driven from the single capture coroutine.
|
||||||
|
*/
|
||||||
|
class Streaming(sourceRate: Int, targetRate: Int) {
|
||||||
|
|
||||||
|
private val kernel: FloatArray? =
|
||||||
|
if (sourceRate <= targetRate) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
kernelFor(targetRate / 2.0 * CUTOFF_FRACTION, sourceRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Samples not yet emitted: filter history plus the lookahead still owed. */
|
||||||
|
private var pending = FloatArray(0)
|
||||||
|
|
||||||
|
/** Filter one chunk, continuing from the previous call. */
|
||||||
|
fun process(chunk: FloatArray): FloatArray {
|
||||||
|
val k = kernel ?: return chunk
|
||||||
|
if (chunk.isEmpty()) return chunk
|
||||||
|
|
||||||
|
val combined = FloatArray(pending.size + chunk.size)
|
||||||
|
pending.copyInto(combined)
|
||||||
|
chunk.copyInto(combined, pending.size)
|
||||||
|
|
||||||
|
// Only samples with a full window on both sides are ready. Everything from
|
||||||
|
// here on still needs input that has not arrived.
|
||||||
|
val ready = combined.size - TAPS + 1
|
||||||
|
if (ready <= 0) {
|
||||||
|
pending = combined
|
||||||
|
return FloatArray(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
val out = FloatArray(ready)
|
||||||
|
for (i in 0 until ready) {
|
||||||
|
var sum = 0f
|
||||||
|
for (t in k.indices) {
|
||||||
|
sum += k[t] * combined[i + TAPS - 1 - t]
|
||||||
|
}
|
||||||
|
out[i] = sum
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carry the tail that the next chunk will complete.
|
||||||
|
pending = combined.copyOfRange(ready, combined.size)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear pending state, e.g. after a decoder reset. */
|
||||||
|
fun reset() {
|
||||||
|
pending = FloatArray(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,242 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.cw
|
||||||
|
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.ceil
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.ln1p
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audio front-end for the DeepCW model: turns PCM samples into the
|
||||||
|
* `[time, frequency]` log-magnitude spectrogram the network expects.
|
||||||
|
*
|
||||||
|
* Mirrors the upstream Python reference (deepcw-engine
|
||||||
|
* `examples/python/decode_morse.py`) step for step:
|
||||||
|
*
|
||||||
|
* resample -> 3200 Hz, reflect-pad by fft/2, periodic Hann window of 256,
|
||||||
|
* real FFT, keep bins [32, 97) i.e. 400-1200 Hz, then log1p.
|
||||||
|
*
|
||||||
|
* The model's fixed 400-1200 Hz window means pitch detection is built in —
|
||||||
|
* no spectral peak tracking or squelch gating is needed on our side.
|
||||||
|
*/
|
||||||
|
object CwDeepSpectrogram {
|
||||||
|
|
||||||
|
/** Model input sample rate, from `model.onnx.json`. */
|
||||||
|
const val SAMPLE_RATE = 3200
|
||||||
|
|
||||||
|
/** FFT window length in samples. */
|
||||||
|
const val FFT_LENGTH = 256
|
||||||
|
|
||||||
|
/** Hop between consecutive frames; 48/3200 = 15.0 ms per frame. */
|
||||||
|
const val HOP_LENGTH = 48
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lower edge of the model's analysis window. Public so [CwToneShifter] can
|
||||||
|
* decide whether a detected tone falls outside it; the value is fixed by the
|
||||||
|
* trained model and must not be changed without retraining.
|
||||||
|
*/
|
||||||
|
const val MIN_FREQ_HZ = 400.0
|
||||||
|
|
||||||
|
/** Upper edge of the model's analysis window; see [MIN_FREQ_HZ]. */
|
||||||
|
const val MAX_FREQ_HZ = 1200.0
|
||||||
|
|
||||||
|
/** Number of frequency bins the model expects. */
|
||||||
|
const val FREQUENCY_BINS = 65
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widest span worth displaying: DC to Nyquist.
|
||||||
|
*
|
||||||
|
* The model reads [MIN_FREQ_HZ]..[MAX_FREQ_HZ], but a tone outside that range leaves
|
||||||
|
* no trace inside it - measured on keyed audio, the brightest column in the narrow
|
||||||
|
* view swings 1.01x between key-down and key-up, against 13.76x for a tone the model
|
||||||
|
* can see. So the narrow view cannot even show that a signal exists, and the display
|
||||||
|
* spans the whole band instead. Nothing above Nyquist can be shown at all: it aliases.
|
||||||
|
*/
|
||||||
|
const val DISPLAY_MIN_FREQ_HZ = 0.0
|
||||||
|
|
||||||
|
/** Upper end of the display span; see [DISPLAY_MIN_FREQ_HZ]. */
|
||||||
|
const val DISPLAY_MAX_FREQ_HZ = SAMPLE_RATE / 2.0
|
||||||
|
|
||||||
|
/** Milliseconds of audio represented by one output frame. */
|
||||||
|
const val MS_PER_FRAME = 1000.0 * HOP_LENGTH / SAMPLE_RATE
|
||||||
|
|
||||||
|
private val hannWindow: FloatArray = FloatArray(FFT_LENGTH) { i ->
|
||||||
|
// numpy: np.hanning(N + 1)[:-1] — the periodic (not symmetric) variant.
|
||||||
|
(0.5 - 0.5 * cos(2.0 * PI * i / FFT_LENGTH)).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inclusive-exclusive bin range covering [minHz, maxHz].
|
||||||
|
* Returns `start to stop`, matching the reference's `frequency_bin_range`.
|
||||||
|
*/
|
||||||
|
fun frequencyBinRange(
|
||||||
|
sampleRate: Int,
|
||||||
|
fftLength: Int,
|
||||||
|
minHz: Double,
|
||||||
|
maxHz: Double
|
||||||
|
): Pair<Int, Int> {
|
||||||
|
val binHz = sampleRate.toDouble() / fftLength
|
||||||
|
val start = ceil(minHz / binHz).toInt()
|
||||||
|
val stop = floor(maxHz / binHz).toInt() + 1
|
||||||
|
return start to stop
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linear-interpolation resampler. Deliberately dependency-light and
|
||||||
|
* identical to the reference implementation so spectrograms match.
|
||||||
|
*/
|
||||||
|
fun resampleLinear(audio: FloatArray, sourceRate: Int, targetRate: Int): FloatArray {
|
||||||
|
if (sourceRate == targetRate || audio.isEmpty()) return audio
|
||||||
|
val targetLength = (audio.size.toDouble() * targetRate / sourceRate).roundToInt()
|
||||||
|
val out = FloatArray(targetLength)
|
||||||
|
val ratio = sourceRate.toDouble() / targetRate
|
||||||
|
for (i in 0 until targetLength) {
|
||||||
|
val position = i * ratio
|
||||||
|
val left = floor(position).toInt()
|
||||||
|
val right = minOf(left + 1, audio.size - 1)
|
||||||
|
val fraction = (position - left).toFloat()
|
||||||
|
out[i] = audio[left] * (1f - fraction) + audio[right] * fraction
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the log-magnitude spectrogram. Input must already be at
|
||||||
|
* [SAMPLE_RATE]; use [resampleLinear] first when it is not.
|
||||||
|
*
|
||||||
|
* @return `[frames][FREQUENCY_BINS]` values, all non-negative.
|
||||||
|
*/
|
||||||
|
fun compute(
|
||||||
|
audio: FloatArray,
|
||||||
|
minHz: Double = MIN_FREQ_HZ,
|
||||||
|
maxHz: Double = MAX_FREQ_HZ
|
||||||
|
): Array<FloatArray> {
|
||||||
|
require(audio.size >= FFT_LENGTH) {
|
||||||
|
"audio is too short for fftLength=$FFT_LENGTH, got ${audio.size}"
|
||||||
|
}
|
||||||
|
|
||||||
|
val (startBin, stopBin) = frequencyBinRange(SAMPLE_RATE, FFT_LENGTH, minHz, maxHz)
|
||||||
|
val bins = stopBin - startBin
|
||||||
|
require(bins > 0) { "empty bin range for $minHz..${maxHz}Hz" }
|
||||||
|
// The model's range must yield exactly the bin count it was trained on. Written as
|
||||||
|
// an implication rather than a disjunction of all three terms: `a != x || b != y ||
|
||||||
|
// bins == n` is satisfied by any custom range regardless of the bin count, which
|
||||||
|
// would leave the invariant unenforced for the caller most likely to break it.
|
||||||
|
val isModelRange = minHz == MIN_FREQ_HZ && maxHz == MAX_FREQ_HZ
|
||||||
|
require(!isModelRange || bins == FREQUENCY_BINS) {
|
||||||
|
"expected $FREQUENCY_BINS bins for the model range, computed $bins"
|
||||||
|
}
|
||||||
|
// Nothing may run off the end of the FFT output: a real signal has FFT_LENGTH / 2
|
||||||
|
// + 1 distinct bins, and asking beyond Nyquist would index past them.
|
||||||
|
require(stopBin <= FFT_LENGTH / 2 + 1) {
|
||||||
|
"maxHz ${maxHz}Hz is above Nyquist ${SAMPLE_RATE / 2}Hz"
|
||||||
|
}
|
||||||
|
|
||||||
|
val padded = reflectPad(audio, FFT_LENGTH / 2)
|
||||||
|
val frames = 1 + (padded.size - FFT_LENGTH) / HOP_LENGTH
|
||||||
|
val result = Array(frames) { FloatArray(bins) }
|
||||||
|
|
||||||
|
val real = FloatArray(FFT_LENGTH)
|
||||||
|
val imag = FloatArray(FFT_LENGTH)
|
||||||
|
for (frame in 0 until frames) {
|
||||||
|
val offset = frame * HOP_LENGTH
|
||||||
|
for (i in 0 until FFT_LENGTH) {
|
||||||
|
real[i] = padded[offset + i] * hannWindow[i]
|
||||||
|
imag[i] = 0f
|
||||||
|
}
|
||||||
|
fftInPlace(real, imag)
|
||||||
|
val row = result[frame]
|
||||||
|
for (bin in startBin until stopBin) {
|
||||||
|
val magnitude = sqrt(real[bin] * real[bin] + imag[bin] * imag[bin])
|
||||||
|
row[bin - startBin] = ln1p(magnitude.toDouble()).toFloat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* numpy `mode="reflect"`: mirrors around the edge samples without
|
||||||
|
* repeating them, so [1,2,3] padded by 2 becomes [3,2,1,2,3,2,1].
|
||||||
|
*/
|
||||||
|
private fun reflectPad(audio: FloatArray, pad: Int): FloatArray {
|
||||||
|
if (pad == 0) return audio
|
||||||
|
val out = FloatArray(audio.size + 2 * pad)
|
||||||
|
for (i in 0 until pad) out[i] = audio[pad - i]
|
||||||
|
audio.copyInto(out, pad)
|
||||||
|
val last = audio.size - 1
|
||||||
|
for (i in 0 until pad) out[pad + audio.size + i] = audio[last - 1 - i]
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterative radix-2 Cooley-Tukey FFT. [FFT_LENGTH] is a power of two, so
|
||||||
|
* no padding case is needed. Only the first half of the output is read by
|
||||||
|
* [compute], which is the real-input equivalent of numpy's `rfft`.
|
||||||
|
*/
|
||||||
|
private fun fftInPlace(real: FloatArray, imag: FloatArray) {
|
||||||
|
val n = real.size
|
||||||
|
|
||||||
|
// Bit-reversal permutation.
|
||||||
|
var j = 0
|
||||||
|
for (i in 1 until n) {
|
||||||
|
var bit = n shr 1
|
||||||
|
while (j and bit != 0) {
|
||||||
|
j = j xor bit
|
||||||
|
bit = bit shr 1
|
||||||
|
}
|
||||||
|
j = j or bit
|
||||||
|
if (i < j) {
|
||||||
|
var tmp = real[i]; real[i] = real[j]; real[j] = tmp
|
||||||
|
tmp = imag[i]; imag[i] = imag[j]; imag[j] = tmp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var length = 2
|
||||||
|
while (length <= n) {
|
||||||
|
val angle = -2.0 * PI / length
|
||||||
|
val wReal = cos(angle).toFloat()
|
||||||
|
val wImag = kotlin.math.sin(angle).toFloat()
|
||||||
|
var i = 0
|
||||||
|
while (i < n) {
|
||||||
|
var curReal = 1f
|
||||||
|
var curImag = 0f
|
||||||
|
for (k in 0 until length / 2) {
|
||||||
|
val evenReal = real[i + k]
|
||||||
|
val evenImag = imag[i + k]
|
||||||
|
val oddReal = real[i + k + length / 2]
|
||||||
|
val oddImag = imag[i + k + length / 2]
|
||||||
|
val mulReal = oddReal * curReal - oddImag * curImag
|
||||||
|
val mulImag = oddReal * curImag + oddImag * curReal
|
||||||
|
real[i + k] = evenReal + mulReal
|
||||||
|
imag[i + k] = evenImag + mulImag
|
||||||
|
real[i + k + length / 2] = evenReal - mulReal
|
||||||
|
imag[i + k + length / 2] = evenImag - mulImag
|
||||||
|
val nextReal = curReal * wReal - curImag * wImag
|
||||||
|
curImag = curReal * wImag + curImag * wReal
|
||||||
|
curReal = nextReal
|
||||||
|
}
|
||||||
|
i += length
|
||||||
|
}
|
||||||
|
length = length shl 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.cw
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pools capture chunks until enough audio is available for tone detection.
|
||||||
|
*
|
||||||
|
* [CwToneShifter.detectToneHz] scans bin by bin, so it needs a few hundred
|
||||||
|
* milliseconds to resolve a pitch. A capture chunk is only 320 samples once
|
||||||
|
* resampled to [CwDeepSpectrogram.SAMPLE_RATE], hence the pooling: without it a
|
||||||
|
* per-chunk size check can never be satisfied and detection silently never runs.
|
||||||
|
*
|
||||||
|
* A ring buffer rather than a sliding array. Detection is throttled to a couple of
|
||||||
|
* seconds while the pool fills in a few hundred milliseconds, so most chunks arrive
|
||||||
|
* at a full buffer; shifting the array down one slot per sample cost 320 copies of
|
||||||
|
* 1280 floats per chunk, measured at 24320 whole-array moves per 10 s of audio on
|
||||||
|
* the capture thread. Writing to a ring index is O(1).
|
||||||
|
*
|
||||||
|
* Not thread-safe: the decoder drives it from a single capture coroutine.
|
||||||
|
*
|
||||||
|
* @param capacity samples retained; also the size [drain] returns once full.
|
||||||
|
*/
|
||||||
|
class CwDetectionPool(val capacity: Int) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(capacity > 0) { "capacity must be positive, was $capacity" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val samples = FloatArray(capacity)
|
||||||
|
private var writeIndex = 0
|
||||||
|
|
||||||
|
/** Samples currently pooled, never above [capacity]. */
|
||||||
|
var size: Int = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** True once [capacity] samples are pooled and detection can run. */
|
||||||
|
val isReady: Boolean get() = size >= capacity
|
||||||
|
|
||||||
|
/** Add a chunk, overwriting the oldest samples once full. */
|
||||||
|
fun add(chunk: FloatArray) {
|
||||||
|
if (chunk.isEmpty()) return
|
||||||
|
// A chunk longer than the pool can only contribute its tail.
|
||||||
|
val start = maxOf(0, chunk.size - capacity)
|
||||||
|
for (i in start until chunk.size) {
|
||||||
|
samples[writeIndex] = chunk[i]
|
||||||
|
writeIndex = (writeIndex + 1) % capacity
|
||||||
|
if (size < capacity) size++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand over the pooled audio in chronological order and empty the pool.
|
||||||
|
*
|
||||||
|
* Oldest sample first: the detector measures a waveform, so returning the ring in
|
||||||
|
* storage order would splice it at the wrap point and corrupt every estimate.
|
||||||
|
*/
|
||||||
|
fun drain(): FloatArray {
|
||||||
|
val out = FloatArray(size)
|
||||||
|
// Once full the oldest sample sits at the write cursor; before that at index 0.
|
||||||
|
val oldest = if (size == capacity) writeIndex else 0
|
||||||
|
for (i in 0 until size) {
|
||||||
|
out[i] = samples[(oldest + i) % capacity]
|
||||||
|
}
|
||||||
|
clear()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discard everything pooled so far. */
|
||||||
|
fun clear() {
|
||||||
|
size = 0
|
||||||
|
writeIndex = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.cw
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides what shift to apply from a sequence of tone estimates.
|
||||||
|
*
|
||||||
|
* Kept out of the decoder so the rule can be exercised directly. The decoder needs an
|
||||||
|
* Android Context and a loaded ONNX session, so a rule living inside it can only be
|
||||||
|
* tested by restating it - and a restated rule cannot fail when the real one is wrong.
|
||||||
|
* Mutation testing proved that: four defects injected into an in-decoder version of this
|
||||||
|
* logic left the whole suite green.
|
||||||
|
*
|
||||||
|
* @param hysteresisHz how far the tone must move before the shift is revised.
|
||||||
|
*/
|
||||||
|
class CwShiftDecider(private val hysteresisHz: Float = DEFAULT_HYSTERESIS_HZ) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Default margin before re-shifting, in Hz.
|
||||||
|
*
|
||||||
|
* Detection resolves to 12.5 Hz and a real tone wanders, so a couple of scan bins
|
||||||
|
* of jitter must not count as a retune: revising the shift costs the whole 20 s
|
||||||
|
* decode window, which is worth far more than perfect centring.
|
||||||
|
*/
|
||||||
|
const val DEFAULT_HYSTERESIS_HZ = 40f
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shift currently applied to incoming audio; 0 when the tone needs no move. */
|
||||||
|
var shiftHz: Float = 0f
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tone that produced [shiftHz]. Hysteresis compares against this rather than against
|
||||||
|
* the previous shift, because a shift of 0 is a real state: at the window edge one
|
||||||
|
* 12.5 Hz estimate hop flips between "inside" (shift 0) and "outside" (a large
|
||||||
|
* shift), and a shift-space comparison lapses exactly where the jump is largest.
|
||||||
|
*/
|
||||||
|
var anchorToneHz: Float? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** What [accept] decided, for logging. */
|
||||||
|
enum class Outcome {
|
||||||
|
/** No tone in the window; the existing shift was retained. */
|
||||||
|
NO_TONE,
|
||||||
|
|
||||||
|
/** The tone moved less than the margin; the existing shift was retained. */
|
||||||
|
WITHIN_HYSTERESIS,
|
||||||
|
|
||||||
|
/** The tone is inside the model window, so no shift is needed. */
|
||||||
|
NO_SHIFT_NEEDED,
|
||||||
|
|
||||||
|
/** The shift was updated to move an out-of-window tone into range. */
|
||||||
|
SHIFTED
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of feeding one detection to the decider. */
|
||||||
|
data class Decision(
|
||||||
|
val outcome: Outcome,
|
||||||
|
/** Shift in force after the decision. */
|
||||||
|
val shiftHz: Float,
|
||||||
|
/** True when [shiftHz] differs from the value before this decision. */
|
||||||
|
val changed: Boolean,
|
||||||
|
/** Tone the decision was based on, null when none was detected. */
|
||||||
|
val toneHz: Float?
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feed one tone analysis and get the shift to apply.
|
||||||
|
*
|
||||||
|
* Silence retains the current shift rather than clearing it: CW is keyed, so a
|
||||||
|
* detection window landing in a gap carries no information about the pitch. Treating
|
||||||
|
* it as an authoritative "no shift" collapsed established shifts - measured over
|
||||||
|
* 180 s of keyed audio at 1400 Hz, 11 of 90 windows saw no tone, and each one left
|
||||||
|
* the following audio unshifted and therefore invisible to the model.
|
||||||
|
*/
|
||||||
|
fun accept(analysis: CwToneShifter.Analysis): Decision {
|
||||||
|
val previousShift = shiftHz
|
||||||
|
val toneHz = analysis.toneHz
|
||||||
|
?: return Decision(Outcome.NO_TONE, previousShift, changed = false, toneHz = null)
|
||||||
|
|
||||||
|
val anchor = anchorToneHz
|
||||||
|
if (anchor != null && abs(toneHz - anchor) < hysteresisHz) {
|
||||||
|
return Decision(Outcome.WITHIN_HYSTERESIS, previousShift, changed = false, toneHz = toneHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
shiftHz = analysis.shiftHz
|
||||||
|
anchorToneHz = toneHz
|
||||||
|
val outcome = if (analysis.needsShift) Outcome.SHIFTED else Outcome.NO_SHIFT_NEEDED
|
||||||
|
return Decision(outcome, shiftHz, changed = shiftHz != previousShift, toneHz = toneHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget the current shift and anchor, e.g. when the feature is toggled or reset. */
|
||||||
|
fun reset() {
|
||||||
|
shiftHz = 0f
|
||||||
|
anchorToneHz = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,323 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.cw
|
||||||
|
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.hypot
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves an out-of-range CW tone into the model's analysis window.
|
||||||
|
*
|
||||||
|
* The DeepCW model only sees [CwDeepSpectrogram.MIN_FREQ_HZ]..[CwDeepSpectrogram.MAX_FREQ_HZ];
|
||||||
|
* its input tensor width is fixed, so the window itself cannot be widened without
|
||||||
|
* retraining. Instead a tone that sits outside the window is frequency-shifted to
|
||||||
|
* [TARGET_HZ] before the spectrogram is built, which extends the usable pitch range
|
||||||
|
* to roughly 100 Hz..Nyquist without touching the model.
|
||||||
|
*
|
||||||
|
* ### Why single-sideband mixing
|
||||||
|
* Plain real mixing (`x * cos(2*pi*delta*t)`) produces both `tone+delta` and
|
||||||
|
* `tone-delta`. Measured on a 1500 Hz tone shifted to 800 Hz, the unwanted image
|
||||||
|
* folded back to 1000 Hz at 0.999 of the wanted amplitude — inside the window and
|
||||||
|
* as loud as the signal. Upsampling first only moves the problem: shifting a 300 Hz
|
||||||
|
* tone up produced a 200 Hz image at 0.996.
|
||||||
|
*
|
||||||
|
* A Hilbert transformer removes the negative-frequency half first, so mixing the
|
||||||
|
* resulting analytic signal yields one sideband only. Across nine probe tones
|
||||||
|
* (150..1550 Hz) that leaves a single spectral peak at the target with no component
|
||||||
|
* above 0.3 relative amplitude.
|
||||||
|
*
|
||||||
|
* All functions are pure; the caller decides whether shifting is wanted.
|
||||||
|
*/
|
||||||
|
object CwToneShifter {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where an out-of-window tone is moved to: the centre of the analysis window,
|
||||||
|
* so the keying sidebands have equal headroom on both sides.
|
||||||
|
*/
|
||||||
|
const val TARGET_HZ = 800.0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tones below this are treated as absent rather than shifted. Mains hum and DC
|
||||||
|
* drift live down here, and a real CW note that low is unusable anyway.
|
||||||
|
*/
|
||||||
|
const val MIN_DETECTABLE_HZ = 100.0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A detected peak must exceed the spectrum mean by this factor to count as a tone.
|
||||||
|
*
|
||||||
|
* Chosen from measurements on 1280-sample (400 ms) windows of keyed CW in noise.
|
||||||
|
* Pure noise peaks at 2.2-3.4 times its own spectral mean, so 3.0 admitted roughly
|
||||||
|
* one noise window in five. Raising it as far as 8.0 then rejected comfortably
|
||||||
|
* copyable signals: keyed CW measures 7.6-9.0 at 0 dB SNR and only 5.2-6.7 at -3 dB.
|
||||||
|
*
|
||||||
|
* 4.5 gives zero false positives across 40 noise windows while keeping the weaker
|
||||||
|
* end of usable signals. The asymmetry is deliberate: a false tone is worse than a
|
||||||
|
* missed one, because it moves a perfectly good signal out of the model's range,
|
||||||
|
* whereas a miss just leaves the audio alone until a stronger window arrives.
|
||||||
|
*
|
||||||
|
* Windows dominated by keying gaps (a slow fist, under ~25% tone) sit at 2.4 and are
|
||||||
|
* indistinguishable from noise at any threshold; those are skipped, not guessed at.
|
||||||
|
*/
|
||||||
|
const val MIN_PROMINENCE = 4.5
|
||||||
|
|
||||||
|
/** Hilbert transformer length. Odd so the group delay is a whole sample. */
|
||||||
|
private const val HILBERT_TAPS = 63
|
||||||
|
|
||||||
|
/** Frequency resolution of [detectToneHz], in Hz. */
|
||||||
|
private const val DETECT_STEP_HZ = 12.5
|
||||||
|
|
||||||
|
/** Windowed Hilbert transformer: h[n] = 2/(pi*n) for odd n, 0 otherwise. */
|
||||||
|
private val hilbertKernel: FloatArray = FloatArray(HILBERT_TAPS) { i ->
|
||||||
|
val n = i - HILBERT_TAPS / 2
|
||||||
|
val ideal = if (n == 0 || n % 2 == 0) 0.0 else 2.0 / (PI * n)
|
||||||
|
// Hamming window; without it the truncated kernel ripples badly.
|
||||||
|
val window = 0.54 - 0.46 * cos(2.0 * PI * i / (HILBERT_TAPS - 1))
|
||||||
|
(ideal * window).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Group delay of [hilbertKernel], applied to the real path to keep them aligned. */
|
||||||
|
private const val HILBERT_DELAY = HILBERT_TAPS / 2
|
||||||
|
|
||||||
|
/** Outcome of inspecting a chunk of audio. */
|
||||||
|
data class Analysis(
|
||||||
|
/** Detected tone in Hz, or null when the audio is noise. */
|
||||||
|
val toneHz: Float?,
|
||||||
|
/** True when [toneHz] sits outside the model's window and can be shifted. */
|
||||||
|
val needsShift: Boolean,
|
||||||
|
/** Hz the tone would be moved by; 0 when no shift applies. */
|
||||||
|
val shiftHz: Float
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate the dominant tone by scanning [MIN_DETECTABLE_HZ]..Nyquist with a
|
||||||
|
* Goertzel-style single-bin DFT.
|
||||||
|
*
|
||||||
|
* Deliberately not reusing [CwDeepSpectrogram]: that clips to the model window,
|
||||||
|
* which is exactly the region an out-of-range tone is *not* in.
|
||||||
|
*
|
||||||
|
* @return the peak frequency, or null when nothing stands out from the noise.
|
||||||
|
*/
|
||||||
|
fun detectToneHz(audio: FloatArray, sampleRate: Int): Float? {
|
||||||
|
if (audio.size < 64) return null
|
||||||
|
val nyquist = sampleRate / 2.0
|
||||||
|
// A Hann window stops the scan from smearing energy across neighbours.
|
||||||
|
val window = FloatArray(audio.size) { i ->
|
||||||
|
(0.5 - 0.5 * cos(2.0 * PI * i / (audio.size - 1))).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
var bestHz = 0.0
|
||||||
|
var bestMagnitude = 0.0
|
||||||
|
var total = 0.0
|
||||||
|
var bins = 0
|
||||||
|
|
||||||
|
var hz = MIN_DETECTABLE_HZ
|
||||||
|
while (hz <= nyquist) {
|
||||||
|
var real = 0.0
|
||||||
|
var imag = 0.0
|
||||||
|
val omega = 2.0 * PI * hz / sampleRate
|
||||||
|
for (i in audio.indices) {
|
||||||
|
val value = audio[i] * window[i]
|
||||||
|
real += value * cos(omega * i)
|
||||||
|
imag -= value * sin(omega * i)
|
||||||
|
}
|
||||||
|
val magnitude = hypot(real, imag) / audio.size
|
||||||
|
total += magnitude
|
||||||
|
bins++
|
||||||
|
if (magnitude > bestMagnitude) {
|
||||||
|
bestMagnitude = magnitude
|
||||||
|
bestHz = hz
|
||||||
|
}
|
||||||
|
hz += DETECT_STEP_HZ
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bins == 0 || bestMagnitude <= 0.0) return null
|
||||||
|
val mean = total / bins
|
||||||
|
// Pure noise has a flat spectrum, so the peak barely beats the mean.
|
||||||
|
if (mean <= 0.0 || bestMagnitude < mean * MIN_PROMINENCE) return null
|
||||||
|
return bestHz.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether [audio] needs shifting, without modifying it.
|
||||||
|
*
|
||||||
|
* A tone already inside the window is left alone: shifting it would add filter
|
||||||
|
* ringing and rounding for no benefit, and the model handles it natively.
|
||||||
|
*/
|
||||||
|
fun analyse(audio: FloatArray, sampleRate: Int): Analysis {
|
||||||
|
val tone = detectToneHz(audio, sampleRate)
|
||||||
|
?: return Analysis(toneHz = null, needsShift = false, shiftHz = 0f)
|
||||||
|
val inWindow = tone >= CwDeepSpectrogram.MIN_FREQ_HZ && tone <= CwDeepSpectrogram.MAX_FREQ_HZ
|
||||||
|
if (inWindow) return Analysis(toneHz = tone, needsShift = false, shiftHz = 0f)
|
||||||
|
return Analysis(
|
||||||
|
toneHz = tone,
|
||||||
|
needsShift = true,
|
||||||
|
shiftHz = (TARGET_HZ - tone).toFloat()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shift [audio] by [shiftHz] using single-sideband mixing.
|
||||||
|
*
|
||||||
|
* The Hilbert transformer suppresses the negative-frequency half, so only the
|
||||||
|
* wanted sideband survives; see the class docs for the measured alternative.
|
||||||
|
* Returns a new array; [audio] is not modified.
|
||||||
|
*
|
||||||
|
* Stateless: [audio] is treated as an isolated signal, so the first and last
|
||||||
|
* [HILBERT_DELAY] samples convolve against zeros instead of the neighbouring
|
||||||
|
* audio. Fine for a whole buffer, but it corrupts 62 of every 320 samples when
|
||||||
|
* called per capture chunk, so streaming callers must use [Streaming].
|
||||||
|
*/
|
||||||
|
fun shift(audio: FloatArray, shiftHz: Float, sampleRate: Int): FloatArray {
|
||||||
|
if (shiftHz == 0f || audio.isEmpty()) return audio
|
||||||
|
|
||||||
|
// Quadrature path: audio convolved with the Hilbert kernel.
|
||||||
|
val quadrature = FloatArray(audio.size)
|
||||||
|
for (i in audio.indices) {
|
||||||
|
var sum = 0f
|
||||||
|
for (k in hilbertKernel.indices) {
|
||||||
|
val j = i - k + HILBERT_DELAY
|
||||||
|
if (j >= 0 && j < audio.size) sum += hilbertKernel[k] * audio[j]
|
||||||
|
}
|
||||||
|
quadrature[i] = sum
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re{(inPhase + j*quadrature) * e^(j*2*pi*shift*t)}
|
||||||
|
val out = FloatArray(audio.size)
|
||||||
|
val step = 2.0 * PI * shiftHz / sampleRate
|
||||||
|
for (i in audio.indices) {
|
||||||
|
val phase = step * i
|
||||||
|
out[i] = clampToUnit(audio[i] * cos(phase) - quadrature[i] * sin(phase))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chunk-by-chunk shifter that carries the state [shift] cannot.
|
||||||
|
*
|
||||||
|
* Two things must survive across calls for concatenated chunks to form a clean
|
||||||
|
* signal:
|
||||||
|
*
|
||||||
|
* 1. **Filter history.** The Hilbert FIR spans [HILBERT_TAPS] samples, so the
|
||||||
|
* first outputs of a chunk need the previous chunk's tail. Without it those
|
||||||
|
* samples convolve against zeros; measured on 320-sample chunks that distorts
|
||||||
|
* 62 of them (19%) and inflates envelope ripple to 8.7x the whole-buffer
|
||||||
|
* baseline.
|
||||||
|
* 2. **Mixer phase.** Restarting the local oscillator at zero every chunk puts a
|
||||||
|
* phase step at every boundary.
|
||||||
|
*
|
||||||
|
* One difference from [shift] remains and is unavoidable: output sample `i` ideally
|
||||||
|
* needs input up to `i + HILBERT_DELAY`, which for the last samples of a chunk has
|
||||||
|
* not been captured yet. Those trailing taps therefore see zeros. Measured against
|
||||||
|
* a whole-buffer shift the divergence is confined to the final 3 samples of each
|
||||||
|
* 320-sample chunk and disappears immediately after the boundary — under 1% of the
|
||||||
|
* audio, versus a 20 WPM dot spanning 192 samples. Buffering a chunk to remove it
|
||||||
|
* would add 10 ms of latency for no decoding benefit.
|
||||||
|
*
|
||||||
|
* Not thread-safe: the decoder drives it from a single capture coroutine.
|
||||||
|
*/
|
||||||
|
class Streaming {
|
||||||
|
|
||||||
|
private val history = FloatArray(HILBERT_TAPS - 1)
|
||||||
|
private var phase = 0.0
|
||||||
|
|
||||||
|
/** Shift one chunk, continuing the filter and oscillator state. */
|
||||||
|
fun process(chunk: FloatArray, shiftHz: Float, sampleRate: Int): FloatArray {
|
||||||
|
if (shiftHz == 0f || chunk.isEmpty()) {
|
||||||
|
// Still advance the history, so enabling a shift later starts from real
|
||||||
|
// audio rather than the silence left over from before.
|
||||||
|
pushHistory(chunk)
|
||||||
|
return chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convolve over [history || chunk] so every output sees real samples.
|
||||||
|
val combined = FloatArray(history.size + chunk.size)
|
||||||
|
history.copyInto(combined)
|
||||||
|
chunk.copyInto(combined, history.size)
|
||||||
|
|
||||||
|
val out = FloatArray(chunk.size)
|
||||||
|
val step = 2.0 * PI * shiftHz / sampleRate
|
||||||
|
for (i in chunk.indices) {
|
||||||
|
val centre = history.size + i
|
||||||
|
var quadrature = 0f
|
||||||
|
for (k in hilbertKernel.indices) {
|
||||||
|
val j = centre - k + HILBERT_DELAY
|
||||||
|
if (j >= 0 && j < combined.size) quadrature += hilbertKernel[k] * combined[j]
|
||||||
|
}
|
||||||
|
val currentPhase = phase + step * i
|
||||||
|
val mixed = combined[centre] * cos(currentPhase) - quadrature * sin(currentPhase)
|
||||||
|
out[i] = clampToUnit(mixed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the phase bounded; letting it grow loses float precision.
|
||||||
|
phase = (phase + step * chunk.size) % (2.0 * PI)
|
||||||
|
pushHistory(chunk)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear filter history and phase, e.g. after a decoder reset. */
|
||||||
|
fun reset() {
|
||||||
|
history.fill(0f)
|
||||||
|
phase = 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the most recent [history] samples of the stream. */
|
||||||
|
private fun pushHistory(chunk: FloatArray) {
|
||||||
|
if (chunk.isEmpty()) return
|
||||||
|
if (chunk.size >= history.size) {
|
||||||
|
chunk.copyInto(history, 0, chunk.size - history.size, chunk.size)
|
||||||
|
} else {
|
||||||
|
history.copyInto(history, 0, chunk.size, history.size)
|
||||||
|
chunk.copyInto(history, history.size - chunk.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience wrapper: analyse [audio] and shift it only when the tone is
|
||||||
|
* outside the model window.
|
||||||
|
*
|
||||||
|
* @return the audio to feed the model (the original array when no shift was
|
||||||
|
* needed) paired with the [Analysis] that produced the decision, so callers
|
||||||
|
* can log what happened.
|
||||||
|
*/
|
||||||
|
fun shiftIfOutsideWindow(audio: FloatArray, sampleRate: Int): Pair<FloatArray, Analysis> {
|
||||||
|
val analysis = analyse(audio, sampleRate)
|
||||||
|
if (!analysis.needsShift) return audio to analysis
|
||||||
|
return shift(audio, analysis.shiftHz, sampleRate) to analysis
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep a mixed sample inside the +/-1.0 range the spectrogram assumes.
|
||||||
|
*
|
||||||
|
* The Hilbert kernel has an L1 gain of 2.51, so summing the in-phase and quadrature
|
||||||
|
* paths can exceed unity even for a full-scale sine (measured 1.05 at 1500 Hz, 2.35
|
||||||
|
* for a square wave). The spectrogram takes log1p of the magnitude, so an overshoot
|
||||||
|
* is not fatal, but it shifts the level the model was trained on.
|
||||||
|
*/
|
||||||
|
private fun clampToUnit(value: Double): Float = when {
|
||||||
|
value > 1.0 -> 1f
|
||||||
|
value < -1.0 -> -1f
|
||||||
|
else -> value.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when [toneHz] lies inside the model's analysis window. */
|
||||||
|
fun isInsideWindow(toneHz: Float): Boolean =
|
||||||
|
toneHz >= CwDeepSpectrogram.MIN_FREQ_HZ && toneHz <= CwDeepSpectrogram.MAX_FREQ_HZ
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.cw
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A CW (Morse code) decoder fed with microphone PCM.
|
||||||
|
*
|
||||||
|
* Implementations live outside `core:domain` when they need platform APIs;
|
||||||
|
* this contract stays pure Kotlin so the UI can depend on it directly.
|
||||||
|
*/
|
||||||
|
interface ICwDecoder {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoded text for the *current* window.
|
||||||
|
*
|
||||||
|
* Note this is **replace** semantics, not append: a whole-segment model
|
||||||
|
* revises earlier characters as more audio arrives, so consumers must show
|
||||||
|
* the current value rather than accumulating emissions.
|
||||||
|
*/
|
||||||
|
val decodedText: StateFlow<String>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transcript of audio that has been archived, and will not be revised.
|
||||||
|
*
|
||||||
|
* Only ever grows until [reset]. [decodedText] is rewritten from scratch on every
|
||||||
|
* redecode, so a pane that concatenates it loses text the operator has already read -
|
||||||
|
* which a paper log does not do. This is the flow such a pane must bind to.
|
||||||
|
*/
|
||||||
|
val historyText: StateFlow<String>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pitch of the tone the model is decoding, in Hz, or null before one is found.
|
||||||
|
*
|
||||||
|
* Derived from the spectrogram, so it can only ever report a frequency inside the
|
||||||
|
* model's analysis window. For the pitch of a tone the model cannot see, use
|
||||||
|
* [detectedToneHz].
|
||||||
|
*/
|
||||||
|
val estimatedPitch: StateFlow<Float?>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pitch of the loudest tone in the raw audio, in Hz, or null when none stands out.
|
||||||
|
*
|
||||||
|
* Unlike [estimatedPitch] this is measured before any shifting and over the full
|
||||||
|
* audio bandwidth, so it can report a tone the model's window excludes — which is
|
||||||
|
* the only way to tell the operator that nothing is being decoded because their tone
|
||||||
|
* is out of range.
|
||||||
|
*/
|
||||||
|
val detectedToneHz: StateFlow<Float?>
|
||||||
|
|
||||||
|
/** Current shift applied to bring the tone into the model's window, 0f when idle. */
|
||||||
|
val activeShiftHz: StateFlow<Float>
|
||||||
|
|
||||||
|
/** Relative signal strength in 0..1 for level meters. */
|
||||||
|
val signalStrength: StateFlow<Float>
|
||||||
|
|
||||||
|
/** Most recent inference duration in milliseconds, for diagnostics. */
|
||||||
|
val lastInferenceMs: StateFlow<Int>
|
||||||
|
|
||||||
|
/** Non-null when the decoder cannot run, for example the model failed to load. */
|
||||||
|
val errorMessage: StateFlow<String?>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode whatever audio is still held in the pipeline into [historyText].
|
||||||
|
*
|
||||||
|
* Nothing reaches [historyText] until audio has been pushed out of the live window and
|
||||||
|
* then accumulated into a full archive batch, so the last stretch of a session is always
|
||||||
|
* still in flight when capture stops - and neither holding place drains on its own. That
|
||||||
|
* stretch is the end of the transmission, the part with the call sign in it. Call on
|
||||||
|
* pause and before [close].
|
||||||
|
*/
|
||||||
|
suspend fun flush()
|
||||||
|
|
||||||
|
/** Feed captured mono PCM in -1..1. Safe to call from a capture thread. */
|
||||||
|
suspend fun processBuffer(samples: FloatArray, sampleRate: Int)
|
||||||
|
|
||||||
|
/** Clear decoded text and buffered audio. */
|
||||||
|
fun reset()
|
||||||
|
|
||||||
|
/** Release native resources. Must be called when the decoder goes away. */
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
@@ -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,38 @@
|
|||||||
|
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, 3 days of state */
|
||||||
|
data class SatStatus(
|
||||||
|
val name: String, // "AO-123_[FM]"
|
||||||
|
val days: List<SatDay>, // 3 天(新→旧)
|
||||||
|
val summaryCount: Int = 0 // 0 means unknown; used for data-completeness marking
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Overall page parse result */
|
||||||
|
data class SatStatusPage(
|
||||||
|
val fetchedAtUtcMs: Long,
|
||||||
|
val statuses: List<SatStatus>,
|
||||||
|
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,36 @@ 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,
|
||||||
|
/**
|
||||||
|
* Shift a CW tone that sits outside the model's 400-1200 Hz analysis window into
|
||||||
|
* it before decoding. Off by default: when disabled the audio path is unchanged,
|
||||||
|
* and a tone already inside the window is never touched either way.
|
||||||
|
*/
|
||||||
|
val cwToneShiftEnabled: Boolean = false,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw each AMSAT day as twelve two-hour stripes rather than one colour.
|
||||||
|
*
|
||||||
|
* On by default: a single colour is taken from the first slot with a report, so a
|
||||||
|
* satellite that worked all morning and failed all afternoon looks identical to one
|
||||||
|
* that worked once. Some operators prefer the older, simpler tile, hence the switch.
|
||||||
|
*/
|
||||||
|
val amsatDayStripes: Boolean = true
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DataSourcesSettings(
|
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,45 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.qrz
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up a station's grid square on QRZ.
|
||||||
|
*
|
||||||
|
* An interface so the log screen can ask for a grid without reaching into core:data, and without
|
||||||
|
* reading the stored cookie itself - a composable was fetching it straight out of
|
||||||
|
* SharedPreferences through LocalContext, which put disk access in composition and bypassed the
|
||||||
|
* repository layer entirely.
|
||||||
|
*/
|
||||||
|
interface IQrzGridLookup {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up [callsign].
|
||||||
|
*
|
||||||
|
* Returns [QrzGrid.SignedOut] when no cookie is stored, since the operator's remedy is the
|
||||||
|
* same either way: put a valid cookie in settings.
|
||||||
|
*/
|
||||||
|
suspend fun lookup(callsign: String): QrzGrid
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the stored cookie by asking QRZ whose account it belongs to.
|
||||||
|
*
|
||||||
|
* Returns the callsign QRZ reports, or null when the cookie is absent or no longer valid. Lets
|
||||||
|
* settings tell the operator which account they pasted rather than only claiming success.
|
||||||
|
*/
|
||||||
|
suspend fun signedInAs(): String?
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.qrz
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of a QRZ grid lookup.
|
||||||
|
*
|
||||||
|
* Four outcomes rather than a nullable string, because the previous null meant any of "the
|
||||||
|
* station has no grid on file", "the cookie expired", "the request timed out" and "QRZ changed
|
||||||
|
* its markup" - and the operator saw the same blank either way, with no way to tell that
|
||||||
|
* re-pasting the cookie would fix it.
|
||||||
|
*/
|
||||||
|
sealed interface QrzGrid {
|
||||||
|
|
||||||
|
/** The station's Maidenhead locator, as QRZ has it. */
|
||||||
|
data class Found(val locator: String) : QrzGrid
|
||||||
|
|
||||||
|
/** The page was read and the station has no locator published. Not an error. */
|
||||||
|
data object NotOnFile : QrzGrid
|
||||||
|
|
||||||
|
/** The detail table was absent, which is what QRZ serves when the cookie is not valid. */
|
||||||
|
data object SignedOut : QrzGrid
|
||||||
|
|
||||||
|
/** The request never completed. [attempts] is how many tries were made before giving up. */
|
||||||
|
data class Unreachable(val attempts: Int) : QrzGrid
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsing of QRZ's callsign page, separate from the fetch so it can be tested without a
|
||||||
|
* network. Pure string work over already-downloaded markup.
|
||||||
|
*/
|
||||||
|
object QrzGridParser {
|
||||||
|
|
||||||
|
/** The detail row QRZ renders for a station that published a locator. */
|
||||||
|
private val gridRow = Regex("""<td class="dh">Grid Square</td>\s*<td class="di">([^<]+)</td>""")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QRZ's own words on a callsign page served to a visitor who is not signed in.
|
||||||
|
*
|
||||||
|
* Classified on this positive notice rather than on the detail table being absent: measured
|
||||||
|
* against live responses, a callsign QRZ has never heard of also returns HTTP 200 with zero
|
||||||
|
* detail rows, because QRZ serves its search form instead of a callsign page. Keying on
|
||||||
|
* absence therefore reported a mistyped callsign as an expired cookie, and would have sent
|
||||||
|
* the operator off to re-paste a cookie that was never broken.
|
||||||
|
*/
|
||||||
|
private val signedOutNotice = Regex("""Login is required for additional detail""")
|
||||||
|
|
||||||
|
/** The account menu on a signed-in page, used to read back whose cookie this is. */
|
||||||
|
private val accountCallsign = Regex("""<li class="leaf last"[^>]*>\s*([A-Z0-9/]+)\s*<ul""")
|
||||||
|
|
||||||
|
/** A cookie name=value pair inside a browser extension's JSON export. */
|
||||||
|
private val jsonCookie = Regex(""""name"\s*:\s*"([^"]+)"\s*,\s*"value"\s*:\s*"([^"]*)"""")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpret a callsign page.
|
||||||
|
*
|
||||||
|
* Only QRZ explicitly saying that a login is required counts as signed out, so an absent
|
||||||
|
* locator degrades to the harmless [QrzGrid.NotOnFile] and only a genuinely stale cookie
|
||||||
|
* sends the operator back to settings. Getting this wrong in either direction misdirects
|
||||||
|
* them: the old client returned null for everything, and keying on the detail table being
|
||||||
|
* absent would have blamed the cookie for a mistyped callsign.
|
||||||
|
*/
|
||||||
|
fun parseGrid(html: String): QrzGrid {
|
||||||
|
val locator = gridRow.find(html)?.groupValues?.get(1)?.trim()
|
||||||
|
if (!locator.isNullOrBlank()) return QrzGrid.Found(locator)
|
||||||
|
if (signedOutNotice.containsMatchIn(html)) return QrzGrid.SignedOut
|
||||||
|
return QrzGrid.NotOnFile
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The callsign this cookie is signed in as, or null when it is not signed in. */
|
||||||
|
fun parseOwnCallsign(html: String): String? =
|
||||||
|
accountCallsign.find(html)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise the pasted cookie into a Cookie header value.
|
||||||
|
*
|
||||||
|
* Accepts a raw `k=v; k=v` header or the JSON array a cookie-export extension produces,
|
||||||
|
* since the operator pastes whatever their browser handed them. Parsed by regex rather
|
||||||
|
* than a JSON library because org.json is compileOnly here - it is supplied by Android at
|
||||||
|
* runtime and absent from unit tests, so a JSON path could not be tested.
|
||||||
|
*/
|
||||||
|
fun cookieHeader(raw: String): String {
|
||||||
|
val text = raw.trim()
|
||||||
|
if (text.isEmpty()) return ""
|
||||||
|
if (!text.startsWith("[")) return text
|
||||||
|
val pairs = jsonCookie.findAll(text)
|
||||||
|
.map { it.groupValues[1] to it.groupValues[2] }
|
||||||
|
.filter { it.first.isNotBlank() }
|
||||||
|
.toList()
|
||||||
|
return if (pairs.isEmpty()) text else pairs.joinToString("; ") { "${it.first}=${it.second}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
+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?
|
||||||
|
}
|
||||||
+11
@@ -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,16 @@ 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
|
||||||
|
|
||||||
|
/** QRZ grid lookup, so the log screen never touches the stored cookie itself. */
|
||||||
|
fun provideQrzGridLookup(): com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup
|
||||||
}
|
}
|
||||||
|
|
||||||
data class MutualPassData(
|
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,15 @@ 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?
|
||||||
|
|
||||||
|
/** Fetch AMSAT API summary for the past N hours (JSON string; null on failure).
|
||||||
|
* Used to compare against the global reports response and flag satellites whose data
|
||||||
|
* was crowded out of the 500-record cap. */
|
||||||
|
suspend fun getAmSatSummary(hours: Int): String?
|
||||||
}
|
}
|
||||||
@@ -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,15 @@ 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)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a resource with format arguments, so a count can appear in a localised message.
|
||||||
|
*
|
||||||
|
* The alternative is building the string in a view model, which puts wording outside the
|
||||||
|
* resource files and hardcodes one language.
|
||||||
|
*/
|
||||||
|
operator fun invoke(resId: Int, vararg formatArgs: Any)
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
+21
-2
@@ -114,11 +114,30 @@ object DopplerFrequencyCalculator {
|
|||||||
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
||||||
.joinToString(separator = " ")
|
.joinToString(separator = " ")
|
||||||
.lowercase(Locale.ENGLISH)
|
.lowercase(Locale.ENGLISH)
|
||||||
val hasLinearName = info.contains("linear")
|
val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
|
||||||
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
|
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
|
||||||
info.contains("xponder") || info.contains("xpdr")
|
info.contains("xponder") || info.contains("xpdr")
|
||||||
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
|
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
|
||||||
|
|
||||||
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode)
|
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()
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/*
|
||||||
|
* 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.wavelog
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a typed callsign can be logged, and what to warn about if it looks odd.
|
||||||
|
*
|
||||||
|
* Deliberately permissive. A survey of real callsigns against a typical strict pattern rejected
|
||||||
|
* 16 of 28 valid ones - `W1AW/4`, `2E0ABC`, `9A1CCY`, `SV2ASP/A` among them - while a pattern
|
||||||
|
* loose enough to accept those also accepts a Maidenhead locator as a callsign. There is no
|
||||||
|
* regex that catches typos without discarding legitimate calls, so anything plausible is
|
||||||
|
* accepted and doubt is reported rather than enforced.
|
||||||
|
*
|
||||||
|
* The previous behaviour was `if (call.length < 3) return`, which discarded the entry with no
|
||||||
|
* message: the operator pressed done during a pass and nothing happened, with no way to tell
|
||||||
|
* that the app had decided against them. None of the logging software surveyed - N1MM+, DXLog,
|
||||||
|
* PoLo, HAMRS - silently drops a submission.
|
||||||
|
*/
|
||||||
|
object CallsignEntry {
|
||||||
|
|
||||||
|
/** Shortest real callsign. Two characters occur in special event calls. */
|
||||||
|
private const val MIN_LENGTH = 2
|
||||||
|
|
||||||
|
/** Longest plausible entry, allowing a portable suffix such as `OH/W1AW/MM`. */
|
||||||
|
private const val MAX_LENGTH = 16
|
||||||
|
|
||||||
|
/** Characters a callsign may contain. */
|
||||||
|
private val allowed = Regex("^[A-Z0-9/-]+$")
|
||||||
|
|
||||||
|
/** The outcome of checking an entry. */
|
||||||
|
sealed interface Verdict {
|
||||||
|
|
||||||
|
/** Log it. [warning] is non-null when the entry is unusual but still plausible. */
|
||||||
|
data class Acceptable(val callsign: String, val warning: Warning? = null) : Verdict
|
||||||
|
|
||||||
|
/** Do not log it, and say why. */
|
||||||
|
data class Rejected(val reason: Reason) : Verdict
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why an entry cannot be logged at all. */
|
||||||
|
enum class Reason {
|
||||||
|
/** Nothing was typed. */
|
||||||
|
EMPTY,
|
||||||
|
|
||||||
|
/** Too short to be any callsign. */
|
||||||
|
TOO_SHORT,
|
||||||
|
|
||||||
|
/** Longer than any real callsign with a portable suffix. */
|
||||||
|
TOO_LONG,
|
||||||
|
|
||||||
|
/** Contains something a callsign cannot: punctuation, spaces, non-ASCII. */
|
||||||
|
ILLEGAL_CHARACTERS,
|
||||||
|
|
||||||
|
/** Digits only, or letters only - no callsign is either. */
|
||||||
|
NOT_A_CALLSIGN
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Something worth mentioning without blocking the entry. */
|
||||||
|
enum class Warning {
|
||||||
|
/** Looks like a Maidenhead locator rather than a callsign, e.g. `GG77DH`. */
|
||||||
|
LOOKS_LIKE_A_GRID,
|
||||||
|
|
||||||
|
/** Already logged in this session - fine on a later pass, likely a slip on this one. */
|
||||||
|
ALREADY_WORKED
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check an entry, optionally against calls already logged in this pass.
|
||||||
|
*
|
||||||
|
* A repeat is a warning rather than a rejection: the same station on a later pass is a
|
||||||
|
* legitimate new contact, and contest loggers default to allowing duplicates - DXLog
|
||||||
|
* describes refusing them as an outdated habit.
|
||||||
|
*/
|
||||||
|
fun check(entry: String, workedThisSession: Set<String> = emptySet()): Verdict {
|
||||||
|
val call = entry.trim().uppercase()
|
||||||
|
if (call.isEmpty()) return Verdict.Rejected(Reason.EMPTY)
|
||||||
|
if (call.length < MIN_LENGTH) return Verdict.Rejected(Reason.TOO_SHORT)
|
||||||
|
if (call.length > MAX_LENGTH) return Verdict.Rejected(Reason.TOO_LONG)
|
||||||
|
if (!allowed.matches(call)) return Verdict.Rejected(Reason.ILLEGAL_CHARACTERS)
|
||||||
|
// Any segment may be the callsign, not just the first. A portable call can be written
|
||||||
|
// prefix-first - DL/W1AW, ZL/JA1ABC, OH/W1AW/MM - where the leading token is a country
|
||||||
|
// prefix with no digit in it. Testing only the first segment rejected all of those, which
|
||||||
|
// the old length-only check had accepted.
|
||||||
|
if (call.split('/', '-').none(::looksLikeCallsign)) {
|
||||||
|
return Verdict.Rejected(Reason.NOT_A_CALLSIGN)
|
||||||
|
}
|
||||||
|
val warning = when {
|
||||||
|
call in workedThisSession -> Warning.ALREADY_WORKED
|
||||||
|
looksLikeGrid(call) -> Warning.LOOKS_LIKE_A_GRID
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
return Verdict.Acceptable(call, warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A segment that could be a callsign: contains both a letter and a digit. */
|
||||||
|
private fun looksLikeCallsign(segment: String): Boolean =
|
||||||
|
segment.any { it.isDigit() } && segment.any { it.isLetter() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this looks like a Maidenhead locator typed into the wrong field.
|
||||||
|
*
|
||||||
|
* Six characters of letter-letter-digit-digit-letter-letter. Worth mentioning because grid
|
||||||
|
* and callsign are exchanged together on FM satellites and the fields sit side by side.
|
||||||
|
*/
|
||||||
|
private fun looksLikeGrid(call: String): Boolean =
|
||||||
|
call.length == 6 &&
|
||||||
|
call[0].isLetter() && call[1].isLetter() &&
|
||||||
|
call[2].isDigit() && call[3].isDigit() &&
|
||||||
|
call[4].isLetter() && call[5].isLetter()
|
||||||
|
}
|
||||||
@@ -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.wavelog
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks a typed counterpart grid before it reaches the log.
|
||||||
|
*
|
||||||
|
* Separate from qthToPosition's validator, which requires six characters and is private. Plenty of
|
||||||
|
* satellite operators exchange only the four-character square, so requiring six would reject
|
||||||
|
* perfectly good entries - and this follows the same rule the callsign field settled on: refuse only
|
||||||
|
* what is certainly wrong, warn about the rest, never silently discard.
|
||||||
|
*
|
||||||
|
* The reason to check at all is that an unchecked value goes into the ADIF GRIDSQUARE field, and a
|
||||||
|
* malformed one is stored by Wavelog as-is. It then pollutes grid statistics and VUCC award
|
||||||
|
* tracking, where a wrong square is worse than a missing one.
|
||||||
|
*/
|
||||||
|
object GridEntry {
|
||||||
|
|
||||||
|
/** What a typed grid amounts to. */
|
||||||
|
sealed interface Verdict {
|
||||||
|
|
||||||
|
/** Usable. [normalised] is what should be logged - upper case for the pair, lower for the subsquare. */
|
||||||
|
data class Acceptable(val normalised: String, val warning: Warning? = null) : Verdict
|
||||||
|
|
||||||
|
/** Certainly not a grid. [reason] says which rule it broke. */
|
||||||
|
data class Unusable(val reason: Reason) : Verdict
|
||||||
|
|
||||||
|
/** Nothing typed. The QRZ lookup should run instead. */
|
||||||
|
data object Empty : Verdict
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Worth mentioning but not worth refusing. */
|
||||||
|
enum class Warning {
|
||||||
|
/**
|
||||||
|
* Two characters. Legal per ADIF, but a field is 20 by 10 degrees - close to useless for a
|
||||||
|
* satellite contact, so it is worth saying rather than refusing.
|
||||||
|
*/
|
||||||
|
FIELD_ONLY,
|
||||||
|
|
||||||
|
/** Four characters, so the location is only accurate to about 100km. */
|
||||||
|
SQUARE_ONLY
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why an entry cannot be a grid. */
|
||||||
|
enum class Reason {
|
||||||
|
/** Not 4, 6 or 8 characters. Maidenhead has no other lengths. */
|
||||||
|
WRONG_LENGTH,
|
||||||
|
|
||||||
|
/** First pair outside A-R. S-X would decode past the poles. */
|
||||||
|
FIELD_OUT_OF_RANGE,
|
||||||
|
|
||||||
|
/** Second pair is not two digits. */
|
||||||
|
SQUARE_NOT_DIGITS,
|
||||||
|
|
||||||
|
/** Third pair outside A-X. */
|
||||||
|
SUBSQUARE_OUT_OF_RANGE
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Judge a typed entry.
|
||||||
|
*
|
||||||
|
* Case is normalised on the way out rather than demanded on the way in - an operator typing
|
||||||
|
* one-handed outdoors should not have to care, and the conventional rendering is upper case for
|
||||||
|
* the field, digits, then lower case for the subsquare.
|
||||||
|
*/
|
||||||
|
fun check(entry: String): Verdict {
|
||||||
|
val text = entry.trim()
|
||||||
|
if (text.isEmpty()) return Verdict.Empty
|
||||||
|
if (text.length !in VALID_LENGTHS) return Verdict.Unusable(Reason.WRONG_LENGTH)
|
||||||
|
|
||||||
|
val upper = text.uppercase()
|
||||||
|
if (upper[0] !in FIELD_RANGE || upper[1] !in FIELD_RANGE) {
|
||||||
|
return Verdict.Unusable(Reason.FIELD_OUT_OF_RANGE)
|
||||||
|
}
|
||||||
|
// ASCII digits only. Char.isDigit() is Unicode-aware and covers the whole Nd category, so
|
||||||
|
// it accepted Arabic-Indic, Devanagari and fullwidth digits - which a localised keypad can
|
||||||
|
// produce without the operator seeing any difference. ADIF 3.1.7 defines Digit as "an ASCII
|
||||||
|
// character whose code lies in the range of 48 through 57", and Wavelog stores GRIDSQUARE
|
||||||
|
// verbatim, so such a value would never match a real grid in any statistics query.
|
||||||
|
// Guarded on length: a 2-character locator has no square pair, and reading index 2 of it
|
||||||
|
// would throw.
|
||||||
|
if (text.length >= SQUARE_LENGTH && (!upper[2].isAsciiDigit() || !upper[3].isAsciiDigit())) {
|
||||||
|
return Verdict.Unusable(Reason.SQUARE_NOT_DIGITS)
|
||||||
|
}
|
||||||
|
if (text.length >= SUBSQUARE_LENGTH) {
|
||||||
|
if (upper[4] !in SUBSQUARE_RANGE || upper[5] !in SUBSQUARE_RANGE) {
|
||||||
|
return Verdict.Unusable(Reason.SUBSQUARE_OUT_OF_RANGE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (text.length == EXTENDED_LENGTH && (!upper[6].isAsciiDigit() || !upper[7].isAsciiDigit())) {
|
||||||
|
return Verdict.Unusable(Reason.SQUARE_NOT_DIGITS)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Verdict.Acceptable(
|
||||||
|
normalised = normalise(upper),
|
||||||
|
warning = when (text.length) {
|
||||||
|
FIELD_LENGTH -> Warning.FIELD_ONLY
|
||||||
|
SQUARE_LENGTH -> Warning.SQUARE_ONLY
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `OL72ap` - upper case field, digits, lower case subsquare, as the convention renders it. */
|
||||||
|
private fun normalise(upper: String): String = buildString {
|
||||||
|
append(upper.take(minOf(upper.length, SQUARE_LENGTH)))
|
||||||
|
if (upper.length >= SUBSQUARE_LENGTH) append(upper.substring(4, 6).lowercase())
|
||||||
|
if (upper.length == EXTENDED_LENGTH) append(upper.substring(6, 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ADIF 3.1.7 defines Digit as ASCII 48-57. Kotlin's isDigit() is far wider. */
|
||||||
|
private fun Char.isAsciiDigit(): Boolean = this in '0'..'9'
|
||||||
|
|
||||||
|
private const val FIELD_LENGTH = 2
|
||||||
|
private const val SQUARE_LENGTH = 4
|
||||||
|
private const val SUBSQUARE_LENGTH = 6
|
||||||
|
private const val EXTENDED_LENGTH = 8
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADIF 3.1.7: GRIDSQUARE takes "2-character, 4-character, 6-character, or 8-character" locators.
|
||||||
|
* A 10 or 12 character locator stores its first 8 here and the rest in GRIDSQUARE_EXT, which
|
||||||
|
* neither WavelogQso nor Wavelog's own field list carries - so the extra pair has nowhere to go
|
||||||
|
* and the UI clips at 8, which produces the spec-correct GRIDSQUARE value.
|
||||||
|
*/
|
||||||
|
private val VALID_LENGTHS = setOf(FIELD_LENGTH, SQUARE_LENGTH, SUBSQUARE_LENGTH, EXTENDED_LENGTH)
|
||||||
|
private val FIELD_RANGE = 'A'..'R'
|
||||||
|
private val SUBSQUARE_RANGE = 'A'..'X'
|
||||||
|
}
|
||||||
+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
|
||||||
|
}
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
/* LotwSatelliteIds.kt - NORAD catalogue number to LoTW satellite name.
|
||||||
|
*
|
||||||
|
* Why the catalogue number and not the name: the same satellite carries different names in
|
||||||
|
* different TLE sources, so a name-keyed table misses whenever the user switches source.
|
||||||
|
* Measured across Celestrak amateur and AMSAT nasabare, 33 of the 49 satellites present in
|
||||||
|
* both are named differently - NORAD 43017 is "RADFXSAT (FOX-1B)" in one and "AO-91" in the
|
||||||
|
* other, 43700 is "ES'HAIL 2" against "QO-100". The catalogue number is identical in every
|
||||||
|
* source, so it is the only stable key.
|
||||||
|
*
|
||||||
|
* LoTW rejects a QSO whose SAT_NAME is not spelled exactly as in its accepted list
|
||||||
|
* (https://lotw.arrl.org/lotw-help/satellite-qsos: "if you enter the satellite name as AO7
|
||||||
|
* instead of AO-7 the data will be rejected"), which is why this maps to the exact spelling
|
||||||
|
* held in LotwSatellites rather than to whatever the TLE happens to say.
|
||||||
|
*
|
||||||
|
* Every number here was read out of live TLE data, never typed from memory. Entries cover the
|
||||||
|
* satellites that both appear in the app's own sources (Sources.satelliteDataUrls) and are in
|
||||||
|
* the LoTW list; the rest of that list is satellites no source still carries, so no user can
|
||||||
|
* track them and no mapping is needed for them.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.wavelog
|
||||||
|
|
||||||
|
object LotwSatelliteIds {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NORAD catalogue number to the LoTW spelling. The trailing comment is one name the
|
||||||
|
* satellite goes by in the sources, kept so a reader can recognise the entry.
|
||||||
|
*
|
||||||
|
* Three numbers had to be decided rather than derived, because one name matched several
|
||||||
|
* catalogued objects. Each was settled by which object the amateur-specific sources carry:
|
||||||
|
* - ARISS is 25544, the station itself. Celestrak's full catalogue also lists ISS (UNITY),
|
||||||
|
* (ZVEZDA), (DESTINY) and (NAUKA), which are modules rather than stations you work.
|
||||||
|
* - IO-117 is 53109: four sources name that number GREENCUBE (IO-117) and only R4UAB calls
|
||||||
|
* it ROBUSTA 1F, which is a different satellite.
|
||||||
|
* - TO-108 is 44881, present in all three amateur sources; 44879 is TIANQIN 1 and appears
|
||||||
|
* only in the general catalogue.
|
||||||
|
*/
|
||||||
|
private val idToName: Map<Int, String> = mapOf(
|
||||||
|
7530 to "AO-7", // AO-07
|
||||||
|
14129 to "AO-10", // PHASE 3B (AO-10)
|
||||||
|
20439 to "AO-16", // OSCAR 16 (PACSAT)
|
||||||
|
20442 to "LO-19", // LO-19
|
||||||
|
22825 to "AO-27", // AO-27
|
||||||
|
23439 to "RS-15", // RADIO ROSTO (RS-15)
|
||||||
|
24278 to "FO-29", // FO-29
|
||||||
|
25544 to "ARISS", // ISS (ZARYA)
|
||||||
|
26609 to "AO-40", // PHASE 3D (AO-40)
|
||||||
|
26931 to "NO-44", // NO-44
|
||||||
|
27607 to "SO-50", // SAUDISAT 1C (SO-50)
|
||||||
|
28650 to "VO-52", // HAMSAT (VO-52)
|
||||||
|
39444 to "AO-73", // AO-73
|
||||||
|
40025 to "EO-79", // FUNCUBE-3 (EO-79)/QB50P1
|
||||||
|
40074 to "UKUBE1", // UKUBE-1
|
||||||
|
40908 to "CAS-3H", // LILACSAT-2
|
||||||
|
40931 to "IO-86", // IO-86
|
||||||
|
40967 to "AO-85", // FOX-1A (AO-85)
|
||||||
|
41847 to "CAS-2T", // CAS-2T
|
||||||
|
43017 to "AO-91", // AO-91
|
||||||
|
43678 to "PO-101", // DIWATA-2B
|
||||||
|
43700 to "QO-100", // ES'HAIL 2
|
||||||
|
43803 to "JO-97", // JO-97
|
||||||
|
44530 to "TAURUS", // TAURUS-1
|
||||||
|
44881 to "TO-108", // CAS-6 (TO-108)
|
||||||
|
44909 to "RS-44", // DOSAAF-85 (RS-44)
|
||||||
|
50466 to "HO-113", // CAMSAT XW-3 (CAS-9)
|
||||||
|
53109 to "IO-117", // GREENCUBE (IO-117)
|
||||||
|
61781 to "AO-123", // AO-123
|
||||||
|
// The TEVEL-2 constellation. Every source writes these TEVEL2-N while LoTW has TEV2-N,
|
||||||
|
// and no amount of separator-stripping bridges that - TEVEL21 is not TEV21 - so without
|
||||||
|
// these nine rows their QSOs upload under a name LoTW refuses. Note the numbering is not
|
||||||
|
// sequential: 63217 is TEVEL2-1 and 63213 is TEVEL2-4.
|
||||||
|
63213 to "TEV2-4",
|
||||||
|
63214 to "TEV2-5",
|
||||||
|
63215 to "TEV2-6",
|
||||||
|
63217 to "TEV2-1",
|
||||||
|
63218 to "TEV2-3",
|
||||||
|
63219 to "TEV2-2",
|
||||||
|
63237 to "TEV2-9",
|
||||||
|
63238 to "TEV2-7",
|
||||||
|
63239 to "TEV2-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The LoTW spelling for [catnum], or null when this satellite is not in the LoTW list. */
|
||||||
|
fun nameFor(catnum: Int): String? = idToName[catnum]
|
||||||
|
|
||||||
|
/** True when [catnum] names a satellite LoTW accepts, so a QSO on it can be confirmed. */
|
||||||
|
fun isKnown(catnum: Int): Boolean = catnum in idToName
|
||||||
|
|
||||||
|
/** Entry count, so a test can catch the table being emptied by a bad edit. */
|
||||||
|
val size: Int get() = idToName.size
|
||||||
|
}
|
||||||
@@ -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,122 @@
|
|||||||
|
/*
|
||||||
|
* 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.wavelog
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Works out what time a contact should carry.
|
||||||
|
*
|
||||||
|
* The logging screen used to stamp System.currentTimeMillis() and offer no way to change it. That
|
||||||
|
* assumes contacts are typed as they happen, and serious satellite operators do not work that way:
|
||||||
|
* the documented practice is to record the pass and transcribe it afterwards, because during eight
|
||||||
|
* minutes of a linear transponder there is no spare attention for a keyboard. A fixed clock makes
|
||||||
|
* every transcribed contact wrong by however long the transcription took.
|
||||||
|
*
|
||||||
|
* Two ways to say when: an absolute UTC time of day, or an offset from now. Both are typed into the
|
||||||
|
* same field, because a separate widget for each is more to reach for than an operator wants while
|
||||||
|
* holding an antenna.
|
||||||
|
*/
|
||||||
|
object PassClock {
|
||||||
|
|
||||||
|
/** What a typed token meant. */
|
||||||
|
sealed interface Command {
|
||||||
|
|
||||||
|
/** Log at this UTC time of day. [minuteOfDay] is minutes since 00:00 UTC. */
|
||||||
|
data class At(val minuteOfDay: Int) : Command
|
||||||
|
|
||||||
|
/** Log this many minutes from now. Negative counts backwards. */
|
||||||
|
data class Shift(val minutes: Int) : Command
|
||||||
|
|
||||||
|
/** Back to the current time. */
|
||||||
|
data object Live : Command
|
||||||
|
|
||||||
|
/** Not a time instruction. The caller should leave the clock alone. */
|
||||||
|
data object Unrecognised : Command
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpret a typed token.
|
||||||
|
*
|
||||||
|
* Deliberately narrow. Anything that is not clearly a time is [Command.Unrecognised] rather than
|
||||||
|
* a guess, because a mis-parsed time silently backdates a contact and nothing downstream would
|
||||||
|
* catch it.
|
||||||
|
*
|
||||||
|
* Accepted: `14:55` or `1455` for a UTC time of day; `+3` or `-2` for a shift in minutes, with
|
||||||
|
* an optional `m`; empty or `now` to return to live time.
|
||||||
|
*/
|
||||||
|
fun parse(entry: String): Command {
|
||||||
|
val text = entry.trim().lowercase()
|
||||||
|
if (text.isEmpty() || text == "now") return Command.Live
|
||||||
|
if (text.startsWith("+") || text.startsWith("-")) return parseShift(text)
|
||||||
|
return parseTimeOfDay(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `+3`, `-2m`, `+15`. */
|
||||||
|
private fun parseShift(text: String): Command {
|
||||||
|
val negative = text.startsWith("-")
|
||||||
|
val digits = text.drop(1).removeSuffix("m")
|
||||||
|
val minutes = digits.toIntOrNull() ?: return Command.Unrecognised
|
||||||
|
if (minutes > MAX_SHIFT_MINUTES) return Command.Unrecognised
|
||||||
|
return Command.Shift(if (negative) -minutes else minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `14:55` or `1455`. */
|
||||||
|
private fun parseTimeOfDay(text: String): Command {
|
||||||
|
val digits = text.replace(":", "")
|
||||||
|
if (digits.length != TIME_DIGITS || digits.any { !it.isDigit() }) return Command.Unrecognised
|
||||||
|
val hours = digits.take(2).toInt()
|
||||||
|
val minutes = digits.drop(2).toInt()
|
||||||
|
if (hours > MAX_HOUR || minutes > MAX_MINUTE) return Command.Unrecognised
|
||||||
|
return Command.At(hours * MINUTES_PER_HOUR + minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a command, returning the timestamp a contact should carry.
|
||||||
|
*
|
||||||
|
* [now] is the current UTC time in milliseconds and [dayStart] is midnight UTC of the day [now]
|
||||||
|
* falls in - passed in rather than computed, because core:domain holds no calendar and the
|
||||||
|
* caller already knows which day it is working with.
|
||||||
|
*
|
||||||
|
* An absolute time later than [now] is read as belonging to the previous day: transcription
|
||||||
|
* happens after the pass, so a pass that ran across midnight UTC is the common case, not an
|
||||||
|
* error. Without this a contact logged at 23:58 while transcribing at 00:05 would land a full
|
||||||
|
* day in the future.
|
||||||
|
*/
|
||||||
|
fun resolve(command: Command, now: Long, dayStart: Long): Long = when (command) {
|
||||||
|
is Command.At -> {
|
||||||
|
val candidate = dayStart + command.minuteOfDay * MILLIS_PER_MINUTE
|
||||||
|
if (candidate > now) candidate - MILLIS_PER_DAY else candidate
|
||||||
|
}
|
||||||
|
is Command.Shift -> now + command.minutes * MILLIS_PER_MINUTE
|
||||||
|
Command.Live, Command.Unrecognised -> now
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a command moves the clock off live time, so the UI can show that it has. */
|
||||||
|
fun isHolding(command: Command): Boolean =
|
||||||
|
command is Command.At || (command is Command.Shift && command.minutes != 0)
|
||||||
|
|
||||||
|
private const val TIME_DIGITS = 4
|
||||||
|
private const val MAX_HOUR = 23
|
||||||
|
private const val MAX_MINUTE = 59
|
||||||
|
private const val MINUTES_PER_HOUR = 60
|
||||||
|
|
||||||
|
/** A pass lasts minutes. Anything larger is a typo, not an intention. */
|
||||||
|
private const val MAX_SHIFT_MINUTES = 720
|
||||||
|
|
||||||
|
private const val MILLIS_PER_MINUTE = 60_000L
|
||||||
|
private const val MILLIS_PER_DAY = 86_400_000L
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
/*
|
||||||
|
* 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name LoTW accepts for this satellite, resolved from its catalogue number when known.
|
||||||
|
*
|
||||||
|
* LoTW rejects a QSO whose SAT_NAME is not spelled as its accepted list has it - its help
|
||||||
|
* page gives AO7 against AO-7 as an example - so this has to produce the exact spelling or
|
||||||
|
* nothing useful at all.
|
||||||
|
*
|
||||||
|
* [catnum] is preferred because the name alone cannot decide it: TLE sources disagree, and
|
||||||
|
* of the 49 satellites carried by both Celestrak amateur and AMSAT nasabare, 33 are named
|
||||||
|
* differently. NORAD 43017 is "RADFXSAT (FOX-1B)" in one and "AO-91" in the other, 43700 is
|
||||||
|
* "ES'HAIL 2" against "QO-100". Deriving the name from the TLE text resolved 0 of 96
|
||||||
|
* satellites to something LoTW accepts, because the descriptive part of a TLE name is never
|
||||||
|
* the OSCAR designator.
|
||||||
|
*
|
||||||
|
* The name path remains as a fallback for QSOs logged before the catalogue number was
|
||||||
|
* recorded. It tries the whole name, then either side of the parentheses, since which side
|
||||||
|
* carries the designator varies - "SAUDISAT 1C (SO-50)" has it inside, "ISS (ZARYA)" does not.
|
||||||
|
*/
|
||||||
|
fun normalizeSatName(raw: String, catnum: Int? = null): String {
|
||||||
|
catnum?.let { LotwSatelliteIds.nameFor(it) }?.let { return it }
|
||||||
|
val trimmed = raw.trim()
|
||||||
|
for (candidate in nameCandidates(trimmed)) {
|
||||||
|
LotwSatellites.names.firstOrNull { it.equals(candidate, ignoreCase = true) }
|
||||||
|
?.let { return it }
|
||||||
|
}
|
||||||
|
// Tolerate a missing or extra hyphen: sources write RS15 where LoTW has RS-15.
|
||||||
|
for (candidate in nameCandidates(trimmed)) {
|
||||||
|
val squashed = candidate.squashSeparators()
|
||||||
|
LotwSatellites.names.firstOrNull { it.squashSeparators() == squashed }
|
||||||
|
?.let { return it }
|
||||||
|
}
|
||||||
|
return trimmed.uppercase(Locale.ENGLISH)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when [normalizeSatName] produced a name LoTW will accept rather than a guess. */
|
||||||
|
fun isLotwSatellite(name: String, catnum: Int? = null): Boolean {
|
||||||
|
val resolved = normalizeSatName(name, catnum)
|
||||||
|
return LotwSatellites.names.any { it.equals(resolved, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The whole name plus either side of the parentheses, longest first. */
|
||||||
|
private fun nameCandidates(raw: String): List<String> {
|
||||||
|
if (raw.isEmpty()) return emptyList()
|
||||||
|
val parts = mutableListOf(raw)
|
||||||
|
val open = raw.indexOf('(')
|
||||||
|
val close = raw.lastIndexOf(')')
|
||||||
|
if (open in 0..<close) {
|
||||||
|
parts += raw.substring(open + 1, close).trim()
|
||||||
|
parts += raw.substring(0, open).trim()
|
||||||
|
}
|
||||||
|
// Formation launches are catalogued as "RS-44 & BREEZE-KM R/B".
|
||||||
|
if ('&' in raw) parts += raw.substringBefore('&').trim()
|
||||||
|
return parts.filter { it.isNotEmpty() }.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.squashSeparators() = replace(Regex("[-\\s._/]"), "").uppercase(Locale.ENGLISH)
|
||||||
|
|
||||||
|
/** 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, qso.catnum.takeIf { it > 0 })
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
// The body decides, not the status code: Wavelog validates after responding, so a rejected
|
||||||
|
// QSO arrives as HTTP 200 with {"status":"failed"}. Trusting the code marked it uploaded
|
||||||
|
// and dropped it from the queue.
|
||||||
|
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
|
||||||
|
val v2Verdict = WavelogResponse.verdict(code, resp)
|
||||||
|
when (v2Verdict) {
|
||||||
|
is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v2")
|
||||||
|
WavelogResponse.Verdict.Duplicate -> return@withContext WavelogResult.Success("duplicate")
|
||||||
|
// Anything else falls through to v1. A rejection here is NOT final: v2 refuses a legacy
|
||||||
|
// v1 key with 401 invalid_token, and returning at that point stopped a v1-only operator
|
||||||
|
// from uploading at all. The v1 attempt below is the one that can speak for them.
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
val v1Verdict = WavelogResponse.verdict(code1, resp1)
|
||||||
|
when (v1Verdict) {
|
||||||
|
is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v1")
|
||||||
|
WavelogResponse.Verdict.Duplicate -> return@withContext WavelogResult.Success("duplicate")
|
||||||
|
// Also falls through: a server with different rewrite rules answers this path with a
|
||||||
|
// 404 page, which is a rejection but says nothing about whether the QSO can be stored.
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1 without index.php, for a server whose rewrite rules differ
|
||||||
|
val (code1b, resp1b) = httpRequest("$base/api/qso", "POST", apiKey, v1Body.toString())
|
||||||
|
when (val verdict = WavelogResponse.verdict(code1b, resp1b)) {
|
||||||
|
is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v1")
|
||||||
|
WavelogResponse.Verdict.Duplicate -> return@withContext WavelogResult.Success("duplicate")
|
||||||
|
is WavelogResponse.Verdict.Rejected ->
|
||||||
|
return@withContext WavelogResult.Failure(verdict.reason)
|
||||||
|
is WavelogResponse.Verdict.Unreadable -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every endpoint answered something we could not read. Keeping the QSO queued is the only
|
||||||
|
// honest outcome: it may have been stored, and dropping it would lose the contact.
|
||||||
|
// No endpoint accepted it. The v2 reason is preferred when it explained itself, since a 401
|
||||||
|
// invalid_token is the most actionable thing an operator can be told; otherwise all three
|
||||||
|
// status codes go out, because the third was previously dropped from this message.
|
||||||
|
val reasons = listOfNotNull(
|
||||||
|
(v1Verdict as? WavelogResponse.Verdict.Rejected)?.reason,
|
||||||
|
(v2Verdict as? WavelogResponse.Verdict.Rejected)?.reason
|
||||||
|
).filter { it.isNotBlank() }
|
||||||
|
WavelogResult.Failure(
|
||||||
|
reasons.firstOrNull()
|
||||||
|
?: ("no endpoint accepted it: v2 HTTP $code, v1 HTTP $code1, v1-alt HTTP $code1b" +
|
||||||
|
" - " + 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 276 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user