Compare commits

..
250 changed files with 14796 additions and 16499 deletions

No files matched your search

-4
View File
@@ -4,7 +4,3 @@ updates:
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "bundler"
directory: "/"
schedule:
interval: "never"
+40 -19
View File
@@ -4,15 +4,10 @@ on:
push:
tags:
- v**
workflow_dispatch:
inputs:
tag_name:
description: 'Release tag name (e.g. v4.4.3)'
required: false
default: ''
env:
TAG_NAME: ${{ github.event.inputs.tag_name || github.ref_name }}
TAG_NAME: ${{ github.ref_name }}
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
jobs:
release:
@@ -21,40 +16,66 @@ jobs:
contents: write
steps:
- name: Checkout Repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Setup Java
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
uses: gradle/actions/setup-gradle@v6
- name: Assemble APK
run: ./gradlew assembleRelease
- name: Assemble Artifacts
run: ./gradlew assembleRelease bundleRelease
- name: Sign APK
run: |
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
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}apksigner sign \
--ks keystore.jks \
--ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
--ks-key-alias ${{ secrets.KEY_ALIAS }} \
--key-pass pass:${{ secrets.KEY_PASSWORD }} \
--out "$SIGNED_APK" \
--out app/build/outputs/apk/release/look4sat.apk \
"$APK"
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
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create $TAG_NAME --title=$TAG_NAME --generate-notes
gh release upload $TAG_NAME "$SIGNED_APK"
gh release upload $TAG_NAME app/build/outputs/apk/release/look4sat.apk
+3 -6
View File
@@ -18,6 +18,7 @@ out/
# Gradle files
.gradle/
build/
.cxx/
# Local configuration file (sdk path, etc)
local.properties
@@ -39,15 +40,12 @@ captures/
.idea/
# Keystore files
*.jks
*.keystore
# Uncomment the following line if you do not want to check your keystore files in.
#*.jks
/*.properties
/keystore.properties
/app/keystore.jks
# Hermes agent workspace (plans, local notes)
.hermes/
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
@@ -70,4 +68,3 @@ fastlane/readme.md
/app/release/output-metadata.json
/app/release/
/.kotlin/sessions/
.hermes/
+49 -52
View File
@@ -8,15 +8,16 @@ All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) po
## Project Overview
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
satellites using Celestrak/SatNOGS orbital data, calculates positions via SGP4/SDP4, and predicts passes relative to
the user's location. Features include polar radar visualization, SSTV image decoding, and ground track mapping. No ads,
no tracking, no network required after initial data download.
satellites using TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
data download.
## Architecture & Design
## Architecture
**MVI (Model-View-Intent)** with unidirectional data flow:
- `State` data class (named `<Feature>State`) exposed via `StateFlow` from ViewModel
- `Action` sealed interface (named `<Feature>Action`) dispatched to ViewModel's `onAction()`
- `State` data class → exposed via `StateFlow` from ViewModel
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
- Jetpack Compose UI observes state and recomposes reactively
**Clean Architecture layers:**
@@ -33,11 +34,9 @@ no tracking, no network required after initial data download.
| `feature:satellites` | Satellite list, filtering, selection |
| `feature:settings` | User preferences |
**Feature isolation:**
- `feature:*` modules depend only on `core:domain` and `core:presentation`.
- No feature-to-feature dependencies; cross-feature communication goes through core layers.
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
## Build & Platform
## Build & Run
```shell
# Debug build
@@ -51,55 +50,54 @@ no tracking, no network required after initial data download.
```
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
- **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/`
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
## Tech Stack
## Key Libraries
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
- **Navigation3**: Type-safe navigation with `@Serializable` nav keys
- **Room** (KSP code generation) for local satellite/orbital storage
- **OkHttp** 5.x for data downloads
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
- **Room** (KSP code generation) for local satellite/TLE storage
- **OkHttp** 5.x for TLE downloads
- **OSMDroid** for map rendering
- **Kotlin Serialization** for navigation args and parsing
- **Kotlin Serialization** for navigation args and data parsing
- **Coroutines** + `StateFlow` for async/reactive patterns
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh)
## Conventions
- **Minimal dependencies**: Avoid adding libraries when a simple manual solution exists. Fewer deps = less maintenance.
- **DI**: Manual — ViewModels use companion `factory()` methods with `IMainContainer` interface.
- **Navigation**: Type-safe Compose Navigation3 with `@Serializable` data classes as nav keys.
- **State naming**: `<Feature>State` data class + `<Feature>Action` sealed interface per feature.
- **No feature-to-feature deps**: All cross-feature communication goes through core layers.
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh).
## Data Formats & Migration
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
**TLE vs. OMM/CSV format:**
- **TLE format**: Legacy 3-line element format limited by 5-digit NORAD IDs
- **OMM/CSV format**: Successor format with ISO 8601 timestamps and larger NORAD ID support
- New 5-digit NORAD IDs are exhausted; TLE is officially deprecated and OMM/CSV is the clear default
- `DataParser.kt` supports both via `parseTLEStream()` and `parseCSVStream()`
- Downloads auto-detect format; both produce identical `OrbitalData` objects
- Existing code already supports transparent source transition without feature changes
- Refresh orbital data weekly for accurate pass prediction (orbital decay)
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
## Engineering Heuristics (Lazy = Efficient)
- **TLE format**: Traditional 3-line element format (deprecated). NORAD catalog numbers are 5-digit integers, which
are running out of space. Celestrak has signaled that TLE format will eventually be phased out.
- **OMM/CSV format**: The future standard. CSV files contain the same orbital parameters as TLE but use ISO 8601
timestamps and support larger NORAD IDs. Celestrak and SatNOGS already provide OMM data in CSV format.
- Treat "lazy" as efficient, not careless: the best code is the code never written.
- First understand the task and trace the real flow end-to-end, then climb this ladder:
1. Does this need to be built now? (YAGNI)
2. Does it already exist in this codebase? Reuse helpers/patterns before rewriting.
3. Does Kotlin/Java stdlib already solve it?
4. Does the Android/platform API already solve it?
5. Does an already-installed dependency solve it?
6. Can this be simpler (including one-liner simple)?
7. Only then: write the minimum code that works.
- Prefer deletion to addition, boring over clever, and the fewest touched files.
- Avoid new abstractions, dependencies, and boilerplate unless explicitly requested.
- Manual DI only: ViewModels use companion `factory()` methods with `IMainContainer`.
- Release builds use ProGuard: avoid reflection-heavy libraries unless explicitly approved.
- When two options are similar in size, choose the edge-case-correct one.
- If you keep a deliberate simplification (for example O(n^2) scan or global lock), leave a short comment with the ceiling and upgrade path.
- For complex asks, challenge scope when appropriate: "Do you need X, or does Y already cover it?"
**Current implementation:**
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly
- TLE data is downloaded from configured sources and stored in Room database
- When downloading satellite data, the app automatically detects format and parses accordingly
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching
## Bug-Fix Policy
**Migration path:**
As NORAD catalog space becomes constrained, OMM/CSV will become the primary format. Look4Sat is already positioned
to handle this transition without code changes — existing users can continue using TLE files while new sources
transition to OMM/CSV automatically.
- Fix root cause, not just the reported symptom.
- If touching a shared function, inspect callers and prefer one shared fix over per-caller patches.
- The smallest correct diff wins only after behavior is understood.
## Code Style
- Prefer **short, focused functions** — single responsibility, easy to read.
- **Exceptions**: Composable functions and math-heavy algorithms (SGP4/SDP4) may be longer.
- Strict code style — no dead code, no unused imports, consistent formatting.
## Roadmap
@@ -107,13 +105,12 @@ Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
## Gotchas
- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully.
- Orbital math lives in `core:domain/predict/` — it's dense vector math (SGP4/SDP4). Tread carefully.
- TLE/OMM data must be refreshed weekly for accurate predictions (satellite orbits decay). TLE format is legacy and
will eventually be deprecated in favor of OMM/CSV as NORAD catalog numbers approach the 5-digit limit.
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules.
- 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.
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
## Copilot Working Mode: Code-Only
+77 -64
View File
@@ -1,5 +1,5 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
@@ -7,15 +7,17 @@
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -24,34 +26,44 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -60,7 +72,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -537,45 +549,35 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. 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.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -633,29 +635,40 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
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 Affero General Public License for more details.
GNU General Public License for more details.
You should have received a copy of the GNU Affero General Public License
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
-24
View File
@@ -1,24 +0,0 @@
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.
--------------------------------------------------------------------------------
+17 -51
View File
@@ -1,60 +1,26 @@
# Look4Sat: Satellite tracker
# Look4Sat-BA7OPF
[![Look4Sat CI](https://github.com/rt-bishop/Look4Sat/actions/workflows/release.yml/badge.svg)](https://github.com/rt-bishop/Look4Sat/actions/workflows/release.yml)
[![Look4Sat CI](https://github.com/atsunatsu/Look4Sat/actions/workflows/release.yml/badge.svg)](https://github.com/atsunatsu/Look4Sat/actions/workflows/release.yml)
[<img src="https://play.google.com/intl/en_gb/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="80">](https://play.google.com/store/apps/details?id=com.rtbishop.look4sat)
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/com.rtbishop.look4sat/)
**BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的业余无线电卫星追踪器,增加了线性卫星频率计算器等功能。
### Radio satellite tracker and pass predictor for Android, inspired by Gpredict
## 本仓库特色功能
<p float="left">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="192"/>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="192"/>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="192"/>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="192">
</p>
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本
- **Passband 模式** — 支持通过位置滑块(Passband)自动计算 TX/RX 频率,避免切换时跳变
- **中文界面优化** — 翻译修正、UI 布局调整
### Track satellite passes with ease!
## 上游仓库
Thanks to [Celestrak](https://celestrak.com/) and [SatNOGS](https://satnogs.org/) you have access to over 9000 active satellites.\
You can search the entire database by NORAD Catalog Number or the satellite's name.
本仓库是 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的分支,上游仓库的原始功能包括:
Orbital positions and passes are calculated relative to your location.\
To get reliable data make sure to set the station position via the app Settings.
- 基于 Celestrak / SatNOGS 数据的 9000+ 活跃卫星追踪
- SGP4/SDP4 轨道预测,10 天过境预报
- 极坐标雷达图、地面轨迹图
- SSTV 图像解码
- 无广告、无跟踪、完全离线
The application is built using Kotlin, Coroutines, Jetpack Compose and Navigation.\
It is now and always will be completely ad-free and open-source.
## 许可证
## Main features:
* Predicting satellite positions and passes for up to 10 days
* Showing the list of currently active and upcoming satellite passes
* Showing the active pass progress, polar trajectory and transceivers info
* Showing the satellite positional data, footprint and ground track on the map
* Custom TLE satellite data import is available via Three Line Element .txt files
* Offline first: calculations are made offline. Weekly TLE data update is recommended.
## License
The Look4Sat application code is licensed under the GNU General Public License v3.0.
The CW decoder in `feature/cw` bundles the [DeepCW](https://github.com/e04/deepcw-engine)
neural decoding model, licensed under the GNU Affero General Public License v3.0 only
(AGPL-3.0-only). Because the combined work incorporates an AGPL-3.0 component, the
combined work is distributed under the
[GNU Affero General Public License v3.0](LICENSE) — GPL-3.0 Section 13 permits the
combination, and AGPL-3.0 Section 13 applies to the combined work as a whole.
Model provenance and attribution are documented in
[`feature/cw/licenses/NOTICE.md`](feature/cw/licenses/NOTICE.md); the original GPL-3.0
text is preserved at `feature/cw/licenses/Look4Sat-GPL-3.0.txt`. The CW model runs
locally on-device and does not provide services over a network.
## Star History
<a href="https://star-history.dera.page/#rt-bishop/Look4Sat&type=timeline&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
</picture>
</a>
GNU General Public License v3.0。详见 [LICENSE](LICENSE)。
+10 -36
View File
@@ -1,50 +1,24 @@
import java.util.Properties
plugins {
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 {
namespace = libs.versions.packageName.get()
defaultConfig {
// ONNX Runtime 的 AAR 自带 4 个架构共 115MB 原生库(arm64 28M / armv7 20M /
// x86 33M / x86_64 34M)。x86 系列只有模拟器用得到, 全打包会让 APK 从 8MB
// 涨到 135MB。仅保留真机需要的两个 ABI。
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
}
}
androidResources {
// 显式保留全部语言(防 shrinkResources 丢弃 in/id 印尼语配置); AGP 9 用 localeFilters
localeFilters += listOf(
"en", "zh", "tr", "in", "id", "es", "ru", "si", "uk"
)
// DeepCW 模型必须以未压缩形式打包: ONNX Runtime 通过 mmap 直接读取
// assets, 压缩后无法映射会导致 createSession 失败。noCompress 只在
// 打包 APK 的 app 模块生效, 在 feature 库模块声明无效。
noCompress += "onnx"
applicationId = "cn.ba7opf.look4sat"
ndk { abiFilters.add("armeabi-v7a") }
}
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
}
create("release") {
storeFile = file(System.getProperty("user.home") + "/my-release-key.jks")
storePassword = "look4sat123"
keyAlias = "look4sat"
keyPassword = "look4sat123"
}
}
buildTypes {
release {
signingConfig = signingConfigs.findByName("release")
// ONNX Runtime 走 JNI, R8 混淆会重命名 ai.onnxruntime.* 类导致 native
// 崩溃。convention 插件已开启 isMinifyEnabled, 必须补 keep 规则。
proguardFiles("proguard-rules.pro")
signingConfig = signingConfigs.getByName("release")
}
}
}
}
-13
View File
@@ -1,13 +0,0 @@
# 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.**
+2 -9
View File
@@ -14,10 +14,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
<application
android:name=".MainApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
@@ -46,11 +43,7 @@
<meta-data
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
android:value="com.rtbishop.look4sat.bg7nta" />
android:value="com.rtbishop.look4sat" />
<service
android:name="com.rtbishop.look4sat.app.AprsForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
@@ -1,200 +0,0 @@
package com.rtbishop.look4sat.app
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.widget.Toast
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import com.rtbishop.look4sat.MainApplication
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
import com.rtbishop.look4sat.core.data.aprs.AprsStore
import com.rtbishop.look4sat.core.data.aprs.AprsReporter
import com.rtbishop.look4sat.core.data.aprs.AprsState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* APRS foreground service: kept alive by a system notification; keeps working across pages.
* START_STICKY: auto-restarted after being killed by the system (same strategy as APRSdroid).
*/
class AprsForegroundService : Service() {
companion object {
const val ACTION_START = AprsStore.ACTION_START
const val ACTION_STOP = AprsStore.ACTION_STOP
const val ACTION_REPORT_NOW = AprsStore.ACTION_REPORT_NOW
const val CHANNEL_ID = "aprs_service"
const val NOTIF_ID = 101
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var reporter: AprsReporter? = null
private var lastState: AprsState = AprsState.Idle
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> stopReporting()
ACTION_REPORT_NOW -> {
if (reporter == null) {
// Service not running: start it first (Toast hint when not configured)
startReporting()
}
reporter?.reportNow()
}
else -> startReporting()
}
return START_STICKY
}
private fun startReporting() {
// onStartCommand reaches here for every ACTION_START and for the null
// intent that START_STICKY delivers on restart. Without this guard each
// call built a fresh AprsReporter and overwrote the field, leaving the
// previous one running with its own scope and timer: the server then
// received one duplicate position report per leaked instance per cycle,
// and ACTION_STOP could only ever stop the newest one.
reporter?.let { existing ->
if (existing.isRunning) return
existing.stop()
}
val cfg = AprsStore.loadConfig(this)
if (!cfg.enabled || cfg.callsign.isBlank()) {
runCatching {
Toast.makeText(this, getString(R.string.aprs_toast_not_configured), Toast.LENGTH_SHORT).show()
}
stopSelf()
return
}
startForegroundWithNotification(cfg)
val rep = AprsReporter(
configProvider = { AprsStore.loadConfig(this) },
positionProvider = { stationPosition() },
onState = { lastState = it },
onReport = { report ->
AprsStore.saveLastReport(this, report.ok, report.detail)
updateNotification(cfg)
// Report result always surfaces: success = short Toast, failure = long Toast + reason
val msg = if (report.ok) {
getString(R.string.aprs_toast_ok)
} else {
getString(R.string.aprs_toast_fail, report.detail)
}
runCatching {
Toast.makeText(this, msg,
if (report.ok) Toast.LENGTH_SHORT else Toast.LENGTH_LONG).show()
}
}
)
reporter = rep
rep.start()
}
private fun stopReporting() {
reporter?.stop()
reporter = null
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun startForegroundWithNotification(cfg: AprsConfig) {
try {
val notif = buildNotification(cfg)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIF_ID, notif)
}
} catch (e: Exception) {
// Vendor ROM / old-system safety net: foreground-start failure only stops the service, never crashes the app
stopSelf()
}
}
private fun buildNotification(cfg: AprsConfig): Notification {
val pi = PendingIntent.getActivity(
this, 0, packageManager.getLaunchIntentForPackage(packageName),
PendingIntent.FLAG_IMMUTABLE
)
val stopPi = PendingIntent.getService(
this, 1, Intent(this, AprsForegroundService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_IMMUTABLE
)
val stateText = when (lastState) {
AprsState.Connected -> getString(R.string.aprs_notif_connected)
AprsState.Error -> getString(R.string.aprs_notif_error)
else -> getString(R.string.aprs_notif_running)
}
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_radios)
.setContentTitle(getString(R.string.aprs_notif_title, cfg.callsign))
.setContentText(stateText)
.setContentIntent(pi)
.setOngoing(true)
.addAction(0, getString(R.string.aprs_notif_stop), stopPi)
.build()
}
private fun updateNotification(cfg: AprsConfig) {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIF_ID, buildNotification(cfg))
}
/** Report position: station QTH from settings first (per user); live GPS as fallback when invalid */
private fun stationPosition(): Pair<Double, Double>? {
// 1. Station QTH (position set in settings)
val station = runCatching {
val container = (application as MainApplication).getMainContainer()
container.settingsRepo.stationPosition.value
}.getOrNull()
if (station != null && (station.latitude != 0.0 || station.longitude != 0.0)) {
return Pair(station.latitude, station.longitude)
}
// 2. Fallback: last live GPS position
return runCatching {
val lm = getSystemService(Context.LOCATION_SERVICE) as android.location.LocationManager
val providers = listOf(
android.location.LocationManager.GPS_PROVIDER,
android.location.LocationManager.NETWORK_PROVIDER
)
for (p in providers) {
val loc = lm.getLastKnownLocation(p) ?: continue
if (loc.latitude != 0.0 || loc.longitude != 0.0) {
return Pair(loc.latitude, loc.longitude)
}
}
null
}.getOrNull()
}
private fun createChannel() {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
CHANNEL_ID, getString(R.string.aprs_notif_channel),
NotificationManager.IMPORTANCE_LOW
)
nm.createNotificationChannel(channel)
}
override fun onDestroy() {
reporter?.stop()
reporter = null
super.onDestroy()
}
}
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
observeNightFilterState()
setContent {
MainTheme(isDarkTheme = true) { NavRoot() }
MainTheme(isDarkTheme = true) { MainScreen() }
}
}
@@ -30,29 +30,10 @@ class MainApplication : Application(), IContainerProvider {
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 onCreate() {
super.onCreate()
installCrashHandler()
container = MainContainer(this)
// trigger automatic update every 48 hours
container.appScope.launch { checkAutoUpdate() }
@@ -17,13 +17,6 @@
*/
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.RepeatMode
import androidx.compose.animation.core.animateFloat
@@ -32,6 +25,8 @@ import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
@@ -49,7 +44,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
@@ -58,14 +53,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -82,7 +73,6 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.navigation.MenuLayout
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
@@ -90,52 +80,44 @@ import com.rtbishop.look4sat.core.presentation.RadarDestination
import com.rtbishop.look4sat.core.presentation.Screen
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
import com.rtbishop.look4sat.feature.cw.CwDecodeScreen
import com.rtbishop.look4sat.feature.map.MapDestination
import com.rtbishop.look4sat.feature.mutual.MutualScreen
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
import com.rtbishop.look4sat.feature.passes.PassesDestination
import com.rtbishop.look4sat.feature.radar.RadarDestination
import com.rtbishop.look4sat.feature.radar.WavelogLogScreen
import com.rtbishop.look4sat.feature.roaming.RoamingScreen
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
import com.rtbishop.look4sat.feature.settings.SettingsDestination
import com.rtbishop.look4sat.feature.status.SatStatusDestination
@Composable
fun NavRoot(deeplink: String? = null) {
val rootBackStack = rememberNavBackStack(Screen.Passes)
val deeplinkResolver = DeeplinkResolver()
LaunchedEffect(deeplink) {
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
deeplink?.let {
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
rootBackStack.add(destination)
}
}
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
slideOutHorizontally(tween(300)) { -it / 3 }
// Reverse: outgoing slides out to the right, incoming drifts in from the left
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
slideOutHorizontally(tween(300)) { it }
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
NavDisplay(
modifier = Modifier.fillMaxSize(),
backStack = rootBackStack,
onBack = navigateBack,
transitionSpec = { pushTransition },
popTransitionSpec = { popTransition },
predictivePopTransitionSpec = { popTransition },
transitionSpec = { slideInTransition },
popTransitionSpec = { slideOutTransition },
predictivePopTransitionSpec = { slideOutTransition },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
),
entryProvider = entryProvider {
entry<Screen.Passes> { MainScreen() }
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
entry<RadarDestination> {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Scaffold { innerPadding ->
RadarDestination(navigateUp = navigateBack)
innerPadding.calculateTopPadding()
}
}
}
@@ -143,40 +125,17 @@ fun NavRoot(deeplink: String? = null) {
}
@Composable
fun MainScreen() {
fun MainScreen(navigateToRadar: () -> Unit = {}) {
val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Map, Screen.Settings)
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
// 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
val mutualViewModel: MutualViewModel = viewModel(
viewModelStoreOwner = context as ViewModelStoreOwner,
@@ -191,41 +150,27 @@ fun MainScreen() {
) {
NavigationSuiteScaffold(
navigationSuiteItems = {
mainNavItems.forEach { screen ->
// Screen subclasses are data objects, so identity is enough and
// newly added pages highlight without touching this call site.
val isSelected = currentKey == screen
navItems.forEach { screen ->
val isSelected = when (currentKey) {
is Screen.Satellites -> screen is Screen.Satellites
is Screen.Passes -> screen is Screen.Passes
is Screen.Radar -> screen is Screen.Radar
is Screen.Mutual -> screen is Screen.Mutual
is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings
else -> false
}
item(
icon = {
Icon(
painter = painterResource(screen.iconResId),
contentDescription = stringResource(screen.titleResId)
)
},
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
label = { Text(stringResource(screen.titleResId)) },
selected = isSelected,
onClick = {
if (isSelected) return@item
moreExpanded = false
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
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(
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
@@ -236,138 +181,101 @@ fun MainScreen() {
else -> NavigationSuiteType.ShortNavigationBarMedium
}
) {
Box {
Column(modifier = Modifier.fillMaxSize()) {
NavDisplay(
backStack = backStack,
modifier = Modifier.weight(1f).fillMaxWidth(),
onBack = navigateBack,
transitionSpec = { fadeTransition },
popTransitionSpec = { fadeTransition },
predictivePopTransitionSpec = { fadeTransition },
entryDecorators = listOf(
// Required for saving Compose state per entry
rememberSaveableStateHolderNavEntryDecorator(),
// Required for ViewModel scoping per entry
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Screen.Satellites> {
SatellitesDestination(navigateUp = navigateBack)
Column {
NavDisplay(
backStack = backStack,
modifier = Modifier.weight(1f),
onBack = navigateBack,
transitionSpec = { fadeTransition },
popTransitionSpec = { fadeTransition },
predictivePopTransitionSpec = { fadeTransition },
entryDecorators = listOf(
// Required for saving Compose state per entry
rememberSaveableStateHolderNavEntryDecorator(),
// Required for ViewModel scoping per entry
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Screen.Satellites> {
SatellitesDestination(navigateUp = navigateBack)
}
entry<Screen.Passes> {
PassesDestination { catNum, aosTime ->
container.setMutualPassData(MutualPassData())
container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar)
// navigateToRadar()
}
entry<Screen.Passes> {
PassesDestination { catNum, aosTime ->
container.setMutualPassData(MutualPassData())
}
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.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
)
}
}
}
}
@@ -1,106 +0,0 @@
/*
* 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
)
}
}
}
}
}
}
-9
View File
@@ -1,9 +0,0 @@
<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>
@@ -31,15 +31,12 @@ internal class ApplicationPlugin : Plugin<Project> {
implementation(project(":core:data"))
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
implementation(project(":feature:cw"))
implementation(project(":feature:map"))
implementation(project(":feature:mutual"))
implementation(project(":feature:passes"))
implementation(project(":feature:radar"))
implementation(project(":feature:roaming"))
implementation(project(":feature:satellites"))
implementation(project(":feature:settings"))
implementation(project(":feature:status"))
implementation(libs.androidx.core.splashscreen)
implementation(libs.compose.material3.adaptive)
implementation(libs.compose.navigation3)
@@ -57,7 +57,7 @@ internal fun Project.setupAndroidApp() {
namespace = libs.versions.packageName.get()
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
applicationId = libs.versions.applicationId.get()
applicationId = libs.versions.packageName.get()
minSdk = libs.versions.minSdk.get().toInt()
versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get()
-6
View File
@@ -5,9 +5,3 @@ plugins {
android {
namespace = "com.rtbishop.look4sat.core.data"
}
dependencies {
// DeepCW 神经网络 CW 解码推理。ONNX 推理属 Android 平台依赖, 放此处而非
// core:domain —— 后者须保持纯 Kotlin/JVM 以留 KMP 迁移余地 (见 AGENTS.md)。
implementation(libs.other.onnxruntime)
}
@@ -1,122 +0,0 @@
package com.rtbishop.look4sat.core.data.aprs
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.io.PrintWriter
import java.net.InetSocketAddress
import java.net.Socket
/**
* APRS-IS TCP client (reverse-ported from APRSdroid TcpUploader.scala).
* Plain-text protocol: one login line + one packet per line; 30 s reconnect after drop.
*/
class AprsIsClient(
private val host: String,
private val port: Int,
private val callsign: String,
private val ssid: String,
private val passcode: Int,
private val version: String,
private val filter: String = "",
private val timeoutSec: Int = 120
) {
private var socket: Socket? = null
private var writer: PrintWriter? = null
private var reader: BufferedReader? = null
private val lock = Any()
val isConnected: Boolean
get() = synchronized(lock) { socket?.isConnected == true && !socket!!.isClosed }
/** Connect + login (synchronous/blocking; call from a background thread) */
@Throws(Exception::class)
fun connect() {
disconnect()
val s = Socket()
try {
s.connect(InetSocketAddress(host, port), 30_000)
s.soTimeout = timeoutSec * 1000
s.tcpNoDelay = true
synchronized(lock) {
socket = s
writer = PrintWriter(OutputStreamWriter(s.getOutputStream(), Charsets.ISO_8859_1), true)
reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.ISO_8859_1), 256)
}
// Login line
val login = AprsPacket.formatLogin(callsign, ssid, passcode, version) + filter
writer?.println(login)
// Read the login response (aprsc replies # logresp ... verified/unverified)
runCatching {
s.soTimeout = 8000
val resp = reader?.readLine()
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
resp.contains("unverified", ignoreCase = true))) {
throw IllegalArgumentException(resp.trim())
}
// Restore timeout
s.soTimeout = timeoutSec * 1000
}
} catch (e: Exception) {
// Close the local socket before re-throwing, so it does not leak when
// an exception is raised after s.connect() but before socket = s.
// Otherwise periodic reconnect attempts (AprsReporter every 1–60 min)
// accumulate leaked fds until the process cannot open any more files.
runCatching { s.close() }
throw e
}
}
/**
* Sends one APRS packet (one line) and tries to read the server ack.
* Returns null=failed to send; Pair(ok, detail)=result (server error text lives in detail)
*/
fun sendPacket(packetLine: String): Pair<Boolean, String>? {
synchronized(lock) {
val w = writer ?: return null
w.println(packetLine)
if (w.checkError()) return Pair(false, "write failed")
// Read the server response inside the same lock: disconnect() (called
// concurrently from stop()/reconnect on another thread) nulls
// writer/reader/socket and closes them. Reading outside the lock raced
// with that: the response read could hit a just-closed socket and the
// swallowing runCatching reported Pair(true,"OK") for a packet that
// never left, or read through a stale reference. Serialising keeps
// the read on the connection this thread just wrote to. The 3 s read
// timeout bounds how long a concurrent disconnect waits.
return runCatching {
val s = socket ?: return@runCatching Pair(true, "OK")
val oldTimeout = s.soTimeout
s.soTimeout = 3000
try {
val resp = reader?.readLine()
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
resp.contains("error", ignoreCase = true))) {
Pair(false, resp.trim())
} else {
Pair(true, if (resp.isNullOrBlank()) "OK" else resp.trim())
}
} finally {
s.soTimeout = oldTimeout
}
}.getOrElse { Pair(true, "OK") }
}
}
/** Read one line (server response; throws on timeout) */
fun readLine(): String? {
return reader?.readLine()
}
fun disconnect() {
synchronized(lock) {
runCatching { writer?.close() }
runCatching { reader?.close() }
runCatching { socket?.close() }
writer = null
reader = null
socket = null
}
}
}
@@ -1,126 +0,0 @@
package com.rtbishop.look4sat.core.data.aprs
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
import com.rtbishop.look4sat.core.domain.aprs.AprsPosition
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/** APRS connection state */
enum class AprsState { Idle, Connecting, Connected, Disconnected, Error }
/** APRS config (persisted in SharedPreferences, saved as filled) */
data class AprsConfig(
val enabled: Boolean = false,
val server: String = "euro.aprs2.net",
val port: Int = 14580,
val callsign: String = "",
val ssid: String = "",
val passcode: String = "",
val intervalMin: Int = 5,
val statusText: String = "Look4Sat APRS",
val symbolTable: String = "/",
val symbolCode: String = ">",
val includeCourseSpeed: Boolean = true,
val includeAltitude: Boolean = true
)
/** APRS report result */
data class AprsReport(
val timestamp: Long,
val packet: String,
val ok: Boolean,
val detail: String
)
/** Report scheduler (periodic + manual trigger); connection management lives in the foreground service */
class AprsReporter(
private val configProvider: () -> AprsConfig,
private val positionProvider: () -> Pair<Double, Double>? = { null },
private val onState: (AprsState) -> Unit = {},
private val onReport: (AprsReport) -> Unit = {}
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var client: AprsIsClient? = null
private var job: Job? = null
private var manualJob: Job? = null
val isRunning: Boolean get() = job?.isActive == true
/** Start periodic reporting (called by the foreground service) */
fun start() {
stop()
val cfg = configProvider()
if (!cfg.enabled || cfg.callsign.isBlank()) {
onState(AprsState.Error)
return
}
job = scope.launch {
while (isActive) {
reportOnce()
delay(cfg.intervalMin.coerceAtLeast(1) * 60_000L)
}
}
}
fun stop() {
job?.cancel()
job = null
runCatching { client?.disconnect() }
client = null
onState(AprsState.Idle)
}
/** Trigger one report manually (immediately, without waiting for the cycle) */
fun reportNow() {
manualJob?.cancel()
manualJob = scope.launch { reportOnce() }
}
private suspend fun reportOnce() {
val cfg = configProvider()
if (!cfg.enabled || cfg.callsign.isBlank()) return
onState(AprsState.Connecting)
try {
val c = client ?: AprsIsClient(
host = cfg.server,
port = cfg.port,
callsign = cfg.callsign,
ssid = cfg.ssid,
passcode = cfg.passcode.toIntOrNull()?.takeIf { it >= 0 } ?: AprsPacket.passcode(cfg.callsign),
version = "Look4Sat 4.5.4"
).also { client = it }
if (!c.isConnected) c.connect()
onState(AprsState.Connected)
val pos = positionProvider()
val packetLine = buildPositionPacket(cfg, pos?.first, pos?.second)
val result = c.sendPacket(packetLine)
val ok = result?.first == true
val detail = result?.second ?: "no connection"
onReport(AprsReport(System.currentTimeMillis(), packetLine, ok, detail))
if (ok) onState(AprsState.Connected) else onState(AprsState.Error)
} catch (e: Exception) {
runCatching { client?.disconnect() }
client = null
onState(AprsState.Error)
onReport(AprsReport(System.currentTimeMillis(), "", false, e.message ?: "error"))
}
}
/** Build position packet: BG7NTA-5>APRS:=DDMM.MMN/DDDMM.MME<status text */
private fun buildPositionPacket(cfg: AprsConfig, lat: Double? = null, lon: Double? = null): String {
val source = AprsPacket.formatCallSsid(cfg.callsign, cfg.ssid)
val pos = AprsPosition(
latitude = lat ?: 0.0,
longitude = lon ?: 0.0,
symbolTable = cfg.symbolTable.firstOrNull() ?: '/',
symbolCode = cfg.symbolCode.firstOrNull() ?: '>'
)
return "$source>APRS:=${pos.toUncompressedString()}${cfg.statusText}"
}
}
@@ -1,83 +0,0 @@
package com.rtbishop.look4sat.core.data.aprs
import android.content.Context
/**
* APRS config storage + service action constants (shared by feature/settings and the app service,
* so feature never depends on app).
*/
object AprsStore {
const val ACTION_START = "com.rtbishop.look4sat.aprs.START"
const val ACTION_STOP = "com.rtbishop.look4sat.aprs.STOP"
const val ACTION_REPORT_NOW = "com.rtbishop.look4sat.aprs.REPORT_NOW"
const val SERVICE_CLASS = "com.rtbishop.look4sat.app.AprsForegroundService"
private const val PREFS = "aprs_config"
private const val KEY_ENABLED = "enabled"
private const val KEY_SERVER = "server"
private const val KEY_PORT = "port"
private const val KEY_CALLSIGN = "callsign"
private const val KEY_SSID = "ssid"
private const val KEY_PASSCODE = "passcode"
private const val KEY_INTERVAL = "interval"
private const val KEY_STATUS = "status"
private const val KEY_SYMBOL_TABLE = "symbol_table"
private const val KEY_SYMBOL_CODE = "symbol_code"
private const val KEY_LAST_TIME = "last_report_time"
private const val KEY_LAST_OK = "last_report_ok"
private const val KEY_LAST_DETAIL = "last_report_detail"
/** Read config (saved as filled; no need to re-enter each time) */
fun loadConfig(context: Context): AprsConfig {
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
return AprsConfig(
enabled = p.getBoolean(KEY_ENABLED, false),
server = p.getString(KEY_SERVER, "euro.aprs2.net") ?: "euro.aprs2.net",
port = p.getInt(KEY_PORT, 14580),
callsign = p.getString(KEY_CALLSIGN, "") ?: "",
ssid = p.getString(KEY_SSID, "") ?: "",
passcode = p.getString(KEY_PASSCODE, "") ?: "",
intervalMin = p.getInt(KEY_INTERVAL, 5),
statusText = p.getString(KEY_STATUS, "Look4Sat APRS") ?: "Look4Sat APRS",
symbolTable = p.getString(KEY_SYMBOL_TABLE, "/") ?: "/",
symbolCode = p.getString(KEY_SYMBOL_CODE, ">") ?: ">"
)
}
/** Last report result (shown on the settings card) */
data class LastReport(val time: Long = 0L, val ok: Boolean = false, val detail: String = "")
fun loadLastReport(context: Context): LastReport {
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
return LastReport(
time = p.getLong(KEY_LAST_TIME, 0L),
ok = p.getBoolean(KEY_LAST_OK, false),
detail = p.getString(KEY_LAST_DETAIL, "") ?: ""
)
}
fun saveLastReport(context: Context, ok: Boolean, detail: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putLong(KEY_LAST_TIME, System.currentTimeMillis())
.putBoolean(KEY_LAST_OK, ok)
.putString(KEY_LAST_DETAIL, detail)
.apply()
}
/** Save config */
fun saveConfig(context: Context, cfg: AprsConfig) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putBoolean(KEY_ENABLED, cfg.enabled)
.putString(KEY_SERVER, cfg.server)
.putInt(KEY_PORT, cfg.port)
.putString(KEY_CALLSIGN, cfg.callsign)
.putString(KEY_SSID, cfg.ssid)
.putString(KEY_PASSCODE, cfg.passcode)
.putInt(KEY_INTERVAL, cfg.intervalMin)
.putString(KEY_STATUS, cfg.statusText)
.putString(KEY_SYMBOL_TABLE, cfg.symbolTable)
.putString(KEY_SYMBOL_CODE, cfg.symbolCode)
.apply()
}
}
@@ -1,579 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.cw
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import android.content.Context
import android.util.Log
import com.rtbishop.look4sat.core.domain.cw.CwCtcDecoder
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
import com.rtbishop.look4sat.core.domain.cw.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.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 {
private companion object {
const val TAG = "CwDeepDecoder"
const val MODEL_ASSET = "deepcw/model.onnx"
const val METADATA_ASSET = "deepcw/model.onnx.json"
/** Evicted audio is decoded into permanent history once this much accumulates. */
const val ARCHIVE_SECONDS = 15.0
val ARCHIVE_THRESHOLD: Int = (CwDeepSpectrogram.SAMPLE_RATE * ARCHIVE_SECONDS).toInt()
/**
* 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()
private val _historyText = MutableStateFlow("")
override val historyText: StateFlow<String> = _historyText.asStateFlow()
private val _estimatedPitch = MutableStateFlow<Float?>(null)
override val estimatedPitch: StateFlow<Float?> = _estimatedPitch.asStateFlow()
private val _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]. Archiving in ~15 s chunks
* keeps the extra inference cheap (short window) while long enough to be
* decoded accurately — the content has already been through the 20 s window
* many times, so a slightly shorter archive decode loses almost nothing.
*/
private val archiveBuffer = FloatArray(CwDeepBuffer.DEFAULT_MAX_SECONDS.toInt() * CwDeepSpectrogram.SAMPLE_RATE)
private var archiveSize = 0
/** Held while inference runs so slow devices skip work instead of queuing it. */
private val inferenceLock = Mutex()
/** 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()
/**
* 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
val resampled = CwDeepSpectrogram.resampleLinear(
samples, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
)
val prepared = applyToneShift(resampled)
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.
*/
private fun dropBufferedAudio() {
buffer.reset()
archiveSize = 0
}
/**
* 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)
}
/**
* Decode a chunk of audio that has scrolled out of the live window and
* append it to [historyText]. Unlike the live window this never replaces —
* the archived audio is final, so its text is permanent.
*/
private suspend fun archiveDecode(audio: FloatArray) = withContext(Dispatchers.Default) {
val activeSession = session ?: return@withContext
val activeEnvironment = environment ?: return@withContext
if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext
val spectrogram = CwDeepSpectrogram.compute(audio)
val text = runInference(activeSession, activeEnvironment, spectrogram)
if (text.isNotEmpty()) {
_historyText.value += text
}
}
/** Run the ONNX model over a pre-computed spectrogram and return the decoded text. */
private fun runInference(
activeSession: OrtSession,
activeEnvironment: OrtEnvironment,
spectrogram: Array<FloatArray>
): String {
val frames = spectrogram.size
val bins = CwDeepSpectrogram.FREQUENCY_BINS
val flat = FloatBuffer.allocate(frames * bins)
for (frame in spectrogram) flat.put(frame)
flat.rewind()
val shape = longArrayOf(1, 1, frames.toLong(), bins.toLong())
val startedAt = System.currentTimeMillis()
val text: String
OnnxTensor.createTensor(activeEnvironment, flat, shape).use { input ->
activeSession.run(mapOf(inputName to input)).use { result ->
@Suppress("UNCHECKED_CAST")
val logits = result[outputName].get().value as Array<Array<FloatArray>>
text = CwCtcDecoder.greedy(logits, chars, blankIndex)
}
}
_lastInferenceMs.value = (System.currentTimeMillis() - startedAt).toInt()
CwProbe.step("infer_done ms=${_lastInferenceMs.value}")
return text
}
/**
* Report the loudest bin as the tone pitch and its prominence over the
* window mean as a 0..1 strength, purely for the UI readout.
*/
private fun updateSignalMetrics(spectrogram: Array<FloatArray>) {
if (spectrogram.isEmpty()) return
var bestBin = 0
var bestValue = 0f
var total = 0f
var count = 0
for (frame in spectrogram) {
for (bin in frame.indices) {
val value = frame[bin]
total += value
count++
if (value > bestValue) {
bestValue = value
bestBin = bin
}
}
}
if (count == 0 || bestValue <= 0f) return
val binHz = CwDeepSpectrogram.SAMPLE_RATE.toDouble() / CwDeepSpectrogram.FFT_LENGTH
// Relative bin 0 is 400 Hz; absolute bin index is 32 + bestBin.
val absoluteBin = 32 + bestBin
// 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
}
override fun reset() {
buffer.reset()
_decodedText.value = ""
_historyText.value = ""
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
}
}
@@ -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.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,12 +45,7 @@ interface Look4SatDao {
@Query("DELETE FROM entries")
suspend fun deleteEntries()
@Query(
"""
SELECT DISTINCT catnum FROM radios WHERE isAlive = 1
AND (downlinkMode IN (:modes) OR uplinkMode IN (:modes))
"""
)
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
suspend fun getIdsWithModes(modes: List<String>): List<Int>
@Query("SELECT COUNT(*) FROM radios")
@@ -76,12 +76,10 @@ class BluetoothReporter(
private fun ensureRotatorConnected() {
if (rotatorConnected || rotatorConnecting || rotatorDeviceId.isBlank()) return
reporterScope.launch {
var opened: android.bluetooth.BluetoothSocket? = null
try {
rotatorConnecting = true
val device = bluetoothManager.adapter.getRemoteDevice(rotatorDeviceId)
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
opened = socket
socket.connect()
rotatorSocket = socket
rotatorStream = socket.outputStream
@@ -89,11 +87,6 @@ class BluetoothReporter(
Log.i(tag, "Rotator connected to $rotatorDeviceId")
} catch (e: Exception) {
Log.e(tag, "Rotator connect error: ${e.message}")
// Close the socket we opened, otherwise a failure after connect()
// leaks it: nothing else holds a reference once this returns.
runCatching { opened?.close() }
rotatorSocket = null
rotatorStream = null
rotatorConnected = false
} finally {
rotatorConnecting = false
@@ -104,12 +97,10 @@ class BluetoothReporter(
private fun ensureFrequencyConnected() {
if (frequencyConnected || frequencyConnecting || frequencyDeviceId.isBlank()) return
reporterScope.launch {
var opened: android.bluetooth.BluetoothSocket? = null
try {
frequencyConnecting = true
val device = bluetoothManager.adapter.getRemoteDevice(frequencyDeviceId)
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
opened = socket
socket.connect()
frequencySocket = socket
frequencyStream = socket.outputStream
@@ -117,11 +108,6 @@ class BluetoothReporter(
Log.i(tag, "Frequency connected to $frequencyDeviceId")
} catch (e: Exception) {
Log.e(tag, "Frequency connect error: ${e.message}")
// Close the socket we opened, otherwise a failure after connect()
// leaks it: nothing else holds a reference once this returns.
runCatching { opened?.close() }
frequencySocket = null
frequencyStream = null
frequencyConnected = false
} finally {
frequencyConnecting = false
@@ -42,9 +42,6 @@ class Ft817Controller(
private val commandDelayMs = 200L
private val maxAckReadFailures = 3
/** Largest frequency the 4-byte BCD / 10 Hz CAT field can represent. */
private val maxFrequencyHz = 999_999_990L
private var socket: BluetoothSocket? = null
private var outputStream: OutputStream? = null
private var inputStream: InputStream? = null
@@ -56,11 +53,9 @@ class Ft817Controller(
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
if (isConnected) return@withContext true
if (deviceAddress.isBlank()) return@withContext false
var opened: android.bluetooth.BluetoothSocket? = null
try {
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
opened = btSocket
btSocket.connect()
socket = btSocket
outputStream = btSocket.outputStream
@@ -71,13 +66,6 @@ class Ft817Controller(
true
} catch (e: Exception) {
Log.e(tag, "Connect error: ${e.message}")
// Close the socket we opened. Without this a failure after connect()
// (e.g. outputStream throwing) leaks the Bluetooth socket, because
// disconnect() only closes what already reached the fields.
runCatching { opened?.close() }
socket = null
outputStream = null
inputStream = null
isConnected = false
false
}
@@ -103,16 +91,6 @@ class Ft817Controller(
}
override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
// The FT-817 CAT frequency field is 4 BCD bytes at 10 Hz resolution,
// so the protocol cannot express anything above 999,999,990 Hz. Below
// that the encoder is exact; above it, the %08d formatting silently
// drops the leading digit and the radio receives a frequency ten
// times lower (e.g. 1267.6 MHz becomes 126.76 MHz), and the tracking
// loop's read-back then locks onto the wrong band. Reject instead.
if (frequencyHz > maxFrequencyHz) {
Log.e(tag, "setFrequency rejected: $frequencyHz Hz exceeds FT-817 CAT limit $maxFrequencyHz")
return@withContext false
}
ioMutex.withLock {
sendCommandWithAck(Ft817CatProtocol.buildSetFreqCommand(frequencyHz))
}
@@ -67,11 +67,9 @@ class Ic705Controller(
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
if (isConnected) return@withContext true
if (deviceAddress.isBlank()) return@withContext false
var opened: android.bluetooth.BluetoothSocket? = null
try {
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
opened = btSocket
btSocket.connect()
socket = btSocket
outputStream = btSocket.outputStream
@@ -86,13 +84,6 @@ class Ic705Controller(
true
} catch (e: Exception) {
Log.e(tag, "Connect error: ${e.message}")
// Close the socket we opened. Without this a failure after connect()
// (e.g. outputStream throwing) leaks the Bluetooth socket, because
// disconnect() only closes what already reached the fields.
runCatching { opened?.close() }
socket = null
outputStream = null
inputStream = null
isConnected = false
false
}
@@ -71,19 +71,14 @@ class NetworkReporter(
private fun ensureRotatorConnected() {
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
reporterScope.launch {
var opened: SocketChannel? = null
try {
rotatorConnecting = true
opened = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
rotatorSocket = opened
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
rotatorConnected = true
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
} catch (e: Exception) {
println("NetworkReporter rotator connect error: ${e.message}")
rotatorConnected = false
// Close a socket that connected but failed during setup, so a
// broken channel is never left referenced without a closer.
opened?.close()
} finally {
rotatorConnecting = false
}
@@ -93,17 +88,14 @@ class NetworkReporter(
private fun ensureFrequencyConnected() {
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
reporterScope.launch {
var opened: SocketChannel? = null
try {
frequencyConnecting = true
opened = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
frequencySocket = opened
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
frequencyConnected = true
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
} catch (e: Exception) {
println("NetworkReporter frequency connect error: ${e.message}")
frequencyConnected = false
opened?.close()
} finally {
frequencyConnecting = false
}
@@ -119,17 +111,6 @@ class NetworkReporter(
} catch (e: Exception) {
println("NetworkReporter write error: ${e.message}")
onError()
// The channel failed a write: drop it and its closure obligation.
// Leaving it referenced lets the next connect overwrite the field
// and leak the old channel. Only the field the caller passed is
// cleared, matching the connected=false the onError sets.
if (socket === rotatorSocket) {
rotatorSocket?.close()
rotatorSocket = null
} else if (socket === frequencySocket) {
frequencySocket?.close()
frequencySocket = null
}
}
}
@@ -267,18 +267,12 @@ class RadioTrackingService(
if (tuningRadio.isEmpty()) {
if (txNow != null && txNow.isConnected && txRadioFreq != null) {
// Only remember the frequency we actually wrote: if the radio
// rejects it (FT-817 CAT limit) or the link dropped, keeping
// lastSetTxFreq updated would make the manual-tuning detector
// see a phantom dial change on the next read-back.
if (txNow.setFrequency(txRadioFreq)) {
lastSetTxFreq = txRadioFreq.toDouble()
}
txNow.setFrequency(txRadioFreq)
lastSetTxFreq = txRadioFreq.toDouble()
}
if (rxNow != null && rxNow.isConnected && rxRadioFreq != null) {
if (rxNow.setFrequency(rxRadioFreq)) {
lastSetRxFreq = rxRadioFreq.toDouble()
}
rxNow.setFrequency(rxRadioFreq)
lastSetRxFreq = rxRadioFreq.toDouble()
}
}
@@ -456,15 +450,13 @@ class RadioTrackingService(
// 0x25/00 = active (RX) VFO, 0x25/01 = inactive (TX) VFO.
if (rxRadioFreq != null) {
Log.d(tag, "Split loop RX (0x25/00): ${rxRadioFreq}Hz")
if (radio.setWorkingFrequency(rxRadioFreq)) {
lastSetRxFreq = rxRadioFreq.toDouble()
}
radio.setWorkingFrequency(rxRadioFreq)
lastSetRxFreq = rxRadioFreq.toDouble()
}
if (txRadioFreq != null) {
Log.d(tag, "Split loop TX (0x25/01): ${txRadioFreq}Hz")
if (radio.setTxVfoFrequency(txRadioFreq)) {
lastSetTxFreq = txRadioFreq.toDouble()
}
radio.setTxVfoFrequency(txRadioFreq)
lastSetTxFreq = txRadioFreq.toDouble()
}
}
@@ -29,7 +29,6 @@ import com.rtbishop.look4sat.core.data.framework.Ft817Controller
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
@@ -41,9 +40,6 @@ import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
import com.rtbishop.look4sat.core.data.usecase.SaveImage
import com.rtbishop.look4sat.core.data.usecase.ShowToast
import com.rtbishop.look4sat.core.domain.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.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
@@ -70,19 +66,16 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.OkHttpClient
import com.rtbishop.look4sat.core.data.wavelog.LotwSatellitesRepo
class MainContainer(private val context: Context) : IMainContainer {
private val localSource = provideLocalSource()
private val remoteSource by lazy { provideRemoteSource() }
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
override val settingsRepo = provideSettingsRepo()
override val selectionRepo = provideSelectionRepo()
override val satelliteRepo = provideSatelliteRepo()
override val databaseRepo = provideDatabaseRepo()
override val amSatRepo by lazy { AmSatRepository(remoteSource) }
override val radioTrackingService: IRadioTrackingService by lazy {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
@@ -97,43 +90,12 @@ class MainContainer(private val context: Context) : IMainContainer {
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
override fun providePairedBluetoothDevices(): List<Pair<String, String>> = buildList {
try {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
manager.adapter?.bondedDevices?.forEach { add(Pair(it.name ?: "Unknown", it.address ?: "")) }
} catch (_: SecurityException) {}
}
override fun provideShowToast(): IShowToast = ShowToast(context)
override fun provideAudioCapture(): IAudioCapture = AudioCapture()
// 每次调用返回新实例: 调用方负责 close() 释放 OrtSession, 且 Radar 内嵌
// 面板与独立 CW 页各自持有自己的解码器
override fun provideCwDecoder(): com.rtbishop.look4sat.core.domain.cw.ICwDecoder =
com.rtbishop.look4sat.core.data.cw.CwDeepDecoder(context) {
// Read per chunk so toggling the setting applies without restarting capture.
settingsRepo.otherSettings.value.cwToneShiftEnabled
}
override fun provideSaveImage(): ISaveImage = SaveImage(context)
// WaveLog logging (4.5.2): local queue + uploader (shared instance)
override val wavelogQueue: WavelogQueue by lazy {
val prefs = context.getSharedPreferences("wavelog", Context.MODE_PRIVATE)
WavelogQueue(object : IWavelogQueueStore {
override fun load(): String = prefs.getString("wavelog_queue", "[]") ?: "[]"
override fun save(json: String) = prefs.edit().putString("wavelog_queue", json).apply()
})
}
override fun provideWavelogUploader(): WavelogUploader = WavelogUploader(settingsRepo, wavelogQueue)
private val lotwRepo: LotwSatellitesRepo by lazy {
LotwSatellitesRepo(context).also { it.restore() }
}
override fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo = lotwRepo
override fun provideBluetoothReporter(): IReporter {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val rc = settingsRepo.rcSettings.value
@@ -187,6 +149,7 @@ class MainContainer(private val context: Context) : IMainContainer {
private fun provideDatabaseRepo(): IDatabaseRepo {
val dbDispatcher = Dispatchers.Default
val dataParser = DataParser(dbDispatcher)
val remoteSource = provideRemoteSource()
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
}
@@ -197,14 +160,7 @@ class MainContainer(private val context: Context) : IMainContainer {
}
private fun provideRemoteSource(): IRemoteSource {
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()
)
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
}
private fun provideSatelliteRepo(): ISatelliteRepo {
@@ -220,6 +176,6 @@ class MainContainer(private val context: Context) : IMainContainer {
val appPrefsFileName = "${context.packageName}_preferences"
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
return SettingsRepo(context, manager, appPreferences, appVersionName)
return SettingsRepo(manager, appPreferences, appVersionName)
}
}
@@ -1,253 +0,0 @@
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
}
}
@@ -69,33 +69,23 @@ class DatabaseRepo(
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
val tleUrls = buildMap {
putAll(Sources.satelliteDataUrls)
// Switch on + non-empty URL -> All uses the custom URL; otherwise the default URL (online-update default source)
put("All", if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank())
dataSourcesSettings.tleUrl else Sources.defaultTleUrl)
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
}.filterValues { it.isNotBlank() }
val radioUrls = buildMap {
putAll(Sources.transceiversDataUrls)
put("SatNOGS", if (dataSourcesSettings.useCustomTransceivers && dataSourcesSettings.transceiversUrl.isNotBlank())
dataSourcesSettings.transceiversUrl else Sources.defaultTransceiversUrl)
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
}.filterValues { it.isNotBlank() }
// launch all network requests concurrently
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
// Count successful sources: zero successes = update failed (timestamp untouched, exception surfaced in the UI)
val tleResults = tleJobs.awaitAll()
val radioResults = radioJobs.awaitAll()
val successCount = tleResults.count { it.second != null } + radioResults.count { it.second != null }
if (successCount == 0) {
throw java.io.IOException("All data sources failed to download")
}
// parse fetched data concurrently and associate with types
val importedEntries = tleResults.flatMap { (url, stream) ->
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
}
}
val importedRadios = radioResults.flatMap { (url, stream) ->
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
}
// insert parsed data into the database
@@ -34,13 +34,9 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.util.TimeZone
import kotlin.time.Duration.Companion.milliseconds
class SatelliteRepo(
private val dispatcher: CoroutineDispatcher,
@@ -54,10 +50,6 @@ class SatelliteRepo(
private val _isCalculating = MutableStateFlow(false)
override val isCalculating: StateFlow<Boolean> = _isCalculating
// Serializes pass calculation. Callers queue instead of being dropped: a
// dropped call would silently discard the filter the user just applied.
private val calculationMutex = Mutex()
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
override val satellites: StateFlow<List<OrbitalObject>> = _satellites
@@ -71,23 +63,19 @@ class SatelliteRepo(
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
override suspend fun initRepository() = withContext(dispatcher) {
combine(
settingsRepo.selectedIds,
settingsRepo.stationPosition
) { selectedIds, _ -> selectedIds }
.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val settings = settingsRepo.passesSettings.value
calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = settings.hoursAhead,
minElevation = settings.minElevation,
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settingsRepo.selectedSatModes.value
)
}
settingsRepo.selectedIds.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val settings = settingsRepo.passesSettings.value
calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = settings.hoursAhead,
minElevation = settings.minElevation,
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settings.selectedModes
)
}
}
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
@@ -107,8 +95,14 @@ class SatelliteRepo(
}
}
override suspend fun getRadios(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio> {
override suspend fun getRadios(
sat: OrbitalObject,
pos: GeoPos,
radios: List<SatRadio>,
time: Long
): List<SatRadio> {
return withContext(dispatcher) {
val satPos = sat.getPosition(pos, time)
radios.map { transmitter ->
transmitter.copy(
downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) },
@@ -129,55 +123,45 @@ class SatelliteRepo(
invertAosTimeWindow: Boolean,
modes: List<String>
) {
// Queue behind any in-flight calculation rather than dropping this call:
// every invocation carries filter settings the user just chose, so a
// dropped one leaves the list showing results for the previous filter.
calculationMutex.withLock {
_isCalculating.value = true
try {
// Normalize to the start of the current minute so that coarse 60-second stepping
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
val normalizedTime = time / 60_000L * 60_000L
val currentSatellites = _satellites.value
withContext(dispatcher) {
val idsWithModes = localStorage.getIdsWithModes(modes)
val stationPos = settingsRepo.stationPosition.value
val filteredSatellites = if (idsWithModes.isEmpty()) {
currentSatellites
} else {
currentSatellites.filter { it.data.catnum in idsWithModes }
}
// Compute passes for each satellite in parallel
val passLists = coroutineScope {
filteredSatellites.map { satellite ->
async { satellite.getPasses(stationPos, normalizedTime, hoursAhead) }
}.awaitAll()
}
// Flatten and filter in a single pass
val timeFuture = normalizedTime + (hoursAhead * 60L * 60L * 1000L)
val newPasses = ArrayList<OrbitalPass>()
for (list in passLists) {
for (pass in list) {
if (
pass.losTime > time
&& pass.aosTime < timeFuture
&& pass.maxElevation > minElevation
&& (pass.isDeepSpace || isAosInRange(pass.aosTime, aosStartMinute, aosEndMinute, invertAosTimeWindow))
) {
newPasses.add(pass)
}
}
}
newPasses.sortBy { it.aosTime }
delay(1000) // Simulate loading time for better UX
_passes.update { newPasses }
}
} finally {
// finally: a thrown/cancelled calculation must not leave the
// progress indicator spinning forever.
_isCalculating.value = false
_isCalculating.value = true
// Normalize to the start of the current minute so that coarse 60-second stepping
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
val normalizedTime = time / 60_000L * 60_000L
val currentSatellites = _satellites.value
withContext(dispatcher) {
val idsWithModes = localStorage.getIdsWithModes(modes)
val stationPos = settingsRepo.stationPosition.value
val filteredSatellites = if (idsWithModes.isEmpty()) {
currentSatellites
} else {
currentSatellites.filter { it.data.catnum in idsWithModes }
}
// Compute passes for each satellite in parallel
val passLists = coroutineScope {
filteredSatellites.map { satellite ->
async { satellite.getPasses(stationPos, normalizedTime, hoursAhead) }
}.awaitAll()
}
// Flatten and filter in a single pass
val timeFuture = normalizedTime + (hoursAhead * 60L * 60L * 1000L)
val newPasses = ArrayList<OrbitalPass>()
for (list in passLists) {
for (pass in list) {
if (
pass.losTime > time
&& pass.aosTime < timeFuture
&& pass.maxElevation > minElevation
&& (pass.isDeepSpace || isAosInRange(pass.aosTime, aosStartMinute, aosEndMinute, invertAosTimeWindow))
) {
newPasses.add(pass)
}
}
}
newPasses.sortBy { it.aosTime }
delay(1000) // Simulate loading time for better UX
_passes.update { newPasses }
}
_isCalculating.value = false
}
private fun isAosInRange(
@@ -192,7 +176,7 @@ class SatelliteRepo(
val inRange = if (aosStartMinute <= aosEndMinute) {
aosMinute in aosStartMinute..aosEndMinute
} else {
aosMinute !in (aosEndMinute + 1)..<aosStartMinute
aosMinute >= aosStartMinute || aosMinute <= aosEndMinute
}
return if (invertAosTimeWindow) !inRange else inRange
}
@@ -258,16 +242,13 @@ class SatelliteRepo(
if (elevation > maxElevation) maxElevation = elevation
} while (elevation < 0.0)
// refine AOS to ~500ms precision via binary search.
// Elevation is monotonic across the horizon crossing, so binary search
// finds the crossing in ~8 SGP4 calls instead of up to 120 linear steps.
var aosLo = calendarTimeMillis - 60L * 1000L // elevation < 0 (below horizon)
var aosHi = calendarTimeMillis // elevation >= 0 (above horizon)
while (aosHi - aosLo > 500L) {
val mid = (aosLo + aosHi) / 2
if (sat.getElevation(pos, mid) < 0.0) aosLo = mid else aosHi = mid
}
calendarTimeMillis = aosHi
// refine AOS to ~500ms precision
calendarTimeMillis -= 60L * 1000L
do {
calendarTimeMillis += 500L
elevation = sat.getElevation(pos, calendarTimeMillis)
if (elevation > maxElevation) maxElevation = elevation
} while (elevation < 0.0)
// Get full position for AOS data (azimuth, altitude)
val aosPos = sat.getFullPosition(pos, calendarTimeMillis)
@@ -281,14 +262,13 @@ class SatelliteRepo(
if (elevation > maxElevation) maxElevation = elevation
} while (elevation > 0.0)
// refine LOS to ~500ms precision via binary search (same monotonic argument)
var losLo = calendarTimeMillis - 30L * 1000L // elevation > 0 (above horizon)
var losHi = calendarTimeMillis // elevation <= 0 (below horizon)
while (losHi - losLo > 500L) {
val mid = (losLo + losHi) / 2
if (sat.getElevation(pos, mid) > 0.0) losLo = mid else losHi = mid
}
calendarTimeMillis = losHi
// refine LOS to ~500ms precision
calendarTimeMillis -= 30L * 1000L
do {
calendarTimeMillis += 500L
elevation = sat.getElevation(pos, calendarTimeMillis)
if (elevation > maxElevation) maxElevation = elevation
} while (elevation > 0.0)
// Get full position for LOS data (azimuth, altitude)
val losPos = sat.getFullPosition(pos, calendarTimeMillis)
@@ -38,16 +38,16 @@ class SelectionRepo(
) : ISelectionRepo {
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
private val currentQuery = MutableStateFlow("")
// Resolve sat IDs once when modes change, then filter items reactively.
// Resolve type IDs once when types change, then filter items reactively.
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
// Directly observe settingsRepo.selectedSatModes to ensure real-time sync across screens.
private val itemsWithModes = settingsRepo.selectedSatModes.flatMapLatest { list: List<String> ->
val catnumSet: Set<Int>? = if (list.isEmpty()) {
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
val catnumSet: Set<Int>? = if (types.isEmpty()) {
null // null = no filtering
} else {
val ids = localSource.getIdsWithModes(list)
val ids = settingsRepo.getSatelliteTypesIds(types)
if (ids.isEmpty()) null else ids.toHashSet()
}
currentItems.map { items ->
@@ -56,18 +56,14 @@ class SelectionRepo(
}
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
itemsWithModes.map { items ->
filterByQuery(items, query).sortedWith(
compareByDescending<SatItem> { it.isSelected }
.thenBy { it.name }
.thenBy { it.catnum }
)
}
itemsWithTypes.map { items -> filterByQuery(items, query) }
}
override fun getCurrentModes() = settingsRepo.selectedSatModes.value
override fun getCurrentTypes() = currentTypes.value
override fun getModesList() = Sources.satelliteModes
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
removeAt(0)
}
override suspend fun getEntriesFlow() = withContext(dispatcher) {
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
@@ -77,8 +73,9 @@ class SelectionRepo(
return@withContext itemsWithQuery
}
override suspend fun setModes(modes: List<String>) {
settingsRepo.setSelectedSatModes(modes)
override suspend fun setTypes(types: List<String>) {
currentTypes.value = types
settingsRepo.setSelectedTypes(types)
}
override suspend fun setQuery(query: String) {
@@ -29,21 +29,16 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.utility.positionToQth
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
import com.rtbishop.look4sat.core.domain.utility.round
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.json.JSONObject
class SettingsRepo(
private val context: android.content.Context,
private val locationManager: LocationManager,
private val preferences: SharedPreferences,
override val appVersionName: String
@@ -72,17 +67,15 @@ class SettingsRepo(
private val keyFrequencyAddress = "frequencyAddress"
private val keyFrequencyPort = "frequencyPort"
private val keyFrequencyFormat = "frequencyFormat"
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
private val keySelectedIds = "selectedIds"
private val keySelectedSatModes = "selectedSatModes"
private val keySelectedTypes = "selectedTypes"
private val keySelectedModes = "selectedModes"
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
private val keyStateOfSensors = "stateOfSensors"
private val keyStateOfSweep = "stateOfSweep"
private val keyStateOfUtc = "stateOfUtc"
private val keyStateOfLightTheme = "stateOfLightTheme"
private val keyStateOfNightMode = "stateOfNightMode"
private val keyHiddenScreens = "hiddenScreens"
private val keyScreenOrder = "screenOrder"
private val keyStationAltitude = "stationAltitude"
private val keyStationLatitude = "stationLatitude"
private val keyStationLongitude = "stationLongitude"
@@ -98,23 +91,13 @@ class SettingsRepo(
private val keyUseCustomTransceivers = "useCustomTransceivers"
private val keyTleUrl = "tleUrl"
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 = ","
//region # Satellites selection settings
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
private val _typesSelection = MutableStateFlow(getSelectedTypes())
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
override val selectedSatModes: StateFlow<List<String>> = _satelliteModeSelection
override val selectedTypes: StateFlow<List<String>> = _typesSelection
override fun setSelectedIds(ids: List<Int>) {
val selectionString = ids.joinToString(separatorComma)
@@ -122,10 +105,10 @@ class SettingsRepo(
_satelliteSelection.value = ids
}
override fun setSelectedSatModes(modes: List<String>) {
val modesString = modes.joinToString(separatorComma)
preferences.edit { putString(keySelectedSatModes, modesString) }
_satelliteModeSelection.value = modes
override fun setSelectedTypes(types: List<String>) {
val typesString = types.joinToString(separatorComma)
preferences.edit { putString(keySelectedTypes, typesString) }
_typesSelection.value = types
}
private fun getSelectedIds(): List<Int> {
@@ -134,10 +117,10 @@ class SettingsRepo(
return selectionString.split(separatorComma).map { it.toInt() }
}
private fun getSelectedSatModes(): List<String> {
val modesString = preferences.getString(keySelectedSatModes, null)
if (modesString.isNullOrEmpty()) return emptyList()
return modesString.split(separatorComma).sorted()
private fun getSelectedTypes(): List<String> {
val typesString = preferences.getString(keySelectedTypes, "Amateur")
if (typesString.isNullOrEmpty()) return emptyList()
return typesString.split(separatorComma)
}
//endregion
@@ -152,6 +135,7 @@ class SettingsRepo(
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
_passesSettings.value = settings
}
@@ -162,13 +146,16 @@ class SettingsRepo(
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
val selectedModesString = preferences.getString(keySelectedModes, null)
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
return PassesSettings(
showDeepSpace,
hoursAhead,
minElevation,
aosStartMinute,
aosEndMinute,
invertAosTimeWindow
invertAosTimeWindow,
selectedModes
)
}
//endregion
@@ -185,50 +172,29 @@ class SettingsRepo(
}
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean {
// 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 newLongitude = if (longitude > 180.0) longitude - 180 else longitude
val locator = positionToQth(latitude, newLongitude) ?: return false
setStationPosition(latitude, newLongitude, altitude, locator)
return true
}
/** GPS fix: one-shot getCurrentLocation (GPS first, 15 s timeout); returns true only with a fix */
override suspend fun setStationPosition(): Boolean {
// Permission gate: fail fast without location permission (no swallowed exceptions)
if (androidx.core.content.ContextCompat.checkSelfPermission(
context, android.Manifest.permission.ACCESS_FINE_LOCATION
) != android.content.pm.PackageManager.PERMISSION_GRANTED
) {
println("GPS: no fine location permission")
return false
}
override fun setStationPosition(): Boolean {
if (!LocationManagerCompat.isLocationEnabled(locationManager)) return false
return kotlinx.coroutines.suspendCancellableCoroutine { cont ->
val signal = android.os.CancellationSignal()
val handler = android.os.Handler(android.os.Looper.getMainLooper())
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) { }
}
try {
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) { }
val hasNet = LocationManagerCompat.hasProvider(locationManager, providerNet)
val provider = if (hasGps) providerGps else if (hasNet) providerNet else providerDef
val location = locationManager.getLastKnownLocation(providerDef)
if (location == null || System.currentTimeMillis() - location.time > 600_000L) {
println("Requesting location for $provider provider")
locationManager.requestLocationUpdates(provider, 0L, 0f, this)
} else {
setStationPosition(location.latitude, location.longitude, location.altitude)
}
cont.invokeOnCancellation { signal.cancel(); handler.removeCallbacksAndMessages(null) }
} catch (exception: SecurityException) {
println("No permissions were given - $exception")
}
return true
}
override fun setStationPosition(locator: String): Boolean {
@@ -247,8 +213,8 @@ class SettingsRepo(
}
private fun setStationPosition(latitude: Double, longitude: Double, altitude: Double, locator: String) {
val newLat = latitude.round(5)
val newLon = longitude.round(5)
val newLat = latitude.round(4)
val newLon = longitude.round(4)
val newAlt = altitude.round(1)
val timestamp = System.currentTimeMillis()
println("Received new Position($newLat, $newLon, $newAlt) & Locator $locator")
@@ -269,7 +235,6 @@ class SettingsRepo(
private val _databaseState = MutableStateFlow(getDatabaseState())
override val databaseState: StateFlow<DatabaseState> = _databaseState
override fun getSatelliteTypesIds(types: List<String>): List<Int> {
val idsSet = mutableSetOf<Int>()
types.forEach { type ->
@@ -333,10 +298,6 @@ class SettingsRepo(
override val rcSettings: StateFlow<RCSettings> = _rcSettings
override fun updateRCSettings(settings: RCSettings) {
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
Constants.FREQ_OFFSET_MIN_HZ,
Constants.FREQ_OFFSET_MAX_HZ
)
preferences.edit {
putBoolean(keyRotatorState, settings.rotatorState)
putString(keyRotatorAddress, settings.rotatorAddress)
@@ -346,7 +307,6 @@ class SettingsRepo(
putString(keyFrequencyAddress, settings.frequencyAddress)
putString(keyFrequencyPort, settings.frequencyPort)
putString(keyFrequencyFormat, settings.frequencyFormat)
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
@@ -355,7 +315,7 @@ class SettingsRepo(
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
}
_rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
_rcSettings.value = settings
}
private fun getRCSettings(): RCSettings = RCSettings(
@@ -367,8 +327,6 @@ class SettingsRepo(
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
frequencyOffsetHz = preferences.getLong(keyFrequencyOffsetHz, 0L)
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ),
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
@@ -398,18 +356,6 @@ class SettingsRepo(
putString(keySstvMode, new.sstvMode)
putLong(keyLowElevation, new.lowElevation.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
}
@@ -425,19 +371,8 @@ class SettingsRepo(
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
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())),
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)
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
)
//endregion
@@ -455,21 +390,12 @@ class SettingsRepo(
_dataSourcesSettings.value = settings
}
private fun getDataSourcesSettings(): DataSourcesSettings {
// 4.4.8 fix: legacy example.com placeholder URLs count as unconfigured -> replaced with the real default URL and the switch forced off,
// otherwise the online All/SatNOGS sources would point at the wrong address and fail to update
val storedTleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl
val storedTxUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl) ?: Sources.defaultTransceiversUrl
val tleUrl = if (storedTleUrl == "https://example.com/tle.txt") Sources.defaultTleUrl else storedTleUrl
val txUrl = if (storedTxUrl == "https://example.com/radio.json") Sources.defaultTransceiversUrl else storedTxUrl
return DataSourcesSettings(
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false) && tleUrl != Sources.defaultTleUrl,
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false) && txUrl != Sources.defaultTransceiversUrl,
tleUrl = tleUrl,
transceiversUrl = txUrl
)
}
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
)
//endregion
//region # Radio control settings
@@ -509,27 +435,5 @@ class SettingsRepo(
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
)
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) }
}
//endregion
}
@@ -20,11 +20,11 @@ package com.rtbishop.look4sat.core.data.source
import android.content.ContentResolver
import androidx.core.net.toUri
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.ByteArrayInputStream
import java.io.InputStream
class RemoteSource(
@@ -37,8 +37,6 @@ class RemoteSource(
try {
val fileUri = uri.toUri()
contentResolver.openInputStream(fileUri)?.buffered()
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
println("RemoteSource file stream exception: $exception")
null
@@ -48,75 +46,13 @@ class RemoteSource(
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
try {
val networkRequest = Request.Builder().url(url).build()
val response = httpClient.newCall(networkRequest).execute()
if (!response.isSuccessful) {
response.close()
return@withContext null
httpClient.newCall(networkRequest).execute().use { response ->
if (!response.isSuccessful) return@withContext null
ByteArrayInputStream(response.body.bytes())
}
// 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) {
println("RemoteSource network stream exception: $exception")
null
}
}
override suspend fun getAmSatCatalog(): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/catalog.php")
.header("User-Agent", "Look4Sat/4.5.7")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body?.string()
}
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
println("RemoteSource amsat catalog exception: $exception")
null
}
}
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit")
.header("User-Agent", "Look4Sat/4.5.7")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body?.string()
}
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
println("RemoteSource amsat reports exception: $exception")
null
}
}
override suspend fun getAmSatSummary(hours: Int): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/summary.php?hours=$hours")
.header("User-Agent", "Look4Sat/4.5.7")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body?.string()
}
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
println("RemoteSource amsat summary exception: $exception")
null
}
}
}
@@ -56,14 +56,8 @@ class AudioCapture : IAudioCapture {
if (read > 0) emit(if (read == chunkSize) buffer.copyOf() else buffer.copyOfRange(0, read))
}
} finally {
// stop() on a recorder that never started throws
// IllegalStateException; wrapping each cleanup step separately
// keeps the original error (e.g. a permission denial during
// startRecording) intact and guarantees release() still runs.
// Without this, a start failure masked the real cause AND leaked
// the recorder because release() was skipped.
runCatching { recorder.stop() }
runCatching { recorder.release() }
recorder.stop()
recorder.release()
}
}.flowOn(Dispatchers.IO)
}
@@ -25,8 +25,4 @@ class ShowToast(private val context: Context) : IShowToast {
override fun invoke(message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
override fun invoke(resId: Int) {
invoke(context.getString(resId))
}
}
@@ -1,62 +0,0 @@
/* 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"
}
}
@@ -1,130 +0,0 @@
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
)
}
}
@@ -1,408 +0,0 @@
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)
}
}
}
@@ -1,366 +0,0 @@
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 } }
)
}
}
@@ -102,8 +102,7 @@ class DatabaseRepoTest {
repository.updateFromRemote()
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
// New semantics: switch on + non-empty URL -> the All source uses the custom URL, data lands in the All type
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
}
private fun validCsvStream(): InputStream = """
@@ -125,12 +124,6 @@ private class FakeRemoteSource : IRemoteSource {
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
override suspend fun getAmSatCatalog(): String? = null
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
override suspend fun getAmSatSummary(hours: Int): String? = null
}
private class FakeLocalSource : ILocalSource {
@@ -172,10 +165,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedSatModes: StateFlow<List<String>> = MutableStateFlow(emptyList())
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
PassesSettings(hoursAhead = 24, minElevation = 0.0)
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
)
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
@@ -183,7 +176,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
)
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
@@ -200,13 +193,13 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun setSelectedIds(ids: List<Int>) = Unit
override fun setSelectedSatModes(modes: List<String>) = Unit
override fun setSelectedTypes(types: List<String>) = Unit
override fun setPassesSettings(settings: PassesSettings) = Unit
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
override suspend fun setStationPosition(): Boolean = true
override fun setStationPosition(): Boolean = true
override fun setStationPosition(locator: String): Boolean = true
@@ -229,10 +222,6 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
}
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
}
private fun defaultDataSourcesSettings(): DataSourcesSettings {
@@ -1,181 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.SatItem
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SelectionRepoTest {
private val dispatcher = StandardTestDispatcher()
@Test
fun `unknown mode values do not crash and do not filter out entries`() = runTest(dispatcher) {
val localSource = FakeLocalSource(
entries = listOf(
SatItem(25544, "ISS (ZARYA)", false),
SatItem(40967, "TIANGONG", false)
)
)
val settingsRepo = FakeSettingsRepo(selectedModes = listOf("REMOVED_MODE"))
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
val flow = repository.getEntriesFlow()
repository.setModes(listOf("REMOVED_MODE"))
val items = flow.first()
assertEquals(listOf(25544, 40967), items.map { it.catnum })
assertEquals(listOf("REMOVED_MODE"), repository.getCurrentModes())
}
@Test
fun `selected satellites are shown first`() = runTest(dispatcher) {
val localSource = FakeLocalSource(
entries = listOf(
SatItem(44444, "Zeta", false),
SatItem(25544, "Alpha", false),
SatItem(40967, "Beta", false)
)
)
val settingsRepo = FakeSettingsRepo(selectedModes = emptyList())
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
val flow = repository.getEntriesFlow()
repository.setSelection(listOf(40967), true)
val items = flow.first()
assertEquals(listOf(40967, 25544, 44444), items.map { it.catnum })
assertEquals(listOf(true, false, false), items.map { it.isSelected })
}
private class FakeLocalSource(
private val entries: List<SatItem>
) : ILocalSource {
override suspend fun getEntriesTotal(): Int = entries.size
override suspend fun getEntriesList(): List<SatItem> = entries
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun 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
}
}
-5
View File
@@ -1,8 +1,3 @@
plugins {
alias(libs.plugins.convention.coreDomainPlugin)
}
dependencies {
// 编译期使用 org.json(构造/解析 WaveLog API 请求体); 运行时用 Android 系统自带的 org.json
compileOnly("org.json:json:20240303")
}
@@ -1,128 +0,0 @@
package com.rtbishop.look4sat.core.domain.aprs
import kotlin.math.abs
import kotlin.math.round
import java.util.Locale
/**
* APRS-IS protocol core (pure Kotlin, no Android dependencies).
* Reverse-ported from APRSdroid 1.6.3d: AprsPacket$.scala + ab0oo Position.java.
*/
object AprsPacket {
/** APRS-IS passcode algorithm (standard): 0x73E2 seed, uppercase callsign + \0, XOR each char pair */
fun passcode(callsign: String): Int {
val s = callsign.split("-")[0].uppercase() + "\u0000"
var hash = 29666 // 0x73E2
var i = 0
while (i <= s.length - 2) {
hash = hash xor (s[i].code * 256 + s[i + 1].code)
i += 2
}
return hash and 0x7FFF
}
/** Login line: user CALL-SSID pass XXXX vers XXXX */
fun formatLogin(callsign: String, ssid: String, passcode: Int, version: String): String {
val callSsid = formatCallSsid(callsign, ssid)
return "user $callSsid pass $passcode vers $version"
}
/** Callsign-SSID join (BG7NTA + 5 -> BG7NTA-5) */
fun formatCallSsid(callsign: String, ssid: String): String {
if (ssid.isNullOrEmpty()) return callsign
return "$callsign-$ssid"
}
/** Optional distance filter: filter r/lat/lon/dist */
fun formatRangeFilter(latitude: Double, longitude: Double, distKm: Int): String {
return String.format(Locale.ROOT, "r/%.3f/%.3f/%d", latitude, longitude, distKm)
}
/**
* Altitude extension /A=000000 (feet). The field is a fixed six-digit
* decimal, so a negative altitude (below sea level, or a bad GPS fix) must
* be clamped: "%06d" of -164 yields "/A=-00164", which is not a valid
* extension and corrupts the rest of the comment field.
*/
fun formatAltitude(altitudeMeters: Double?): String {
if (altitudeMeters == null) return ""
val feet = (altitudeMeters * 3.2808399).toInt().coerceIn(0, 999999)
return String.format(Locale.ROOT, "/A=%06d", feet)
}
/**
* Speed/course extension /CCC/SSS (degrees/knots). Course wraps into
* 0..359 and speed is clamped to three digits, because "%03d" of an
* out-of-range value widens the field and breaks the fixed-width format.
*/
fun formatCourseSpeed(speedMps: Double?, bearing: Float?): String {
if (speedMps == null || bearing == null) return ""
val knots = (speedMps * 1.94384449).toInt().coerceIn(0, 999)
val course = ((bearing.toInt() % 360) + 360) % 360
return String.format(Locale.ROOT, "/%03d/%03d", course, knots)
}
}
/**
* APRS position encoding (reverse-ported from ab0oo Position.java).
* Uncompressed: DDMM.MMN/DDDMM.MME; compressed: base91.
*/
class AprsPosition(
val latitude: Double,
val longitude: Double,
val symbolTable: Char,
val symbolCode: Char,
val positionAmbiguity: Int = 0
) {
/** Uncompressed format (APRS-IS default reporting format) */
fun toUncompressedString(): String {
val lat = getDMS(latitude, true)
val lon = getDMS(longitude, false)
return "$lat$symbolTable$lon$symbolCode"
}
/** Compressed format (base91, standard APRS algorithm) */
fun toCompressedString(): String {
val jRound = round((90.0 - latitude) * 380926.0).toLong()
val j = jRound / 753571 + 33
val j2 = jRound % 753571
val j3 = j2 / 8281 + 33
val j4 = j2 % 8281
val i = (j4 % 91).toInt() + 33
val jRound2 = round((longitude + 180.0) * 190463.0).toLong()
val j5 = jRound2 / 753571 + 33
val j6 = jRound2 % 753571
val j7 = 33 + j6 / 8281
val j8 = j6 % 8281
return "" + symbolTable + j.toInt().toChar() + j3.toInt().toChar() +
((j4 / 91).toInt() + 33).toChar() + i.toChar() +
j5.toInt().toChar() + j7.toInt().toChar() +
((j8 / 91).toInt() + 33).toChar() +
((j8 % 91).toInt() + 33).toChar() + symbolCode
}
/** Single-axis DMS encoding (hundredths) */
private fun getDMS(value: Double, isLat: Boolean): String {
var iRound = round(value * 6000.0).toInt()
if (iRound < 0) iRound = -iRound
val degrees = iRound / 6000
val minutes = (iRound / 100) % 60
val hundredths = iRound % 100
val frac = when (positionAmbiguity) {
1 -> " . "
2 -> String.format(Locale.ROOT, "%d . ", minutes / 10)
3 -> String.format(Locale.ROOT, "%02d. ", minutes)
4 -> String.format(Locale.ROOT, "%02d.%d ", minutes, hundredths / 10)
else -> String.format(Locale.ROOT, "%02d.%02d", minutes, hundredths)
}
return if (isLat) {
val ns = if (value >= 0) 'N' else 'S'
String.format(Locale.ROOT, "%02d%s%c", degrees, frac, ns)
} else {
val ew = if (value >= 0) 'E' else 'W'
String.format(Locale.ROOT, "%03d%s%c", degrees, frac, ew)
}
}
}
@@ -0,0 +1,154 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Bayesian Morse timing decoder.
* Replaces hard thresholds with probability-based decision making.
*
* Inspired by VE3NEA's CW Skimmer approach:
* "Instead of making a hard decision at every input sample whether the signal
* is present or not, compute the probability that the signal is present."
*
* Uses Gaussian probability density centered on expected durations:
* P(dit | duration) = exp(-(duration - dotMs)^2 / (2 * variance^2))
* P(dash | duration) = exp(-(duration - 3*dotMs)^2 / (2 * variance^2))
*/
internal class CwBayesianDecoder {
// Morse timing parameters
private var dotDurationMs = 60f // initial 20 WPM
private var speedWpm = 20f
// Current symbol being accumulated
private var currentSymbol = StringBuilder()
private var textBuffer = StringBuilder()
// Recent dit lengths for speed estimation
private val recentDits = mutableListOf<Float>()
// Output
private var _decodedText = ""
val decodedText: String get() = _decodedText
/** Gaussian probability. */
private fun gaussianProb(durationMs: Float, expectedMs: Float, varianceMs: Float): Float {
if (varianceMs <= 0f) return 0f
val diff = durationMs - expectedMs
return kotlin.math.exp(-(diff * diff) / (2 * varianceMs * varianceMs))
}
/** Process a tone duration. Returns the symbol type with highest probability. */
fun processTone(durationMs: Float): ToneResult {
val ditProb = gaussianProb(durationMs, dotDurationMs, dotDurationMs * 0.4f)
val dashProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
return if (ditProb > dashProb && ditProb > 0.05f) {
currentSymbol.append('0')
recentDits.add(durationMs)
updateSpeed()
ToneResult('0', ditProb)
} else if (dashProb > 0.05f) {
currentSymbol.append('1')
ToneResult('1', dashProb)
} else {
ToneResult(null, 0f)
}
}
/** Process a gap duration. Returns decoded character or null. */
fun processGap(durationMs: Float): Char? {
if (currentSymbol.isEmpty()) {
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
if (wordProb > 0.2f) {
textBuffer.append(' ')
_decodedText = textBuffer.toString()
return ' '
}
return null
}
val interCharProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
if (wordProb > interCharProb && wordProb > 0.2f) {
val char = flushSymbol()
textBuffer.append(' ')
_decodedText = textBuffer.toString()
return char
}
if (interCharProb > 0.15f) {
val char = flushSymbol()
_decodedText = textBuffer.toString()
return char
}
return null
}
private fun flushSymbol(): Char? {
if (currentSymbol.isEmpty()) return null
val morse = currentSymbol.toString()
currentSymbol.clear()
val char = morseToChar(morse)
if (char != null) textBuffer.append(char)
return char
}
private fun updateSpeed() {
if (recentDits.size < 3) return
val sorted = recentDits.sorted()
val median = sorted[sorted.size / 2]
if (median > 0f) {
dotDurationMs = dotDurationMs * 0.7f + median * 0.3f
val wpm = 60.0f / (50.0f * dotDurationMs / 1000.0f)
if (wpm in 5f..55f) speedWpm = wpm
}
}
fun getSpeed(): Float = speedWpm
fun reset() {
dotDurationMs = 60f
speedWpm = 20f
recentDits.clear()
currentSymbol.clear()
textBuffer.clear()
_decodedText = ""
}
companion object {
private val MORSE_TABLE = mapOf(
"01" to 'A', "1000" to 'B', "1010" to 'C', "100" to 'D', "0" to 'E',
"0010" to 'F', "110" to 'G', "0000" to 'H', "00" to 'I', "0111" to 'J',
"101" to 'K', "0100" to 'L', "11" to 'M', "10" to 'N', "111" to 'O',
"0110" to 'P', "1101" to 'Q', "010" to 'R', "000" to 'S', "1" to 'T',
"001" to 'U', "0001" to 'V', "011" to 'W', "1001" to 'X', "1011" to 'Y',
"1100" to 'Z', "01111" to '1', "00111" to '2', "00011" to '3',
"00001" to '4', "00000" to '5', "10000" to '6', "11000" to '7',
"11100" to '8', "11110" to '9', "11111" to '0',
"010101" to '.', "110011" to ',', "001100" to '?', "011110" to '\'',
"101011" to '!', "10010" to '/', "10110" to '(', "101101" to ')',
"01000" to '&', "111000" to ':', "101010" to ';', "10001" to '=',
"01010" to '+', "100001" to '-', "001101" to '_', "010010" to '"',
"0001001" to '$', "011010" to '@'
)
fun morseToChar(morse: String): Char? = MORSE_TABLE[morse]
}
}
data class ToneResult(val symbol: Char?, val probability: Float)
@@ -0,0 +1,114 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Multi-channel CW signal tracker.
* Monitors the spectrogram for active frequency bins and extracts
* energy envelopes for each detected signal.
*
* Inspired by CW Skimmer's multi-channel approach:
* tracks all active signals in the passband simultaneously,
* selects the best one for decoded output.
*/
internal class CwChannelTracker(
private val spectrogram: CwSpectrogram,
private val maxChannels: Int = 3
) {
data class Channel(
val bin: Int,
val frequency: Float,
var active: Boolean = false,
var energy: Float = 0f,
val history: MutableList<Float> = mutableListOf(),
var confidence: Float = 0f
)
private val channels = Array(maxChannels) { Channel(0, 0f) }
/** Scan the current spectrogram column and update channel tracking. */
fun update(): List<Channel> {
val col = spectrogram.getCurrentColumn()
val peaks = findPeaks(col, threshold = 0.3f, minDistance = 2)
// Update existing channels
for (ch in channels) {
if (ch.active) {
if (peaks.contains(ch.bin)) {
ch.energy = col[ch.bin]
ch.history.add(ch.energy)
if (ch.history.size > 40) ch.history.removeAt(0)
ch.confidence = computeConfidence(ch.history)
} else {
// Signal lost — decay confidence
ch.history.add(0f)
if (ch.history.size > 40) ch.history.removeAt(0)
ch.confidence *= 0.9f
if (ch.confidence < 0.1f) ch.active = false
}
}
}
// Assign new peaks to inactive channels
var peakIdx = 0
for (ch in channels) {
if (!ch.active && peakIdx < peaks.size) {
val bin = peaks[peakIdx]
val freq = spectrogram.binToFreq(bin)
// Re-initialize channel
channels[peakIdx] = Channel(bin, freq, true, col[bin], mutableListOf(), 0.5f)
peakIdx++
}
}
return channels.filter { it.active }
}
/** Find peak bins in the spectrum. */
private fun findPeaks(spectrum: FloatArray, threshold: Float, minDistance: Int): List<Int> {
val peaks = mutableListOf<Int>()
for (i in 1 until spectrum.size - 1) {
if (spectrum[i] > spectrum[i - 1] && spectrum[i] > spectrum[i + 1] && spectrum[i] > threshold) {
if (peaks.isEmpty() || i - peaks.last() >= minDistance) {
peaks.add(i)
}
}
}
return peaks.sortedByDescending { spectrum[it] }
}
/** Compute confidence from energy history. Lower variance = higher confidence. */
private fun computeConfidence(history: List<Float>): Float {
if (history.size < 10) return 0.3f
val recent = history.takeLast(10)
val mean = recent.average().toFloat()
val variance = recent.map { (it - mean) * (it - mean) }.average().toFloat()
return if (mean > 0f) (mean / (mean + variance + 0.1f)).coerceIn(0f, 1f) else 0f
}
/** Get the channel with highest confidence. */
fun getBestChannel(): Channel? {
return channels.filter { it.active }.maxByOrNull { it.confidence }
}
fun reset() {
for (i in channels.indices) {
channels[i] = Channel(0, 0f)
}
}
}
@@ -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
/**
* 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()
}
}
@@ -0,0 +1,165 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* CW (Morse code) decoder v3 — Spectrogram-based multi-channel Bayesian decoder.
*
* Architecture inspired by Morse Expert / CW Skimmer (VE3NEA):
* 1. FFT spectrogram creates a frequency×time matrix
* 2. Multi-channel peak detector finds all active signals
* 3. Per-channel energy envelope extraction
* 4. Bayesian probability for symbol timing (Gaussian likelihood)
* 5. Best channel selected for output
*
* Timing analysis is performed per spectrogram column (hop).
* Each column represents hopSize/sampleRate seconds of audio.
*/
class CwDecoder(
val sampleRate: Int = 8000,
cwToneFreq: Float = -1f // ignored in v3 (auto-detect via spectrogram)
) {
companion object {
private const val FFT_SIZE = 256
private const val HOP_SIZE = 64
}
private val spectrogram = CwSpectrogram(
fftSize = FFT_SIZE,
hopSize = HOP_SIZE,
sampleRate = sampleRate,
minBin = 6,
maxBin = 38,
historyCols = 40
)
private val channelTracker = CwChannelTracker(spectrogram, maxChannels = 3)
private val bayesianDecoder = CwBayesianDecoder()
// Timing state per channel
private data class ChannelTiming(
var isSignal: Boolean = false,
var toneTicks: Int = 0,
var gapTicks: Int = 0
)
private val timingStates = Array(3) { ChannelTiming() }
// Time per spectrogram column in milliseconds
private val tickMs = 1000f * HOP_SIZE / sampleRate
// Output flows
private val _decodedTextFlow = MutableStateFlow("")
val decodedTextFlow: StateFlow<String> = _decodedTextFlow
private val _signalStrength = MutableStateFlow(0f)
val signalStrength: StateFlow<Float> = _signalStrength
private val _estimatedPitch = MutableStateFlow<Float?>(null)
val estimatedPitch: StateFlow<Float?> = _estimatedPitch
private val _estimatedSpeed = MutableStateFlow<Float?>(null)
val estimatedSpeed: StateFlow<Float?> = _estimatedSpeed
private var frameCount = 0
init {
if (cwToneFreq > 0f) {
_estimatedPitch.value = cwToneFreq
}
}
fun processBuffer(buffer: FloatArray) {
// 1. Feed samples to spectrogram
spectrogram.addSamples(buffer)
// 2. Get number of new columns generated
val newCols = spectrogram.getNewColumns()
if (newCols == 0) return
// 3. Update channel tracker (uses latest column for peak detection)
val activeChannels = channelTracker.update()
// 4. Process each new column for timing analysis
// Columns are indexed 0..historyCols-1, where historyCols-1 is the newest
val baseIdx = (spectrogram.historyCols - newCols).coerceAtLeast(0)
for (colOffset in 0 until newCols) {
val col = spectrogram.getColumn(baseIdx + colOffset)
for ((idx, channel) in activeChannels.withIndex()) {
if (idx >= timingStates.size) break
val state = timingStates[idx]
val energy = if (channel.bin in col.indices) col[channel.bin] else 0f
// Adaptive threshold
val threshold = 0.3f + (energy - 0.3f) * 0.3f
if (energy > threshold) {
if (!state.isSignal) {
if (state.gapTicks > 0) {
val gapMs = state.gapTicks * tickMs
bayesianDecoder.processGap(gapMs)
}
state.gapTicks = 0
state.isSignal = true
}
state.toneTicks++
} else {
if (state.isSignal) {
if (state.toneTicks > 0) {
val toneMs = state.toneTicks * tickMs
bayesianDecoder.processTone(toneMs)
}
state.toneTicks = 0
state.isSignal = false
}
state.gapTicks++
}
}
}
// 5. Update outputs
frameCount++
if (frameCount % 5 == 0) {
val bestChannel = channelTracker.getBestChannel()
if (bestChannel != null) {
_estimatedPitch.value = bestChannel.frequency
_signalStrength.value = bestChannel.confidence
_estimatedSpeed.value = bayesianDecoder.getSpeed()
}
_decodedTextFlow.value = bayesianDecoder.decodedText
}
}
fun resetDecoder() {
spectrogram.reset()
channelTracker.reset()
bayesianDecoder.reset()
for (state in timingStates) {
state.isSignal = false
state.toneTicks = 0
state.gapTicks = 0
}
frameCount = 0
_decodedTextFlow.value = ""
_signalStrength.value = 0f
_estimatedPitch.value = null
_estimatedSpeed.value = null
}
}
@@ -1,140 +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
/**
* 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)
}
}
@@ -1,242 +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.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
}
}
}
@@ -1,88 +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
/**
* 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
}
}
@@ -0,0 +1,102 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* DSP utilities for CW (Morse code) decoding.
* Pure Kotlin, no NDK required.
*/
internal object CwDsp {
/**
* Design a simple bandpass FIR filter coefficients using windowed sinc method.
* @param lowCutoff lower cutoff frequency (Hz) as fraction of sampleRate
* @param highCutoff upper cutoff frequency (Hz) as fraction of sampleRate
* @param taps filter length (must be odd)
*/
fun bandpassFir(lowCutoff: Double, highCutoff: Double, taps: Int): FloatArray {
val n = if (taps % 2 == 0) taps + 1 else taps
val half = n / 2
val coeffs = FloatArray(n)
for (i in 0 until n) {
val idx = i - half
if (idx == 0) {
coeffs[i] = (2.0 * (highCutoff - lowCutoff)).toFloat()
} else {
val x = PI * idx
coeffs[i] = ((sin(2 * highCutoff * x) - sin(2 * lowCutoff * x)) / x).toFloat()
}
// Hamming window
coeffs[i] = (coeffs[i] * (0.54 - 0.46 * cos(2 * PI * i / (n - 1)))).toFloat()
}
// Normalize
val sum = coeffs.sum()
if (sum != 0f) for (i in 0 until n) coeffs[i] /= sum
return coeffs
}
/** Apply FIR filter to a buffer. */
fun applyFir(buffer: FloatArray, coeffs: FloatArray): FloatArray {
val out = FloatArray(buffer.size)
for (i in buffer.indices) {
var sum = 0f
for (j in coeffs.indices) {
val idx = i - j
if (idx >= 0) sum += buffer[idx] * coeffs[j]
}
out[i] = sum
}
return out
}
/** Simple envelope detector: abs + low-pass smoothing. */
fun envelope(signal: FloatArray, alpha: Float = 0.1f): FloatArray {
val env = FloatArray(signal.size)
var s = 0f
for (i in signal.indices) {
s = alpha * kotlin.math.abs(signal[i]) + (1 - alpha) * s
env[i] = s
}
return env
}
/** Estimate noise floor from envelope for adaptive thresholding. */
fun noiseFloor(env: FloatArray, fraction: Float = 0.3f): Float {
val sorted = env.sortedArray()
val median = sorted[sorted.size / 2]
return median + (sorted[sorted.size * 9 / 10] - median) * fraction
}
/** Simple Goertzel to detect a specific tone frequency. */
fun goertzel(buffer: FloatArray, targetFreq: Float, sampleRate: Int): Float {
val omega = 2.0 * PI * targetFreq / sampleRate
val coeff = 2.0 * cos(omega)
var s0 = 0.0; var s1 = 0.0; var s2 = 0.0
for (sample in buffer) {
s0 = sample.toDouble() + coeff * s1 - s2
s2 = s1; s1 = s0
}
val power = s2 * s2 + s1 * s1 - coeff * s1 * s2
return sqrt(kotlin.math.abs(power)).toFloat()
}
}
@@ -0,0 +1,87 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.cos
import kotlin.math.sqrt
/**
* Radix-2 FFT for real-valued input.
* Produces magnitude spectrum for the first N/2+1 bins.
* Used by CwSpectrogram for time-frequency analysis.
*/
internal class CwFFT(private val n: Int) {
init {
require(n > 0 && n and (n - 1) == 0) { "FFT size must be power of 2, got $n" }
}
private val cosTable = FloatArray(n / 2)
private val sinTable = FloatArray(n / 2)
init {
for (i in 0 until n / 2) {
val angle = -2.0 * kotlin.math.PI * i / n
cosTable[i] = cos(angle).toFloat()
sinTable[i] = kotlin.math.sin(angle).toFloat()
}
}
/** Compute magnitude spectrum for real input. Returns array of size n/2+1. */
fun magnitudeSpectrum(input: FloatArray): FloatArray {
require(input.size == n) { "Input size must be $n, got ${input.size}" }
val real = input.copyOf()
val imag = FloatArray(n)
// Bit-reversal permutation
var j = 0
for (i in 1 until n) {
var bit = n shr 1
while (j and bit != 0) { j = j xor bit; bit = bit shr 1 }
j = j xor bit
if (i < j) {
var tmp = real[i]; real[i] = real[j]; real[j] = tmp
}
}
// Radix-2 Cooley-Tukey FFT
var len = 2
while (len <= n) {
val half = len / 2
val step = n / len
for (i in 0 until n step len) {
for (k in 0 until half) {
val tReal = real[i + k + half] * cosTable[k * step] - imag[i + k + half] * sinTable[k * step]
val tImag = real[i + k + half] * sinTable[k * step] + imag[i + k + half] * cosTable[k * step]
real[i + k + half] = real[i + k] - tReal
imag[i + k + half] = imag[i + k] - tImag
real[i + k] += tReal
imag[i + k] += tImag
}
}
len = len shl 1
}
// Magnitude spectrum (first N/2+1 bins)
val mag = FloatArray(n / 2 + 1)
for (i in 0..n / 2) {
mag[i] = sqrt(real[i] * real[i] + imag[i] * imag[i]) / n
}
return mag
}
}
@@ -0,0 +1,48 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* First-order IIR filters.
* Ported from ggmorse/src/filter.h
*/
internal class CwFilter {
private var z1 = 0f
companion object {
private const val PI_F = 3.141592653589793f
}
fun highPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
val rc = 1.0f / (2f * PI_F * cutoffHz)
val dt = 1.0f / sampleRate
val alpha = dt / (rc + dt)
z1 = alpha * (z1 + sample - z1)
return sample - z1
}
fun lowPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
val rc = 1.0f / (2f * PI_F * cutoffHz)
val dt = 1.0f / sampleRate
val alpha = dt / (rc + dt)
z1 += alpha * (sample - z1)
return z1
}
fun reset() { z1 = 0f }
}
@@ -0,0 +1,54 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.cos
import kotlin.math.sqrt
/**
* Running Goertzel filter for CW tone detection.
* Tracks a specific frequency over time with a sliding window.
* Ported from ggmorse/src/goertzel.h
*/
internal class CwGoertzel {
private var s1 = 0.0
private var s2 = 0.0
private var coeff = 0.0
fun init(sampleRate: Float, targetFreq: Float) {
val omega = 2.0 * kotlin.math.PI * targetFreq / sampleRate
coeff = 2.0 * cos(omega)
s1 = 0.0
s2 = 0.0
}
fun process(sample: Float) {
val s0 = sample.toDouble() + coeff * s1 - s2
s2 = s1
s1 = s0
}
fun getPower(): Float {
return sqrt(s2 * s2 + s1 * s1 - coeff * s1 * s2).toFloat()
}
fun reset() {
s1 = 0.0
s2 = 0.0
}
}
@@ -0,0 +1,53 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Simple linear resampler.
* Downsamples from input sample rate to output sample rate.
* Ported from ggmorse/src/resampler.h
*/
internal class CwResampler(private val inputRate: Float, private val outputRate: Float) {
private val ratio = inputRate / outputRate
private var lastSample = 0f
fun process(input: FloatArray): FloatArray {
if (ratio <= 0f || input.isEmpty()) return input
val outputLen = (input.size / ratio).toInt() + 1
val output = FloatArray(outputLen)
var idx = 0f
for (i in output.indices) {
val intIdx = idx.toInt()
val frac = idx - intIdx
if (intIdx + 1 < input.size) {
output[i] = input[intIdx] * (1 - frac) + input[intIdx + 1] * frac
} else if (intIdx < input.size) {
output[i] = input[intIdx] * (1 - frac) + lastSample * frac
} else {
output[i] = lastSample
}
idx += ratio
}
lastSample = input.lastOrNull() ?: lastSample
return output
}
fun reset() {
lastSample = 0f
}
}
@@ -0,0 +1,61 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Lightweight pitch detector using DFT at specific frequency bins.
* Only scans [200, 1200] Hz in configurable steps — much faster than full FFT.
* Ported from ggmorse/src/stfft.h (simplified for CW use case).
*/
internal class CwPitchDetector(
private val sampleRate: Float,
private val minFreq: Float = 200f,
private val maxFreq: Float = 1200f,
private val stepHz: Float = 10f
) {
/**
* Find the dominant pitch frequency in the buffer.
* Returns null if no significant pitch found.
*/
fun findPitch(buffer: FloatArray): Float? {
if (buffer.isEmpty()) return null
var bestFreq = 0f
var bestPower = 0f
var freq = minFreq
while (freq <= maxFreq) {
var real = 0.0
var imag = 0.0
val omega = 2.0 * kotlin.math.PI * freq / sampleRate
for (i in buffer.indices) {
real += buffer[i] * cos(omega * i)
imag += buffer[i] * -sin(omega * i)
}
val power = (real * real + imag * imag).toFloat()
if (power > bestPower) {
bestPower = power
bestFreq = freq
}
freq += stepHz
}
return if (bestPower > 0.001f) bestFreq else null
}
}
@@ -1,115 +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.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
}
}
@@ -0,0 +1,170 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Sliding-window spectrogram for CW decoding.
* Maintains a time-frequency matrix updated with each audio frame.
*
* FFT size: 256, hop size: 64, sample rate: 4000 (or native)
* Frequency bins: 6..38 (187-1187 Hz, covers typical CW range)
* History: 40 columns (320 ms window)
* Time resolution: 64/4000 = 16 ms, Frequency resolution: 4000/256 = 15.625 Hz
*/
internal class CwSpectrogram(
private val fftSize: Int = 256,
private val hopSize: Int = 64,
private val sampleRate: Int = 4000,
private val minBin: Int = 6,
private val maxBin: Int = 38,
val historyCols: Int = 40
) {
private val fft = CwFFT(fftSize)
val numBins: Int get() = maxBin - minBin + 1
// Hanning window
private val hanning = FloatArray(fftSize) {
(0.5 - 0.5 * kotlin.math.cos(2.0 * kotlin.math.PI * it / (fftSize - 1))).toFloat()
}
// Spectrogram data: [timeCol][freqBin]
private val spectrogram = Array(historyCols) { FloatArray(numBins) }
private var currentCol = 0
private var samplesBuffered = 0
private val buffer = FloatArray(fftSize)
// Per-bin running energy for normalization
private val binEnergy = FloatArray(numBins) { 1f }
private val alpha = 0.95f
// Counter for new columns generated since last check
private var newColumnCount = 0
/** Add audio samples, compute FFTs for each complete hop. */
fun addSamples(samples: FloatArray) {
var offset = 0
while (offset < samples.size) {
val needed = fftSize - samplesBuffered
val copyLen = minOf(needed, samples.size - offset)
System.arraycopy(samples, offset, buffer, samplesBuffered, copyLen)
samplesBuffered += copyLen
offset += copyLen
if (samplesBuffered >= fftSize) {
processFrame()
newColumnCount++
// Shift buffer: keep last (fftSize - hopSize) samples
System.arraycopy(buffer, hopSize, buffer, 0, fftSize - hopSize)
samplesBuffered = fftSize - hopSize
}
}
}
/** Get number of new columns generated since the last call to this method. */
fun getNewColumns(): Int {
val count = newColumnCount
newColumnCount = 0
return count
}
private fun processFrame() {
// Apply Hanning window
val windowed = FloatArray(fftSize) { buffer[it] * hanning[it] }
// Compute FFT magnitude spectrum
val mag = fft.magnitudeSpectrum(windowed)
// Update spectrogram column
val col = spectrogram[currentCol]
for (b in 0 until numBins) {
val binIdx = minBin + b
val rawMag = mag[binIdx]
// Running energy normalization
binEnergy[b] = alpha * binEnergy[b] + (1 - alpha) * rawMag
col[b] = if (binEnergy[b] > 1e-6f) rawMag / binEnergy[b] else 0f
}
currentCol = (currentCol + 1) % historyCols
}
/** Get the current spectrogram as a 2D array in chronological order. */
fun getSpectrogram(): Array<FloatArray> {
val result = Array(historyCols) { i ->
val srcIdx = (currentCol + i) % historyCols
spectrogram[srcIdx].copyOf()
}
return result
}
/** Get the most recent column (current energy across all frequencies). */
fun getCurrentColumn(): FloatArray {
val prevCol = (currentCol - 1 + historyCols) % historyCols
return spectrogram[prevCol].copyOf()
}
/** Get a column by index from the history (0 = oldest, historyCols-1 = newest). */
fun getColumn(index: Int): FloatArray {
val clamped = index.coerceIn(0, historyCols - 1)
val srcIdx = (currentCol - historyCols + clamped + historyCols) % historyCols
return spectrogram[srcIdx].copyOf()
}
/** Find the frequency bin with peak energy. Returns -1 if no significant signal. */
fun findPeakBin(): Int {
val col = getCurrentColumn()
var maxBin = -1
var maxVal = 0f
for (i in col.indices) {
if (col[i] > maxVal) {
maxVal = col[i]
maxBin = i
}
}
return if (maxVal > 0.3f) maxBin else -1
}
/** Get energy at a specific bin over the last N columns in chronological order. */
fun getBinEnergy(bin: Int, numCols: Int): FloatArray {
val clamped = minOf(numCols, historyCols)
val result = FloatArray(clamped)
for (i in 0 until clamped) {
val colIdx = (currentCol - clamped + i + historyCols) % historyCols
result[i] = spectrogram[colIdx][bin]
}
return result
}
/** Get the bin index for a frequency in Hz. */
fun freqToBin(freqHz: Float): Int {
val bin = (freqHz * fftSize / sampleRate).toInt()
return (bin - minBin).coerceIn(0, numBins - 1)
}
/** Get the center frequency for a bin. */
fun binToFreq(bin: Int): Float {
return (minBin + bin).toFloat() * sampleRate / fftSize
}
fun reset() {
for (col in spectrogram) col.fill(0f)
currentCol = 0
samplesBuffered = 0
buffer.fill(0f)
binEnergy.fill(1f)
}
}
@@ -1,323 +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.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
}
@@ -1,85 +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.StateFlow
/**
* A CW (Morse code) decoder fed with microphone PCM.
*
* Implementations live outside `core:domain` when they need platform APIs;
* this contract stays pure Kotlin so the UI can depend on it directly.
*/
interface ICwDecoder {
/**
* Decoded text for the *current* window.
*
* Note this is **replace** semantics, not append: a whole-segment model
* revises earlier characters as more audio arrives, so consumers must show
* the current value rather than accumulating emissions.
*/
val decodedText: StateFlow<String>
/**
* Permanent transcript of everything that has scrolled out of the live
* window. Unlike [decodedText] this only ever grows (until [reset]); it is
* what the user reads back after a signal has passed.
*/
val historyText: StateFlow<String>
/**
* 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?>
/** 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()
}
@@ -1,23 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.model
object Constants {
const val FREQ_OFFSET_MIN_HZ = -50_000L
const val FREQ_OFFSET_MAX_HZ = 50_000L
}
@@ -1,38 +0,0 @@
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,7 +29,8 @@ data class PassesSettings(
val minElevation: Double,
val aosStartMinute: Int = 0,
val aosEndMinute: Int = 23 * 60 + 59,
val invertAosTimeWindow: Boolean = false
val invertAosTimeWindow: Boolean = false,
val selectedModes: List<String>
)
data class RCSettings(
@@ -41,7 +42,6 @@ data class RCSettings(
val frequencyAddress: String,
val frequencyPort: String,
val frequencyFormat: String,
val frequencyOffsetHz: Long = 0L,
val bluetoothRotatorState: Boolean,
val bluetoothRotatorFormat: String,
val bluetoothRotatorName: String,
@@ -62,36 +62,7 @@ data class OtherSettings(
val shouldSeeWhatsNew: Boolean,
val sstvMode: String = "Auto",
val lowElevation: Double = 15.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
val highElevation: Double = 45.0
)
data class DataSourcesSettings(
@@ -1,143 +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.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
}
}
@@ -424,12 +424,8 @@ object CelestialComputer {
}
if (sunrise == 0.0) sunrise = daynum
// Phase 4: fast-forward through the day until sun drops back below threshold.
// Start from just after sunrise (small offset) so the sun is clearly above
// the threshold. This prevents a bug where Phase 3 converges to a point
// slightly below -threshold, causing Phase 4 to skip and Phase 5 to converge
// to the same time as sunrise, producing identical sunrise/sunset times.
daynum = sunrise + 0.001
// Phase 4: fast-forward through the day until sun drops back below threshold
daynum = sunrise
sunPos = getSunPosition(observer, daynumToMillis(daynum))
guard = 0
while (sunPos.elevation > -threshold && guard++ < 500) {
@@ -1,71 +0,0 @@
/* QrzGridClient.kt - QRZ callsign grid scraper (4.5.5, pure JVM in domain).
* How it works (verified): GET https://www.qrz.com/db/{callsign} with the user's QRZ login Cookie,
* the Detail table on the page holds <td class="dh">Grid Square</td><td class="di">XXX</td>.
* Without cookies the Detail is unavailable (confirmed); if the other station has no grid, the row is absent (null).
* The Cookie is pasted by the user in settings (EditThisCookie JSON export or a raw cookie string),
* never built into the app.
*/
package com.rtbishop.look4sat.core.domain.qrz
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URL
object QrzGridClient {
/** Parse the pasted Cookie (both formats): EditThisCookie JSON array or raw "k=v; k=v" string */
fun parseCookies(raw: String): String {
val text = raw.trim()
if (text.isEmpty()) return ""
// JSON array format: [{"name":"qz_userid","value":"1266043",...}, ...]
if (text.startsWith("[")) {
return try {
val arr = org.json.JSONArray(text)
val parts = mutableListOf<String>()
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
val name = o.optString("name")
val value = o.optString("value")
if (name.isNotBlank()) parts.add("$name=$value")
}
parts.joinToString("; ")
} catch (_: Exception) { text }
}
return text
}
/** Detect the callsign logged in with these cookies (fetch db home, extract the account menu callsign). Null on failure */
suspend fun fetchOwnCallsign(cookieHeader: String): String? = withContext(Dispatchers.IO) {
if (cookieHeader.isBlank()) return@withContext null
try {
val url = URL("https://www.qrz.com/db/")
val conn = url.openConnection()
conn.connectTimeout = 10_000
conn.readTimeout = 20_000
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat-Pro")
conn.setRequestProperty("Cookie", cookieHeader)
val html = conn.getInputStream().bufferedReader().use { it.readText() }
// Logged-in account menu (verified): <li class="leaf last" onclick="return true">BG7NTA <ul class="sub">
val pattern = Regex("<li class=\"leaf last\"[^>]*>\\s*([A-Z0-9/]+)\\s*<ul")
pattern.find(html)?.groupValues?.get(1)?.trim()
} catch (_: Exception) { null }
}
/** Look up a callsign's grid. Null = not set / not found (silent, does not block logging) */
suspend fun lookupGrid(callsign: String, cookieHeader: String): String? = withContext(Dispatchers.IO) {
if (callsign.isBlank() || cookieHeader.isBlank()) return@withContext null
try {
val url = URL("https://www.qrz.com/db/${callsign.trim().uppercase()}")
val conn = url.openConnection()
conn.connectTimeout = 10_000
conn.readTimeout = 20_000
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat-Pro")
conn.setRequestProperty("Cookie", cookieHeader)
val html = conn.getInputStream().bufferedReader().use { it.readText() }
// Detail table Grid Square row (verified format)
val m = Regex("""<td class="dh">Grid Square</td><td class="di">([^<]+)</td>""")
.find(html)
m?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
} catch (_: Exception) { null }
}
}
@@ -1,9 +0,0 @@
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?
}
@@ -30,12 +30,10 @@ interface IMainContainer {
val selectionRepo: ISelectionRepo
val satelliteRepo: ISatelliteRepo
val databaseRepo: IDatabaseRepo
val amSatRepo: IAmSatRepository
val radioTrackingService: IRadioTrackingService
val mutualPassData: StateFlow<MutualPassData>
fun setMutualPassData(data: MutualPassData)
fun provideAddToCalendar(): IAddToCalendar
fun providePairedBluetoothDevices(): List<Pair<String, String>>
fun provideShowToast(): IShowToast
fun provideBluetoothReporter(): IReporter
fun provideNetworkReporter(): IReporter
@@ -43,13 +41,7 @@ interface IMainContainer {
fun provideTxRadioController(): IRadioController
fun provideRxRadioController(): IRadioController
fun provideAudioCapture(): IAudioCapture
fun provideCwDecoder(): com.rtbishop.look4sat.core.domain.cw.ICwDecoder
fun provideSaveImage(): ISaveImage
// WaveLog logging (4.5.2)
val wavelogQueue: com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
fun provideWavelogUploader(): com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
}
data class MutualPassData(
@@ -61,7 +61,7 @@ interface ISatelliteRepo {
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. */
suspend fun getRadios(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio>
suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio>
/** Fetch radio transceivers for a satellite by its catalog number. */
suspend fun getRadiosWithId(id: Int): List<SatRadio>
@@ -21,10 +21,10 @@ import com.rtbishop.look4sat.core.domain.model.SatItem
import kotlinx.coroutines.flow.Flow
interface ISelectionRepo {
fun getCurrentModes(): List<String>
fun getModesList(): List<String>
fun getCurrentTypes(): List<String>
fun getTypesList(): List<String>
suspend fun getEntriesFlow(): Flow<List<SatItem>>
suspend fun setModes(modes: List<String>)
suspend fun setTypes(types: List<String>)
suspend fun setQuery(query: String)
suspend fun setSelection(selectAll: Boolean)
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
@@ -32,9 +32,9 @@ interface ISettingsRepo {
//region # Satellites selection settings
val selectedIds: StateFlow<List<Int>>
val selectedSatModes: StateFlow<List<String>>
val selectedTypes: StateFlow<List<String>>
fun setSelectedIds(ids: List<Int>)
fun setSelectedSatModes(modes: List<String>)
fun setSelectedTypes(types: List<String>)
//endregion
//region # Passes filter settings
@@ -45,8 +45,7 @@ interface ISettingsRepo {
//region # Station position settings
val stationPosition: StateFlow<GeoPos>
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
/** GPS fix (suspend): true only with a fix; false on missing permission/timeout/no signal */
suspend fun setStationPosition(): Boolean
fun setStationPosition(): Boolean
fun setStationPosition(locator: String): Boolean
//endregion
@@ -78,9 +77,4 @@ interface ISettingsRepo {
val radioControlSettings: StateFlow<RadioControlSettings>
fun updateRadioControlSettings(settings: RadioControlSettings)
//endregion
//region # Per-satellite calculator offset settings
fun getSatelliteOffset(catnum: Int): String
fun setSatelliteOffset(catnum: Int, offset: String)
//endregion
}
@@ -22,15 +22,4 @@ import java.io.InputStream
interface IRemoteSource {
suspend fun getFileStream(uri: 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,10 +18,6 @@
package com.rtbishop.look4sat.core.domain.source
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(
"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",
@@ -52,15 +48,6 @@ object Sources {
"R4UAB" to "https://r4uab.ru/satonline.txt",
"Other" to "" // key for sats filter
)
val satelliteModes = listOf(
"4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK",
"BPSK PMT-A3", "CERTO", "CW", "DATV", "DBPSK", "DOKA", "DPSK", "DQPSK", "DSB", "DSTAR",
"DUV", "DVB-S2", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5", "FSK AX.100 Mode 6",
"FSK AX.25 G3RUH", "FT8", "GENESIS FSK", "GFSK", "GFSK Pkst", "GFSK Rktr", "GFSK/BPSK",
"GMSK", "GMSK USP", "HRPT", "LoRa", "LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5",
"MSK AX.100 Mode 6", "OFDM", "OQPSK", "PPM", "PSK", "PSK31", "PSK63", "QPSK", "QPSK31",
"QPSK63", "SIDLOC", "SQPSK", "SSDV", "SSTV", "UNKNOWN", "USB", "WSJT"
)
val transceiversDataUrls = mapOf(
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
)
@@ -19,7 +19,4 @@ package com.rtbishop.look4sat.core.domain.usecase
interface IShowToast {
operator fun invoke(message: String)
/** Show by resource ID (four-language text) */
operator fun invoke(resId: Int)
}
@@ -70,13 +70,8 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
val min = timestamp.substring(14, 16).toInt() * 60000
val sec = timestamp.substring(17, 19).toInt() * 1000
val ms = timestamp.substring(20, 26).toInt() / 1000.0
// Add the day fraction numerically. Building it by string surgery breaks
// 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
val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1)
val epoch = "${year.substring(2)}$day$frac".toDouble()
OrbitalData(
name = name,
epoch = epoch,
@@ -96,7 +91,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
val line1 = tle[1]
val line2 = tle[2]
OrbitalData(
name = tle[0].trim().removePrefix("0 "),
name = tle[0].trim(),
epoch = line1.substring(18, 32).toDouble(),
meanmo = line2.substring(52, 63).toDouble(),
eccn = line2.substring(26, 33).toDouble() / 1e7,
@@ -16,20 +16,28 @@ import java.util.Locale
/**
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
*
* For a linear (passband) transponder, uplink and downlink frequencies are
* related by a fixed passband offset. When the satellite moves, both are
* Doppler-shifted. Given one, we compute the other:
* The full physical path:
*
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler)
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
* TX→RX (uplink → downlink):
* ① 地面发射 f_tx
* ② 卫星收到 f_tx × (c - v) / c (上行多普勒)
* ③ 卫星转发 = passband映射(②) (在卫星上做映射)
* ④ 地面听到 ③ × (c - v) / c (下行多普勒)
*
* RX→TX (downlink → uplink):
* ④ 地面听到 f_rx
* ③ 卫星转发 = f_rx × (c + v) / c (逆下行多普勒)
* ② 卫星收到 = 逆passband映射(③)
* ① 地面应发射 = ② × (c + v) / c (逆上行多普勒)
*
* Addresses GitHub issue #91 (Custom frequency Doppler correction).
*/
object DopplerFrequencyCalculator {
/**
* Given a downlink frequency, compute the Doppler-corrected uplink frequency.
* Returns null if the transponder is not a linear passband type.
* Given a downlink frequency (what the user hears), compute the
* uplink frequency the user should transmit.
* Full path: ④→③→②→①
*/
fun computeUplinkFromDownlink(
downlinkHz: Long,
@@ -37,17 +45,22 @@ object DopplerFrequencyCalculator {
orbitalPos: OrbitalPos
): Long? {
if (!isLinearTransponder(transponder)) return null
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz, transponder) ?: return null
return orbitalPos.getUplinkFreq(baseUplink)
// ④→③ 逆下行多普勒:卫星转发的频率
val satTx = orbitalPos.getUplinkFreq(downlinkHz)
// ③→② 逆 passband 映射
val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null
// ②→① 逆上行多普勒:地面应发射的频率
return orbitalPos.getUplinkFreq(satRx)
}
/**
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
* with an offset applied to the downlink (in Hz).
* Returns null if the transponder is not a linear passband type.
* Given a downlink frequency (what the user hears), compute the
* uplink frequency the user should transmit, with an offset applied
* to the downlink (in Hz).
* Full path: ④→③→②→①
*
* The user-entered downlink frequency already includes the offset, so subtract
* it before mapping the downlink passband position back to the uplink.
* it before the inverse downlink Doppler.
*/
fun computeUplinkFromDownlinkWithOffset(
downlinkHz: Long,
@@ -56,13 +69,20 @@ object DopplerFrequencyCalculator {
offsetHz: Long
): Long? {
if (!isLinearTransponder(transponder)) return null
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
return orbitalPos.getUplinkFreq(baseUplink)
// ④→③ 逆下行多普勒:卫星转发的频率(含 offset)
val satTxWithOffset = orbitalPos.getUplinkFreq(downlinkHz)
// ③ 去掉 offset(offset 在卫星本地频率域)
val satTx = satTxWithOffset - offsetHz
// ③→② 逆 passband 映射
val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null
// ②→① 逆上行多普勒:地面应发射的频率
return orbitalPos.getUplinkFreq(satRx)
}
/**
* Given an uplink frequency, compute the Doppler-corrected downlink frequency.
* Returns null if the transponder is not a linear passband type.
* Given an uplink frequency (what the user transmits), compute the
* downlink frequency the user will hear.
* Full path: ①→②→③→④
*/
fun computeDownlinkFromUplink(
uplinkHz: Long,
@@ -70,14 +90,19 @@ object DopplerFrequencyCalculator {
orbitalPos: OrbitalPos
): Long? {
if (!isLinearTransponder(transponder)) return null
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
return orbitalPos.getDownlinkFreq(baseDownlink)
// ①→② 上行多普勒:卫星收到的频率
val satRx = orbitalPos.getDownlinkFreq(uplinkHz)
// ②→③ passband 映射
val satTx = TransponderMapper.mapUplinkToDownlink(satRx, transponder) ?: return null
// ③→④ 下行多普勒:地面听到的
return orbitalPos.getDownlinkFreq(satTx)
}
/**
* Given an uplink frequency, compute the Doppler-corrected downlink frequency
* with an offset applied to the downlink (in Hz).
* Returns null if the transponder is not a linear passband type.
* Given an uplink frequency (what the user transmits), compute the
* downlink frequency the user will hear, with an offset applied
* to the downlink (in Hz).
* Full path: ①→②→③→④
*/
fun computeDownlinkFromUplinkWithOffset(
uplinkHz: Long,
@@ -86,8 +111,13 @@ object DopplerFrequencyCalculator {
offsetHz: Long
): Long? {
if (!isLinearTransponder(transponder)) return null
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
return orbitalPos.getDownlinkFreq(baseDownlink + offsetHz)
// ①→② 上行多普勒:卫星收到的频率
val satRx = orbitalPos.getDownlinkFreq(uplinkHz)
// ②→③ passband 映射
val satTx = TransponderMapper.mapUplinkToDownlink(satRx, transponder) ?: return null
// ③ 加上 offset(offset 在卫星本地频率域)
// ③→④ 下行多普勒:地面听到的
return orbitalPos.getDownlinkFreq(satTx + offsetHz)
}
/** True if this transponder supports linear passband mapping. */
@@ -114,30 +144,11 @@ object DopplerFrequencyCalculator {
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
.joinToString(separator = " ")
.lowercase(Locale.ENGLISH)
val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
val hasLinearName = info.contains("linear")
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
info.contains("xponder") || info.contains("xpdr")
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) ||
(hasLinearName && hasLinearMode)
}
/**
* Removes duplicate transponder entries that describe the same physical
* transponder with different mode labels (e.g. SatNOGS lists AO-7's Mode A
* as both "Lin SSB" and "Lin CW", and JO-97's U/V transponder as both
* "CW Transponder" and "SSB Transponder").
*
* Entries sharing the same uplink/downlink frequency range are considered
* the same transponder. The non-CW entry is preferred because its invert
* flag is more reliable (e.g. JO-97's CW entry wrongly has invert=false).
*/
fun deduplicateTransponders(radios: List<SatRadio>): List<SatRadio> {
return radios.groupBy { radio ->
listOf(radio.uplinkLow, radio.uplinkHigh, radio.downlinkLow, radio.downlinkHigh)
}.values.map { group ->
group.firstOrNull { it.downlinkMode?.equals("CW", ignoreCase = true) != true } ?: group.first()
}
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode)
}
}
@@ -41,21 +41,6 @@ fun Double.round(decimals: Int): Double {
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 {
// val hexChars = "0123456789ABCDEF"
// val bytes = MessageDigest.getInstance(type).digest(this.toByteArray())
@@ -81,18 +81,9 @@ fun clipLat(latitude: 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
result = ((result + 180.0) % 360.0 + 360.0) % 360.0 - 180.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
while (result < MIN_LONGITUDE) result += 360.0
while (result > MAX_LONGITUDE) result -= 360.0
return clip(result, MIN_LONGITUDE, MAX_LONGITUDE)
}
@@ -19,136 +19,37 @@ package com.rtbishop.look4sat.core.domain.utility
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? {
val trimmedQth = locator.trim().uppercase()
val trimmedQth = locator.take(6)
if (!isValidLocator(trimmedQth)) return null
val lonFirst = (trimmedQth[0].code - 65) * 20
val latFirst = (trimmedQth[1].code - 65) * 10
val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20
val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10
val lonSecond = trimmedQth[2].toString().toInt() * 2
val latSecond = trimmedQth[3].toString().toInt()
val lonThird = (trimmedQth[4].lowercaseChar().code - 97) / 12.0
val latThird = (trimmedQth[5].lowercaseChar().code - 97) / 24.0
var longitude = lonFirst + lonSecond + lonThird - 180
var latitude = latFirst + latSecond + latThird - 90
// 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))
val lonThird = (((trimmedQth[4].lowercaseChar().code - 97) / 12.0) + (1.0 / 24.0)) - 180
val latThird = (((trimmedQth[5].lowercaseChar().code - 97) / 24.0) + (1.0 / 48.0)) - 90
val longitude = (lonFirst + lonSecond + lonThird).round(4)
val latitude = (latFirst + latSecond + latThird).round(4)
return GeoPos(latitude, longitude)
}
/**
* 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? {
fun positionToQth(latitude: Double, longitude: Double): String? {
if (!isValidPosition(latitude, longitude)) return null
// The grid spans [0, 360) lon and [0, 180) lat once shifted. Clamping the
// field index alone (coerceIn below) is not enough: at exactly +90 lat or
// +180 lon the field saturates to R while the square/subsquare terms come
// from a modulo that has already wrapped to 0, so the encoded locator
// 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 newLongitude = if (longitude > 180.0) longitude else longitude + 180
val newLatitude = latitude + 90
val lonFirst = (65 + (newLongitude / 20)).toInt().toChar()
val latFirst = (65 + (newLatitude / 10)).toInt().toChar()
val lonSecond = ((newLongitude / 2) % 10).toInt()
val latSecond = (newLatitude % 10).toInt()
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar()
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar()
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"
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
}
private fun isValidPosition(lat: Double, lon: Double): Boolean {
return lat in -90.0..90.0 && lon in -180.0..180.0
return (lat >= -90.0 && lat <= 90.0) && (lon >= -180.0 && lon <= 360.0)
}
private fun isValidLocator(locator: String): Boolean {
// 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())
return locator.matches("[a-xA-X][a-xA-X]\\d\\d[a-xA-X][a-xA-X]".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()
@@ -1,15 +0,0 @@
/* 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
}
@@ -1,22 +0,0 @@
/* 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
}
}
@@ -1,332 +0,0 @@
/*
* WaveLogApi.kt - WaveLog log server API client (4.5.2 override fix 2).
*
* Supports both v1 and v2 (user's server only has v1 in practice; v2 returns 404):
* v2: POST {base}/api/v2/qso (Authorization: Bearer + JSON fields)
* v1: POST {base}/index.php/api/qso (key in JSON body + ADIF string)
* Strategy: try v2 first, auto-fallback to v1 on 404.
* Test connection: v2 GET api/v2/token; on 404 use v1 POST api/get_contacts_adif.
* Station grid: only v2 has GET api/v2/station/{id}; v1 lacks it -> fall back to user QTH.
*/
package com.rtbishop.look4sat.core.domain.wavelog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
import java.util.Locale
/** Station info (GET /api/v2/station/{id} result) */
data class WavelogStation(
val id: Int,
val name: String,
val callsign: String,
val gridsquare: String
)
sealed class WavelogResult {
data class Success(val message: String) : WavelogResult()
data class Failure(val message: String) : WavelogResult()
}
object WaveLogApi {
private const val TIMEOUT_MS = 15000
/** Normalize server URL: strip trailing slash/index.php; prepend https:// when missing */
fun normalizeUrl(raw: String): String {
var u = raw.trim().trimEnd('/')
if (u.isBlank()) return ""
if (!u.startsWith("http://") && !u.startsWith("https://")) u = "https://$u"
if (u.endsWith("/index.php")) u = u.removeSuffix("/index.php")
return u
}
/** Test connection: v2 GET api/v2/token; on 404 use v1 POST api/get_contacts_adif */
suspend fun testToken(url: String, apiKey: String, stationId: String = ""): WavelogResult = withContext(Dispatchers.IO) {
val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
// v2: GET /index.php/api/v2/token
val v2 = httpRequest("$base/index.php/api/v2/token", "GET", apiKey, null)
if (v2.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v2)")
// v1: POST /index.php/api/get_contacts_adif (key in body)
if (stationId.isNotBlank()) {
val body = JSONObject().apply {
put("key", apiKey)
put("station_id", stationId)
put("fetchfromid", 0)
}.toString()
val v1 = httpRequest("$base/index.php/api/get_contacts_adif", "POST", apiKey, body)
if (v1.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
if (v1.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
}
// v1 attempt without index.php
val body = JSONObject().apply {
put("key", apiKey)
put("station_id", stationId)
put("fetchfromid", 0)
}.toString()
val v1b = httpRequest("$base/api/get_contacts_adif", "POST", apiKey, body)
if (v1b.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
if (v1b.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
WavelogResult.Failure("连接失败: v2 HTTP ${v2.first}, v1 HTTP ${v1b.first} — 请确认服务器地址/密钥正确")
}
/** Station info: v2 only; v1 lacks the endpoint (grid check falls back to user QTH) */
suspend fun getStation(url: String, apiKey: String, stationId: String): WavelogResult = withContext(Dispatchers.IO) {
val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
val (code, resp) = httpRequest("$base/index.php/api/v2/station/$stationId", "GET", apiKey, null)
if (code in 200..299) {
return@withContext try {
val obj = JSONObject(resp)
val data = obj.optJSONObject("data") ?: obj
val station = WavelogStation(
id = data.optInt("id"),
name = data.optString("name"),
callsign = data.optString("callsign"),
gridsquare = data.optString("gridsquare")
)
WavelogResult.Success(JSONObject().apply {
put("id", station.id); put("name", station.name)
put("callsign", station.callsign); put("gridsquare", station.gridsquare)
}.toString())
} catch (e: Exception) {
WavelogResult.Failure("解析失败: ${e.message}")
}
}
// v1 has no station endpoint -> return empty Success (caller falls back to user QTH)
WavelogResult.Success("")
}
/**
* ADIF band code from a frequency in Hz. "SAT" is NOT a legal ADIF band
* value (the Band enumeration is 160M/80M/.../2M/70CM/23CM...); a logger
* that fails to parse an illegal band falls back to a default such as
* 160m. Satellite QSOs must carry the real band of the TX frequency.
*/
fun bandFromHz(freqHz: Long): String = when {
freqHz >= 1240_000_000 -> "23CM"
freqHz >= 902_000_000 -> "33CM"
freqHz >= 420_000_000 -> "70CM"
freqHz >= 222_000_000 -> "1.25M"
freqHz >= 144_000_000 -> "2M"
freqHz >= 50_000_000 -> "6M"
freqHz >= 28_000_000 -> "10M"
freqHz >= 24_890_000 -> "12M"
freqHz >= 21_000_000 -> "15M"
freqHz >= 18_068_000 -> "17M"
freqHz >= 14_000_000 -> "20M"
freqHz >= 10_000_000 -> "30M"
freqHz >= 7_000_000 -> "40M"
freqHz >= 5_102_000 -> "60M"
freqHz >= 3_500_000 -> "80M"
freqHz >= 1_800_000 -> "160M"
else -> "160M"
}
/** Band class letter for satellite mode derivation: VHF=V, UHF=U, SHF=S. */
private fun bandLetter(freqHz: Long): String = when {
freqHz >= 1_240_000_000 -> "S"
freqHz >= 420_000_000 -> "U"
freqHz >= 144_000_000 -> "V"
else -> "V"
}
/**
* ADIF SAT_MODE (free text, satellite convention): "V/U" = VHF up /
* UHF down, "U/V", "V/S", "U/S"... Derived from the actual TX/RX bands.
*/
fun satModeFrom(txFreqHz: Long, rxFreqHz: Long): String {
if (rxFreqHz <= 0) return ""
val up = bandLetter(txFreqHz)
val down = bandLetter(rxFreqHz)
return if (up == down) "" else "$up/$down"
}
/** LoTW-recognized satellite name: main name before parentheses, uppercased (ISS special case) */
fun normalizeSatName(raw: String): String {
val main = raw.substringBefore('(').trim()
.ifBlank { raw.trim() }
.uppercase(Locale.ENGLISH)
// Matching logic (mirrors WaveLog satellite table name/displayname matching + LoTW list):
// 1. Already in LoTW list (common TLE name == common name) -> return as-is
// 2. Not present -> check Celestrak alias map (SAUDISAT 1C -> SO-50 etc.); mapped name must be in LoTW list
// 3. Still unmatched -> return as-is (uploads are not blocked; QSO is still saved)
val commonName = mapOf(
"ZARYA" to "ARISS",
"ARISS" to "ARISS",
"FUNCUBE-1" to "AO-73",
"DIWATA-2B" to "PO-101",
"SAUDISAT-1C" to "SO-50",
"SAUDISAT 1C" to "SO-50",
"DIWATA-2A" to "PO-101"
)
val candidate = commonName[main] ?: main
return if (candidate in LotwSatellites.names) candidate else main
}
/** Create QSO: v2 first, fall back to v1 (ADIF) on 404 */
suspend fun postQso(
url: String,
apiKey: String,
stationProfileId: String,
qso: WavelogQso,
gridsquare: String
): WavelogResult = withContext(Dispatchers.IO) {
val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
val satName = normalizeSatName(qso.satName)
// v2: POST /index.php/api/v2/qso (JSON fields)
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
val v2Body = JSONObject().apply {
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
put("call", qso.call)
put("band", bandFromHz(qso.freqTxHz))
put("mode", qso.mode)
put("qso_date", utcDate(qso.timeUtcMs))
put("time_on", utcTime(qso.timeUtcMs))
put("freq", String.format(Locale.ENGLISH, "%.6fM", qso.freqTxHz / 1_000_000.0))
put("freq_rx", String.format(Locale.ENGLISH, "%.6fM", qso.freqRxHz / 1_000_000.0))
put("gridsquare", gridsquare)
put("rst_sent", "59")
put("rst_rcvd", "59")
put("sat_name", satName)
if (satMode.isNotBlank()) put("sat_mode", satMode)
}
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)")
if (code == 409) return@withContext WavelogResult.Success("重复(已存在)")
// v1: POST /index.php/api/qso (key in body + ADIF)
val v1Body = JSONObject().apply {
put("key", apiKey)
put("station_profile_id", stationProfileId)
put("type", "adif")
put("string", toAdif(qso, gridsquare, satName))
}
val (code1, resp1) = httpRequest("$base/index.php/api/qso", "POST", apiKey, v1Body.toString())
if (code1 in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
// v1 without index.php
val (code1b, resp1b) = httpRequest("$base/api/qso", "POST", apiKey, v1Body.toString())
if (code1b in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
WavelogResult.Failure("上传失败: v2 HTTP $code, v1 HTTP $code1 — ${shortError(resp1.ifBlank { resp1b })}")
}
/** v1 ADIF string (freq in MHz, length = UTF-8 byte count, sat_name normalized) */
internal fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String {
fun field(name: String, value: String): String {
val bytes = value.toByteArray(Charsets.UTF_8).size
return "<$name:$bytes>$value"
}
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
return buildString {
append(field("call", qso.call))
append(field("band", bandFromHz(qso.freqTxHz)))
append(field("mode", qso.mode))
append(field("freq", String.format(Locale.ENGLISH, "%.6f", qso.freqTxHz / 1_000_000.0)))
if (qso.freqRxHz > 0) {
append(field("freq_rx", String.format(Locale.ENGLISH, "%.6f", qso.freqRxHz / 1_000_000.0)))
}
append(field("qso_date", utcDateCompact(qso.timeUtcMs)))
append(field("time_on", utcTimeCompact(qso.timeUtcMs)))
append(field("rst_sent", "59"))
append(field("rst_rcvd", "59"))
// Send the grid at full precision. Truncating to 4 characters threw
// away the 6-character locator the QRZ lookup provides, coarsening the
// stored position from ~4.6 km to ~100 km and making a QSO logged via
// v1 disagree with the same QSO logged via v2 (which sends it whole).
if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare))
if (satName.isNotBlank()) {
append(field("sat_name", satName))
if (satMode.isNotBlank()) append(field("sat_mode", satMode))
append(field("prop_mode", "SAT"))
}
append("<eor>")
}
}
/** Generic HTTP request (returns code + body) */
private fun httpRequest(url: String, method: String, apiKey: String, jsonBody: String?): Pair<Int, String> {
return try {
val conn = URL(url).openConnection() as HttpURLConnection
conn.requestMethod = method
conn.connectTimeout = TIMEOUT_MS
conn.readTimeout = TIMEOUT_MS
if (apiKey.isNotBlank()) conn.setRequestProperty("Authorization", "Bearer $apiKey")
if (jsonBody != null) {
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Accept", "application/json")
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(jsonBody) }
}
val code = conn.responseCode
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
val body = if (stream != null) {
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).use { it.readText() }
} else ""
code to body
} catch (e: Exception) {
-1 to (e.message ?: e.javaClass.simpleName)
}
}
private fun shortError(body: String): String {
if (body.startsWith("<")) return body.take(80) // HTML error page
return try {
val obj = JSONObject(body)
val err = obj.optJSONObject("error")
err?.optString("message")?.ifBlank { body.take(120) }
?: obj.optString("reason").ifBlank { obj.optString("message").ifBlank { body.take(120) } }
} catch (_: Exception) {
body.take(120)
}
}
private fun utcDate(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
cal.timeInMillis = ms
return "%04d-%02d-%02d".format(
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
cal.get(java.util.Calendar.DAY_OF_MONTH)
)
}
private fun utcTime(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
cal.timeInMillis = ms
return "%02d:%02d:%02d".format(
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
cal.get(java.util.Calendar.SECOND)
)
}
private fun utcDateCompact(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
cal.timeInMillis = ms
return "%04d%02d%02d".format(
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
cal.get(java.util.Calendar.DAY_OF_MONTH)
)
}
private fun utcTimeCompact(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
cal.timeInMillis = ms
return "%02d%02d%02d".format(
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
cal.get(java.util.Calendar.SECOND)
)
}
}
@@ -1,109 +0,0 @@
/*
* WavelogQueue.kt - WaveLog local log queue (4.5.2).
*
* Pure Kotlin (no Android deps): storage goes through the IWavelogQueueStore interface,
* implemented with SharedPreferences in core/data.
* Queue capped at 500 entries (oldest dropped beyond that).
*/
package com.rtbishop.look4sat.core.domain.wavelog
import org.json.JSONArray
import org.json.JSONObject
/** Storage abstraction (SharedPreferences impl lives in core/data) */
interface IWavelogQueueStore {
fun load(): String
fun save(json: String)
}
/** QSO entry awaiting upload (local queue element, mirrors POST /api/v2/qso fields) */
data class WavelogQso(
val id: String, // 本地唯一 id(UUID)
val timeUtcMs: Long, // 回车时刻 UTC 毫秒(本地显示 + 组装 qso_date/time_on)
val call: String,
val mode: String,
val freqTxHz: Long, // 上行(回车那一秒多普勒修正)
val freqRxHz: Long, // 下行
val satName: String,
val sessionId: String = "", // 场次 ID: 卫星名-AOS 时间戳(过境仰角 0 秒), 空=未分组(旧数据)
val gridsquare: String = "", // 对方网格(QRZ 爬虫填入, 4.5.5), 空=未查到
val uploaded: Boolean = false // 是否已成功上传(4.5.2 修复: 成功后保留标记, 表格打勾)
)
class WavelogQueue(private val store: IWavelogQueueStore) {
private val key = "wavelog_queue"
fun all(): List<WavelogQso> {
val raw = store.load()
return try {
val arr = JSONArray(raw)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
WavelogQso(
id = o.getString("id"),
timeUtcMs = o.getLong("timeUtcMs"),
call = o.optString("call"),
mode = o.optString("mode"),
freqTxHz = o.optLong("freqTxHz"),
freqRxHz = o.optLong("freqRxHz"),
satName = o.optString("satName"),
sessionId = o.optString("sessionId"),
gridsquare = o.optString("gridsquare"),
uploaded = o.optBoolean("uploaded", false)
)
}
} catch (_: Exception) { emptyList() }
}
@Synchronized
fun add(qso: WavelogQso) {
val list = all().toMutableList()
list.add(0, qso) // 最新在前
if (list.size > 500) list.removeAt(list.size - 1)
save(list)
}
@Synchronized
fun remove(id: String) {
save(all().filter { it.id != id })
}
@Synchronized
fun removeAll(ids: Set<String>) {
save(all().filter { it.id !in ids })
}
/** Mark as uploaded (kept in the queue; checkmark in the table) */
@Synchronized
fun markUploaded(id: String) {
save(all().map { if (it.id == id) it.copy(uploaded = true) else it })
}
/** Update a QSO's counterpart grid (async backfill from the QRZ scraper, 4.5.5) */
@Synchronized
fun updateGridsquare(id: String, grid: String) {
save(all().map { if (it.id == id) it.copy(gridsquare = grid) else it })
}
/** Remove all uploaded entries (optional; keeps the queue lean) */
@Synchronized
fun removeUploaded() {
save(all().filter { !it.uploaded })
}
private fun save(list: List<WavelogQso>) {
val arr = JSONArray()
list.forEach { q ->
arr.put(JSONObject().apply {
put("id", q.id); put("timeUtcMs", q.timeUtcMs); put("call", q.call)
put("mode", q.mode); put("freqTxHz", q.freqTxHz)
put("freqRxHz", q.freqRxHz); put("satName", q.satName)
put("sessionId", q.sessionId)
put("gridsquare", q.gridsquare)
put("uploaded", q.uploaded)
})
}
store.save(arr.toString())
}
}
@@ -1,92 +0,0 @@
/*
* WavelogUploader.kt - WaveLog queue upload scheduler (4.5.2).
*
* Manual/periodic uploads share: per-batch grid check (user QTH first 4 chars vs station grid first 4 chars)
* -> POST /api/v2/qso -> success removes from the queue.
* Grid mismatch: returns NeedConfirm (UI dialog "Ignore and upload / Cancel"),
* retrying the batch with force=true once confirmed.
*/
package com.rtbishop.look4sat.core.domain.wavelog
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import org.json.JSONObject
sealed class UploadOutcome {
data class NeedConfirm(val stationGrid: String, val userGrid: String) : UploadOutcome()
data class Done(
val successCount: Int,
val failedCount: Int,
val message: String,
val firstError: String = ""
) : UploadOutcome()
}
class WavelogUploader(
private val settingsRepo: ISettingsRepo,
private val queue: WavelogQueue
) {
// Station grid cache (refreshed before each upload; stale value kept on failure)
private var cachedStationGrid: String? = null
/** Upload the whole queue. force=true skips the grid confirm (user chose "Ignore and upload") */
suspend fun uploadQueue(force: Boolean = false): UploadOutcome {
val settings = settingsRepo.otherSettings.value
val url = settings.wavelogUrl
val apiKey = settings.wavelogApiKey
val stationId = settings.wavelogStationId
if (url.isBlank() || apiKey.isBlank() || stationId.isBlank()) {
return UploadOutcome.Done(0, queue.all().size, "未配置 WaveLog 服务器")
}
// 1. Fetch station info (station grid); fall back to user QTH when v1 lacks the endpoint
val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid()
if (stationGrid.isNullOrBlank()) {
return UploadOutcome.Done(0, queue.all().size, "无法获取站点信息(检查站点 ID/密钥权限)")
}
// 2. Grid check: cloud station grid first 4 chars vs current station QTH first 4 chars
// (guards against a misconfigured station; unrelated to the QSO counterpart grid - per user)
if (!force) {
val userGrid = userQthGrid()
if (userGrid != null && stationGrid.take(4).lowercase() != userGrid.take(4).lowercase()) {
return UploadOutcome.NeedConfirm(stationGrid, userGrid)
}
}
// 3. Upload one by one. ADIF gridsquare = counterpart grid (the QSO partner); blank until the scraper lands
val entries = queue.all()
var ok = 0
var fail = 0
var firstError = ""
for (qso in entries) {
if (qso.uploaded) { ok++; continue }
val result = WaveLogApi.postQso(url, apiKey, stationId, qso, qso.gridsquare)
if (result is WavelogResult.Success) {
ok++
queue.markUploaded(qso.id)
} else {
fail++
if (firstError.isBlank()) firstError = (result as? WavelogResult.Failure)?.message ?: ""
}
}
val message = if (fail == 0) "成功上传 $ok 条" else "成功 $ok 条, 失败 $fail 条(保留待重试)"
return UploadOutcome.Done(ok, fail, message, firstError)
}
private suspend fun getStationGrid(url: String, apiKey: String, stationId: String): String? {
val result = WaveLogApi.getStation(url, apiKey, stationId)
if (result is WavelogResult.Success) {
return try {
JSONObject(result.message).optString("gridsquare").takeIf { it.isNotBlank() }
?: cachedStationGrid
} catch (_: Exception) { cachedStationGrid }
}
return cachedStationGrid
}
/** User's current QTH grid (first 4 chars; null when no QTH = check skipped) */
private fun userQthGrid(): String? {
return settingsRepo.stationPosition.value.qthLocator?.takeIf { it.length >= 4 }
}
}
@@ -18,11 +18,9 @@
package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.utility.DataParser
import com.rtbishop.look4sat.core.domain.utility.aprsPasscode
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
@ExperimentalCoroutinesApi
@@ -113,47 +111,6 @@ class DataParserTest {
assert(dataParser.parseCSVStream(invalidCSVStream).isEmpty())
}
private fun csvWithEpoch(epoch: String) = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,$epoch,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream()
@Test
fun `Given CSV epoch one minute past midnight the day fraction is correct`() = runTest(testDispatcher) {
// Regression: the day fraction used to be built by string surgery
// (Double.toString().substring(1)), but toString switches to scientific
// notation below 1e-3, so the leading significant digit was truncated.
// 00:01:00 produced "25001.944444444444445E-4" -> 2.50019..., an epoch
// roughly 26 years off, with no exception to reveal it.
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:01:00.000000"))[0]
assertEquals(25001.0 + 60.0 / 86400.0, sat.epoch, 1e-9)
}
@Test
fun `Given CSV epoch one second past midnight the day fraction is correct`() = runTest(testDispatcher) {
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:00:01.000000"))[0]
assertEquals(25001.0 + 1.0 / 86400.0, sat.epoch, 1e-9)
}
@Test
fun `Given CSV epoch exactly at midnight the day fraction is zero`() = runTest(testDispatcher) {
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:00:00.000000"))[0]
assertEquals(25001.0, sat.epoch, 1e-9)
}
@Test
fun `Given CSV epoch at midday the day fraction is one half`() = runTest(testDispatcher) {
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T12:00:00.000000"))[0]
assertEquals(25001.5, sat.epoch, 1e-9)
}
@Test
fun `Given CSV epoch late in the day the day fraction stays below one`() = runTest(testDispatcher) {
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T23:59:59.999000"))[0]
assert(sat.epoch > 25001.999) { "expected almost a full day, got ${sat.epoch}" }
assert(sat.epoch < 25002.0) { "day fraction must not roll into the next day, got ${sat.epoch}" }
}
@Test
fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) {
val parsedList = dataParser.parseTLEStream(validTLEStream)
@@ -282,10 +239,4 @@ class DataParserTest {
// Matches the CSV test data epoch: 2021-11-16 → day 320
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
}
@Test
fun `check APRS passcode calculation`() {
assert("M7LNB".aprsPasscode() == 12443)
assert("N0CALL".aprsPasscode() == 13023)
}
}
@@ -3,17 +3,12 @@ package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.*
import org.junit.Test
class DopplerFrequencyCalculatorTest {
private fun linearTransponder(
uuid: String = "linear",
upLow: Long = 145_000_000L,
upHigh: Long = 145_500_000L,
downLow: Long = 435_000_000L,
@@ -23,7 +18,7 @@ class DopplerFrequencyCalculatorTest {
downlinkMode: String? = "USB",
uplinkMode: String? = "LSB"
) = SatRadio(
uuid = uuid, info = info, isAlive = true,
uuid = "linear", info = info, isAlive = true,
downlinkLow = downLow, downlinkHigh = downHigh,
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
@@ -73,99 +68,19 @@ class DopplerFrequencyCalculatorTest {
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
}
@Test
fun isNamedLinearTransponder_returnsTrueForAbbreviatedLinName() {
// AO-7 style: "Mode V/A (A) Lin SSB" — "Lin" abbreviation, no "transponder" word
val ao7Entry = linearTransponder(info = "Mode V/A (A) Lin SSB", downlinkMode = "USB", uplinkMode = "USB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7Entry))
val ao7CwEntry = linearTransponder(info = "Mode V/A (A) Lin CW", downlinkMode = "CW", uplinkMode = "CW")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7CwEntry))
val ao7ModeBEntry = linearTransponder(info = "Mode U/V (B) Lin", downlinkMode = "USB", uplinkMode = "LSB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7ModeBEntry))
}
@Test
fun isNamedLinearTransponder_returnsTrueForLinearWithoutTransponderWord() {
// AO-73 style: "Mode U/V Linear" — has "Linear" but no "transponder"
val ao73Entry = linearTransponder(info = "Mode U/V Linear", downlinkMode = "USB", uplinkMode = "LSB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao73Entry))
}
@Test
fun isNamedLinearTransponder_returnsFalseForDownlinkContainingLinInsideWord() {
// "Downlink" contains "lin" but is not a linear-transponder name
val downlinkEntry = linearTransponder(info = "Mode U Downlink", downlinkMode = "FM", uplinkMode = "FM")
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(downlinkEntry))
}
@Test
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
}
@Test
fun deduplicateTransponders_mergesSameFrequencyRange() {
// AO-7's Mode A: same range, SSB and CW entries
val ssb = linearTransponder(
uuid = "ssb-uuid", info = "Mode V/A (A) Lin SSB",
downlinkMode = "USB", uplinkMode = "USB"
)
val cw = linearTransponder(
uuid = "cw-uuid", info = "Mode V/A (A) Lin CW",
downlinkMode = "CW", uplinkMode = "CW"
)
val modeB = linearTransponder(
uuid = "modeb-uuid", info = "Mode U/V (B) Lin",
upLow = 432_125_000L, upHigh = 432_175_000L,
downLow = 145_925_000L, downHigh = 145_975_000L,
downlinkMode = "USB", uplinkMode = "LSB"
)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(ssb, cw, modeB))
assertEquals(2, result.size)
// SSB entry should be preferred over CW (same range)
assertEquals("ssb-uuid", result[0].uuid)
assertEquals("modeb-uuid", result[1].uuid)
}
@Test
fun deduplicateTransponders_prefersNonCwEntry() {
// JO-97: CW entry has invert=false (wrong), SSB has invert=true (correct)
val cw = linearTransponder(
uuid = "cw-uuid", info = "U/V CW Transponder",
downlinkMode = "CW", uplinkMode = "CW",
upLow = 435_100_000L, upHigh = 435_120_000L,
downLow = 145_855_000L, downHigh = 145_875_000L
)
val ssb = linearTransponder(
uuid = "ssb-uuid", info = "U/V SSB Transponder",
downlinkMode = "USB", uplinkMode = "LSB",
upLow = 435_100_000L, upHigh = 435_120_000L,
downLow = 145_855_000L, downHigh = 145_875_000L,
inverted = true
)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(cw, ssb))
assertEquals(1, result.size)
assertEquals("ssb-uuid", result[0].uuid)
// Verify the correct invert flag is preserved
assertTrue(result[0].isInverted)
}
@Test
fun deduplicateTransponders_preservesUniqueEntries() {
val t1 = linearTransponder(uuid = "t1", upLow = 145_000_000L, upHigh = 145_500_000L,
downLow = 435_000_000L, downHigh = 435_500_000L)
val t2 = linearTransponder(uuid = "t2", upLow = 435_000_000L, upHigh = 435_500_000L,
downLow = 145_000_000L, downHigh = 145_500_000L)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(t1, t2))
assertEquals(2, result.size)
}
@Test
fun computeUplinkFromDownlink_linear_noDoppler() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
assertNotNull(uplink)
assertTrue(uplink!! > 0)
// With zero Doppler, result equals mapDownlinkToUplink output
assertEquals(145_200_000L, uplink)
}
@@ -180,23 +95,24 @@ class DopplerFrequencyCalculatorTest {
@Test
fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() {
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate.
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate
val xpdr = linearTransponder()
val orbitalPos = pos(7.0)
val orbitalPos = pos(7.0) // ~7 km/s receding
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
assertNotNull(uplink)
// Uplink freq should be Doppler shifted UP (compensating for receding)
assertTrue(uplink!! > 145_200_000L)
}
@Test
fun computeUplinkFromDownlink_fmTransponder_returnsNull() {
fun computeUplinkFromDownlink_fm_transponder_returnsNull() {
val orbitalPos = pos()
val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos)
assertNull(result)
}
@Test
fun computeDownlinkFromUplink_fmTransponder_returnsNull() {
fun computeDownlinkFromUplink_fm_transponder_returnsNull() {
val orbitalPos = pos()
val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos)
assertNull(result)
@@ -255,11 +171,13 @@ class DopplerFrequencyCalculatorTest {
val orbitalPos = pos(0.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
assertNotNull(uplink)
// Inverted: offset from high end → maps to high end of uplink
assertEquals(145_300_000L, uplink)
}
@Test
fun computeUplinkFromDownlink_roundTrip() {
// downlink → uplink → downlink should round-trip
val xpdr = linearTransponder()
val orbitalPos = pos(3.5)
val originalDownlink = 435_250_000L
@@ -267,6 +185,7 @@ class DopplerFrequencyCalculatorTest {
assertNotNull(uplink)
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
assertNotNull(roundTripDownlink)
// Doppler round-trip: small residual due to freq-dependent Doppler
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
assertTrue("Round-trip error too large: $error", error < 10000)
}
@@ -18,9 +18,7 @@
package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.utility.positionToQth
import com.rtbishop.look4sat.core.domain.utility.qthNeighbors
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
import com.rtbishop.look4sat.core.domain.utility.qthToSquare
import org.junit.Test
class QthConverterTest {
@@ -28,36 +26,21 @@ class QthConverterTest {
@Test
fun `Given valid QTH returns correct POS`() {
var result = qthToPosition("io91VL39FX")
assert(result?.latitude == 51.499913 && result.longitude == -0.22309)
assert(result?.latitude == 51.4792 && result.longitude == -0.2083)
result = qthToPosition("gf15vc")
assert(result?.latitude == -34.895833 && result.longitude == -56.208333)
// 8-char locators: finer 30" x 15" cell center
result = qthToPosition("io91vl47")
assert(result?.latitude == 51.489583 && result.longitude == -0.2125)
result = qthToPosition("jn58td25")
assert(result?.latitude == 48.147917 && result.longitude == 11.604167)
assert(result?.latitude == -34.8958 && result.longitude == -56.2083)
}
@Test
fun `Given invalid QTH returns null`() {
assert(qthToPosition("ZZ00zz") == null)
assert(qthToPosition("JN58") == null)
assert(qthToPosition("io9") == null)
assert(qthToPosition("IO91VL7") == null)
assert(qthToPosition("IO91VL4X") == null)
}
@Test
fun `Given valid POS returns correct QTH`() {
// default precision is 8 chars
assert(positionToQth(51.4878, -0.2146) == "IO91vl47")
assert(positionToQth(48.1466, 11.6083) == "JN58td25")
// 6-char precision still available for backwards compatibility
assert(positionToQth(51.4878, -0.2146, 6) == "IO91vl")
assert(positionToQth(48.1466, 11.6083, 6) == "JN58td")
// 10-char precision
assert(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb")
assert(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe")
assert(positionToQth(51.4878, -0.2146) == "IO91vl")
assert(positionToQth(48.1466, 11.6083) == "JN58td")
}
@Test
@@ -65,112 +48,4 @@ class QthConverterTest {
assert(positionToQth(91.0542, -170.1142) == null)
assert(positionToQth(89.0542, -240.1142) == null)
}
@Test
fun `Given boundary POS stays in valid grid`() {
// antipodal / edge cases must not overflow the A-R / 0-9 / a-x alphabet
assert(positionToQth(-90.0, -180.0, 8) == "AA00aa00")
// Exact positive bounds belong to the final cell, not a modulo-wrapped
// R-field/0-square combination that decodes 10°/20° away.
assert(positionToQth(90.0, 180.0, 8) == "RR99xx99")
assert(positionToQth(0.0, 0.0, 8) == "JJ00aa00")
// roundtrip stability: 8-char roundtrip is stable across a sample of positions
val positions = listOf(
Pair(51.4878, -0.2146), Pair(48.1466, 11.6083), Pair(-33.8688, 151.2093),
Pair(39.9042, 116.4074), Pair(35.6895, 139.6917), Pair(41.714, -72.727)
)
positions.forEach { (lat, lon) ->
val qth = positionToQth(lat, lon, 8)
val pos = qthToPosition(qth!!)
val qth2 = positionToQth(pos!!.latitude, pos.longitude, 8)
assert(qth == qth2) { "Roundtrip failed for ($lat, $lon): $qth -> $qth2" }
}
}
@Test
fun `Encoded locator always decodes back within one cell`() {
// An 8-char cell is 30" lon x 15" lat, so a correct encode/decode pair
// can never differ by more than that. Field clamping used to break this
// near +90 / +180 and produced errors up to 10 deg lat / 20 deg lon.
var worstLat = 0.0
var worstLon = 0.0
var worst = ""
var lat = -90.0
while (lat <= 90.0) {
var lon = -180.0
while (lon <= 180.0) {
val qth = positionToQth(lat, lon, 8)
?: error("valid position rejected: ($lat, $lon)")
val pos = qthToPosition(qth) ?: error("own output rejected: $qth")
val dLat = kotlin.math.abs(pos.latitude - lat)
val dLon = kotlin.math.abs(pos.longitude - lon)
if (dLat > worstLat || dLon > worstLon) {
worstLat = maxOf(worstLat, dLat)
worstLon = maxOf(worstLon, dLon)
worst = "($lat, $lon) -> $qth -> (${pos.latitude}, ${pos.longitude})"
}
lon += 0.5
}
lat += 0.5
}
assert(worstLat <= 0.01 && worstLon <= 0.01) {
"roundtrip drifted by (${worstLat}, ${worstLon}) deg, worst: $worst"
}
}
@Test
fun `Given out of range longitude returns null`() {
// Maidenhead only covers -180..180; 181..360 used to be accepted and
// encoded into a plausible-looking locator 20-200 deg away.
assert(positionToQth(0.0, 181.0) == null)
assert(positionToQth(0.0, 270.0) == null)
assert(positionToQth(0.0, 360.0) == null)
}
@Test
fun `Given locator with out of range field returns null`() {
// Fields run A-R; S-X in the first pair decoded past the poles.
assert(qthToPosition("SS00aa") == null)
assert(qthToPosition("XX99xx") == null)
assert(qthToPosition("AS00aa") == null)
assert(qthToPosition("AX99xx") == null)
}
@Test
fun `Given square returns correct 3x3 neighbors`() {
// Reference grid from the QTH Locator screenshot: OL42
val neighbors = qthNeighbors("OL42")
assert(neighbors == listOf(
"OL33", "OL43", "OL53",
"OL32", "OL42", "OL52",
"OL31", "OL41", "OL51"
)) { "OL42 grid mismatch: $neighbors" }
// Center cell must be the input itself
assert(neighbors[4] == "OL42")
// 9 cells, all distinct
assert(neighbors.size == 9 && neighbors.toSet().size == 9)
}
@Test
fun `Given boundary square wraps fields correctly`() {
// South-west corner: AA00 neighbors wrap to RR99 / RA90 etc.
val sw = qthNeighbors("AA00")
assert(sw.size == 9 && sw.toSet().size == 9)
assert(sw[0] == "RA91" && sw[4] == "AA00" && sw[6] == "RR99" && sw[8] == "AR19")
// North-east corner: RR99 wraps to AA00
val ne = qthNeighbors("RR99")
assert(ne.size == 9 && ne.toSet().size == 9)
assert(ne[0] == "RA80" && ne[4] == "RR99" && ne[8] == "AR08")
// Field boundary: IO91's east neighbors cross into J field
val london = qthNeighbors("IO91")
assert(london[2] == "JO02" && london[5] == "JO01")
}
@Test
fun `Given full locator returns square part`() {
assert(qthToSquare("OL42ih45") == "OL42")
assert(qthToSquare("io91VL39FX") == "IO91")
assert(qthToSquare("JN58") == "JN58")
assert(qthToSquare("garbage!!") == "----")
}
}
@@ -1,91 +0,0 @@
package com.rtbishop.look4sat.core.domain.aprs
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Locale
/**
* APRS-IS is an ASCII line protocol. Formatting the position, altitude and
* course/speed extensions with the JVM default locale produced Eastern Arabic
* or Bengali digits on devices set to ar/fa/bn, and the server rejects those
* packets.
*
* Regression guard: every formatted field must stay ASCII regardless of the
* default locale.
*/
class AprsPacketLocaleTest {
private val original: Locale = Locale.getDefault()
@After
fun restoreLocale() {
Locale.setDefault(original)
}
private val asciiPacket = Regex("^[\\x20-\\x7E]*$")
@Test
fun position_staysAsciiUnderArabicLocale() {
Locale.setDefault(Locale.forLanguageTag("ar-EG"))
val encoded = AprsPosition(39.9042, 116.4074, '/', '>').toUncompressedString()
assertTrue("not ASCII: $encoded", asciiPacket.matches(encoded))
assertEquals("3954.25N/11624.44E>", encoded)
}
@Test
fun position_staysAsciiUnderBengaliLocale() {
Locale.setDefault(Locale.forLanguageTag("bn-BD"))
val encoded = AprsPosition(-33.8688, 151.2093, '/', '>').toUncompressedString()
assertTrue("not ASCII: $encoded", asciiPacket.matches(encoded))
assertEquals("3352.13S/15112.56E>", encoded)
}
@Test
fun altitudeAndCourseSpeed_stayAsciiUnderPersianLocale() {
Locale.setDefault(Locale.forLanguageTag("fa-IR"))
val altitude = AprsPacket.formatAltitude(100.0)
val courseSpeed = AprsPacket.formatCourseSpeed(10.0, 90f)
val filter = AprsPacket.formatRangeFilter(39.9042, 116.4074, 100)
assertTrue("not ASCII: $altitude", asciiPacket.matches(altitude))
assertTrue("not ASCII: $courseSpeed", asciiPacket.matches(courseSpeed))
assertTrue("not ASCII: $filter", asciiPacket.matches(filter))
assertEquals("/A=000328", altitude)
assertEquals("/090/019", courseSpeed)
assertEquals("r/39.904/116.407/100", filter)
}
@Test
fun altitude_clampsNegativeToKeepSixDigitField() {
// "%06d" of a negative value yields "/A=-00164": the '-' takes a digit
// slot, so the extension is no longer a valid fixed-width field.
assertEquals("/A=000000", AprsPacket.formatAltitude(-50.0))
assertEquals("/A=000000", AprsPacket.formatAltitude(-1.0))
assertEquals("/A=000328", AprsPacket.formatAltitude(100.0))
}
@Test
fun courseSpeed_wrapsCourseIntoValidRange() {
assertEquals("/000/019", AprsPacket.formatCourseSpeed(10.0, 360f))
assertEquals("/359/019", AprsPacket.formatCourseSpeed(10.0, -1f))
assertEquals("/090/019", AprsPacket.formatCourseSpeed(10.0, 90f))
}
@Test
fun ambiguousPosition_staysAsciiUnderArabicLocale() {
Locale.setDefault(Locale.forLanguageTag("ar-EG"))
for (ambiguity in 1..4) {
val encoded = AprsPosition(39.9042, 116.4074, '/', '>', ambiguity)
.toUncompressedString()
assertTrue("ambiguity=$ambiguity not ASCII: $encoded", asciiPacket.matches(encoded))
}
}
}
@@ -1,90 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Greedy CTC collapse, matching the reference implementation's
* `greedy_ctc_decode`: drop blanks, then drop runs of the same label.
*
* Alphabet from `model.onnx.json` — 41 symbols plus blank at index 41.
*/
class CwCtcDecoderTest {
private val chars = listOf(
",", ".", "/", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "?",
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N",
"O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", " "
)
private val blank = 41
/** Build a `[1, T, 42]` log-prob tensor whose argmax follows [path]. */
private fun logits(path: IntArray): Array<Array<FloatArray>> {
val frames = Array(path.size) { t ->
FloatArray(42) { -10f }.also { it[path[t]] = 0f }
}
return arrayOf(frames)
}
@Test
fun alphabetSizeMatchesModelMetadata() {
assertEquals("41 symbols + blank = 42 classes", 41, chars.size)
}
@Test
fun greedy_dropsRunsOfTheSameLabel() {
assertEquals("A", CwCtcDecoder.greedy(logits(intArrayOf(14, 14, 14)), chars, blank))
}
@Test
fun greedy_keepsRepeatsSeparatedByBlank() {
// A A <blank> A collapses to "AA": the blank breaks the run.
assertEquals("AA", CwCtcDecoder.greedy(logits(intArrayOf(14, 14, blank, 14)), chars, blank))
}
@Test
fun greedy_allBlanksYieldEmptyString() {
assertEquals("", CwCtcDecoder.greedy(logits(intArrayOf(blank, blank, blank)), chars, blank))
}
@Test
fun greedy_emptyInputYieldsEmptyString() {
assertEquals("", CwCtcDecoder.greedy(logits(intArrayOf()), chars, blank))
}
@Test
fun greedy_decodesCallsignWithSpaceAndDigits() {
// "CQ BG7" — C=16 Q=30 space=40 B=15 G=20 7=10
val path = intArrayOf(
blank, 16, 16, blank, 30, blank, 40,
15, blank, 20, blank, 10, blank
)
assertEquals("CQ BG7", CwCtcDecoder.greedy(logits(path), chars, blank))
}
@Test
fun greedy_picksHighestScoringClassPerFrame() {
// Frame favours S (32) over T (33); only S must survive.
val frame = FloatArray(42) { -10f }
frame[33] = -1f
frame[32] = -0.1f
assertEquals("S", CwCtcDecoder.greedy(arrayOf(arrayOf(frame)), chars, blank))
}
}
@@ -0,0 +1,301 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.*
import org.junit.Test
import kotlin.math.PI
import kotlin.math.sin
class CwDecoderTest {
// --- Morse table ---
@Test
fun morseToChar_basicLetters() {
assertEquals('A', CwBayesianDecoder.morseToChar("01"))
assertEquals('S', CwBayesianDecoder.morseToChar("000"))
assertEquals('O', CwBayesianDecoder.morseToChar("111"))
}
@Test
fun morseToChar_numbers() {
assertEquals('1', CwBayesianDecoder.morseToChar("01111"))
assertEquals('0', CwBayesianDecoder.morseToChar("11111"))
}
@Test
fun morseToChar_unknown_returnsNull() {
assertNull(CwBayesianDecoder.morseToChar("......."))
assertNull(CwBayesianDecoder.morseToChar(""))
}
// --- FFT ---
@Test
fun fft_magnitudeSpectrum_detectsTone() {
val fft = CwFFT(256)
val sampleRate = 8000f
val freq = 700f
val buffer = FloatArray(256) { (sin(2.0 * PI * freq * it / sampleRate)).toFloat() }
val mag = fft.magnitudeSpectrum(buffer)
// Peak should be at bin around 700 * 256 / 8000 ≈ 22.4
var maxBin = 0
var maxVal = 0f
for (i in mag.indices) {
if (mag[i] > maxVal) { maxVal = mag[i]; maxBin = i }
}
assertTrue("Peak bin $maxBin should be near 22", maxBin in 18..26)
assertTrue("Peak value $maxVal should be positive", maxVal > 0.01f)
}
@Test
fun fft_magnitudeSpectrum_silence_isFlat() {
val fft = CwFFT(256)
val buffer = FloatArray(256) { 0f }
val mag = fft.magnitudeSpectrum(buffer)
for (v in mag) assertEquals("Silence spectrum should be 0, got $v", 0f, v, 1e-6f)
}
@Test
fun fft_rejectsWrongSize() {
assertThrows(IllegalArgumentException::class.java) { CwFFT(100) }
}
// --- Spectrogram ---
@Test
fun spectrogram_addSamples_updatesEnergy() {
val spec = CwSpectrogram(sampleRate = 8000)
val freq = 700f
// Feed multiple frames to stabilize energy normalization
for (i in 0..5) {
val buffer = FloatArray(256) { (sin(2.0 * PI * freq * it / 8000.0)).toFloat() }
spec.addSamples(buffer)
}
val col = spec.getCurrentColumn()
val peakBin = spec.findPeakBin()
assertTrue("Peak bin $peakBin should be >= 0", peakBin >= 0)
}
@Test
fun spectrogram_findPeakBin_returnsValidBin() {
val spec = CwSpectrogram(sampleRate = 8000)
// Add multiple frames of 700 Hz tone
for (i in 0..5) {
val buffer = FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() }
spec.addSamples(buffer)
}
val peakBin = spec.findPeakBin()
assertTrue("Peak bin should be >= 0, got $peakBin", peakBin >= 0)
}
@Test
fun spectrogram_freqToBin_roundtrip() {
val spec = CwSpectrogram(sampleRate = 8000)
val freq = 700f
val bin = spec.freqToBin(freq)
val backFreq = spec.binToFreq(bin)
assertTrue("Freq $freq → bin $bin → freq $backFreq", backFreq > 600f && backFreq < 800f)
}
@Test
fun spectrogram_getBinEnergy_returnsCorrectLength() {
val spec = CwSpectrogram(sampleRate = 8000)
val energy = spec.getBinEnergy(0, 10)
assertEquals(10, energy.size)
}
@Test
fun spectrogram_reset() {
val spec = CwSpectrogram(sampleRate = 8000)
spec.addSamples(FloatArray(256) { 1f })
spec.reset()
assertEquals(-1, spec.findPeakBin())
}
// --- Bayesian decoder ---
@Test
fun bayesian_processTone_dit() {
val decoder = CwBayesianDecoder()
// At 20 WPM, dot = 60 ms
val result = decoder.processTone(60f)
assertEquals('0', result.symbol)
assertTrue("Dit probability should be positive", result.probability > 0.1f)
}
@Test
fun bayesian_processTone_dash() {
val decoder = CwBayesianDecoder()
// Dash = 3 * dot = 180 ms
val result = decoder.processTone(180f)
assertEquals('1', result.symbol)
assertTrue("Dash probability should be positive", result.probability > 0.1f)
}
@Test
fun bayesian_processTone_unknown_returnsNull() {
val decoder = CwBayesianDecoder()
// Very long tone — low probability for both dit and dash
val result = decoder.processTone(5000f)
assertNull(result.symbol)
}
@Test
fun bayesian_processGap_interChar_returnsChar() {
val decoder = CwBayesianDecoder()
decoder.processTone(60f) // dit
decoder.processTone(60f) // dit
decoder.processTone(60f) // dit
// 3 dots = "000" = 'S'
val char = decoder.processGap(180f) // 3 * dot = inter-char gap
assertEquals('S', char)
}
@Test
fun bayesian_processGap_wordGap_addsSpace() {
val decoder = CwBayesianDecoder()
decoder.processTone(60f) // dit = 'E'
decoder.processGap(180f) // inter-char gap
// Now word gap
val space = decoder.processGap(420f) // 7 * dot
assertEquals(' ', space)
}
@Test
fun bayesian_decodedText_accumulates() {
val decoder = CwBayesianDecoder()
decoder.processTone(60f) // dit = 'E'
decoder.processGap(180f) // inter-char
assertTrue(decoder.decodedText.isNotEmpty())
}
@Test
fun bayesian_reset() {
val decoder = CwBayesianDecoder()
decoder.processTone(60f)
decoder.reset()
assertEquals("", decoder.decodedText)
}
@Test
fun bayesian_getSpeed() {
val decoder = CwBayesianDecoder()
// Send 3 dits at 20 WPM (60 ms each)
decoder.processTone(60f)
decoder.processTone(60f)
decoder.processTone(60f)
val speed = decoder.getSpeed()
assertTrue("Speed should be ~20 WPM, got $speed", speed > 15f && speed < 30f)
}
// --- Channel tracker ---
@Test
fun channelTracker_initialState() {
val spec = CwSpectrogram(sampleRate = 8000)
val tracker = CwChannelTracker(spec)
val channels = tracker.update()
assertTrue("No channels should be active initially", channels.isEmpty())
}
@Test
fun channelTracker_detectsTone() {
val spec = CwSpectrogram(sampleRate = 8000)
// Feed a tone
for (i in 0..5) {
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
}
val tracker = CwChannelTracker(spec)
val channels = tracker.update()
assertTrue("Should detect at least 1 channel", channels.isNotEmpty())
}
@Test
fun channelTracker_bestChannel() {
val spec = CwSpectrogram(sampleRate = 8000)
for (i in 0..5) {
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
}
val tracker = CwChannelTracker(spec)
tracker.update()
val best = tracker.getBestChannel()
assertNotNull("Best channel should exist", best)
if (best != null) assertTrue(best.frequency in 600f..800f)
}
@Test
fun channelTracker_reset() {
val spec = CwSpectrogram(sampleRate = 8000)
for (i in 0..5) {
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
}
val tracker = CwChannelTracker(spec)
tracker.update()
tracker.reset()
assertNull(tracker.getBestChannel())
}
// --- Full decoder ---
@Test
fun decoder_initialState() {
val decoder = CwDecoder()
assertEquals("", decoder.decodedTextFlow.value)
assertEquals(0f, decoder.signalStrength.value, 0.001f)
}
@Test
fun decoder_processSilence_doesNotCrash() {
val decoder = CwDecoder()
decoder.processBuffer(FloatArray(256) { 0f })
assertEquals("", decoder.decodedTextFlow.value)
}
@Test
fun decoder_processNoise_doesNotCrash() {
val decoder = CwDecoder()
decoder.processBuffer(FloatArray(256) { (Math.random() * 2 - 1).toFloat() * 0.1f })
assertNotNull(decoder.decodedTextFlow.value)
}
@Test
fun decoder_processTone_doesNotCrash() {
val decoder = CwDecoder()
for (i in 0..20) {
decoder.processBuffer(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
}
assertNotNull(decoder.decodedTextFlow.value)
}
@Test
fun decoder_reset() {
val decoder = CwDecoder()
decoder.processBuffer(FloatArray(256) { 1f })
decoder.resetDecoder()
assertEquals("", decoder.decodedTextFlow.value)
assertEquals(0f, decoder.signalStrength.value, 0.001f)
}
@Test
fun decoder_withFixedPitch() {
val decoder = CwDecoder(sampleRate = 8000, cwToneFreq = 700f)
assertEquals(700f, decoder.estimatedPitch.value)
}
}
@@ -1,152 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The rolling audio buffer feeding DeepCW.
*
* DeepCW is a whole-segment CTC model, not a sample-by-sample decoder: it
* rewrites earlier output whenever more context arrives, so incremental
* stitching is impossible. Instead we keep a bounded window and re-decode all
* of it periodically, replacing the displayed text.
*/
class CwDeepBufferTest {
@Test
fun capacityIsCappedAtMaxSeconds() {
val buffer = CwDeepBuffer(sampleRate = 3200, maxSeconds = 20.0)
repeat(30) { buffer.append(FloatArray(3200)) }
assertEquals(3200 * 20, buffer.size)
}
@Test
fun oldestSamplesAreDiscardedFirst() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f))
buffer.append(floatArrayOf(4f, 5f))
assertArrayEquals(floatArrayOf(2f, 3f, 4f, 5f), buffer.snapshot(), 0f)
}
@Test
fun snapshotIsChronologicalAfterWrapAround() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f))
assertArrayEquals(floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), 0f)
}
@Test
fun appendLargerThanCapacityKeepsOnlyTheTail() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f))
assertEquals(4, buffer.size)
assertArrayEquals(floatArrayOf(6f, 7f, 8f, 9f), buffer.snapshot(), 0f)
}
@Test
fun redecodeIsSignalledOncePerInterval() {
// 1.5 s at 3200 Hz is 4800 samples; 1600 samples is 0.5 s.
val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500)
assertFalse("1.0s elapsed: interval not reached", buffer.append(FloatArray(3200)))
assertTrue("1.5s elapsed: first trigger", buffer.append(FloatArray(1600)))
assertFalse("2.0s: only 0.5s since trigger", buffer.append(FloatArray(1600)))
assertFalse("2.5s: only 1.0s since trigger", buffer.append(FloatArray(1600)))
assertTrue("3.0s: 1.5s since trigger, fires again", buffer.append(FloatArray(1600)))
}
@Test
fun redecodeIntervalDoesNotDriftOverManyChunks() {
// 100 ms chunks, as AudioCapture emits them: exactly 15 chunks per
// 1.5 s interval, so 150 chunks must fire exactly 10 times.
val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500)
var fired = 0
repeat(150) { if (buffer.append(FloatArray(320))) fired++ }
assertEquals(10, fired)
}
@Test
fun snapshotDoesNotAliasInternalStorage() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
buffer.snapshot()[0] = 99f
assertEquals("caller must not be able to mutate the buffer", 1f, buffer.snapshot()[0], 0f)
}
@Test
fun resetClearsSamplesAndIntervalCounter() {
val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500)
buffer.append(FloatArray(3200))
buffer.reset()
assertEquals(0, buffer.size)
assertFalse("counter restarted, 1.0s must not trigger", buffer.append(FloatArray(3200)))
}
@Test
fun hasEnoughAudioTracksTheModelMinimum() {
// compute() needs at least FFT_LENGTH samples to produce one frame.
val buffer = CwDeepBuffer(3200, 20.0)
buffer.append(FloatArray(100))
assertFalse(buffer.hasEnoughAudio)
buffer.append(FloatArray(200))
assertTrue(buffer.hasEnoughAudio)
}
@Test
fun defaultsMatchTheMeasuredOptimum() {
// 20s / 1.5s were chosen from measurements: 20s is the smallest window
// reaching 0.0% CER, and keeps inference well inside real time.
val buffer = CwDeepBuffer()
assertEquals(CwDeepSpectrogram.SAMPLE_RATE * 20, buffer.capacity)
}
@Test
fun overflowCollectsEvictedSamplesInOrder() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) // capacity 4
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
assertEquals("nothing evicted before the window is full", 0, buffer.overflowCount)
buffer.append(floatArrayOf(5f, 6f)) // overwrites 1, 2
assertArrayEquals("evicted samples, oldest first", floatArrayOf(1f, 2f), buffer.drainOverflow(), 0f)
assertArrayEquals("live window still correct", floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), 0f)
}
@Test
fun drainOverflowClearsItself() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
buffer.append(floatArrayOf(5f))
assertEquals(1, buffer.overflowCount)
buffer.drainOverflow()
assertEquals(0, buffer.overflowCount)
assertArrayEquals(FloatArray(0), buffer.drainOverflow(), 0f)
}
@Test
fun resetClearsOverflow() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
buffer.append(floatArrayOf(5f))
assertEquals(1, buffer.overflowCount)
buffer.reset()
assertEquals(0, buffer.overflowCount)
}
}
@@ -1,130 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sin
/**
* Pins the Kotlin front-end to the upstream Python reference implementation.
*
* `golden_spec.txt` was produced by deepcw-engine's own preprocessing code
* (numpy reflect padding, `np.hanning(N+1)[:-1]`, `np.fft.rfft`, `log1p`) over
* the audio [generateTestAudio] builds. Both sides synthesise the audio from
* the same deterministic formula, so only the spectrogram needs pinning.
*
* A mismatch here means the model would receive subtly wrong input and emit
* plausible-looking garbage, which is very hard to diagnose downstream — so
* this test guards the whole pipeline.
*
* Regenerate with `scripts/deepcw_gen_golden.py` from the skill library if the
* model metadata ever changes.
*/
class CwDeepGoldenVectorTest {
private companion object {
const val SRC_RATE = 8000
const val TONE_HZ = 700.0
const val AMPLITUDE = 0.6
const val DOT_SAMPLES = 480 // 20 WPM at 8000 Hz: 1.2/20*8000
const val PATTERN = "-.-." // the letter C
const val TOLERANCE = 1e-4f
}
/** Square-keyed 700 Hz tone: 4 dots of silence, "C", 4 dots of silence. */
private fun generateTestAudio(): FloatArray {
val keying = ArrayList<Int>()
repeat(4 * DOT_SAMPLES) { keying.add(0) }
for ((i, element) in PATTERN.withIndex()) {
val length = if (element == '-') 3 * DOT_SAMPLES else DOT_SAMPLES
repeat(length) { keying.add(1) }
if (i < PATTERN.length - 1) repeat(DOT_SAMPLES) { keying.add(0) }
}
repeat(4 * DOT_SAMPLES) { keying.add(0) }
return FloatArray(keying.size) { i ->
if (keying[i] == 1) {
(AMPLITUDE * sin(2.0 * PI * TONE_HZ * i / SRC_RATE)).toFloat()
} else {
0f
}
}
}
private fun readGoldenSpectrogram(): Array<FloatArray> {
val stream = javaClass.classLoader?.getResourceAsStream("cw/golden_spec.txt")
?: throw IllegalStateException("cw/golden_spec.txt missing from test resources")
stream.bufferedReader().use { reader ->
val (frames, bins) = reader.readLine().trim().split(" ").map(String::toInt)
return Array(frames) {
val row = reader.readLine().trim().split(" ")
require(row.size == bins) { "expected $bins values, got ${row.size}" }
FloatArray(bins) { i -> row[i].toFloat() }
}
}
}
@Test
fun spectrogramMatchesPythonReferenceFrameByFrame() {
val expected = readGoldenSpectrogram()
val audio = CwDeepSpectrogram.resampleLinear(
generateTestAudio(), SRC_RATE, CwDeepSpectrogram.SAMPLE_RATE
)
val actual = CwDeepSpectrogram.compute(audio)
assertEquals("frame count", expected.size, actual.size)
assertEquals("bin count", expected[0].size, actual[0].size)
var worstDelta = 0f
var worstAt = ""
for (t in expected.indices) {
for (f in expected[t].indices) {
val delta = abs(expected[t][f] - actual[t][f])
if (delta > worstDelta) {
worstDelta = delta
worstAt = "frame $t bin $f: expected ${expected[t][f]}, got ${actual[t][f]}"
}
}
}
assertTrue(
"front-end diverges from the Python reference — worst delta $worstDelta at $worstAt",
worstDelta <= TOLERANCE
)
}
@Test
fun resampledLengthMatchesReference() {
val audio = generateTestAudio()
assertEquals("source audio length", 9120, audio.size)
val resampled = CwDeepSpectrogram.resampleLinear(
audio, SRC_RATE, CwDeepSpectrogram.SAMPLE_RATE
)
assertEquals("resampled length", 3648, resampled.size)
}
@Test
fun goldenVectorHasExpectedShape() {
val golden = readGoldenSpectrogram()
assertEquals("frames", 77, golden.size)
assertEquals("bins", CwDeepSpectrogram.FREQUENCY_BINS, golden[0].size)
}
}
@@ -1,151 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sin
/**
* Verifies the DeepCW front-end against the upstream Python reference
* implementation (deepcw-engine examples/python/decode_morse.py).
*
* Model metadata: sampleRate 3200, fftLength 256, hopLength 48,
* 400-1200 Hz -> 65 bins, log1p normalization.
*/
class CwDeepSpectrogramTest {
@Test
fun frequencyBinRange_matchesModelMetadata() {
// binHz = 3200/256 = 12.5; start = ceil(400/12.5) = 32; stop = floor(1200/12.5)+1 = 97
val (start, stop) = CwDeepSpectrogram.frequencyBinRange(3200, 256, 400.0, 1200.0)
assertEquals(32, start)
assertEquals(97, stop)
assertEquals("metadata declares 65 frequency bins", 65, stop - start)
}
@Test
fun compute_producesTimeBy65Matrix() {
// 1 second at 3200 Hz. Reflect padding adds fft/2 on both sides,
// so frames = 1 + (3200 + 256 - 256)/48 = 1 + 66 = 67
val spec = CwDeepSpectrogram.compute(FloatArray(3200))
assertEquals(67, spec.size)
assertEquals(65, spec[0].size)
}
@Test
fun compute_toneLandsInExpectedBin() {
// 700 Hz -> absolute bin 700/12.5 = 56 -> relative index 56 - 32 = 24
val audio = FloatArray(3200) { (0.6 * sin(2.0 * PI * 700.0 * it / 3200.0)).toFloat() }
val spec = CwDeepSpectrogram.compute(audio)
val middle = spec[spec.size / 2]
val peak = middle.indices.maxByOrNull { middle[it] } ?: -1
assertTrue("peak at index $peak, expected near 24", abs(peak - 24) <= 1)
}
/**
* The waterfall asks for the whole band so that a tone the model cannot read is still
* in the picture. Inside the model's window such a tone leaves nothing to see: the
* brightest column there is noise, and it does not even follow the keying.
*/
@Test
fun compute_wholeBandPlacesAnOutOfWindowTone() {
val audio = FloatArray(3200) { (0.6 * sin(2.0 * PI * 1500.0 * it / 3200.0)).toFloat() }
val display = CwDeepSpectrogram.compute(
audio,
CwDeepSpectrogram.DISPLAY_MIN_FREQ_HZ,
CwDeepSpectrogram.DISPLAY_MAX_FREQ_HZ
)
// DC to Nyquist inclusive: 0..1600 Hz in 12.5 Hz steps.
assertEquals(129, display[0].size)
val middle = display[display.size / 2]
val peak = middle.indices.maxByOrNull { middle[it] } ?: -1
val binHz = CwDeepSpectrogram.SAMPLE_RATE.toDouble() / CwDeepSpectrogram.FFT_LENGTH
assertEquals("1500 Hz must land on its own bin", 1500.0, peak * binHz, binHz)
}
/** The model's own call must keep its exact shape, whatever the display asks for. */
@Test
fun compute_defaultsToTheModelWindow() {
val audio = FloatArray(3200) { (0.6 * sin(2.0 * PI * 700.0 * it / 3200.0)).toFloat() }
val model = CwDeepSpectrogram.compute(audio)
val explicit = CwDeepSpectrogram.compute(
audio, CwDeepSpectrogram.MIN_FREQ_HZ, CwDeepSpectrogram.MAX_FREQ_HZ
)
assertEquals(CwDeepSpectrogram.FREQUENCY_BINS, model[0].size)
assertEquals(model.size, explicit.size)
for (frame in model.indices) {
assertArrayEquals(
"explicit model range must equal the default",
model[frame], explicit[frame], 0f
)
}
}
@Test
fun compute_appliesLog1pSoValuesAreNonNegative() {
val audio = FloatArray(3200) { (0.6 * sin(2.0 * PI * 700.0 * it / 3200.0)).toFloat() }
val spec = CwDeepSpectrogram.compute(audio)
for (frame in spec) {
for (v in frame) {
assertTrue("log1p of a magnitude must be >= 0, got $v", v >= 0f)
}
}
}
@Test
fun resampleLinear_convertsRateAndLength() {
assertEquals(3200, CwDeepSpectrogram.resampleLinear(FloatArray(8000), 8000, 3200).size)
assertEquals(3200, CwDeepSpectrogram.resampleLinear(FloatArray(44100), 44100, 3200).size)
}
@Test
fun resampleLinear_sameRateIsIdentity() {
val input = floatArrayOf(0.1f, 0.2f, 0.3f)
val out = CwDeepSpectrogram.resampleLinear(input, 3200, 3200)
assertEquals(3, out.size)
assertEquals(0.2f, out[1], 1e-6f)
}
@Test
fun resampleLinear_preservesToneFrequency() {
// A 700 Hz tone sampled at 8000 Hz must still peak at bin 24 after
// resampling to 3200 Hz — this is the path real microphone audio takes.
val at8k = FloatArray(8000) { (0.6 * sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() }
val at3200 = CwDeepSpectrogram.resampleLinear(at8k, 8000, 3200)
val spec = CwDeepSpectrogram.compute(at3200)
val middle = spec[spec.size / 2]
val peak = middle.indices.maxByOrNull { middle[it] } ?: -1
assertTrue("resampled tone peak at $peak, expected near 24", abs(peak - 24) <= 1)
}
@Test
fun compute_rejectsAudioShorterThanFftLength() {
try {
CwDeepSpectrogram.compute(FloatArray(100))
throw AssertionError("expected an exception for audio shorter than fftLength")
} catch (expected: IllegalArgumentException) {
// desired path
}
}
}
@@ -1,182 +0,0 @@
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The pool feeds [CwToneShifter.detectToneHz], which measures a waveform, so the
* samples it hands over must be the most recent audio in chronological order. Getting
* the ring wrap wrong would splice the waveform and corrupt every pitch estimate
* silently - no downstream assertion would notice, which is why these tests drive the
* real class rather than restating its logic.
*/
class CwDetectionPoolTest {
private val capacity = 1280
/** Chunk of a monotonic ramp, so any reordering is visible. */
private fun ramp(from: Int, count: Int) = FloatArray(count) { (from + it).toFloat() }
private fun assertAscending(values: FloatArray) {
for (i in 1 until values.size) {
assertEquals(
"sample $i breaks the ramp, so the ring wrap is wrong",
values[i - 1] + 1f, values[i], 0f
)
}
}
@Test
fun `reports readiness only once capacity is reached`() {
val pool = CwDetectionPool(capacity)
assertFalse("an empty pool is not ready", pool.isReady)
assertEquals(0, pool.size)
// Three 320-sample chunks are 960 samples: still short.
repeat(3) { pool.add(ramp(it * 320, 320)) }
assertEquals(960, pool.size)
assertFalse("960 of $capacity samples is not ready", pool.isReady)
pool.add(ramp(960, 320))
assertEquals(capacity, pool.size)
assertTrue("a full pool must report ready", pool.isReady)
}
@Test
fun `drains a partial fill without stale slots`() {
val pool = CwDetectionPool(capacity)
pool.add(ramp(500, 320))
val drained = pool.drain()
assertEquals("only what was added may come back", 320, drained.size)
assertEquals(500f, drained.first(), 0f)
assertEquals(819f, drained.last(), 0f)
assertAscending(drained)
assertEquals("draining empties the pool", 0, pool.size)
}
@Test
fun `drains exactly the most recent samples once wrapped`() {
val pool = CwDetectionPool(capacity)
// 10 chunks of 320 = 3200 samples through a 1280-sample pool.
repeat(10) { pool.add(ramp(it * 320, 320)) }
val drained = pool.drain()
assertEquals(capacity, drained.size)
assertEquals("the newest sample fed must be last", 3199f, drained.last(), 0f)
assertEquals("the oldest retained sample must be first", (3200 - capacity).toFloat(), drained.first(), 0f)
assertAscending(drained)
}
@Test
fun `keeps only the tail of an oversized chunk`() {
val pool = CwDetectionPool(capacity)
pool.add(ramp(0, 5000))
val drained = pool.drain()
assertEquals(capacity, drained.size)
assertEquals(4999f, drained.last(), 0f)
assertEquals((5000 - capacity).toFloat(), drained.first(), 0f)
assertAscending(drained)
}
@Test
fun `handles single-sample chunks`() {
val pool = CwDetectionPool(capacity)
// Far more single-sample adds than the capacity, exercising every wrap position.
repeat(2000) { pool.add(floatArrayOf(it.toFloat())) }
val drained = pool.drain()
assertEquals(capacity, drained.size)
assertEquals(1999f, drained.last(), 0f)
assertEquals((2000 - capacity).toFloat(), drained.first(), 0f)
assertAscending(drained)
}
@Test
fun `is reusable after draining`() {
val pool = CwDetectionPool(capacity)
repeat(5) { pool.add(ramp(it * 320, 320)) }
pool.drain()
// A second pass must not inherit anything from the first.
pool.add(ramp(9000, 320))
val drained = pool.drain()
assertEquals(320, drained.size)
assertEquals(9000f, drained.first(), 0f)
assertEquals(9319f, drained.last(), 0f)
assertAscending(drained)
}
@Test
fun `clear discards pooled audio`() {
val pool = CwDetectionPool(capacity)
pool.add(ramp(0, 640))
pool.clear()
assertEquals(0, pool.size)
assertFalse(pool.isReady)
pool.add(ramp(7000, 320))
val drained = pool.drain()
assertEquals("cleared samples must not reappear", 320, drained.size)
assertEquals(7000f, drained.first(), 0f)
}
@Test
fun `empty chunks are ignored`() {
val pool = CwDetectionPool(capacity)
pool.add(ramp(0, 320))
pool.add(FloatArray(0))
assertEquals("an empty chunk must not change the pool", 320, pool.size)
assertAscending(pool.drain())
}
@Test
fun `chunk exactly the size of the pool is kept whole`() {
val pool = CwDetectionPool(capacity)
pool.add(ramp(100, capacity))
val drained = pool.drain()
assertEquals(capacity, drained.size)
assertEquals(100f, drained.first(), 0f)
assertEquals((100 + capacity - 1).toFloat(), drained.last(), 0f)
assertAscending(drained)
}
@Test
fun `pooled audio is long enough for the detector to resolve a pitch`() {
// The pool exists to make detection possible at all; prove the pooled length
// actually works rather than only that the plumbing moves samples around.
val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
val pool = CwDetectionPool(capacity)
var phase = 0
repeat(4) {
pool.add(FloatArray(320) { i ->
kotlin.math.sin(2.0 * Math.PI * 1500.0 * (phase + i) / sampleRate).toFloat()
})
phase += 320
}
assertTrue(pool.isReady)
val detected = CwToneShifter.detectToneHz(pool.drain(), sampleRate)
assertEquals(
"four pooled capture chunks must be enough to detect a 1500 Hz tone",
1500.0, detected!!.toDouble(), 25.0
)
}
@Test
fun `rejects a non-positive capacity`() {
for (bad in listOf(0, -1, -1280)) {
try {
CwDetectionPool(bad)
throw AssertionError("capacity $bad should have been rejected")
} catch (expected: IllegalArgumentException) {
// The decoder derives capacity from a constant; a zero would otherwise
// fail later as a division by zero in the ring arithmetic.
}
}
}
}
@@ -1,271 +0,0 @@
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sin
import kotlin.random.Random
/**
* Drives the real [CwShiftDecider] with the real [CwToneShifter.analyse].
*
* This suite exists because an earlier version of the same rule lived inside the decoder,
* where tests could only restate it. Mutation testing then showed four injected defects -
* removing the silence guard, comparing shifts instead of tones, never setting the anchor,
* and inverting the hysteresis comparison - all left the suite green. Every test below
* targets one of those, so each is now a real tripwire.
*/
class CwShiftDeciderTest {
private val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
private val hysteresisHz = CwShiftDecider.DEFAULT_HYSTERESIS_HZ
private fun steadyTone(hz: Double, samples: Int = 1280): FloatArray =
FloatArray(samples) { i -> sin(2.0 * PI * hz * i / sampleRate).toFloat() }
private fun noise(samples: Int = 1280, seed: Int = 1, level: Double = 0.02): FloatArray {
val random = Random(seed)
return FloatArray(samples) { ((random.nextDouble() - 0.5) * 2 * level).toFloat() }
}
private fun analyse(audio: FloatArray) = CwToneShifter.analyse(audio, sampleRate)
private fun feed(decider: CwShiftDecider, audio: FloatArray) = decider.accept(analyse(audio))
// --- Mutant (a): the silence guard ---------------------------------------------
@Test
fun `silence retains an established shift`() {
val decider = CwShiftDecider()
val established = feed(decider, steadyTone(1400.0))
assertEquals(CwShiftDecider.Outcome.SHIFTED, established.outcome)
assertTrue("a 1400 Hz tone must produce a shift", established.shiftHz != 0f)
val silent = feed(decider, noise())
assertEquals(
"silence must be reported as no tone, not as a zero shift",
CwShiftDecider.Outcome.NO_TONE, silent.outcome
)
assertEquals(
"silence must not change the shift",
established.shiftHz, silent.shiftHz, 0f
)
assertFalse("a silent window is not a change", silent.changed)
assertEquals(
"the decider's state must still hold the shift",
established.shiftHz, decider.shiftHz, 0f
)
}
@Test
fun `a run of silence does not erode the shift`() {
val decider = CwShiftDecider()
val established = feed(decider, steadyTone(1400.0)).shiftHz
repeat(8) { i ->
val decision = feed(decider, noise(seed = i + 2))
assertEquals(
"silent window $i changed the shift",
established, decision.shiftHz, 0f
)
}
assertEquals(established, decider.shiftHz, 0f)
assertNotNull("the anchor must survive silence", decider.anchorToneHz)
}
// --- Mutants (b) and (c): hysteresis anchored on the tone ----------------------
@Test
fun `an estimate hopping across the window edge does not re-shift`() {
// 1200.0 Hz is inside the window (shift 0); 1212.5 Hz, one scan bin away, is
// outside (a large shift). A shift-space comparison lapses here because one side
// is zero, which is exactly where the jump is largest.
val decider = CwShiftDecider()
val first = feed(decider, steadyTone(1212.5))
assertEquals(CwShiftDecider.Outcome.SHIFTED, first.outcome)
val hop = feed(decider, steadyTone(1200.0))
assertEquals(
"a one-bin hop back across the edge must be absorbed",
CwShiftDecider.Outcome.WITHIN_HYSTERESIS, hop.outcome
)
assertEquals("the shift must not move", first.shiftHz, hop.shiftHz, 0f)
assertFalse(hop.changed)
}
@Test
fun `the anchor is set from the tone that produced the shift`() {
val decider = CwShiftDecider()
assertNull("no anchor before the first detection", decider.anchorToneHz)
feed(decider, steadyTone(1400.0))
assertEquals(
"the anchor must be the detected tone",
1400.0, decider.anchorToneHz!!.toDouble(), 25.0
)
// An in-window tone must anchor too, otherwise a tone drifting from inside the
// window to outside would be measured against a stale reference.
feed(decider, steadyTone(700.0))
assertEquals(
"an in-window tone must also become the anchor",
700.0, decider.anchorToneHz!!.toDouble(), 25.0
)
assertEquals("an in-window tone needs no shift", 0f, decider.shiftHz, 0f)
}
@Test
fun `hysteresis is measured against the anchor, not the previous estimate`() {
// Walk in 25 Hz steps: each step is under the 40 Hz margin, so a comparison
// against the previous estimate would never fire. Anchored, the shift updates
// once the accumulated move clears the margin.
val decider = CwShiftDecider()
feed(decider, steadyTone(1300.0))
val anchorAtStart = decider.anchorToneHz!!
var tone = 1325.0
var updates = 0
while (tone <= 1450.0) {
if (feed(decider, steadyTone(tone)).changed) updates++
tone += 25.0
}
assertTrue(
"accumulated drift must eventually re-shift; anchor started at $anchorAtStart " +
"and the shift updated $updates times",
updates >= 1
)
}
// --- Mutant (d): the comparison direction --------------------------------------
@Test
fun `a large retune is followed while small moves are absorbed`() {
val decider = CwShiftDecider()
val before = feed(decider, steadyTone(1400.0)).shiftHz
// Well inside the margin: must be absorbed.
val small = feed(decider, steadyTone(1412.5))
assertEquals(CwShiftDecider.Outcome.WITHIN_HYSTERESIS, small.outcome)
assertEquals(before, small.shiftHz, 0f)
// Well beyond it: must be followed. An inverted comparison would absorb this and
// react to the small move instead.
val large = feed(decider, steadyTone(1000.0))
assertTrue(
"a 400 Hz retune must change the shift (was $before, now ${large.shiftHz})",
large.changed
)
assertEquals(
"a 1000 Hz tone is inside the window, so no shift is needed",
CwShiftDecider.Outcome.NO_SHIFT_NEEDED, large.outcome
)
assertEquals(0f, large.shiftHz, 0f)
}
@Test
fun `an edge tone settles instead of thrashing`() {
val decider = CwShiftDecider()
var changes = 0
// Estimates hopping around the 1200 Hz edge, the worst case for a shift-space rule.
val hops = listOf(1200.0, 1212.5, 1200.0, 1187.5, 1212.5, 1200.0, 1225.0, 1200.0)
repeat(4) {
for (hz in hops) {
if (feed(decider, steadyTone(hz)).changed) changes++
}
}
assertTrue(
"an edge tone must settle; the shift changed $changes times in ${hops.size * 4} detections",
changes <= 3
)
}
// --- Drift and state consistency ----------------------------------------------
@Test
fun `slow drift keeps the shifted tone inside the model window`() {
val decider = CwShiftDecider()
var tone = 1300.0
var worstOffset = 0.0
while (tone <= 1550.0) {
val decision = feed(decider, steadyTone(tone))
val landed = tone + decision.shiftHz
worstOffset = maxOf(worstOffset, abs(landed - CwToneShifter.TARGET_HZ))
assertTrue(
"a ${tone}Hz tone landed at ${landed}Hz, outside the model window",
CwToneShifter.isInsideWindow(landed.toFloat())
)
tone += 12.5
}
assertTrue(
"staleness must stay near the margin, worst offset was $worstOffset Hz",
worstOffset <= hysteresisHz + 12.5
)
}
@Test
fun `reset clears both the shift and the anchor together`() {
val decider = CwShiftDecider()
feed(decider, steadyTone(1400.0))
assertTrue(decider.shiftHz != 0f)
assertNotNull(decider.anchorToneHz)
decider.reset()
assertEquals("reset must clear the shift", 0f, decider.shiftHz, 0f)
assertNull("reset must clear the anchor", decider.anchorToneHz)
// After a reset the next tone must be acted on rather than absorbed.
val decision = feed(decider, steadyTone(1400.0))
assertEquals(CwShiftDecider.Outcome.SHIFTED, decision.outcome)
assertTrue(decision.changed)
}
@Test
fun `a non-zero shift always has an anchor`() {
// An inconsistent pair would make hysteresis behave differently depending on how
// the state was reached, so pin the invariant across a mixed sequence.
val decider = CwShiftDecider()
val sequence = listOf(
steadyTone(1400.0), noise(), steadyTone(1412.5), steadyTone(300.0),
noise(seed = 5), steadyTone(700.0), steadyTone(1500.0), noise(seed = 9)
)
for ((index, audio) in sequence.withIndex()) {
feed(decider, audio)
if (decider.shiftHz != 0f) {
assertNotNull(
"step $index left a shift of ${decider.shiftHz}Hz with no anchor",
decider.anchorToneHz
)
}
}
}
@Test
fun `shift always lands the tone on the target`() {
for (hz in listOf(150.0, 250.0, 300.0, 1250.0, 1400.0, 1500.0)) {
val decider = CwShiftDecider()
val decision = feed(decider, steadyTone(hz))
assertEquals(
"a ${hz}Hz tone must be shifted to the window centre",
CwToneShifter.TARGET_HZ, hz + decision.shiftHz, 30.0
)
}
}
@Test
fun `in-window tones are never shifted`() {
for (hz in listOf(400.0, 500.0, 800.0, 1100.0, 1200.0)) {
val decider = CwShiftDecider()
val decision = feed(decider, steadyTone(hz))
assertEquals(
"a ${hz}Hz tone is inside the window and must not be shifted",
CwShiftDecider.Outcome.NO_SHIFT_NEEDED, decision.outcome
)
assertEquals(0f, decision.shiftHz, 0f)
}
}
}
@@ -1,167 +0,0 @@
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sin
import kotlin.random.Random
/**
* Signal-level properties of the shifter: the range the spectrogram expects, the
* detector's threshold trade-off, and behaviour on inputs a phone mic can really produce.
*
* The decision rule that consumes these estimates is covered by [CwShiftDeciderTest].
*/
class CwToneShiftSignalTest {
private val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
/** Keyed CW: gated tone with noise, 60 ms on / 30 ms off, roughly 20 WPM. */
private fun keyedTone(hz: Double, samples: Int = 1280, seed: Int = 1, noise: Double = 0.02): FloatArray {
val random = Random(seed)
val period = sampleRate * 90 / 1000
return FloatArray(samples) { i ->
val gate = if (i % period < sampleRate * 60 / 1000) 1.0 else 0.0
(gate * sin(2.0 * PI * hz * i / sampleRate) +
(random.nextDouble() - 0.5) * 2 * noise).toFloat()
}
}
private fun noiseOnly(samples: Int = 1280, seed: Int = 2, level: Double = 1.0): FloatArray {
val random = Random(seed)
return FloatArray(samples) { ((random.nextDouble() - 0.5) * 2 * level).toFloat() }
}
/**
* The prominence threshold sits between two measured populations and both sides
* matter. Too low and noise is mistaken for a tone, which moves a good signal out of
* the model's range; too high and copyable weak signals are never shifted, which is
* the very failure the feature exists to prevent.
*/
@Test
fun `prominence threshold rejects noise without rejecting weak signals`() {
var falsePositives = 0
repeat(20) { seed ->
if (CwToneShifter.detectToneHz(noiseOnly(seed = seed + 500), sampleRate) != null) {
falsePositives++
}
}
assertEquals("noise must never be reported as a tone", 0, falsePositives)
// Noise at 0.7 against a unit-amplitude tone is roughly 3 dB SNR: audible,
// decodable, and the region an over-tight threshold silently discards.
for (hz in listOf(300.0, 800.0, 1400.0)) {
val detected = CwToneShifter.detectToneHz(keyedTone(hz, noise = 0.7), sampleRate)
assertEquals(
"a weak but usable ${hz}Hz signal must be detected, not rejected as noise",
hz, detected!!.toDouble(), 25.0
)
}
assertTrue(
"MIN_PROMINENCE ${CwToneShifter.MIN_PROMINENCE} must clear the measured noise " +
"ceiling of ~3.4",
CwToneShifter.MIN_PROMINENCE > 3.4
)
assertTrue(
"MIN_PROMINENCE ${CwToneShifter.MIN_PROMINENCE} must not reject weak signals; " +
"keyed CW measures 7.6-9.0 at 0 dB SNR and 5.2-6.7 at -3 dB",
CwToneShifter.MIN_PROMINENCE < 5.2
)
}
/**
* The Hilbert kernel's L1 gain is 2.51, so summing the in-phase and quadrature paths
* overshoots: a full-scale square wave measured 2.35 and even a plain sine 1.05. The
* spectrogram takes log1p of the magnitude, so an overshoot is not fatal, but it
* moves the level away from what the model was trained on.
*/
@Test
fun `shifted output stays within the range the spectrogram expects`() {
val shifter = CwToneShifter.Streaming()
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
val square = FloatArray(1280) { if ((it / 8) % 2 == 0) 1f else -1f }
val shiftedSquare = shifter.process(square, shiftHz, sampleRate)
assertTrue(
"a full-scale square wave overshot: peak was ${shiftedSquare.maxOf { abs(it) }}",
shiftedSquare.all { abs(it) <= 1f }
)
shifter.reset()
val sine = FloatArray(1280) { i -> sin(2.0 * PI * 1500.0 * i / sampleRate).toFloat() }
val shiftedSine = shifter.process(sine, shiftHz, sampleRate)
assertTrue(
"a full-scale sine overshot: peak was ${shiftedSine.maxOf { abs(it) }}",
shiftedSine.all { abs(it) <= 1f }
)
// Limiting must not flatten the signal away: the tone still has to be there.
val detected = CwToneShifter.detectToneHz(shiftedSine, sampleRate)
assertEquals(
"limiting must preserve the shifted tone",
CwToneShifter.TARGET_HZ, detected!!.toDouble(), 30.0
)
}
@Test
fun `stateless shift also stays in range`() {
val square = FloatArray(1280) { if ((it / 8) % 2 == 0) 1f else -1f }
val shifted = CwToneShifter.shift(square, -700f, sampleRate)
assertTrue(
"peak was ${shifted.maxOf { abs(it) }}",
shifted.all { abs(it) <= 1f }
)
}
@Test
fun `detector tolerates pathological input`() {
// A wrong shift moves a perfectly good tone out of range, so a bogus estimate is
// worse than none: these inputs must produce the right tone or nothing at all.
for (offset in listOf(0.5, 1.0, 5.0, 50.0)) {
val biased = FloatArray(1280) { i ->
(offset + sin(2.0 * PI * 800.0 * i / sampleRate)).toFloat()
}
val detected = CwToneShifter.detectToneHz(biased, sampleRate)
assertEquals(
"a DC offset of $offset must not hide the tone",
800.0, detected!!.toDouble(), 25.0
)
}
assertNull(
"all zeros must not report a tone",
CwToneShifter.detectToneHz(FloatArray(1280), sampleRate)
)
for (size in listOf(0, 1, 2, 63)) {
assertNull(
"a $size-sample buffer is too short to detect from",
CwToneShifter.detectToneHz(FloatArray(size), sampleRate)
)
}
val withNan = FloatArray(1280) { i ->
if (i == 640) Float.NaN else sin(2.0 * PI * 800.0 * i / sampleRate).toFloat()
}
assertNull(
"a NaN sample must yield no tone rather than a garbage shift",
CwToneShifter.detectToneHz(withNan, sampleRate)
)
// Clipping must not let a harmonic outrank the fundamental.
for (drive in listOf(1.0, 4.0, 20.0, 200.0)) {
val clipped = FloatArray(1280) { i ->
(drive * sin(2.0 * PI * 500.0 * i / sampleRate)).coerceIn(-1.0, 1.0).toFloat()
}
val detected = CwToneShifter.detectToneHz(clipped, sampleRate)
assertEquals(
"at ${drive}x drive the fundamental must still win",
500.0, detected!!.toDouble(), 25.0
)
}
}
}
Loaded 100 of 250 files, more files were not shown because too many files have changed in this diff. Show more