mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
169
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25a41def35 | ||
|
|
d9dc0a1735 | ||
|
|
692e917893 | ||
|
|
6371bb114d | ||
|
|
2d76aeda23 | ||
|
|
ecc24fd836 | ||
|
|
689f08881c | ||
|
|
d467200a45 | ||
|
|
6277e36a96 | ||
|
|
08a5c8c460 | ||
|
|
1ffb5ed9e8 | ||
|
|
cc684b6e3a | ||
|
|
6cb5b062d3 | ||
|
|
9c12b1eccb | ||
|
|
d56392f3c6 | ||
|
|
14b98cc7b1 | ||
|
|
200d2274de | ||
|
|
3d950f90ad | ||
|
|
1cc0bc770c | ||
|
|
84ba25c163 | ||
|
|
5bf4546ee3 | ||
|
|
030a6eecc9 | ||
|
|
42174da0be | ||
|
|
06421f8cfc | ||
|
|
9ea5b533c1 | ||
|
|
b6a9331ba3 | ||
|
|
390fd7717d | ||
|
|
ef0a34723b | ||
|
|
36d091a7e1 | ||
|
|
20143cf096 | ||
|
|
a64f9c80b9 | ||
|
|
db0320f521 | ||
|
|
adc9ac008b | ||
|
|
4a33d1b19a | ||
|
|
cce9209b3d | ||
|
|
237e4cb27a | ||
|
|
cd3eded77c | ||
|
|
7d7f8f1eda | ||
|
|
11b46c6710 | ||
|
|
da1facac64 | ||
|
|
cb1f341ed9 | ||
|
|
c2e496c406 | ||
|
|
7afce7ee68 | ||
|
|
86048ee1fd | ||
|
|
42a6ffa4c9 | ||
|
|
cf1d8e6539 | ||
|
|
ab8b072f1f | ||
|
|
1d4036e3d0 | ||
|
|
ab22244f86 | ||
|
|
3b0874d707 | ||
|
|
2c5b1479f1 | ||
|
|
39d4498a54 | ||
|
|
9ed2aebc9d | ||
|
|
72d5e13acf | ||
|
|
736dc23aae | ||
|
|
052ba79c94 | ||
|
|
cb648caf65 | ||
|
|
05170541ed | ||
|
|
e75d2dc410 | ||
|
|
d55a07bc26 | ||
|
|
c372386a77 | ||
|
|
de6ade025c | ||
|
|
2eb6d6cd5b | ||
|
|
c09f718aae | ||
|
|
a9fe0b74e9 | ||
|
|
2c9a5b376f | ||
|
|
422e480b2f | ||
|
|
efd6931abb | ||
|
|
c7ca77a943 | ||
|
|
be6473e692 | ||
|
|
75d162301b | ||
|
|
07c09ec3e9 | ||
|
|
513d50e56a | ||
|
|
366edd8bbb | ||
|
|
13df0dfcec | ||
|
|
eb1a5d51a2 | ||
|
|
4e3547649f | ||
|
|
7abbeb6eb3 | ||
|
|
5c73d5ed7a | ||
|
|
a663ef7f1b | ||
|
|
1f1e1fb144 | ||
|
|
5166daa297 | ||
|
|
604c2ec59d | ||
|
|
1904a6b3e6 | ||
|
|
7f390117b8 | ||
|
|
346803fe6e | ||
|
|
47b87a1604 | ||
|
|
935fe6d99f | ||
|
|
69af1c38bb | ||
|
|
5c35f2418d | ||
|
|
2aa9858660 | ||
|
|
bc6e065804 | ||
|
|
7924a6f69e | ||
|
|
9a2a715d1f | ||
|
|
ee32c7a1eb | ||
|
|
1d54ac4718 | ||
|
|
433ce85561 | ||
|
|
cebe3b7ec4 | ||
|
|
d8f60cf270 | ||
|
|
4a3332fdb5 | ||
|
|
9e7fdbfe71 | ||
|
|
463df58eb9 | ||
|
|
58b8801c0c | ||
|
|
2581ba92c0 | ||
|
|
e5131ebb72 | ||
|
|
f9accdd517 | ||
|
|
ee3c1e7058 | ||
|
|
a08a28df39 | ||
|
|
759a6de2c6 | ||
|
|
ba5a984c16 | ||
|
|
083b8a2847 | ||
|
|
e8ebfb1ef3 | ||
|
|
12eed1d9a9 | ||
|
|
b3859fbece | ||
|
|
f40a67e1b7 | ||
|
|
16dce5df2a | ||
|
|
80a58bed61 | ||
|
|
a8afc63bba | ||
|
|
dbf9a2a65f | ||
|
|
ddecaa6017 | ||
|
|
418a05e343 | ||
|
|
c80e351212 | ||
|
|
a333192df5 | ||
|
|
a7b41c95c7 | ||
|
|
2298d8eea4 | ||
|
|
1813e1bfa4 | ||
|
|
bd09ad22ff | ||
|
|
7e90071c4d | ||
|
|
d1cc944be2 | ||
|
|
fb28283679 | ||
|
|
81ae8772d2 | ||
|
|
571145838d | ||
|
|
017b178f2e | ||
|
|
670444858c | ||
|
|
7bdff5df81 | ||
|
|
df083840ac | ||
|
|
a0279bac17 | ||
|
|
3d630041e1 | ||
|
|
ea92d3c150 | ||
|
|
86ee3dcbac | ||
|
|
43788ba7a7 | ||
|
|
1a3f1bb34c | ||
|
|
998c98267b | ||
|
|
28fa373db0 | ||
|
|
4ca33c37ad | ||
|
|
bd42e643ea | ||
|
|
4baf3821e5 | ||
|
|
d7e1ce11c9 | ||
|
|
5de370983d | ||
|
|
f272e268ab | ||
|
|
e59507a4d3 | ||
|
|
0bae306312 | ||
|
|
65f61a5147 | ||
|
|
1064dc739e | ||
|
|
8aedd38b51 | ||
|
|
ad8f08a577 | ||
|
|
2afce54472 | ||
|
|
8e50fcf8fb | ||
|
|
e11b03d56b | ||
|
|
fa55b82a5a | ||
|
|
d57832b0d4 | ||
|
|
1d0e8921a9 | ||
|
|
23d95b8241 | ||
|
|
8af80866bc | ||
|
|
b9e5ff70f5 | ||
|
|
c176d6ad88 | ||
|
|
5a6b162e4f | ||
|
|
33712c29fc | ||
|
|
eac1e6e273 |
No files matched your search
@@ -4,7 +4,3 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "bundler"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "never"
|
||||
+5
-1
@@ -1,6 +1,9 @@
|
||||
# Built application files
|
||||
*.ap_
|
||||
|
||||
# Hermes AI plans and metadata
|
||||
.hermes/
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
@@ -15,6 +18,7 @@ out/
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
.cxx/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
@@ -64,4 +68,4 @@ fastlane/readme.md
|
||||
/app/release/output-metadata.json
|
||||
/app/release/
|
||||
/.kotlin/sessions/
|
||||
.hermes/
|
||||
dist/
|
||||
@@ -8,15 +8,16 @@ All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) po
|
||||
## Project Overview
|
||||
|
||||
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
||||
satellites using Celestrak/SatNOGS orbital data, calculates positions via SGP4/SDP4, and predicts passes relative to
|
||||
the user's location. Features include polar radar visualization, SSTV image decoding, and ground track mapping. No ads,
|
||||
no tracking, no network required after initial data download.
|
||||
satellites using TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays
|
||||
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
|
||||
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
|
||||
data download.
|
||||
|
||||
## Architecture & Design
|
||||
## Architecture
|
||||
|
||||
**MVI (Model-View-Intent)** with unidirectional data flow:
|
||||
- `State` data class (named `<Feature>State`) exposed via `StateFlow` from ViewModel
|
||||
- `Action` sealed interface (named `<Feature>Action`) dispatched to ViewModel's `onAction()`
|
||||
- `State` data class → exposed via `StateFlow` from ViewModel
|
||||
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
|
||||
- Jetpack Compose UI observes state and recomposes reactively
|
||||
|
||||
**Clean Architecture layers:**
|
||||
@@ -33,11 +34,9 @@ no tracking, no network required after initial data download.
|
||||
| `feature:satellites` | Satellite list, filtering, selection |
|
||||
| `feature:settings` | User preferences |
|
||||
|
||||
**Feature isolation:**
|
||||
- `feature:*` modules depend only on `core:domain` and `core:presentation`.
|
||||
- No feature-to-feature dependencies; cross-feature communication goes through core layers.
|
||||
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
|
||||
|
||||
## Build & Platform
|
||||
## Build & Run
|
||||
|
||||
```shell
|
||||
# Debug build
|
||||
@@ -51,55 +50,54 @@ no tracking, no network required after initial data download.
|
||||
```
|
||||
|
||||
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
||||
- **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/`
|
||||
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
|
||||
|
||||
## Tech Stack
|
||||
## Key Libraries
|
||||
|
||||
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
||||
- **Navigation3**: Type-safe navigation with `@Serializable` nav keys
|
||||
- **Room** (KSP code generation) for local satellite/orbital storage
|
||||
- **OkHttp** 5.x for data downloads
|
||||
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
|
||||
- **Room** (KSP code generation) for local satellite/TLE storage
|
||||
- **OkHttp** 5.x for TLE downloads
|
||||
- **OSMDroid** for map rendering
|
||||
- **Kotlin Serialization** for navigation args and parsing
|
||||
- **Kotlin Serialization** for navigation args and data parsing
|
||||
- **Coroutines** + `StateFlow` for async/reactive patterns
|
||||
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh)
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Minimal dependencies**: Avoid adding libraries when a simple manual solution exists. Fewer deps = less maintenance.
|
||||
- **DI**: Manual — ViewModels use companion `factory()` methods with `IMainContainer` interface.
|
||||
- **Navigation**: Type-safe Compose Navigation3 with `@Serializable` data classes as nav keys.
|
||||
- **State naming**: `<Feature>State` data class + `<Feature>Action` sealed interface per feature.
|
||||
- **No feature-to-feature deps**: All cross-feature communication goes through core layers.
|
||||
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh).
|
||||
|
||||
## Data Formats & Migration
|
||||
|
||||
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
|
||||
**TLE vs. OMM/CSV format:**
|
||||
|
||||
- **TLE format**: Legacy 3-line element format limited by 5-digit NORAD IDs
|
||||
- **OMM/CSV format**: Successor format with ISO 8601 timestamps and larger NORAD ID support
|
||||
- New 5-digit NORAD IDs are exhausted; TLE is officially deprecated and OMM/CSV is the clear default
|
||||
- `DataParser.kt` supports both via `parseTLEStream()` and `parseCSVStream()`
|
||||
- Downloads auto-detect format; both produce identical `OrbitalData` objects
|
||||
- Existing code already supports transparent source transition without feature changes
|
||||
- Refresh orbital data weekly for accurate pass prediction (orbital decay)
|
||||
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
|
||||
|
||||
## Engineering Heuristics (Lazy = Efficient)
|
||||
- **TLE format**: Traditional 3-line element format (deprecated). NORAD catalog numbers are 5-digit integers, which
|
||||
are running out of space. Celestrak has signaled that TLE format will eventually be phased out.
|
||||
- **OMM/CSV format**: The future standard. CSV files contain the same orbital parameters as TLE but use ISO 8601
|
||||
timestamps and support larger NORAD IDs. Celestrak and SatNOGS already provide OMM data in CSV format.
|
||||
|
||||
- Treat "lazy" as efficient, not careless: the best code is the code never written.
|
||||
- First understand the task and trace the real flow end-to-end, then climb this ladder:
|
||||
1. Does this need to be built now? (YAGNI)
|
||||
2. Does it already exist in this codebase? Reuse helpers/patterns before rewriting.
|
||||
3. Does Kotlin/Java stdlib already solve it?
|
||||
4. Does the Android/platform API already solve it?
|
||||
5. Does an already-installed dependency solve it?
|
||||
6. Can this be simpler (including one-liner simple)?
|
||||
7. Only then: write the minimum code that works.
|
||||
- Prefer deletion to addition, boring over clever, and the fewest touched files.
|
||||
- Avoid new abstractions, dependencies, and boilerplate unless explicitly requested.
|
||||
- Manual DI only: ViewModels use companion `factory()` methods with `IMainContainer`.
|
||||
- Release builds use ProGuard: avoid reflection-heavy libraries unless explicitly approved.
|
||||
- When two options are similar in size, choose the edge-case-correct one.
|
||||
- If you keep a deliberate simplification (for example O(n^2) scan or global lock), leave a short comment with the ceiling and upgrade path.
|
||||
- For complex asks, challenge scope when appropriate: "Do you need X, or does Y already cover it?"
|
||||
**Current implementation:**
|
||||
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly
|
||||
- TLE data is downloaded from configured sources and stored in Room database
|
||||
- When downloading satellite data, the app automatically detects format and parses accordingly
|
||||
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching
|
||||
|
||||
## Bug-Fix Policy
|
||||
**Migration path:**
|
||||
As NORAD catalog space becomes constrained, OMM/CSV will become the primary format. Look4Sat is already positioned
|
||||
to handle this transition without code changes — existing users can continue using TLE files while new sources
|
||||
transition to OMM/CSV automatically.
|
||||
|
||||
- Fix root cause, not just the reported symptom.
|
||||
- If touching a shared function, inspect callers and prefer one shared fix over per-caller patches.
|
||||
- The smallest correct diff wins only after behavior is understood.
|
||||
## Code Style
|
||||
|
||||
- Prefer **short, focused functions** — single responsibility, easy to read.
|
||||
- **Exceptions**: Composable functions and math-heavy algorithms (SGP4/SDP4) may be longer.
|
||||
- Strict code style — no dead code, no unused imports, consistent formatting.
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -107,9 +105,12 @@ Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully.
|
||||
- Orbital math lives in `core:domain/predict/` — it's dense vector math (SGP4/SDP4). Tread carefully.
|
||||
- TLE/OMM data must be refreshed weekly for accurate predictions (satellite orbits decay). TLE format is legacy and
|
||||
will eventually be deprecated in favor of OMM/CSV as NORAD catalog numbers approach the 5-digit limit.
|
||||
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
||||
- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules.
|
||||
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
|
||||
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
|
||||
|
||||
## Copilot Working Mode: Code-Only
|
||||
|
||||
|
||||
@@ -1,45 +1,43 @@
|
||||
# Look4Sat: Satellite tracker
|
||||
# Look4Sat-BA7OPF
|
||||
|
||||
[](https://github.com/rt-bishop/Look4Sat/actions/workflows/release.yml)
|
||||
[](https://github.com/atsunatsu/Look4Sat/releases)
|
||||
|
||||
[<img src="https://play.google.com/intl/en_gb/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="80">](https://play.google.com/store/apps/details?id=com.rtbishop.look4sat)
|
||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/com.rtbishop.look4sat/)
|
||||
**BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的业余无线电卫星追踪器,增加了线性卫星频率计算器等功能。
|
||||
|
||||
### Radio satellite tracker and pass predictor for Android, inspired by Gpredict
|
||||
## 本仓库特色功能
|
||||
|
||||
<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>
|
||||
- **网格地图模式(Grid Mode)** — 地图页内置 VUCC 网格覆盖图:已通联网格绿色填充、漫游网格蓝色 45° 斜纹(疏朗半透明样式)、当前所在方格淡黄加粗框(field 与 sub-square 缩放均显示);极区(南北纬 80°–90°)网格线与二位标签完整可见;点击已通联网格弹出该网格内通联明细弹窗;进入网格模式默认 VUCC 视图
|
||||
- **奖状边界模式(Award Boundaries)** — 在网格地图上叠加 DXCC / WAPC / WAJA / WAZ / WAS 国界·省界·分区边界,已确认区域绿色填充;香港/澳门作为独立实体显示(DXCC 321/152),港澳标签强制显示
|
||||
- **LoTW 网格同步** — 直连 ARRL Logbook(账号密码验证)下载确认报告,worked / roamed 网格自动更新(兼容任意字段顺序的 ADIF 解析);同步失败按凭据错误/限流/超时分类提示
|
||||
- **双站过境匹配(Mutual Pass)** — 输入友台经纬度/网格,筛选双方同时可见的卫星过境,在地图上同时显示双方仰角曲线和地面轨迹,支持一键跳转雷达页查看详情
|
||||
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
|
||||
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本(arm64 + armeabi-v7a 双架构)
|
||||
- **Passband 模式** — 支持通过位置滑块(Passband)自动计算 TX/RX 频率,避免切换时跳变
|
||||
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放,启动时预取缓存
|
||||
- **应用内更新检查** — 设置页一键检查 GitHub 最新 Release,展示版本说明并直接下载安装
|
||||
- **自定义 TLE 数据源** — 可添加自定义 TLE/Celestrak CSV 数据源,长按拖拽排序,数据源启停开关与 HTTP 状态码显示
|
||||
- **中文界面优化** — 翻译修正、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.
|
||||
GNU General Public License v3.0。详见 [LICENSE](LICENSE)。
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=rt-bishop%2FLook4Sat&type=timeline&legend=top-left">
|
||||
<a href="https://star-history.dera.page/#atsunatsu/Look4Sat&type=timeline&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
@@ -1,3 +1,41 @@
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.convention.applicationPlugin)
|
||||
}
|
||||
|
||||
// Release signing credentials live in local.properties (gitignored) or
|
||||
// environment variables — never hardcode passwords in VCS.
|
||||
val releaseProps = Properties().apply {
|
||||
val propsFile = rootProject.file("local.properties")
|
||||
if (propsFile.exists()) FileInputStream(propsFile).use { load(it) }
|
||||
}
|
||||
fun releaseCred(name: String): String =
|
||||
releaseProps.getProperty(name) ?: System.getenv(name) ?: ""
|
||||
|
||||
android {
|
||||
namespace = libs.versions.packageName.get()
|
||||
defaultConfig {
|
||||
applicationId = "cn.ba7opf.look4sat"
|
||||
// CW decoder ships for both ABIs (see feature/cw jniLibs); the APK must
|
||||
// not be pinned to 32-bit only or 64-bit devices fall back to ARM32.
|
||||
ndk {
|
||||
abiFilters.add("armeabi-v7a")
|
||||
abiFilters.add("arm64-v8a")
|
||||
}
|
||||
}
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file(System.getProperty("user.home") + "/my-release-key.jks")
|
||||
storePassword = releaseCred("RELEASE_STORE_PASSWORD")
|
||||
keyAlias = releaseCred("RELEASE_KEY_ALIAS").ifEmpty { "look4sat" }
|
||||
keyPassword = releaseCred("RELEASE_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
@@ -25,6 +26,7 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.Look4Sat.SplashScreen">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -44,5 +46,15 @@
|
||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||
android:value="com.rtbishop.look4sat" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
observeNightFilterState()
|
||||
setContent {
|
||||
MainTheme(isDarkTheme = true) { MainScreen() }
|
||||
MainTheme(isDarkTheme = true) { NavRoot() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.app.Application.ActivityLifecycleCallbacks
|
||||
import android.os.Bundle
|
||||
import com.rtbishop.look4sat.core.data.injection.MainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
@@ -35,12 +38,37 @@ class MainApplication : Application(), IContainerProvider {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = MainContainer(this)
|
||||
clearAmSatCacheOnAppBackground()
|
||||
// trigger automatic update every 48 hours
|
||||
container.appScope.launch { checkAutoUpdate() }
|
||||
// load satellite data on every app start
|
||||
container.appScope.launch { container.satelliteRepo.initRepository() }
|
||||
}
|
||||
|
||||
private fun clearAmSatCacheOnAppBackground() {
|
||||
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
|
||||
private var startedActivityCount = 0
|
||||
|
||||
override fun onActivityStarted(activity: Activity) {
|
||||
if (startedActivityCount == 0) {
|
||||
container.appScope.launch { container.amSatRepo.prefetchStatus() }
|
||||
}
|
||||
startedActivityCount += 1
|
||||
}
|
||||
|
||||
override fun onActivityStopped(activity: Activity) {
|
||||
startedActivityCount = (startedActivityCount - 1).coerceAtLeast(0)
|
||||
if (startedActivityCount == 0) container.amSatRepo.clearStatusCache()
|
||||
}
|
||||
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
|
||||
override fun onActivityResumed(activity: Activity) = Unit
|
||||
override fun onActivityPaused(activity: Activity) = Unit
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
|
||||
override fun onActivityDestroyed(activity: Activity) = Unit
|
||||
})
|
||||
}
|
||||
|
||||
private suspend fun checkAutoUpdate(timeNow: Long = System.currentTimeMillis()) {
|
||||
if (container.settingsRepo.otherSettings.value.stateOfAutoUpdate) {
|
||||
val timeDelta = timeNow - container.settingsRepo.databaseState.value.updateTimestamp
|
||||
|
||||
@@ -25,8 +25,6 @@ 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
|
||||
@@ -44,7 +42,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||
@@ -53,6 +51,8 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -63,13 +63,16 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
||||
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
||||
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
||||
@@ -78,41 +81,52 @@ 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.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.satellites.SatellitesDestination
|
||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||
import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
||||
|
||||
@Composable
|
||||
fun NavRoot(deeplink: String? = null) {
|
||||
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||
val deeplinkResolver = DeeplinkResolver()
|
||||
LaunchedEffect(deeplink) {
|
||||
deeplink?.let {
|
||||
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
|
||||
rootBackStack.add(destination)
|
||||
}
|
||||
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
||||
}
|
||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
||||
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
||||
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
||||
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
|
||||
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
||||
slideOutHorizontally(tween(300)) { -it / 3 }
|
||||
// Reverse: outgoing slides out to the right, incoming drifts in from the left
|
||||
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
|
||||
slideOutHorizontally(tween(300)) { it }
|
||||
NavDisplay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
backStack = rootBackStack,
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { slideInTransition },
|
||||
popTransitionSpec = { slideOutTransition },
|
||||
predictivePopTransitionSpec = { slideOutTransition },
|
||||
transitionSpec = { pushTransition },
|
||||
popTransitionSpec = { popTransition },
|
||||
predictivePopTransitionSpec = { popTransition },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
|
||||
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
|
||||
entry<Screen.Passes> {
|
||||
MainScreen(
|
||||
navigateToRadar = navigateToRadar
|
||||
)
|
||||
}
|
||||
entry<RadarDestination> {
|
||||
Scaffold { innerPadding ->
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
innerPadding.calculateTopPadding()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,17 +134,27 @@ fun NavRoot(deeplink: String? = null) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
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.Map, Screen.Settings)
|
||||
// Map sits at position 4 from the left, matching the upstream nav order
|
||||
// (Satellites, Passes, AMSAT/Status, Map, ...), with the fork's Mutual tab
|
||||
// appended before Settings.
|
||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.AMSAT, Screen.Map, Screen.Mutual, Screen.Settings)
|
||||
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||
// Activity-scoped so the mutual query results survive navigation to Radar and back
|
||||
val mutualViewModel: MutualViewModel = viewModel(
|
||||
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||
factory = MutualViewModel.factory(container)
|
||||
)
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalElevationThresholds provides ElevationThresholds(
|
||||
@@ -144,8 +168,9 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
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.Map -> screen is Screen.Map
|
||||
is Screen.AMSAT -> screen is Screen.AMSAT
|
||||
is Screen.Mutual -> screen is Screen.Mutual
|
||||
is Screen.Settings -> screen is Screen.Settings
|
||||
else -> false
|
||||
}
|
||||
@@ -189,18 +214,28 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
SatellitesDestination(navigateUp = navigateBack)
|
||||
}
|
||||
entry<Screen.Passes> {
|
||||
PassesDestination { catNum, aosTime ->
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
// navigateToRadar()
|
||||
}
|
||||
}
|
||||
entry<Screen.Radar> {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
PassesDestination(
|
||||
navigateToRadar = { catNum, aosTime ->
|
||||
container.setMutualPassData(MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
navigateToRadar()
|
||||
}
|
||||
)
|
||||
}
|
||||
entry<Screen.Map> {
|
||||
MapDestination()
|
||||
}
|
||||
entry<Screen.Mutual> {
|
||||
MutualScreen(
|
||||
viewModel = mutualViewModel,
|
||||
navigateToRadar = { catNum, aosTime, pass ->
|
||||
container.setMutualPassData(pass ?: MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
navigateToRadar()
|
||||
}
|
||||
)
|
||||
}
|
||||
entry<Screen.AMSAT> { SatStatusDestination() }
|
||||
entry<Screen.Settings> {
|
||||
SettingsDestination()
|
||||
}
|
||||
@@ -224,8 +259,9 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
.clickable {
|
||||
val pass = trackingState.currentPass
|
||||
if (pass != null) {
|
||||
container.setMutualPassData(MutualPassData())
|
||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
navigateToRadar()
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path name="updates" path="." />
|
||||
</paths>
|
||||
+2
@@ -32,10 +32,12 @@ internal class ApplicationPlugin : Plugin<Project> {
|
||||
implementation(project(":core:domain"))
|
||||
implementation(project(":core:presentation"))
|
||||
implementation(project(":feature:map"))
|
||||
implementation(project(":feature:mutual"))
|
||||
implementation(project(":feature:passes"))
|
||||
implementation(project(":feature:radar"))
|
||||
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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
+69
-28
@@ -17,8 +17,10 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.framework
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -31,18 +33,30 @@ class NetworkReporter(
|
||||
private val rotatorServer: String,
|
||||
private val rotatorPort: Int,
|
||||
private val frequencyServer: String,
|
||||
private val frequencyPort: Int
|
||||
private val frequencyPort: Int,
|
||||
private val frequencyOffsetHz: Long = 0L
|
||||
) : IReporter {
|
||||
|
||||
private val writeMutex = Mutex()
|
||||
private val connectionMutex = Mutex()
|
||||
private val frequencyCommands = Channel<String>(Channel.CONFLATED)
|
||||
|
||||
private var rotatorSocket: SocketChannel? = null
|
||||
private var rotatorConnected = false
|
||||
private var rotatorConnecting = false
|
||||
|
||||
private var frequencySocket: SocketChannel? = null
|
||||
private var frequencyConnected = false
|
||||
private var frequencyConnecting = false
|
||||
|
||||
init {
|
||||
// Keep only the latest frequency command to avoid stale backlog and effective lag.
|
||||
reporterScope.launch {
|
||||
for (command in frequencyCommands) {
|
||||
ensureFrequencyConnected()
|
||||
if (!frequencyConnected) continue
|
||||
write(frequencySocket, command) { resetFrequencyConnection() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
|
||||
reporterScope.launch {
|
||||
@@ -53,51 +67,48 @@ class NetworkReporter(
|
||||
.replace($$"$AZ", azimuth.toString())
|
||||
.replace($$"$EL", el.toString())
|
||||
.unescapeControlChars()
|
||||
write(rotatorSocket, command) { rotatorConnected = false }
|
||||
write(rotatorSocket, command) { resetRotatorConnection() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun reportFrequency(format: String, frequency: Long) {
|
||||
reporterScope.launch {
|
||||
ensureFrequencyConnected()
|
||||
if (!frequencyConnected) return@launch
|
||||
val command = format
|
||||
.replace($$"$FREQ", frequency.toString())
|
||||
.unescapeControlChars()
|
||||
write(frequencySocket, command) { frequencyConnected = false }
|
||||
}
|
||||
val clampedOffset = frequencyOffsetHz.coerceIn(
|
||||
Constants.FREQ_OFFSET_MIN_HZ,
|
||||
Constants.FREQ_OFFSET_MAX_HZ
|
||||
)
|
||||
val correctedFreq = frequency.coerceAtLeast(0L).safeAdd(clampedOffset).coerceAtLeast(0L)
|
||||
val command = format
|
||||
.replace($$"$FREQ", correctedFreq.toString())
|
||||
.unescapeControlChars()
|
||||
frequencyCommands.trySend(command)
|
||||
}
|
||||
|
||||
private fun ensureRotatorConnected() {
|
||||
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
|
||||
reporterScope.launch {
|
||||
private suspend fun ensureRotatorConnected() {
|
||||
connectionMutex.withLock {
|
||||
if (rotatorConnected || rotatorServer.isBlank()) return
|
||||
try {
|
||||
rotatorConnecting = true
|
||||
resetRotatorConnection()
|
||||
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
|
||||
} finally {
|
||||
rotatorConnecting = false
|
||||
resetRotatorConnection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureFrequencyConnected() {
|
||||
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
|
||||
reporterScope.launch {
|
||||
private suspend fun ensureFrequencyConnected() {
|
||||
connectionMutex.withLock {
|
||||
if (frequencyConnected || frequencyServer.isBlank()) return
|
||||
try {
|
||||
frequencyConnecting = true
|
||||
resetFrequencyConnection()
|
||||
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
|
||||
} finally {
|
||||
frequencyConnecting = false
|
||||
resetFrequencyConnection()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,7 +117,9 @@ class NetworkReporter(
|
||||
try {
|
||||
writeMutex.withLock {
|
||||
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
|
||||
socket?.write(buffer)
|
||||
while (buffer.hasRemaining()) {
|
||||
socket?.write(buffer)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter write error: ${e.message}")
|
||||
@@ -114,6 +127,34 @@ class NetworkReporter(
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetRotatorConnection() {
|
||||
rotatorConnected = false
|
||||
closeQuietly(rotatorSocket)
|
||||
rotatorSocket = null
|
||||
}
|
||||
|
||||
private fun resetFrequencyConnection() {
|
||||
frequencyConnected = false
|
||||
closeQuietly(frequencySocket)
|
||||
frequencySocket = null
|
||||
}
|
||||
|
||||
private fun closeQuietly(socket: SocketChannel?) {
|
||||
try {
|
||||
socket?.close()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun Long.safeAdd(delta: Long): Long {
|
||||
return when {
|
||||
delta > 0 && this > Long.MAX_VALUE - delta -> Long.MAX_VALUE
|
||||
delta < 0 && this < Long.MIN_VALUE - delta -> Long.MIN_VALUE
|
||||
else -> this + delta
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.unescapeControlChars(): String =
|
||||
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
|
||||
replace("""\r""", 13.toChar().toString())
|
||||
.replace("""\n""", 10.toChar().toString())
|
||||
.replace("""\t""", 9.toChar().toString())
|
||||
}
|
||||
@@ -29,11 +29,15 @@ 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
|
||||
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.UpdateRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.LoTWRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.WavelogRepository
|
||||
import com.rtbishop.look4sat.core.data.source.LocalSource
|
||||
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
||||
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
||||
@@ -46,10 +50,13 @@ import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||
@@ -61,22 +68,37 @@ import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class MainContainer(private val context: Context) : IMainContainer {
|
||||
|
||||
private val localSource = provideLocalSource()
|
||||
private val remoteSource = 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, appScope) }
|
||||
override val updateRepo by lazy { UpdateRepository(remoteSource) }
|
||||
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
|
||||
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
|
||||
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||
}
|
||||
|
||||
private val _mutualPassData = MutableStateFlow(MutualPassData())
|
||||
override val mutualPassData: StateFlow<MutualPassData> = _mutualPassData.asStateFlow()
|
||||
|
||||
override fun setMutualPassData(data: MutualPassData) {
|
||||
_mutualPassData.value = data
|
||||
}
|
||||
|
||||
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
||||
|
||||
override fun provideShowToast(): IShowToast = ShowToast(context)
|
||||
@@ -103,7 +125,8 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
rc.rotatorAddress,
|
||||
rc.rotatorPort.toIntOrNull() ?: 0,
|
||||
rc.frequencyAddress,
|
||||
rc.frequencyPort.toIntOrNull() ?: 0
|
||||
rc.frequencyPort.toIntOrNull() ?: 0,
|
||||
rc.frequencyOffsetHz
|
||||
)
|
||||
}
|
||||
|
||||
@@ -135,13 +158,6 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
return SensorsRepo(manager, displayManager)
|
||||
}
|
||||
|
||||
override fun providePairedBluetoothDevices(): List<Pair<String, String>> = buildList {
|
||||
try {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
manager.adapter?.bondedDevices?.forEach { add(Pair(it.name ?: "Unknown", it.address ?: "")) }
|
||||
} catch (_: SecurityException) {}
|
||||
}
|
||||
|
||||
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||
val dbDispatcher = Dispatchers.Default
|
||||
val dataParser = DataParser(dbDispatcher)
|
||||
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
|
||||
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.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/** One report from the AMSAT API (data layer model). */
|
||||
private data class ApiReport(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val callsign: String,
|
||||
val report: String,
|
||||
val gridSquare: String,
|
||||
val reportedTimeUtcSec: Long
|
||||
)
|
||||
|
||||
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
|
||||
class AmSatRepository(
|
||||
private val remoteSource: IRemoteSource,
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
) : IAmSatRepository {
|
||||
|
||||
private val statusCacheMutex = Mutex()
|
||||
|
||||
@Volatile
|
||||
private var statusCache: SatStatusPage? = null
|
||||
|
||||
@Volatile
|
||||
private var statusFetchInFlight: Deferred<SatStatusPage?>? = null
|
||||
|
||||
@Volatile
|
||||
private var cacheGeneration = 0
|
||||
|
||||
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
override fun getCachedStatus(): SatStatusPage? = statusCache
|
||||
|
||||
override suspend fun fetchStatus(forceRefresh: Boolean): SatStatusPage? {
|
||||
if (!forceRefresh) statusCache?.let { return it }
|
||||
|
||||
var cachedPage: SatStatusPage? = null
|
||||
val inFlightFetch = statusCacheMutex.withLock {
|
||||
if (!forceRefresh) {
|
||||
val cached = statusCache
|
||||
if (cached != null) {
|
||||
cachedPage = cached
|
||||
return@withLock null
|
||||
}
|
||||
}
|
||||
|
||||
statusFetchInFlight ?: scope.async(Dispatchers.IO) {
|
||||
fetchStatusFromRemote(cacheGeneration)
|
||||
}.also { statusFetchInFlight = it }
|
||||
}
|
||||
|
||||
cachedPage?.let { return it }
|
||||
val fetch = inFlightFetch ?: return null
|
||||
return try {
|
||||
fetch.await()
|
||||
} finally {
|
||||
statusCacheMutex.withLock {
|
||||
if (statusFetchInFlight === fetch && fetch.isCompleted) statusFetchInFlight = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchStatusFromRemote(generation: Int): SatStatusPage? {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
val catalogJson = remoteSource.getAmSatCatalog() ?: return 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 null
|
||||
|
||||
val names = parseCatalog(catalogJson)
|
||||
val reports = parseReports(reportsJson)
|
||||
|
||||
if (names.isEmpty() && reports.isEmpty()) return null
|
||||
|
||||
val statuses = buildStatuses(names, reports, nowSec)
|
||||
val reportMap = reports.associate { it.id to toSatReport(it) }
|
||||
return SatStatusPage(System.currentTimeMillis(), statuses, reportMap).also { page ->
|
||||
if (generation == cacheGeneration) statusCache = page
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun prefetchStatus() {
|
||||
fetchStatus(forceRefresh = false)
|
||||
}
|
||||
|
||||
override fun clearStatusCache() {
|
||||
cacheGeneration += 1
|
||||
statusCache = null
|
||||
statusFetchInFlight = null
|
||||
}
|
||||
|
||||
override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
|
||||
val payload = JSONObject().apply {
|
||||
put("name", submission.name)
|
||||
put("report", submission.report)
|
||||
put("callsign", submission.callsign)
|
||||
put("reported_at", isoUtcFormat.format(Date(submission.reportedAtUtcMillis)))
|
||||
if (submission.gridSquare.isNotBlank()) put("grid_square", submission.gridSquare)
|
||||
}
|
||||
val response = remoteSource.submitAmSatReport(payload.toString())
|
||||
?: return@withContext AmSatReportSubmitResult(success = false, message = "Network request failed")
|
||||
val (code, body) = response
|
||||
val errorMessage = parseSubmitError(body)
|
||||
return@withContext if (code in 200..299 && errorMessage.isBlank()) {
|
||||
AmSatReportSubmitResult(success = true, reportId = parseSubmitReportId(body))
|
||||
} else {
|
||||
AmSatReportSubmitResult(
|
||||
success = false,
|
||||
message = errorMessage.ifBlank { "HTTP $code" }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSubmitError(json: String): String {
|
||||
return try {
|
||||
JSONObject(json).optJSONObject("error")?.optString("message").orEmpty()
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSubmitReportId(json: String): String? {
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
obj.optJSONObject("data")?.optString("id")?.takeIf { it.isNotBlank() }
|
||||
?: obj.optString("id").takeIf { it.isNotBlank() }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse catalog JSON to list of satellite names */
|
||||
private fun parseCatalog(json: String): List<String> {
|
||||
return try {
|
||||
val arr = JSONObject(json).getJSONArray("data")
|
||||
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse reports JSON to list of ApiReport domain objects */
|
||||
private fun parseReports(json: String): List<ApiReport> {
|
||||
return try {
|
||||
val arr = JSONObject(json).getJSONArray("data")
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
val iso = o.optString("reported_time", "")
|
||||
if (iso.isEmpty()) null else ApiReport(
|
||||
id = o.optString("id", ""),
|
||||
name = o.optString("name", ""),
|
||||
callsign = o.optString("callsign", ""),
|
||||
report = o.optString("report", ""),
|
||||
gridSquare = o.optString("grid_square", ""),
|
||||
reportedTimeUtcSec = parseIsoUtcSec(iso)
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */
|
||||
private fun parseIsoUtcSec(iso: String): Long {
|
||||
return try {
|
||||
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
/** Build one SatStatus (3 days x 12 slots) per catalog satellite, slotting reports by age. */
|
||||
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||
val byName = reports.groupBy { it.name }
|
||||
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
val labels = (0 until 3).map { d ->
|
||||
utc.timeInMillis = (nowSec - d * 86400L) * 1000
|
||||
formatDayLabel(utc)
|
||||
}
|
||||
return names.map { name ->
|
||||
val slots = (0 until 36).map { slotIdx ->
|
||||
val slotStart = nowSec - (slotIdx + 1) * 7200L
|
||||
val slotEnd = nowSec - slotIdx * 7200L
|
||||
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
|
||||
if (inSlot.isEmpty()) {
|
||||
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
|
||||
} else {
|
||||
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
|
||||
SatSlot(
|
||||
statusColor = statusColorOf(newest.report),
|
||||
count = inSlot.size,
|
||||
reportIds = inSlot.map { it.id }
|
||||
)
|
||||
}
|
||||
}
|
||||
val days = (0 until 3).map { d ->
|
||||
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
|
||||
}
|
||||
SatStatus(name = name, days = days)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDayLabel(calendar: Calendar): String {
|
||||
return if (Locale.getDefault().language == Locale.CHINESE.language) {
|
||||
"${calendar.get(Calendar.MONTH) + 1}月${calendar.get(Calendar.DAY_OF_MONTH)}日"
|
||||
} else {
|
||||
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
"${monthAbbr[calendar.get(Calendar.MONTH)]} ${calendar.get(Calendar.DAY_OF_MONTH)}"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+43
-19
@@ -47,6 +47,7 @@ class DatabaseRepo(
|
||||
remoteSource.getFileStream(uri)?.let { stream ->
|
||||
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
|
||||
localSource.insertEntries(entries)
|
||||
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
|
||||
importedCount = entries.size
|
||||
}
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
@@ -65,25 +66,45 @@ class DatabaseRepo(
|
||||
}
|
||||
|
||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||
val tleUrls = buildMap {
|
||||
putAll(Sources.satelliteDataUrls)
|
||||
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
|
||||
}.filterValues { it.isNotBlank() }
|
||||
val radioUrls = buildMap {
|
||||
putAll(Sources.transceiversDataUrls)
|
||||
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) } }
|
||||
// parse fetched data concurrently
|
||||
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty()
|
||||
}
|
||||
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
||||
}
|
||||
val settings = settingsRepo.dataSourcesSettings.value
|
||||
// Keep the raw URL as the status key (matches what the settings UI
|
||||
// displays) while requesting with the normalized URL.
|
||||
val tleUrls = settings.satelliteUrls
|
||||
.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) }
|
||||
.map { it to normalizeUrl(it) }
|
||||
.distinctBy { it.second }
|
||||
val radioUrls = settings.transceiversUrls
|
||||
.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) }
|
||||
.map { it to normalizeUrl(it) }
|
||||
.distinctBy { it.second }
|
||||
val builtinTypesByUrl = Sources.satelliteDataUrls
|
||||
.filterValues { it.isNotBlank() }
|
||||
.mapValues { normalizeUrl(it.value) }
|
||||
.entries
|
||||
.associate { (type, url) -> url to type }
|
||||
val importedTypeIds = mutableMapOf<String, MutableList<Int>>()
|
||||
// launch all network requests concurrently, keeping the raw url as key for status reporting
|
||||
val tleJobs = tleUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
|
||||
val radioJobs = radioUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
|
||||
val tleResults = tleJobs.awaitAll()
|
||||
val radioResults = radioJobs.awaitAll()
|
||||
// report the HTTP status code of every source (200, 404, ...)
|
||||
settingsRepo.updateDataSourcesStatus(
|
||||
(tleResults + radioResults).associate { (url, result) -> url to result.code }
|
||||
)
|
||||
// parse fetched data concurrently and associate known built-in URLs with existing type filters.
|
||||
val importedEntries = tleResults.flatMap { (rawUrl, result) ->
|
||||
val normUrl = normalizeUrl(rawUrl)
|
||||
val entries = result.stream?.let { parseSatelliteStream(normUrl, unwrapIfZipped(normUrl, it)) }.orEmpty()
|
||||
val type = builtinTypesByUrl[normUrl] ?: customSourceType
|
||||
importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum })
|
||||
entries
|
||||
}.distinctBy { it.catnum }
|
||||
importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
|
||||
val importedRadios = radioResults.flatMap { (rawUrl, result) ->
|
||||
val normUrl = normalizeUrl(rawUrl)
|
||||
result.stream?.let { dataParser.parseJSONStream(unwrapIfZipped(normUrl, it)) }.orEmpty()
|
||||
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
|
||||
// insert parsed data into the database
|
||||
localSource.insertEntries(importedEntries)
|
||||
localSource.insertRadios(importedRadios)
|
||||
@@ -96,6 +117,9 @@ class DatabaseRepo(
|
||||
setUpdateSuccessful(0L)
|
||||
}
|
||||
|
||||
private fun normalizeUrl(url: String): String =
|
||||
if (url.startsWith("http", ignoreCase = true)) url else "https://$url"
|
||||
|
||||
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
|
||||
val bufferedStream = stream.buffered()
|
||||
return when {
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
import java.io.IOException
|
||||
import javax.net.ssl.SSLException
|
||||
|
||||
/**
|
||||
* Fetches confirmed gridsquares directly from ARRL LoTW via the official report endpoint:
|
||||
* GET https://lotw.arrl.org/lotwuser/lotwreport.adi?login=<call>&password=<pwd>
|
||||
* &qso_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes&qso_qslsince=<date>
|
||||
*
|
||||
* Only QSL_RCVD=Y records are returned by LoTW for qso_qsl=yes, so every grid in the
|
||||
* report is a *confirmed* grid (green on the map). GRIDSQUARE may be a 4- or 6-char
|
||||
* value; VUCC_GRIDS ("EN52en,EN53fa") also yields 4-char fields. All values are
|
||||
* truncated/expanded to the 4-char form used by the map overlay.
|
||||
*
|
||||
* ARRL rate-limits the report endpoint: one download in progress per user id, and
|
||||
* frequent full-report pulls get refused. Failures are reported with an explicit
|
||||
* cause (see LoTWResult) so the UI can tell the user what to do next.
|
||||
*/
|
||||
class LoTWRepository : ILoTWRepository {
|
||||
|
||||
override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
fetchReportBody(callsign, password).fold(
|
||||
onSuccess = { body ->
|
||||
parseBoth(body)?.let { (grids, _, roamed) ->
|
||||
LoTWResult.Success(grids, emptyMap(), roamed)
|
||||
} ?: LoTWResult.RateLimited
|
||||
},
|
||||
onFailure = { toResult(it) }
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun fetchConfirmedGridQsos(
|
||||
callsign: String,
|
||||
password: String
|
||||
): LoTWResult = withContext(Dispatchers.IO) {
|
||||
fetchReportBody(callsign, password).fold(
|
||||
onSuccess = { body ->
|
||||
parseBoth(body)?.let { (grids, qsos, roamed) ->
|
||||
LoTWResult.Success(grids, qsos, roamed)
|
||||
} ?: LoTWResult.RateLimited
|
||||
},
|
||||
onFailure = { toResult(it) }
|
||||
)
|
||||
}
|
||||
|
||||
internal fun toResult(e: Throwable): LoTWResult = when (e) {
|
||||
is CredentialsException -> LoTWResult.BadCredentials
|
||||
is RateLimitException -> LoTWResult.RateLimited
|
||||
is TimeoutException -> LoTWResult.Timeout
|
||||
else -> LoTWResult.NetworkError(e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
|
||||
/** Single report fetch feeding the grid set, the per-QSO detail and the roamed set. */
|
||||
private fun parseBoth(
|
||||
body: String
|
||||
): Triple<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>, Set<String>>? {
|
||||
val grids = parseConfirmedGrids(body) ?: return null
|
||||
val qsos = parseConfirmedGridQsos(body) ?: return null
|
||||
val roamed = parseRoamedGrids(body) ?: return null
|
||||
return Triple(grids, qsos, roamed)
|
||||
}
|
||||
|
||||
private fun fetchReportBody(callsign: String, password: String): Result<String> {
|
||||
val call = callsign.trim().uppercase()
|
||||
val pwd = password.trim()
|
||||
if (call.isBlank() || pwd.isBlank()) return Result.failure(IOException("empty credentials"))
|
||||
// qso_qslsince with an early date forces a FULL confirmed-QSL report.
|
||||
// Without it, LoTW applies a "system supplied default" since-date and
|
||||
// only returns confirmations newer than the account's last query —
|
||||
// subsequent syncs would return an empty/incremental report.
|
||||
val since = "2000-01-01"
|
||||
val query = buildString {
|
||||
append("login=").append(URLEncoder.encode(call, "UTF-8"))
|
||||
append("&password=").append(URLEncoder.encode(pwd, "UTF-8"))
|
||||
append("&qso_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes")
|
||||
append("&qso_qslsince=").append(URLEncoder.encode(since, "UTF-8"))
|
||||
}
|
||||
return try {
|
||||
val connection = URL("$BASE_URL?$query").openConnection() as HttpURLConnection
|
||||
// ARRL can be slow to accept connections from mobile networks
|
||||
// (long TLS handshakes across the Pacific, occasional server-side
|
||||
// queueing). 30s connect + 120s read gives the request enough
|
||||
// headroom; the sync button stays disabled meanwhile so users
|
||||
// see progress rather than a hung dialog.
|
||||
connection.connectTimeout = 30_000
|
||||
connection.readTimeout = 120_000
|
||||
connection.requestMethod = "GET"
|
||||
connection.setRequestProperty("Accept-Encoding", "gzip")
|
||||
connection.instanceFollowRedirects = true
|
||||
val code = connection.responseCode
|
||||
if (code !in 200..299) {
|
||||
connection.disconnect()
|
||||
// Observed in the wild (CQRLOG #2422): LoTW answers a throttled
|
||||
// report pull with HTTP 503 "Page request limit".
|
||||
return if (code == 503 || code == 429) Result.failure(RateLimitException())
|
||||
else Result.failure(IOException("HTTP $code"))
|
||||
}
|
||||
val stream = connection.inputStream
|
||||
val body = ("gzip".equals(connection.contentEncoding, ignoreCase = true))
|
||||
.let { gz -> if (gz) java.util.zip.GZIPInputStream(stream) else stream }
|
||||
.bufferedReader().use { it.readText() }
|
||||
connection.disconnect()
|
||||
when {
|
||||
// Login failure: HTTP 200 + HTML login page with the error text.
|
||||
body.contains("incorrect", ignoreCase = true) &&
|
||||
body.contains("assword", ignoreCase = true) -> Result.failure(CredentialsException())
|
||||
// Any other non-ADIF body: rate-limit refusal / server error page.
|
||||
!body.contains("<eoh>", ignoreCase = true) -> Result.failure(RateLimitException())
|
||||
else -> Result.success(body)
|
||||
}
|
||||
} catch (e: SocketTimeoutException) {
|
||||
Result.failure(TimeoutException(e.message ?: "timed out"))
|
||||
} catch (e: SSLException) {
|
||||
Result.failure(IOException("TLS: ${e.message ?: "handshake failed"}"))
|
||||
} catch (e: Exception) {
|
||||
println("LoTWRepository fetch failure: $e")
|
||||
Result.failure(IOException(e.message ?: e.javaClass.simpleName))
|
||||
}
|
||||
}
|
||||
|
||||
internal class CredentialsException : Exception("bad callsign/password")
|
||||
internal class RateLimitException : Exception("report refused (rate limit / server error)")
|
||||
internal class TimeoutException(message: String) : Exception(message)
|
||||
|
||||
internal fun parseConfirmedGridQsos(
|
||||
body: String
|
||||
): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>? {
|
||||
if (!body.contains("<eoh>", ignoreCase = true)) return null
|
||||
// One ADIF record = fields up to <EOR>. We buffer the fields we care
|
||||
// about, then emit a GridQso on <EOR> when the record is a satellite
|
||||
// QSO (PROP_MODE=SAT) carrying a grid.
|
||||
val result = mutableMapOf<String, MutableList<com.rtbishop.look4sat.core.domain.model.GridQso>>()
|
||||
var propMode: String? = null
|
||||
var call = ""
|
||||
var qsoDate = ""
|
||||
var timeOn = ""
|
||||
var satName = ""
|
||||
var mode = ""
|
||||
var bandUp = ""
|
||||
var bandDown = ""
|
||||
var dxcc: Int? = null
|
||||
var country: String? = null
|
||||
var cqz: Int? = null
|
||||
var state: String? = null
|
||||
val gridsInRecord = mutableListOf<String>()
|
||||
|
||||
fun emitRecord() {
|
||||
if (propMode != "SAT" || gridsInRecord.isEmpty()) return
|
||||
val epochMs = adifTimestampToEpoch(qsoDate, timeOn)
|
||||
val qso = com.rtbishop.look4sat.core.domain.model.GridQso(
|
||||
call = call, epochMs = epochMs, satName = satName,
|
||||
mode = mode, bandUp = bandUp, bandDown = bandDown,
|
||||
dxcc = dxcc, country = country, cqz = cqz, state = state
|
||||
)
|
||||
for (grid in gridsInRecord) {
|
||||
result.getOrPut(grid) { mutableListOf() }.add(qso)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetRecord() {
|
||||
propMode = null; call = ""; qsoDate = ""; timeOn = ""
|
||||
satName = ""; mode = ""; bandUp = ""; bandDown = ""
|
||||
dxcc = null; country = null; cqz = null; state = null
|
||||
gridsInRecord.clear()
|
||||
}
|
||||
|
||||
for (raw in body.lineSequence()) {
|
||||
val line = raw.trim()
|
||||
when {
|
||||
line.equals("<EOR>", ignoreCase = true) -> {
|
||||
emitRecord()
|
||||
resetRecord()
|
||||
}
|
||||
line.startsWith("<PROP_MODE:") ->
|
||||
propMode = adifValue(line).uppercase()
|
||||
line.startsWith("<CALL:") ->
|
||||
call = adifValue(line).uppercase()
|
||||
line.startsWith("<QSO_DATE:") ->
|
||||
qsoDate = adifValue(line)
|
||||
line.startsWith("<TIME_ON:") ->
|
||||
timeOn = adifValue(line)
|
||||
line.startsWith("<SAT_NAME:") ->
|
||||
satName = adifValue(line)
|
||||
line.startsWith("<MODE:") ->
|
||||
mode = adifValue(line)
|
||||
line.startsWith("<BAND_RX:") ->
|
||||
bandUp = adifValue(line).uppercase()
|
||||
line.startsWith("<BAND:") && !line.startsWith("<BAND_RX:") ->
|
||||
bandDown = adifValue(line).uppercase()
|
||||
line.startsWith("<DXCC:") ->
|
||||
dxcc = adifValue(line).toIntOrNull()
|
||||
line.startsWith("<COUNTRY:") ->
|
||||
country = adifValue(line).ifBlank { null }
|
||||
line.startsWith("<CQZ:") ->
|
||||
cqz = adifValue(line).toIntOrNull()
|
||||
line.startsWith("<STATE:") ->
|
||||
state = adifValue(line).trim().ifBlank { null }?.let { normalizeState(it) }
|
||||
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
|
||||
// VUCC_GRIDS holds a comma-separated list of grids
|
||||
// ("EN52en,EN53fa"), up to four for contacts spanning
|
||||
// several squares; split and keep every 4-char field.
|
||||
adifValue(line).split(',').forEach { grid ->
|
||||
val field = grid.trim().uppercase()
|
||||
if (field.length >= 4) gridsInRecord.add(field.take(4))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** ADIF field value: "<GRIDSQUARE:4>OL62" -> "OL62". */
|
||||
private fun adifValue(line: String): String =
|
||||
line.substringAfter('>').substringBefore("E<").trim()
|
||||
|
||||
/**
|
||||
* LoTW returns STATE as "CODE // NAME" (e.g. "HB // Hubei" for China,
|
||||
* "34 // Tottori-ken" for Japan, "CA // California" for the US). The award
|
||||
* statistics match on the short CODE only (China 2-letter province pinyin,
|
||||
* Japan 2-digit prefecture, US 2-letter state), so strip the " // NAME"
|
||||
* suffix here once, storing the clean code for every downstream consumer
|
||||
* (persistence, AwardCalculator).
|
||||
*/
|
||||
private fun normalizeState(raw: String): String = raw.substringBefore(" // ").trim()
|
||||
|
||||
/** "20260820" + "1130" (or "113000") -> UTC epoch ms; 0 when unparseable. */
|
||||
private fun adifTimestampToEpoch(date: String, time: String): Long = try {
|
||||
val d = date.trim()
|
||||
val t = time.trim().padEnd(6, '0').take(6)
|
||||
val fmt = java.time.format.DateTimeFormatterBuilder()
|
||||
.appendPattern("yyyyMMddHHmmss")
|
||||
.toFormatter()
|
||||
.withZone(java.time.ZoneOffset.UTC)
|
||||
java.time.Instant.from(fmt.parse(d + t)).toEpochMilli()
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
|
||||
internal fun parseConfirmedGrids(body: String): Set<String>? {
|
||||
// LoTW answers with ADIF text; on bad credentials it returns a short error
|
||||
// page without an <eoh> header terminator. Real reports always carry <eoh>
|
||||
// (LoTW writes it lowercase). Match case-insensitively to be safe.
|
||||
if (!body.contains("<eoh>", ignoreCase = true)) return null
|
||||
val grids = mutableSetOf<String>()
|
||||
// ADIF fields of one QSO record span multiple lines and are terminated by
|
||||
// <EOR>. Satellite QSOs carry <PROP_MODE:3>SAT (plus <SAT_NAME>); ground
|
||||
// QSOs omit it. Grid fields (GRIDSQUARE / VUCC_GRIDS) must only be
|
||||
// collected for records whose PROP_MODE is SAT, otherwise the map mixes
|
||||
// in terrestrial contacts.
|
||||
//
|
||||
// Buffered per-record pattern (same as parseConfirmedGridQsos): field order
|
||||
// is NOT reliable — ADIF producers (incl. LoTW) emit fields alphabetically,
|
||||
// so <GRIDSQUARE> (G) arrives BEFORE <PROP_MODE> (P) within a record. A
|
||||
// sequential gate ("only add while propMode == SAT") silently drops every
|
||||
// grid on real reports. Buffer the record and decide at <EOR> instead.
|
||||
var propMode: String? = null
|
||||
var pendingGrids = mutableListOf<String>()
|
||||
for (raw in body.lineSequence()) {
|
||||
val line = raw.trim()
|
||||
when {
|
||||
line.equals("<EOR>", ignoreCase = true) -> {
|
||||
if (propMode == "SAT") grids.addAll(pendingGrids)
|
||||
propMode = null
|
||||
pendingGrids = mutableListOf()
|
||||
}
|
||||
line.startsWith("<PROP_MODE:") -> {
|
||||
propMode = adifValue(line).uppercase()
|
||||
}
|
||||
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
|
||||
// VUCC_GRIDS holds a comma-separated PAIR of grids
|
||||
// ("EN52en,EN53fa") for contacts spanning two squares —
|
||||
// split on ',' and take the 4-char field of each, or the
|
||||
// second grid is silently dropped.
|
||||
val value = adifValue(line)
|
||||
value.split(',').forEach { grid ->
|
||||
val field = grid.trim().uppercase()
|
||||
if (field.length >= 4) pendingGrids.add(field.take(4))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return grids
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct 4-char gridsquares the account itself operated from, taken from
|
||||
* ADIF <MY_GRIDSQUARE> of satellite QSO records only. This is the
|
||||
* "roamed / activated" set shown as blue stripes on the map — the grids
|
||||
* where the operator's own station was located during confirmed QSOs (a
|
||||
* rover may log several distinct grids; the home grid is included too).
|
||||
* Returns null when the body is not an ADIF report (mirrors the other parsers).
|
||||
*/
|
||||
internal fun parseRoamedGrids(body: String): Set<String>? {
|
||||
if (!body.contains("<eoh>", ignoreCase = true)) return null
|
||||
val grids = mutableSetOf<String>()
|
||||
// Buffered per-record pattern (same as parseConfirmedGridQsos): field
|
||||
// order is NOT reliable — ADIF producers (incl. LoTW) emit fields
|
||||
// alphabetically, so <MY_GRIDSQUARE> (M) arrives BEFORE <PROP_MODE> (P)
|
||||
// within a record. A sequential gate ("only add while propMode == SAT")
|
||||
// would silently drop every own grid on real reports; buffer the record
|
||||
// and decide at <EOR> instead.
|
||||
var propMode: String? = null
|
||||
var myGrid: String? = null
|
||||
for (raw in body.lineSequence()) {
|
||||
val line = raw.trim()
|
||||
when {
|
||||
line.equals("<EOR>", ignoreCase = true) -> {
|
||||
if (propMode == "SAT" && myGrid != null) grids.add(myGrid)
|
||||
propMode = null
|
||||
myGrid = null
|
||||
}
|
||||
line.startsWith("<PROP_MODE:") -> {
|
||||
propMode = adifValue(line).uppercase()
|
||||
}
|
||||
line.startsWith("<MY_GRIDSQUARE:") -> {
|
||||
// MY_GRIDSQUARE must not be mistaken for GRIDSQUARE (the
|
||||
// opposite station's grid) — only own-station grids count.
|
||||
val value = adifValue(line)
|
||||
if (value.length >= 4) myGrid = value.take(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
return grids
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val BASE_URL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
|
||||
}
|
||||
}
|
||||
+20
-16
@@ -34,10 +34,10 @@ 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.withContext
|
||||
import java.util.TimeZone
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class SatelliteRepo(
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
@@ -64,19 +64,23 @@ class SatelliteRepo(
|
||||
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||
|
||||
override suspend fun initRepository() = withContext(dispatcher) {
|
||||
settingsRepo.selectedIds.collect { selectedIds ->
|
||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||
val settings = settingsRepo.passesSettings.value
|
||||
calculatePasses(
|
||||
time = System.currentTimeMillis(),
|
||||
hoursAhead = settings.hoursAhead,
|
||||
minElevation = settings.minElevation,
|
||||
aosStartMinute = settings.aosStartMinute,
|
||||
aosEndMinute = settings.aosEndMinute,
|
||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||
modes = settingsRepo.selectedSatModes.value
|
||||
)
|
||||
}
|
||||
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 = settings.selectedModes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
|
||||
@@ -159,7 +163,7 @@ class SatelliteRepo(
|
||||
}
|
||||
}
|
||||
newPasses.sortBy { it.aosTime }
|
||||
delay(1000.milliseconds) // Simulate loading time for better UX
|
||||
delay(1000) // Simulate loading time for better UX
|
||||
_passes.update { newPasses }
|
||||
}
|
||||
_isCalculating.value = false
|
||||
@@ -177,7 +181,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
|
||||
}
|
||||
|
||||
+13
-16
@@ -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) {
|
||||
|
||||
+359
-30
@@ -29,6 +29,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
@@ -37,6 +38,10 @@ 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
|
||||
import java.util.Locale
|
||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
|
||||
class SettingsRepo(
|
||||
private val locationManager: LocationManager,
|
||||
@@ -67,14 +72,17 @@ 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 keyStateOfMapGrid = "stateOfMapGrid"
|
||||
private val keyStationAltitude = "stationAltitude"
|
||||
private val keyStationLatitude = "stationLatitude"
|
||||
private val keyStationLongitude = "stationLongitude"
|
||||
@@ -86,19 +94,25 @@ class SettingsRepo(
|
||||
private val keySstvMode = "sstvMode"
|
||||
private val keyLowElevation = "lowElevation"
|
||||
private val keyHighElevation = "highElevation"
|
||||
private val keyRadarCompassOffset = "radarCompassOffset"
|
||||
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
|
||||
private val keyUseCustomTle = "useCustomTle"
|
||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||
private val keyTleUrl = "tleUrl"
|
||||
private val keyTransceiversUrl = "transceiversUrl"
|
||||
private val keySatelliteUrls = "satelliteUrls"
|
||||
private val keyTransceiversUrls = "transceiversUrls"
|
||||
private val keySatelliteEnabled = "satelliteEnabled"
|
||||
private val keyTransceiversEnabled = "transceiversEnabled"
|
||||
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
|
||||
private val separatorComma = ","
|
||||
private val separatorUrl = "\n"
|
||||
private val legacyCelestrakSatnogsUrl =
|
||||
"https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv"
|
||||
|
||||
//region # Satellites selection settings
|
||||
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
||||
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
|
||||
private val _typesSelection = MutableStateFlow(getSelectedTypes())
|
||||
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
|
||||
override val selectedSatModes: StateFlow<List<String>> = _satelliteModeSelection
|
||||
override val selectedTypes: StateFlow<List<String>> = _typesSelection
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) {
|
||||
val selectionString = ids.joinToString(separatorComma)
|
||||
@@ -106,10 +120,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> {
|
||||
@@ -118,14 +132,163 @@ class SettingsRepo(
|
||||
return selectionString.split(separatorComma).map { it.toInt() }
|
||||
}
|
||||
|
||||
private fun getSelectedSatModes(): List<String> {
|
||||
val modesString = preferences.getString(keySelectedSatModes, null)
|
||||
if (modesString.isNullOrEmpty()) return emptyList()
|
||||
return modesString.split(separatorComma).sorted()
|
||||
private fun getSelectedTypes(): List<String> {
|
||||
val typesString = preferences.getString(keySelectedTypes, "Amateur")
|
||||
if (typesString.isNullOrEmpty()) return emptyList()
|
||||
return typesString.split(separatorComma)
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # Passes filter settings
|
||||
//region # Wavelog worked-grids settings
|
||||
private val keyWavelogUrl = "wavelogUrl"
|
||||
private val keyWavelogToken = "wavelogToken"
|
||||
private val keyWorkedGrids = "workedGrids"
|
||||
|
||||
private val _wavelogSettings = MutableStateFlow(getWavelogSettings())
|
||||
override val wavelogSettings: StateFlow<WavelogSettings> = _wavelogSettings
|
||||
|
||||
override fun updateWavelogSettings(settings: WavelogSettings) {
|
||||
preferences.edit {
|
||||
putString(keyWavelogUrl, settings.url.trim())
|
||||
putString(keyWavelogToken, settings.token.trim())
|
||||
}
|
||||
_wavelogSettings.value = settings.copy(url = settings.url.trim(), token = settings.token.trim())
|
||||
}
|
||||
|
||||
private fun getWavelogSettings(): WavelogSettings = WavelogSettings(
|
||||
url = preferences.getString(keyWavelogUrl, null).orEmpty(),
|
||||
token = preferences.getString(keyWavelogToken, null).orEmpty()
|
||||
)
|
||||
|
||||
override fun getWorkedGrids(): Set<String> {
|
||||
val json = preferences.getString(keyWorkedGrids, null).orEmpty()
|
||||
if (json.isBlank()) return emptySet()
|
||||
return try {
|
||||
val array = org.json.JSONArray(json)
|
||||
(0 until array.length()).mapNotNull { i ->
|
||||
array.optString(i).takeIf { it.isNotBlank() }
|
||||
}.toSet()
|
||||
} catch (_: Exception) {
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setWorkedGrids(grids: Set<String>) {
|
||||
val array = org.json.JSONArray()
|
||||
grids.sorted().forEach { array.put(it) }
|
||||
preferences.edit { putString(keyWorkedGrids, array.toString()) }
|
||||
}
|
||||
|
||||
// Confirmed satellite QSOs per worked gridsquare, persisted as a single JSON
|
||||
// object: {"OL62":[{"c":call,"t":epochMs,"s":sat,"m":mode,"bu":up,"bd":down}]}.
|
||||
private val keyWorkedGridQsos = "workedGridQsos"
|
||||
|
||||
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> {
|
||||
val json = preferences.getString(keyWorkedGridQsos, null).orEmpty()
|
||||
if (json.isBlank()) return emptyMap()
|
||||
return try {
|
||||
val root = org.json.JSONObject(json)
|
||||
val result = mutableMapOf<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>()
|
||||
for (grid in root.keys()) {
|
||||
val array = root.optJSONArray(grid) ?: continue
|
||||
val list = (0 until array.length()).mapNotNull { i ->
|
||||
val o = array.optJSONObject(i) ?: return@mapNotNull null
|
||||
com.rtbishop.look4sat.core.domain.model.GridQso(
|
||||
call = o.optString("c"),
|
||||
epochMs = o.optLong("t"),
|
||||
satName = o.optString("s"),
|
||||
mode = o.optString("m"),
|
||||
bandUp = o.optString("bu"),
|
||||
bandDown = o.optString("bd"),
|
||||
// New award fields: absent in pre-award data -> null.
|
||||
dxcc = o.optInt("dx", 0).takeIf { it > 0 },
|
||||
country = o.optString("cty").ifBlank { null },
|
||||
cqz = o.optInt("cq", 0).takeIf { it > 0 },
|
||||
state = o.optString("st").ifBlank { null }
|
||||
)
|
||||
}
|
||||
if (list.isNotEmpty()) result[grid] = list
|
||||
}
|
||||
result
|
||||
} catch (_: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) {
|
||||
val root = org.json.JSONObject()
|
||||
for ((grid, list) in qsos) {
|
||||
val array = org.json.JSONArray()
|
||||
for (q in list) {
|
||||
array.put(
|
||||
org.json.JSONObject()
|
||||
.put("c", q.call)
|
||||
.put("t", q.epochMs)
|
||||
.put("s", q.satName)
|
||||
.put("m", q.mode)
|
||||
.put("bu", q.bandUp)
|
||||
.put("bd", q.bandDown)
|
||||
.put("dx", q.dxcc ?: 0)
|
||||
.put("cty", q.country ?: "")
|
||||
.put("cq", q.cqz ?: 0)
|
||||
.put("st", q.state ?: "")
|
||||
)
|
||||
}
|
||||
root.put(grid, array)
|
||||
}
|
||||
preferences.edit { putString(keyWorkedGridQsos, root.toString()) }
|
||||
}
|
||||
|
||||
// Distinct 4-char gridsquares the account operated from (LoTW <MY_GRIDSQUARE>,
|
||||
// satellite QSOs only). Stored as a JSONArray like workedGrids.
|
||||
private val keyRoamedGrids = "roamedGrids"
|
||||
|
||||
override fun getRoamedGrids(): Set<String> {
|
||||
val json = preferences.getString(keyRoamedGrids, null).orEmpty()
|
||||
if (json.isBlank()) return emptySet()
|
||||
return try {
|
||||
val array = org.json.JSONArray(json)
|
||||
(0 until array.length()).mapNotNull { i ->
|
||||
array.optString(i).takeIf { it.isNotBlank() }
|
||||
}.toSet()
|
||||
} catch (_: Exception) {
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setRoamedGrids(grids: Set<String>) {
|
||||
val array = org.json.JSONArray()
|
||||
grids.sorted().forEach { array.put(it) }
|
||||
preferences.edit { putString(keyRoamedGrids, array.toString()) }
|
||||
}
|
||||
|
||||
// LoTW credentials (stored locally on the device only)
|
||||
private val keyLoTWCall = "lotwCallsign"
|
||||
private val keyLoTWPass = "lotwPassword"
|
||||
|
||||
override val lotwSettings: kotlinx.coroutines.flow.StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
|
||||
get() = _lotwSettings
|
||||
private val _lotwSettings = MutableStateFlow(getLoTWSettings())
|
||||
|
||||
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) {
|
||||
preferences.edit {
|
||||
putString(keyLoTWCall, settings.callsign.trim().uppercase())
|
||||
putString(keyLoTWPass, settings.password)
|
||||
}
|
||||
_lotwSettings.value = settings.copy(
|
||||
callsign = settings.callsign.trim().uppercase(),
|
||||
password = settings.password
|
||||
)
|
||||
}
|
||||
|
||||
private fun getLoTWSettings(): com.rtbishop.look4sat.core.domain.model.LoTWSettings =
|
||||
com.rtbishop.look4sat.core.domain.model.LoTWSettings(
|
||||
callsign = preferences.getString(keyLoTWCall, null).orEmpty(),
|
||||
password = preferences.getString(keyLoTWPass, null).orEmpty()
|
||||
)
|
||||
//endregion
|
||||
|
||||
//region # Transceivers settings
|
||||
private val _passesSettings = MutableStateFlow(getPassesSettings())
|
||||
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
|
||||
|
||||
@@ -136,6 +299,7 @@ class SettingsRepo(
|
||||
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
||||
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
||||
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
||||
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
|
||||
_passesSettings.value = settings
|
||||
}
|
||||
|
||||
@@ -146,13 +310,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
|
||||
@@ -232,6 +399,25 @@ class SettingsRepo(
|
||||
private val _databaseState = MutableStateFlow(getDatabaseState())
|
||||
override val databaseState: StateFlow<DatabaseState> = _databaseState
|
||||
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> {
|
||||
val idsSet = mutableSetOf<Int>()
|
||||
types.forEach { type ->
|
||||
val typeString = preferences.getString("type$type", null)
|
||||
val typeIds = if (typeString.isNullOrBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
typeString.split(separatorComma).map { it.toInt() }
|
||||
}
|
||||
idsSet.addAll(typeIds)
|
||||
}
|
||||
return idsSet.toList()
|
||||
}
|
||||
|
||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
||||
if (type == "All") return
|
||||
val typesString = ids.joinToString(separatorComma)
|
||||
preferences.edit { putString("type$type", typesString) }
|
||||
}
|
||||
|
||||
override fun updateDatabaseState(state: DatabaseState) = preferences.edit {
|
||||
putInt(keyNumberOfSatellites, state.numberOfSatellites)
|
||||
@@ -276,6 +462,10 @@ class SettingsRepo(
|
||||
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
||||
|
||||
override fun updateRCSettings(settings: RCSettings) {
|
||||
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
|
||||
Constants.FREQ_OFFSET_MIN_HZ,
|
||||
Constants.FREQ_OFFSET_MAX_HZ
|
||||
)
|
||||
preferences.edit {
|
||||
putBoolean(keyRotatorState, settings.rotatorState)
|
||||
putString(keyRotatorAddress, settings.rotatorAddress)
|
||||
@@ -285,6 +475,7 @@ class SettingsRepo(
|
||||
putString(keyFrequencyAddress, settings.frequencyAddress)
|
||||
putString(keyFrequencyPort, settings.frequencyPort)
|
||||
putString(keyFrequencyFormat, settings.frequencyFormat)
|
||||
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
|
||||
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
||||
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
||||
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
||||
@@ -293,7 +484,7 @@ class SettingsRepo(
|
||||
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
||||
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
||||
}
|
||||
_rcSettings.value = settings
|
||||
_rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
|
||||
}
|
||||
|
||||
private fun getRCSettings(): RCSettings = RCSettings(
|
||||
@@ -305,6 +496,8 @@ class SettingsRepo(
|
||||
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
|
||||
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
|
||||
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
|
||||
frequencyOffsetHz = preferences.getLong(keyFrequencyOffsetHz, 0L)
|
||||
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ),
|
||||
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
|
||||
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
||||
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
||||
@@ -329,13 +522,12 @@ class SettingsRepo(
|
||||
putBoolean(keyStateOfUtc, new.stateOfUtc)
|
||||
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
|
||||
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
|
||||
putBoolean(keyStateOfMapGrid, new.stateOfMapGrid)
|
||||
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
||||
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
||||
putString(keySstvMode, new.sstvMode)
|
||||
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
||||
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
|
||||
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
|
||||
}
|
||||
new
|
||||
}
|
||||
@@ -348,13 +540,12 @@ class SettingsRepo(
|
||||
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
|
||||
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
|
||||
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
||||
stateOfMapGrid = preferences.getBoolean(keyStateOfMapGrid, false),
|
||||
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
|
||||
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
|
||||
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f)
|
||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
|
||||
)
|
||||
//endregion
|
||||
|
||||
@@ -363,21 +554,108 @@ class SettingsRepo(
|
||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
|
||||
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
|
||||
// Normalize the enabled lists so they are positionally aligned with the URL lists.
|
||||
// Missing entries default to enabled (true), keeping the persisted "one flag per URL"
|
||||
// invariant intact even when a default empty list is used to construct the model.
|
||||
val normalized = settings.copy(
|
||||
satelliteEnabled = alignFlags(settings.satelliteUrls, settings.satelliteEnabled),
|
||||
transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled)
|
||||
)
|
||||
preferences.edit {
|
||||
putBoolean(keyUseCustomTle, settings.useCustomTLE)
|
||||
putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers)
|
||||
putString(keyTleUrl, settings.tleUrl)
|
||||
putString(keyTransceiversUrl, settings.transceiversUrl)
|
||||
putString(keySatelliteUrls, normalized.satelliteUrls.joinToString(separatorUrl))
|
||||
putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
|
||||
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
|
||||
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
|
||||
putBoolean(keySatnogsTleSourceMigration, true)
|
||||
}
|
||||
_dataSourcesSettings.value = settings
|
||||
_dataSourcesSettings.value = normalized
|
||||
}
|
||||
|
||||
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") ?: ""
|
||||
satelliteUrls = getDataSourceUrls(
|
||||
key = keySatelliteUrls,
|
||||
defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
|
||||
legacyEnabledKey = keyUseCustomTle,
|
||||
legacyUrlKey = keyTleUrl
|
||||
).migrateSatnogsTleSource(),
|
||||
transceiversUrls = getDataSourceUrls(
|
||||
key = keyTransceiversUrls,
|
||||
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
|
||||
legacyEnabledKey = keyUseCustomTransceivers,
|
||||
legacyUrlKey = keyTransceiversUrl
|
||||
),
|
||||
satelliteEnabled = readEnabledFlags(keySatelliteEnabled),
|
||||
transceiversEnabled = readEnabledFlags(keyTransceiversEnabled)
|
||||
)
|
||||
|
||||
/** Read the persisted per-source enabled flags (empty when never stored). */
|
||||
private fun readEnabledFlags(key: String): List<Boolean> {
|
||||
return preferences.getString(key, null)
|
||||
?.split(separatorComma)
|
||||
?.mapNotNull { it.trim() }
|
||||
?.filter { it == "true" || it == "false" }
|
||||
?.map { it == "true" }
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
/** Keep the flags positionally aligned with the URL list, defaulting to enabled. */
|
||||
private fun alignFlags(urls: List<String>, flags: List<Boolean>): List<Boolean> {
|
||||
if (flags.size >= urls.size) return flags.take(urls.size)
|
||||
return flags + List(urls.size - flags.size) { true }
|
||||
}
|
||||
|
||||
private fun getDataSourceUrls(
|
||||
key: String,
|
||||
defaultUrls: List<String>,
|
||||
legacyEnabledKey: String,
|
||||
legacyUrlKey: String
|
||||
): List<String> {
|
||||
val stored = preferences.getString(key, null)
|
||||
?.split(separatorUrl)
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotBlank() }
|
||||
if (stored != null) return stored
|
||||
val legacyCustomUrl = preferences.getString(legacyUrlKey, null)?.trim().orEmpty()
|
||||
return if (preferences.getBoolean(legacyEnabledKey, false) && legacyCustomUrl.isNotBlank()) {
|
||||
(defaultUrls + legacyCustomUrl).distinct()
|
||||
} else {
|
||||
defaultUrls
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<String>.migrateSatnogsTleSource(): List<String> {
|
||||
if (preferences.getBoolean(keySatnogsTleSourceMigration, false)) return this
|
||||
val satnogsTleUrl = Sources.satelliteDataUrls["SatNOGS"].orEmpty()
|
||||
if (satnogsTleUrl.isBlank() || containsSourceUrl(satnogsTleUrl)) return this
|
||||
val celestrakSatnogsIndex = indexOfFirst { isSameSourceUrl(it, legacyCelestrakSatnogsUrl) }
|
||||
if (celestrakSatnogsIndex < 0) return this
|
||||
|
||||
val migrated = toMutableList().apply { add(celestrakSatnogsIndex + 1, satnogsTleUrl) }
|
||||
preferences.edit {
|
||||
putString(keySatelliteUrls, migrated.joinToString(separatorUrl))
|
||||
putBoolean(keySatnogsTleSourceMigration, true)
|
||||
}
|
||||
return migrated
|
||||
}
|
||||
|
||||
private fun List<String>.containsSourceUrl(url: String): Boolean = any { isSameSourceUrl(it, url) }
|
||||
|
||||
private fun isSameSourceUrl(first: String, second: String): Boolean =
|
||||
normalizeSourceUrl(first).equals(normalizeSourceUrl(second), ignoreCase = true)
|
||||
|
||||
private fun normalizeSourceUrl(url: String): String {
|
||||
val trimmed = url.trim()
|
||||
return if (trimmed.startsWith("http", ignoreCase = true)) trimmed else "https://$trimmed"
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # Data sources status
|
||||
private val _dataSourcesStatus = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||
override val dataSourcesStatus: StateFlow<Map<String, Int>> = _dataSourcesStatus
|
||||
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) {
|
||||
_dataSourcesStatus.value = status
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
@@ -418,4 +696,55 @@ class SettingsRepo(
|
||||
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
||||
)
|
||||
//endregion
|
||||
|
||||
//region # Per-satellite calculator offset settings
|
||||
private val keySatelliteOffsets = "satelliteOffsets"
|
||||
private val keyLegacySatelliteOffsetPrefix = "offset_khz_"
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String {
|
||||
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||
val stored = try {
|
||||
JSONObject(json).optString(catnum.toString(), "")
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
if (stored.isNotEmpty()) return stored
|
||||
|
||||
// Lazy migration from the fork's old UI-layer implementation, which stored
|
||||
// one SharedPreferences entry per satellite directly from TransceiversPage.
|
||||
val legacyKey = "$keyLegacySatelliteOffsetPrefix$catnum"
|
||||
val legacy = preferences.getString(legacyKey, "").orEmpty()
|
||||
if (legacy.isNotEmpty()) {
|
||||
setSatelliteOffset(catnum, legacy)
|
||||
}
|
||||
return legacy
|
||||
}
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) {
|
||||
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) {
|
||||
if (offset.isEmpty()) "{}" else """{"$catnum": "$offset"}"""
|
||||
}
|
||||
preferences.edit {
|
||||
putString(keySatelliteOffsets, updated)
|
||||
remove("$keyLegacySatelliteOffsetPrefix$catnum")
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # AMSAT status report settings
|
||||
private val keyAmSatCallsign = "amSatCallsign"
|
||||
|
||||
override fun getAmSatCallsign(): String {
|
||||
return preferences.getString(keyAmSatCallsign, "").orEmpty()
|
||||
}
|
||||
|
||||
override fun setAmSatCallsign(callsign: String) {
|
||||
preferences.edit { putString(keyAmSatCallsign, callsign.trim().uppercase(Locale.US)) }
|
||||
}
|
||||
//endregion
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.LatestRelease
|
||||
import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
class UpdateRepository(
|
||||
private val remoteSource: IRemoteSource
|
||||
) : IUpdateRepository {
|
||||
|
||||
override suspend fun getLatestRelease(): LatestRelease? = withContext(Dispatchers.IO) {
|
||||
// Use the GitHub releases web page instead of the REST API endpoint:
|
||||
// the API endpoint is rate-limited to 60 requests/hour per IP, which is
|
||||
// quickly exhausted on shared egress IPs (e.g. VPN proxies), causing 403
|
||||
// failures. The web endpoint redirects to the latest tag with no such limit.
|
||||
// When GitHub is unreachable (common on mainland-China networks without a
|
||||
// proxy), fall back to community GitHub accelerator mirrors that proxy the
|
||||
// same page; each mirror resolves the identical tag and asset URLs.
|
||||
for (baseUrl in LATEST_RELEASE_URLS) {
|
||||
val result = remoteSource.getNetworkStream(baseUrl)
|
||||
val stream = result.stream ?: continue
|
||||
val parsed = try {
|
||||
parseRelease(stream.bufferedReader().use { it.readText() }, baseUrl)
|
||||
} catch (e: Exception) {
|
||||
println("UpdateRepository parse failure: $e")
|
||||
null
|
||||
}
|
||||
if (parsed != null) return@withContext parsed
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
override suspend fun downloadApk(url: String, dest: File): Boolean = withContext(Dispatchers.IO) {
|
||||
if (url.isBlank()) return@withContext false
|
||||
val result = remoteSource.getNetworkStream(url)
|
||||
val stream = result.stream ?: return@withContext false
|
||||
try {
|
||||
dest.outputStream().use { out -> stream.use { it.copyTo(out) } }
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
println("UpdateRepository download failure: $e")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseRelease(html: String, sourceUrl: String): LatestRelease? {
|
||||
// The redirect target is the latest release's tag page. Extract the tag
|
||||
// from the og:url meta tag ("…/releases/tag/v4.4.6-ba7opf.8") — stable and
|
||||
// unambiguous. The <title> also holds the release name, but that is free
|
||||
// text and cannot be used to build the asset URL.
|
||||
val ogUrl = Regex("<meta\\s+property=\"og:url\"\\s+content=\"([^\"]*)\"").find(html)
|
||||
?.groupValues?.get(1) ?: return null
|
||||
val tag = Regex("/releases/tag/(v[0-9][0-9A-Za-z.\\-]*)$").find(ogUrl)
|
||||
?.groupValues?.get(1) ?: return null
|
||||
val title = Regex("<title>(.*?)</title>", RegexOption.DOT_MATCHES_ALL)
|
||||
.find(html)?.groupValues?.get(1)?.trim()
|
||||
?.substringBefore("·")?.removePrefix("Release")?.trim() ?: tag
|
||||
// Release notes live in the page's markdown-body section (first occurrence
|
||||
// is the release description). Strip HTML tags for plain-text display.
|
||||
val raw = Regex("<div[^>]*class=\"[^\"]*markdown-body[^\"]*\"[^>]*>(.*?)</div>", RegexOption.DOT_MATCHES_ALL)
|
||||
.find(html)?.groupValues?.get(1) ?: ""
|
||||
val body = raw.replace(Regex("<[^>]+>"), "")
|
||||
.replace("<", "<").replace(">", ">").replace("&", "&")
|
||||
.replace(""", "\"").replace("'", "'")
|
||||
.trim()
|
||||
// The release APK asset naming: Look4Sat-<tag without leading v>.apk
|
||||
// (since v4.4.6-ba7opf.9.9 the releases are uploaded with the bare
|
||||
// versioned filename, no "-release" suffix). When the page was fetched
|
||||
// through an accelerator mirror (https://<mirror>/https://github.com/...),
|
||||
// download through the same mirror — raw github.com is unreachable on the
|
||||
// networks that needed the mirror in the first place.
|
||||
val mirrorPrefix = sourceUrl.substringBefore("https://github.com")
|
||||
val apkName = "Look4Sat-${tag.removePrefix("v")}.apk"
|
||||
val apkUrl = if (mirrorPrefix.isEmpty()) {
|
||||
"$DOWNLOAD_BASE_URL/$tag/$apkName"
|
||||
} else {
|
||||
"${mirrorPrefix}https://github.com/atsunatsu/Look4Sat/releases/download/$tag/$apkName"
|
||||
}
|
||||
return LatestRelease(
|
||||
versionTag = tag,
|
||||
title = title,
|
||||
body = body,
|
||||
apkUrl = apkUrl
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Web pages are used instead of api.github.com to avoid the 60 req/hour
|
||||
// anonymous rate limit (see getLatestRelease above). The GitHub URL is
|
||||
// tried first; if it is unreachable (no proxy on mainland networks) the
|
||||
// accelerator mirrors are tried in order. Download URLs always point at
|
||||
// the mirrors too — raw github.com release downloads are equally blocked
|
||||
// without a proxy, so a mirror-resolved tag must be downloaded via the
|
||||
// same mirror.
|
||||
val LATEST_RELEASE_URLS = listOf(
|
||||
"https://github.com/atsunatsu/Look4Sat/releases/latest",
|
||||
"https://ghfast.top/https://github.com/atsunatsu/Look4Sat/releases/latest",
|
||||
"https://gh.llkk.cc/https://github.com/atsunatsu/Look4Sat/releases/latest",
|
||||
"https://github.moeyy.xyz/https://github.com/atsunatsu/Look4Sat/releases/latest"
|
||||
)
|
||||
const val DOWNLOAD_BASE_URL = "https://github.com/atsunatsu/Look4Sat/releases/download"
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Fetches the list of worked gridsquares from a self-hosted Wavelog instance.
|
||||
*
|
||||
* Uses the Wavelog v1 API endpoint (verified against Wavelog 3.0.2):
|
||||
* POST {base}/index.php/api/logbook_get_worked_grids
|
||||
* Body: {"key": "<api key>", "logbook_id": "<logbook id>"}
|
||||
* Response 201: JSON array of 4-char gridsquares, e.g. ["JN47","JO30"]
|
||||
* (see Wavelog Api.php logbook_get_worked_grids / Api_model::get_grids_worked_in_logbook)
|
||||
*
|
||||
* The token field carries "<api_key>:<logbook_id>" so one credential line
|
||||
* configures both values (the dialog explains this to the user).
|
||||
*/
|
||||
class WavelogRepository : IWavelogRepository {
|
||||
|
||||
override suspend fun fetchWorkedGrids(url: String, token: String): Set<String>? = withContext(Dispatchers.IO) {
|
||||
val parts = token.trim().split(":")
|
||||
val apiKey = parts.getOrNull(0)?.trim().orEmpty()
|
||||
val logbookId = parts.getOrNull(1)?.trim().orEmpty()
|
||||
if (url.isBlank() || apiKey.isBlank() || logbookId.isBlank()) return@withContext null
|
||||
val base = url.trim().trimEnd('/')
|
||||
val requestUrl = "$base/index.php/api/logbook_get_worked_grids"
|
||||
try {
|
||||
val connection = java.net.URL(requestUrl).openConnection() as java.net.HttpURLConnection
|
||||
connection.connectTimeout = 10_000
|
||||
connection.readTimeout = 15_000
|
||||
connection.requestMethod = "POST"
|
||||
connection.doOutput = true
|
||||
connection.setRequestProperty("Content-Type", "application/json")
|
||||
val body = JSONObject().apply {
|
||||
put("key", apiKey)
|
||||
put("logbook_id", logbookId)
|
||||
}.toString().toByteArray()
|
||||
connection.outputStream.use { it.write(body) }
|
||||
val code = connection.responseCode
|
||||
if (code !in 200..299) {
|
||||
connection.disconnect()
|
||||
return@withContext null
|
||||
}
|
||||
val response = connection.inputStream.bufferedReader().use { it.readText() }
|
||||
connection.disconnect()
|
||||
parseWorkedGrids(response)
|
||||
} catch (e: Exception) {
|
||||
println("WavelogRepository fetch failure: $e")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parseWorkedGrids(body: String): Set<String>? {
|
||||
return try {
|
||||
val array = when {
|
||||
body.trimStart().startsWith("[") -> JSONArray(body)
|
||||
else -> {
|
||||
// Tolerate {"grids": [...]} wrappers too
|
||||
JSONObject(body).optJSONArray("grids") ?: return null
|
||||
}
|
||||
}
|
||||
val result = mutableSetOf<String>()
|
||||
for (i in 0 until array.length()) {
|
||||
val grid = array.optString(i, "").trim().uppercase()
|
||||
// Keep only well-formed 4-character Maidenhead squares (e.g. OL62)
|
||||
if (grid.length == 4 && grid[0] in 'A'..'R' && grid[1] in 'A'..'R'
|
||||
&& grid[2] in '0'..'9' && grid[3] in '0'..'9'
|
||||
) {
|
||||
result.add(grid)
|
||||
}
|
||||
}
|
||||
result
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,13 @@ 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 com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.InputStream
|
||||
|
||||
class RemoteSource(
|
||||
@@ -42,19 +45,74 @@ class RemoteSource(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
||||
override suspend fun getNetworkStream(url: String): NetworkResult = withContext(dispatcher) {
|
||||
try {
|
||||
val networkRequest = Request.Builder().url(url).build()
|
||||
val response = httpClient.newCall(networkRequest).execute()
|
||||
if (!response.isSuccessful) {
|
||||
val code = response.code
|
||||
response.close()
|
||||
return@withContext null
|
||||
return@withContext NetworkResult(code, null)
|
||||
}
|
||||
// Return the body stream directly as the caller is responsible for closing it
|
||||
// That returns the connection to OkHttp's pool
|
||||
response.body.byteStream().buffered()
|
||||
// Return the body stream directly as the caller is responsible for closing it.
|
||||
// Closing the stream returns the connection to OkHttp's pool.
|
||||
val body = response.body
|
||||
if (body == null) {
|
||||
response.close()
|
||||
return@withContext NetworkResult(response.code, null)
|
||||
}
|
||||
NetworkResult(response.code, body.byteStream().buffered())
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource network stream exception: $exception")
|
||||
NetworkResult(NetworkResult.CONNECTION_ERROR, 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")
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body.string()
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource getAmSatCatalog 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")
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body.string()
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource getAmSatReports exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = withContext(dispatcher) {
|
||||
try {
|
||||
val body = payloadJson.toRequestBody("application/json; charset=utf-8".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("https://www.amsat.org/status/api/v1/reports.php")
|
||||
.header("User-Agent", "Look4Sat")
|
||||
.post(body)
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
response.code to response.body.string()
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource submitAmSatReport exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
import java.io.InputStream
|
||||
|
||||
class AmSatRepositoryTest {
|
||||
|
||||
@Test
|
||||
fun fetchStatusReturnsSeededCacheUntilCacheIsCleared() = runTest {
|
||||
val remoteSource = FakeAmSatRemoteSource()
|
||||
val repository = AmSatRepository(remoteSource)
|
||||
val cachedPage = SatStatusPage(
|
||||
fetchedAtUtcMs = 123L,
|
||||
statuses = listOf(SatStatus(name = "AO-7", days = emptyList())),
|
||||
reports = emptyMap()
|
||||
)
|
||||
repository.seedStatusCache(cachedPage)
|
||||
|
||||
val firstPage = repository.fetchStatus()
|
||||
val secondPage = repository.fetchStatus()
|
||||
|
||||
assertSame(cachedPage, firstPage)
|
||||
assertSame(cachedPage, secondPage)
|
||||
assertSame(cachedPage, repository.getCachedStatus())
|
||||
assertEquals(0, remoteSource.catalogRequests)
|
||||
assertEquals(0, remoteSource.reportRequests)
|
||||
|
||||
repository.clearStatusCache()
|
||||
assertNull(repository.getCachedStatus())
|
||||
repository.fetchStatus()
|
||||
|
||||
assertEquals(1, remoteSource.catalogRequests)
|
||||
assertEquals(1, remoteSource.reportRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forceRefreshBypassesCachedPage() = runTest {
|
||||
val remoteSource = FakeAmSatRemoteSource()
|
||||
val repository = AmSatRepository(remoteSource)
|
||||
repository.seedStatusCache(
|
||||
SatStatusPage(
|
||||
fetchedAtUtcMs = 123L,
|
||||
statuses = listOf(SatStatus(name = "AO-7", days = emptyList())),
|
||||
reports = emptyMap()
|
||||
)
|
||||
)
|
||||
|
||||
repository.fetchStatus(forceRefresh = true)
|
||||
|
||||
assertEquals(1, remoteSource.catalogRequests)
|
||||
assertEquals(1, remoteSource.reportRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchStatusSharesStartupPrefetchRequest() = runTest {
|
||||
val remoteSource = FakeAmSatRemoteSource(responseDelayMillis = 50)
|
||||
val repository = AmSatRepository(remoteSource)
|
||||
|
||||
val prefetch = async { repository.prefetchStatus() }
|
||||
val pageFetch = async { repository.fetchStatus() }
|
||||
prefetch.await()
|
||||
val page = pageFetch.await()
|
||||
|
||||
assertSame(page, repository.getCachedStatus())
|
||||
assertEquals(1, remoteSource.catalogRequests)
|
||||
assertEquals(1, remoteSource.reportRequests)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
|
||||
val cacheField = AmSatRepository::class.java.getDeclaredField("statusCache")
|
||||
cacheField.isAccessible = true
|
||||
cacheField.set(this, page)
|
||||
}
|
||||
|
||||
private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L) : IRemoteSource {
|
||||
var catalogRequests = 0
|
||||
var reportRequests = 0
|
||||
|
||||
override suspend fun getFileStream(uri: String): InputStream? = null
|
||||
|
||||
override suspend fun getNetworkStream(url: String): NetworkResult = NetworkResult(404, null)
|
||||
|
||||
override suspend fun getAmSatCatalog(): String? {
|
||||
if (responseDelayMillis > 0L) delay(responseDelayMillis)
|
||||
catalogRequests += 1
|
||||
return """{"data":[{"name":"AO-7"}]}"""
|
||||
}
|
||||
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? {
|
||||
if (responseDelayMillis > 0L) delay(responseDelayMillis)
|
||||
reportRequests += 1
|
||||
return """{"data":[]}"""
|
||||
}
|
||||
|
||||
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
||||
}
|
||||
+103
-14
@@ -19,6 +19,7 @@ package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
@@ -31,6 +32,8 @@ 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 com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -62,6 +65,7 @@ class DatabaseRepoTest {
|
||||
|
||||
assertEquals(1, localSource.insertedEntries.size)
|
||||
assertEquals(25544, localSource.insertedEntries.first().catnum)
|
||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
||||
assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0)
|
||||
}
|
||||
|
||||
@@ -90,10 +94,8 @@ class DatabaseRepoTest {
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
useCustomTLE = true,
|
||||
useCustomTransceivers = false,
|
||||
tleUrl = customCsvUrl,
|
||||
transceiversUrl = ""
|
||||
satelliteUrls = listOf(customCsvUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
@@ -101,6 +103,28 @@ class DatabaseRepoTest {
|
||||
repository.updateFromRemote()
|
||||
|
||||
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remote update imports satellites from real SatNOGS TLE source`() = runTest(dispatcher) {
|
||||
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[satnogsUrl] = { jamxTleStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(satnogsUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
repository.updateFromRemote()
|
||||
|
||||
assertTrue(localSource.insertedEntries.any { it.name == "JAMX-0825b" && it.catnum == 98248 })
|
||||
assertEquals(listOf(98248), settingsRepo.satelliteTypeIdsByType["SatNOGS"])
|
||||
}
|
||||
|
||||
private fun validCsvStream(): InputStream = """
|
||||
@@ -113,6 +137,12 @@ class DatabaseRepoTest {
|
||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
private fun jamxTleStream(): InputStream = """
|
||||
JAMX-0825b
|
||||
1 98248U 26237.16675926 .00015724 00000-0 97477-3 0 00013
|
||||
2 98248 097.5373 310.9694 0011309 278.1232 340.7230 15.09766181000012
|
||||
""".trimIndent().byteInputStream()
|
||||
}
|
||||
|
||||
private class FakeRemoteSource : IRemoteSource {
|
||||
@@ -121,7 +151,14 @@ 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 getNetworkStream(url: String): NetworkResult =
|
||||
networkStreams[url]?.let { NetworkResult(200, it()) } ?: NetworkResult(404, null)
|
||||
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||
|
||||
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
||||
}
|
||||
|
||||
private class FakeLocalSource : ILocalSource {
|
||||
@@ -163,10 +200,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))
|
||||
@@ -174,11 +211,14 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||
|
||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
|
||||
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||
)
|
||||
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
OtherSettings(false, false, false, false, false, false, false, false)
|
||||
OtherSettings(
|
||||
false, false, false, false, false, false, false,
|
||||
shouldSeeWarning = false, shouldSeeWhatsNew = false
|
||||
)
|
||||
)
|
||||
|
||||
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
|
||||
@@ -187,9 +227,11 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
|
||||
val satelliteTypeIdsByType = mutableMapOf<String, List<Int>>()
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||
|
||||
override fun setSelectedSatModes(modes: List<String>) = Unit
|
||||
override fun setSelectedTypes(types: List<String>) = Unit
|
||||
|
||||
override fun setPassesSettings(settings: PassesSettings) = Unit
|
||||
|
||||
@@ -199,6 +241,11 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
|
||||
override fun setStationPosition(locator: String): Boolean = true
|
||||
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
|
||||
|
||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
||||
satelliteTypeIdsByType[type] = ids
|
||||
}
|
||||
|
||||
override fun updateDatabaseState(state: DatabaseState) {
|
||||
databaseState.value = state
|
||||
@@ -212,14 +259,56 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
dataSourcesSettings.value = settings
|
||||
}
|
||||
|
||||
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) {
|
||||
(dataSourcesStatus as? MutableStateFlow)?.value = status
|
||||
}
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
|
||||
override fun setAmSatCallsign(callsign: String) = Unit
|
||||
|
||||
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
|
||||
|
||||
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
|
||||
|
||||
override fun getWorkedGrids(): Set<String> = emptySet()
|
||||
|
||||
private val workedGrids = MutableStateFlow(emptySet<String>())
|
||||
|
||||
override fun setWorkedGrids(grids: Set<String>) {
|
||||
workedGrids.value = grids
|
||||
}
|
||||
|
||||
override val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
|
||||
|
||||
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit
|
||||
|
||||
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
|
||||
|
||||
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) = Unit
|
||||
|
||||
override fun getRoamedGrids(): Set<String> = emptySet()
|
||||
|
||||
private val roamedGrids = MutableStateFlow(emptySet<String>())
|
||||
|
||||
override fun setRoamedGrids(grids: Set<String>) {
|
||||
roamedGrids.value = grids
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||
return DataSourcesSettings(
|
||||
useCustomTLE = false,
|
||||
useCustomTransceivers = false,
|
||||
tleUrl = "",
|
||||
transceiversUrl = ""
|
||||
satelliteUrls = emptyList(),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
}
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class LoTWRepositoryTest {
|
||||
|
||||
private val repo = LoTWRepository()
|
||||
|
||||
private fun report(vararg records: String): String =
|
||||
buildString {
|
||||
append("<ADIF_VERS:5>3.1.4\n")
|
||||
append("<PROGRAMID:8>look4sat\n")
|
||||
append("<eoh>\n")
|
||||
records.forEach { append(it).append("\n") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseRejectsBodyWithoutEoh() {
|
||||
assertNull(repo.parseConfirmedGrids("<HTML>Username/password incorrect</HTML>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseCollectsOnlySatelliteGrids() {
|
||||
// Satellite QSO: PROP_MODE SAT, confirmed, opponent grid.
|
||||
val satQso = "<CALL:6>BA7OPF\n" +
|
||||
"<QSO_DATE:8>20260820\n" +
|
||||
"<PROP_MODE:3>SAT\n" +
|
||||
"<SAT_NAME:5>FO-29\n" +
|
||||
"<GRIDSQUARE:4>NL47\n" +
|
||||
"<EOR>\n"
|
||||
// Ground QSO: same grid but no PROP_MODE -> must be excluded.
|
||||
val groundQso = "<CALL:6>BA7OPF\n" +
|
||||
"<QSO_DATE:8>20260821\n" +
|
||||
"<GRIDSQUARE:4>NL47\n" +
|
||||
"<EOR>\n"
|
||||
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
|
||||
assertEquals(setOf("NL47"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseSupportsVuccGridPairsAndSixCharGrids() {
|
||||
val satQso = "<PROP_MODE:3>SAT\n" +
|
||||
"<SAT_NAME:5>SO-50\n" +
|
||||
"<GRIDSQUARE:6>OM60IL\n" +
|
||||
"<VUCC_GRIDS:11>EN52en,EN53fa\n" +
|
||||
"<EOR>\n"
|
||||
val result = repo.parseConfirmedGrids(report(satQso))
|
||||
assertEquals(setOf("OM60", "EN52", "EN53"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsePropModeDoesNotLeakAcrossRecords() {
|
||||
// PROP_MODE SAT record first, then a ground QSO with a grid — the second
|
||||
// record must not inherit the satellite flag after its own <EOR>.
|
||||
val satQso = "<PROP_MODE:3>SAT\n<GRIDSQUARE:4>OL62\n<EOR>\n"
|
||||
val groundQso = "<GRIDSQUARE:4>PM00\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
|
||||
assertEquals(setOf("OL62"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseHandlesAlphabeticalFieldOrder() {
|
||||
// Real lotwreport.adi emits fields in ALPHABETICAL order: GRIDSQUARE (G)
|
||||
// and VUCC_GRIDS come BEFORE PROP_MODE (P) inside each record. The old
|
||||
// sequential gate (add only while propMode == SAT) dropped every grid on
|
||||
// real reports — the buffered parser must collect them regardless of
|
||||
// field order and filter at <EOR>.
|
||||
val satQso = "<CALL:6>BA7OPF\n" +
|
||||
"<GRIDSQUARE:4>NL47\n" +
|
||||
"<MODE:3>FM\n" +
|
||||
"<PROP_MODE:3>SAT\n" +
|
||||
"<SAT_NAME:5>FO-29\n" +
|
||||
"<VUCC_GRIDS:11>EN52en,EN53fa\n" +
|
||||
"<EOR>\n"
|
||||
val groundQso = "<CALL:6>BA7OPF\n" +
|
||||
"<GRIDSQUARE:4>PM95\n" +
|
||||
"<QSO_DATE:8>20260821\n" +
|
||||
"<EOR>\n"
|
||||
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
|
||||
assertEquals(setOf("NL47", "EN52", "EN53"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseReturnsEmptySetForReportWithoutGrids() {
|
||||
val satQso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>AO-07\n<EOR>\n"
|
||||
assertEquals(emptySet<String>(), repo.parseConfirmedGrids(report(satQso)))
|
||||
}
|
||||
|
||||
// region parseConfirmedGridQsos
|
||||
|
||||
private val satQsoFull = "<CALL:5>A50QO\n" +
|
||||
"<QSO_DATE:8>20260820\n" +
|
||||
"<TIME_ON:6>113045\n" +
|
||||
"<PROP_MODE:3>SAT\n" +
|
||||
"<SAT_NAME:5>FO-29\n" +
|
||||
"<MODE:2>CW\n" +
|
||||
"<BAND:3>70CM\n" +
|
||||
"<BAND_RX:3>2M\n" +
|
||||
"<GRIDSQUARE:4>NL47\n" +
|
||||
"<EOR>\n"
|
||||
|
||||
@Test
|
||||
fun parseQsosExtractsSatelliteDetail() {
|
||||
val result = repo.parseConfirmedGridQsos(report(satQsoFull))!!
|
||||
val qsos = result["NL47"]!!
|
||||
assertEquals(1, qsos.size)
|
||||
val qso = qsos[0]
|
||||
assertEquals("A50QO", qso.call)
|
||||
assertEquals("FO-29", qso.satName)
|
||||
assertEquals("CW", qso.mode)
|
||||
assertEquals("2M", qso.bandUp)
|
||||
assertEquals("70CM", qso.bandDown)
|
||||
assertEquals("V/U", qso.bandLabel)
|
||||
assertEquals(2026, java.time.Instant.ofEpochMilli(qso.epochMs).atZone(java.time.ZoneOffset.UTC).year)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosExcludesGroundQsos() {
|
||||
val groundQso = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(satQsoFull, groundQso))!!
|
||||
assertEquals(1, result["NL47"]!!.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosVuccPairFeedsBothGrids() {
|
||||
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
|
||||
"<QSO_DATE:8>20260819\n<TIME_ON:4>1231\n" +
|
||||
"<VUCC_GRIDS:11>EN52en,EN53fa\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
||||
assertEquals(setOf("EN52", "EN53"), result.keys)
|
||||
assertEquals(result["EN52"]!!.first(), result["EN53"]!!.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosVuccQuadrupleFeedsAllFourGrids() {
|
||||
// VUCC allows up to four grids in one contact.
|
||||
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
|
||||
"<QSO_DATE:8>20260819\n<TIME_ON:4>1231\n" +
|
||||
"<VUCC_GRIDS:23>EN52en,EN53fa,EN42gj,EN43kh\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
||||
assertEquals(setOf("EN52", "EN53", "EN42", "EN43"), result.keys)
|
||||
result.values.forEach { assertEquals(1, it.size) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosRejectsBodyWithoutEoh() {
|
||||
assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>"))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region parseRoamedGrids (MY_GRIDSQUARE = grids the account operated from)
|
||||
|
||||
@Test
|
||||
fun parseRoamedGridsCollectsOwnStationGrids() {
|
||||
// Satellite QSOs carry MY_GRIDSQUARE = the grid the account itself
|
||||
// operated from (the "roamed/activated" set shown as blue stripes).
|
||||
val qso1 = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260820\n<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n" +
|
||||
"<MY_GRIDSQUARE:4>OL62\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
||||
val qso2 = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260901\n<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n" +
|
||||
"<MY_GRIDSQUARE:4>OL72\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
assertEquals(setOf("OL62", "OL72"), repo.parseRoamedGrids(report(qso1, qso2)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseRoamedGridsIgnoresGroundQsos() {
|
||||
val ground = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<MY_GRIDSQUARE:4>OL72\n<EOR>\n"
|
||||
assertEquals(emptySet<String>(), repo.parseRoamedGrids(report(ground)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseRoamedGridsDoesNotConfuseGridsquareWithMyGridsquare() {
|
||||
// GRIDSQUARE (opposite station's grid) must never leak into the roamed set.
|
||||
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<MY_GRIDSQUARE:4>OL62\n" +
|
||||
"<GRIDSQUARE:6>OM60IL\n<EOR>\n"
|
||||
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseRoamedGridsTruncatesSixCharToFour() {
|
||||
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n<MY_GRIDSQUARE:6>OL62AA\n<EOR>\n"
|
||||
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseRoamedGridsHandlesAlphabeticalFieldOrder() {
|
||||
// Real lotwreport.adi emits fields alphabetically, so <MY_GRIDSQUARE>
|
||||
// (M) arrives BEFORE <PROP_MODE> (P). The old sequential gate (add only
|
||||
// while propMode == SAT) silently returned an EMPTY set on real reports —
|
||||
// this is the exact bug that made the blue roamed stripes never appear.
|
||||
val qso = "<CALL:6>BA7OPF\n" +
|
||||
"<GRIDSQUARE:4>PM95\n" +
|
||||
"<MY_GRIDSQUARE:6>OL72XX\n" +
|
||||
"<PROP_MODE:3>SAT\n" +
|
||||
"<SAT_NAME:5>IO-86\n" +
|
||||
"<EOR>\n"
|
||||
assertEquals(setOf("OL72"), repo.parseRoamedGrids(report(qso)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseRoamedGridsRejectsBodyWithoutEoh() {
|
||||
assertNull(repo.parseRoamedGrids("<HTML>Username/password incorrect</HTML>"))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region failure classification (fetchReportBody -> LoTWResult mapping)
|
||||
|
||||
@Test
|
||||
fun failureClassificationMapsEachException() {
|
||||
// LoTWRepository.toResult() must keep each failure cause distinct so
|
||||
// the UI can show a specific message per cause.
|
||||
assertEquals(
|
||||
LoTWResult.BadCredentials,
|
||||
repo.toResult(LoTWRepository.CredentialsException())
|
||||
)
|
||||
assertEquals(
|
||||
LoTWResult.RateLimited,
|
||||
repo.toResult(LoTWRepository.RateLimitException())
|
||||
)
|
||||
assertEquals(
|
||||
LoTWResult.Timeout,
|
||||
repo.toResult(LoTWRepository.TimeoutException("read timed out"))
|
||||
)
|
||||
val net = repo.toResult(java.io.IOException("HTTP 500"))
|
||||
assertEquals(LoTWResult.NetworkError("HTTP 500"), net)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun parseQsosExtractsAwardFields() {
|
||||
// Award statistics rely on the DXCC/CQZ/STATE fields coming straight
|
||||
// from LoTW's qso_qsldetail report (STATE depends on DXCC). LoTW
|
||||
// writes STATE as "CODE // NAME" (verified with a real report 2026-09);
|
||||
// the parser must strip the name suffix and keep the bare code.
|
||||
val qso = "<CALL:5>BG7XYZ\n" +
|
||||
"<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<MODE:2>CW\n" +
|
||||
"<DXCC:3>318\n<COUNTRY:5>CHINA\n<CQZ:2>24\n<STATE:13>GD // Guangdong\n" +
|
||||
"<GRIDSQUARE:4>OL62\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
||||
val parsed = result["OL62"]!!.first()
|
||||
assertEquals(318, parsed.dxcc)
|
||||
assertEquals("CHINA", parsed.country)
|
||||
assertEquals(24, parsed.cqz)
|
||||
assertEquals("GD", parsed.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosStripsStateNameSuffixForAllEntities() {
|
||||
// Japan prefecture: "34 // Tottori-ken" -> "34" (WAJA matching needs
|
||||
// the 2-digit code). US states also arrive as "CODE // Name".
|
||||
val jp = "<CALL:5>JH0ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<DXCC:3>339\n<COUNTRY:7>JAPAN\n" +
|
||||
"<STATE:18>34 // Tottori-ken\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
||||
val us = "<CALL:5>K1ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>AO-07\n<DXCC:3>291\n<COUNTRY:12>UNITED STATES\n" +
|
||||
"<STATE:22>CA // California\n<GRIDSQUARE:4>EM40\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(jp, us))!!
|
||||
assertEquals("34", result["PM95"]!!.first().state)
|
||||
assertEquals("CA", result["EM40"]!!.first().state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosLeavesAwardFieldsNullWhenAbsent() {
|
||||
val qso = "<CALL:5>JH0ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
||||
val parsed = result["PM95"]!!.first()
|
||||
assertNull(parsed.dxcc)
|
||||
assertNull(parsed.country)
|
||||
assertNull(parsed.cqz)
|
||||
assertNull(parsed.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosAwardFieldsDoNotLeakAcrossRecords() {
|
||||
val withFields = "<CALL:5>BG7AAA\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<DXCC:3>318\n<CQZ:2>24\n<STATE:2>GD\n" +
|
||||
"<GRIDSQUARE:4>OL62\n<EOR>\n"
|
||||
val withoutFields = "<CALL:5>JH0BBB\n<QSO_DATE:8>20260821\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(withFields, withoutFields))!!
|
||||
assertNull(result["PM95"]!!.first().dxcc)
|
||||
assertNull(result["PM95"]!!.first().cqz)
|
||||
assertNull(result["PM95"]!!.first().state)
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
-173
@@ -1,173 +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, "", "", "", 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 fun setStationPosition(): Boolean = true
|
||||
|
||||
override fun setStationPosition(locator: String): Boolean = true
|
||||
|
||||
override fun updateDatabaseState(state: DatabaseState) {
|
||||
databaseState.value = state
|
||||
}
|
||||
|
||||
override fun updateRCSettings(settings: RCSettings) = Unit
|
||||
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
|
||||
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
/**
|
||||
* Bayesian Morse timing decoder.
|
||||
* Replaces hard thresholds with probability-based decision making.
|
||||
*
|
||||
* Inspired by VE3NEA's CW Skimmer approach:
|
||||
* "Instead of making a hard decision at every input sample whether the signal
|
||||
* is present or not, compute the probability that the signal is present."
|
||||
*
|
||||
* Uses Gaussian probability density centered on expected durations:
|
||||
* P(dit | duration) = exp(-(duration - dotMs)^2 / (2 * variance^2))
|
||||
* P(dash | duration) = exp(-(duration - 3*dotMs)^2 / (2 * variance^2))
|
||||
*/
|
||||
internal class CwBayesianDecoder {
|
||||
|
||||
// Morse timing parameters
|
||||
private var dotDurationMs = 60f // initial 20 WPM
|
||||
private var speedWpm = 20f
|
||||
|
||||
// Current symbol being accumulated
|
||||
private var currentSymbol = StringBuilder()
|
||||
private var textBuffer = StringBuilder()
|
||||
|
||||
// Recent dit lengths for speed estimation
|
||||
private val recentDits = mutableListOf<Float>()
|
||||
|
||||
// Output
|
||||
private var _decodedText = ""
|
||||
val decodedText: String get() = _decodedText
|
||||
|
||||
/** Gaussian probability. */
|
||||
private fun gaussianProb(durationMs: Float, expectedMs: Float, varianceMs: Float): Float {
|
||||
if (varianceMs <= 0f) return 0f
|
||||
val diff = durationMs - expectedMs
|
||||
return kotlin.math.exp(-(diff * diff) / (2 * varianceMs * varianceMs))
|
||||
}
|
||||
|
||||
/** Process a tone duration. Returns the symbol type with highest probability. */
|
||||
fun processTone(durationMs: Float): ToneResult {
|
||||
val ditProb = gaussianProb(durationMs, dotDurationMs, dotDurationMs * 0.4f)
|
||||
val dashProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
|
||||
|
||||
return if (ditProb > dashProb && ditProb > 0.05f) {
|
||||
currentSymbol.append('0')
|
||||
recentDits.add(durationMs)
|
||||
updateSpeed()
|
||||
ToneResult('0', ditProb)
|
||||
} else if (dashProb > 0.05f) {
|
||||
currentSymbol.append('1')
|
||||
ToneResult('1', dashProb)
|
||||
} else {
|
||||
ToneResult(null, 0f)
|
||||
}
|
||||
}
|
||||
|
||||
/** Process a gap duration. Returns decoded character or null. */
|
||||
fun processGap(durationMs: Float): Char? {
|
||||
if (currentSymbol.isEmpty()) {
|
||||
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
|
||||
if (wordProb > 0.2f) {
|
||||
textBuffer.append(' ')
|
||||
_decodedText = textBuffer.toString()
|
||||
return ' '
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
val interCharProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
|
||||
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
|
||||
|
||||
if (wordProb > interCharProb && wordProb > 0.2f) {
|
||||
val char = flushSymbol()
|
||||
textBuffer.append(' ')
|
||||
_decodedText = textBuffer.toString()
|
||||
return char
|
||||
}
|
||||
if (interCharProb > 0.15f) {
|
||||
val char = flushSymbol()
|
||||
_decodedText = textBuffer.toString()
|
||||
return char
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun flushSymbol(): Char? {
|
||||
if (currentSymbol.isEmpty()) return null
|
||||
val morse = currentSymbol.toString()
|
||||
currentSymbol.clear()
|
||||
val char = morseToChar(morse)
|
||||
if (char != null) textBuffer.append(char)
|
||||
return char
|
||||
}
|
||||
|
||||
private fun updateSpeed() {
|
||||
if (recentDits.size < 3) return
|
||||
val sorted = recentDits.sorted()
|
||||
val median = sorted[sorted.size / 2]
|
||||
if (median > 0f) {
|
||||
dotDurationMs = dotDurationMs * 0.7f + median * 0.3f
|
||||
val wpm = 60.0f / (50.0f * dotDurationMs / 1000.0f)
|
||||
if (wpm in 5f..55f) speedWpm = wpm
|
||||
}
|
||||
}
|
||||
|
||||
fun getSpeed(): Float = speedWpm
|
||||
|
||||
fun reset() {
|
||||
dotDurationMs = 60f
|
||||
speedWpm = 20f
|
||||
recentDits.clear()
|
||||
currentSymbol.clear()
|
||||
textBuffer.clear()
|
||||
_decodedText = ""
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val MORSE_TABLE = mapOf(
|
||||
"01" to 'A', "1000" to 'B', "1010" to 'C', "100" to 'D', "0" to 'E',
|
||||
"0010" to 'F', "110" to 'G', "0000" to 'H', "00" to 'I', "0111" to 'J',
|
||||
"101" to 'K', "0100" to 'L', "11" to 'M', "10" to 'N', "111" to 'O',
|
||||
"0110" to 'P', "1101" to 'Q', "010" to 'R', "000" to 'S', "1" to 'T',
|
||||
"001" to 'U', "0001" to 'V', "011" to 'W', "1001" to 'X', "1011" to 'Y',
|
||||
"1100" to 'Z', "01111" to '1', "00111" to '2', "00011" to '3',
|
||||
"00001" to '4', "00000" to '5', "10000" to '6', "11000" to '7',
|
||||
"11100" to '8', "11110" to '9', "11111" to '0',
|
||||
"010101" to '.', "110011" to ',', "001100" to '?', "011110" to '\'',
|
||||
"101011" to '!', "10010" to '/', "10110" to '(', "101101" to ')',
|
||||
"01000" to '&', "111000" to ':', "101010" to ';', "10001" to '=',
|
||||
"01010" to '+', "100001" to '-', "001101" to '_', "010010" to '"',
|
||||
"0001001" to '$', "011010" to '@'
|
||||
)
|
||||
fun morseToChar(morse: String): Char? = MORSE_TABLE[morse]
|
||||
}
|
||||
}
|
||||
|
||||
data class ToneResult(val symbol: Char?, val probability: Float)
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
/**
|
||||
* Multi-channel CW signal tracker.
|
||||
* Monitors the spectrogram for active frequency bins and extracts
|
||||
* energy envelopes for each detected signal.
|
||||
*
|
||||
* Inspired by CW Skimmer's multi-channel approach:
|
||||
* tracks all active signals in the passband simultaneously,
|
||||
* selects the best one for decoded output.
|
||||
*/
|
||||
internal class CwChannelTracker(
|
||||
private val spectrogram: CwSpectrogram,
|
||||
private val maxChannels: Int = 3
|
||||
) {
|
||||
data class Channel(
|
||||
val bin: Int,
|
||||
val frequency: Float,
|
||||
var active: Boolean = false,
|
||||
var energy: Float = 0f,
|
||||
val history: MutableList<Float> = mutableListOf(),
|
||||
var confidence: Float = 0f
|
||||
)
|
||||
|
||||
private val channels = Array(maxChannels) { Channel(0, 0f) }
|
||||
|
||||
/** Scan the current spectrogram column and update channel tracking. */
|
||||
fun update(): List<Channel> {
|
||||
val col = spectrogram.getCurrentColumn()
|
||||
val peaks = findPeaks(col, threshold = 0.3f, minDistance = 2)
|
||||
|
||||
// Update existing channels
|
||||
for (ch in channels) {
|
||||
if (ch.active) {
|
||||
if (peaks.contains(ch.bin)) {
|
||||
ch.energy = col[ch.bin]
|
||||
ch.history.add(ch.energy)
|
||||
if (ch.history.size > 40) ch.history.removeAt(0)
|
||||
ch.confidence = computeConfidence(ch.history)
|
||||
} else {
|
||||
// Signal lost — decay confidence
|
||||
ch.history.add(0f)
|
||||
if (ch.history.size > 40) ch.history.removeAt(0)
|
||||
ch.confidence *= 0.9f
|
||||
if (ch.confidence < 0.1f) ch.active = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assign new peaks to inactive channels
|
||||
var peakIdx = 0
|
||||
for (ch in channels) {
|
||||
if (!ch.active && peakIdx < peaks.size) {
|
||||
val bin = peaks[peakIdx]
|
||||
val freq = spectrogram.binToFreq(bin)
|
||||
// Re-initialize channel
|
||||
channels[peakIdx] = Channel(bin, freq, true, col[bin], mutableListOf(), 0.5f)
|
||||
peakIdx++
|
||||
}
|
||||
}
|
||||
|
||||
return channels.filter { it.active }
|
||||
}
|
||||
|
||||
/** Find peak bins in the spectrum. */
|
||||
private fun findPeaks(spectrum: FloatArray, threshold: Float, minDistance: Int): List<Int> {
|
||||
val peaks = mutableListOf<Int>()
|
||||
for (i in 1 until spectrum.size - 1) {
|
||||
if (spectrum[i] > spectrum[i - 1] && spectrum[i] > spectrum[i + 1] && spectrum[i] > threshold) {
|
||||
if (peaks.isEmpty() || i - peaks.last() >= minDistance) {
|
||||
peaks.add(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
return peaks.sortedByDescending { spectrum[it] }
|
||||
}
|
||||
|
||||
/** Compute confidence from energy history. Lower variance = higher confidence. */
|
||||
private fun computeConfidence(history: List<Float>): Float {
|
||||
if (history.size < 10) return 0.3f
|
||||
val recent = history.takeLast(10)
|
||||
val mean = recent.average().toFloat()
|
||||
val variance = recent.map { (it - mean) * (it - mean) }.average().toFloat()
|
||||
return if (mean > 0f) (mean / (mean + variance + 0.1f)).coerceIn(0f, 1f) else 0f
|
||||
}
|
||||
|
||||
/** Get the channel with highest confidence. */
|
||||
fun getBestChannel(): Channel? {
|
||||
return channels.filter { it.active }.maxByOrNull { it.confidence }
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
for (i in channels.indices) {
|
||||
channels[i] = Channel(0, 0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* CW (Morse code) decoder v3 — Spectrogram-based multi-channel Bayesian decoder.
|
||||
*
|
||||
* Architecture inspired by Morse Expert / CW Skimmer (VE3NEA):
|
||||
* 1. FFT spectrogram creates a frequency×time matrix
|
||||
* 2. Multi-channel peak detector finds all active signals
|
||||
* 3. Per-channel energy envelope extraction
|
||||
* 4. Bayesian probability for symbol timing (Gaussian likelihood)
|
||||
* 5. Best channel selected for output
|
||||
*
|
||||
* Timing analysis is performed per spectrogram column (hop).
|
||||
* Each column represents hopSize/sampleRate seconds of audio.
|
||||
*/
|
||||
class CwDecoder(
|
||||
val sampleRate: Int = 8000,
|
||||
cwToneFreq: Float = -1f // ignored in v3 (auto-detect via spectrogram)
|
||||
) {
|
||||
companion object {
|
||||
private const val FFT_SIZE = 256
|
||||
private const val HOP_SIZE = 64
|
||||
}
|
||||
|
||||
private val spectrogram = CwSpectrogram(
|
||||
fftSize = FFT_SIZE,
|
||||
hopSize = HOP_SIZE,
|
||||
sampleRate = sampleRate,
|
||||
minBin = 6,
|
||||
maxBin = 38,
|
||||
historyCols = 40
|
||||
)
|
||||
private val channelTracker = CwChannelTracker(spectrogram, maxChannels = 3)
|
||||
private val bayesianDecoder = CwBayesianDecoder()
|
||||
|
||||
// Timing state per channel
|
||||
private data class ChannelTiming(
|
||||
var isSignal: Boolean = false,
|
||||
var toneTicks: Int = 0,
|
||||
var gapTicks: Int = 0
|
||||
)
|
||||
private val timingStates = Array(3) { ChannelTiming() }
|
||||
|
||||
// Time per spectrogram column in milliseconds
|
||||
private val tickMs = 1000f * HOP_SIZE / sampleRate
|
||||
|
||||
// Output flows
|
||||
private val _decodedTextFlow = MutableStateFlow("")
|
||||
val decodedTextFlow: StateFlow<String> = _decodedTextFlow
|
||||
|
||||
private val _signalStrength = MutableStateFlow(0f)
|
||||
val signalStrength: StateFlow<Float> = _signalStrength
|
||||
|
||||
private val _estimatedPitch = MutableStateFlow<Float?>(null)
|
||||
val estimatedPitch: StateFlow<Float?> = _estimatedPitch
|
||||
|
||||
private val _estimatedSpeed = MutableStateFlow<Float?>(null)
|
||||
val estimatedSpeed: StateFlow<Float?> = _estimatedSpeed
|
||||
|
||||
private var frameCount = 0
|
||||
|
||||
init {
|
||||
if (cwToneFreq > 0f) {
|
||||
_estimatedPitch.value = cwToneFreq
|
||||
}
|
||||
}
|
||||
|
||||
fun processBuffer(buffer: FloatArray) {
|
||||
// 1. Feed samples to spectrogram
|
||||
spectrogram.addSamples(buffer)
|
||||
|
||||
// 2. Get number of new columns generated
|
||||
val newCols = spectrogram.getNewColumns()
|
||||
if (newCols == 0) return
|
||||
|
||||
// 3. Update channel tracker (uses latest column for peak detection)
|
||||
val activeChannels = channelTracker.update()
|
||||
|
||||
// 4. Process each new column for timing analysis
|
||||
// Columns are indexed 0..historyCols-1, where historyCols-1 is the newest
|
||||
val baseIdx = (spectrogram.historyCols - newCols).coerceAtLeast(0)
|
||||
for (colOffset in 0 until newCols) {
|
||||
val col = spectrogram.getColumn(baseIdx + colOffset)
|
||||
|
||||
for ((idx, channel) in activeChannels.withIndex()) {
|
||||
if (idx >= timingStates.size) break
|
||||
val state = timingStates[idx]
|
||||
val energy = if (channel.bin in col.indices) col[channel.bin] else 0f
|
||||
|
||||
// Adaptive threshold
|
||||
val threshold = 0.3f + (energy - 0.3f) * 0.3f
|
||||
|
||||
if (energy > threshold) {
|
||||
if (!state.isSignal) {
|
||||
if (state.gapTicks > 0) {
|
||||
val gapMs = state.gapTicks * tickMs
|
||||
bayesianDecoder.processGap(gapMs)
|
||||
}
|
||||
state.gapTicks = 0
|
||||
state.isSignal = true
|
||||
}
|
||||
state.toneTicks++
|
||||
} else {
|
||||
if (state.isSignal) {
|
||||
if (state.toneTicks > 0) {
|
||||
val toneMs = state.toneTicks * tickMs
|
||||
bayesianDecoder.processTone(toneMs)
|
||||
}
|
||||
state.toneTicks = 0
|
||||
state.isSignal = false
|
||||
}
|
||||
state.gapTicks++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Update outputs
|
||||
frameCount++
|
||||
if (frameCount % 5 == 0) {
|
||||
val bestChannel = channelTracker.getBestChannel()
|
||||
if (bestChannel != null) {
|
||||
_estimatedPitch.value = bestChannel.frequency
|
||||
_signalStrength.value = bestChannel.confidence
|
||||
_estimatedSpeed.value = bayesianDecoder.getSpeed()
|
||||
}
|
||||
_decodedTextFlow.value = bayesianDecoder.decodedText
|
||||
}
|
||||
}
|
||||
|
||||
fun resetDecoder() {
|
||||
spectrogram.reset()
|
||||
channelTracker.reset()
|
||||
bayesianDecoder.reset()
|
||||
for (state in timingStates) {
|
||||
state.isSignal = false
|
||||
state.toneTicks = 0
|
||||
state.gapTicks = 0
|
||||
}
|
||||
frameCount = 0
|
||||
_decodedTextFlow.value = ""
|
||||
_signalStrength.value = 0f
|
||||
_estimatedPitch.value = null
|
||||
_estimatedSpeed.value = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* DSP utilities for CW (Morse code) decoding.
|
||||
* Pure Kotlin, no NDK required.
|
||||
*/
|
||||
internal object CwDsp {
|
||||
|
||||
/**
|
||||
* Design a simple bandpass FIR filter coefficients using windowed sinc method.
|
||||
* @param lowCutoff lower cutoff frequency (Hz) as fraction of sampleRate
|
||||
* @param highCutoff upper cutoff frequency (Hz) as fraction of sampleRate
|
||||
* @param taps filter length (must be odd)
|
||||
*/
|
||||
fun bandpassFir(lowCutoff: Double, highCutoff: Double, taps: Int): FloatArray {
|
||||
val n = if (taps % 2 == 0) taps + 1 else taps
|
||||
val half = n / 2
|
||||
val coeffs = FloatArray(n)
|
||||
for (i in 0 until n) {
|
||||
val idx = i - half
|
||||
if (idx == 0) {
|
||||
coeffs[i] = (2.0 * (highCutoff - lowCutoff)).toFloat()
|
||||
} else {
|
||||
val x = PI * idx
|
||||
coeffs[i] = ((sin(2 * highCutoff * x) - sin(2 * lowCutoff * x)) / x).toFloat()
|
||||
}
|
||||
// Hamming window
|
||||
coeffs[i] = (coeffs[i] * (0.54 - 0.46 * cos(2 * PI * i / (n - 1)))).toFloat()
|
||||
}
|
||||
// Normalize
|
||||
val sum = coeffs.sum()
|
||||
if (sum != 0f) for (i in 0 until n) coeffs[i] /= sum
|
||||
return coeffs
|
||||
}
|
||||
|
||||
/** Apply FIR filter to a buffer. */
|
||||
fun applyFir(buffer: FloatArray, coeffs: FloatArray): FloatArray {
|
||||
val out = FloatArray(buffer.size)
|
||||
for (i in buffer.indices) {
|
||||
var sum = 0f
|
||||
for (j in coeffs.indices) {
|
||||
val idx = i - j
|
||||
if (idx >= 0) sum += buffer[idx] * coeffs[j]
|
||||
}
|
||||
out[i] = sum
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Simple envelope detector: abs + low-pass smoothing. */
|
||||
fun envelope(signal: FloatArray, alpha: Float = 0.1f): FloatArray {
|
||||
val env = FloatArray(signal.size)
|
||||
var s = 0f
|
||||
for (i in signal.indices) {
|
||||
s = alpha * kotlin.math.abs(signal[i]) + (1 - alpha) * s
|
||||
env[i] = s
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
/** Estimate noise floor from envelope for adaptive thresholding. */
|
||||
fun noiseFloor(env: FloatArray, fraction: Float = 0.3f): Float {
|
||||
val sorted = env.sortedArray()
|
||||
val median = sorted[sorted.size / 2]
|
||||
return median + (sorted[sorted.size * 9 / 10] - median) * fraction
|
||||
}
|
||||
|
||||
/** Simple Goertzel to detect a specific tone frequency. */
|
||||
fun goertzel(buffer: FloatArray, targetFreq: Float, sampleRate: Int): Float {
|
||||
val omega = 2.0 * PI * targetFreq / sampleRate
|
||||
val coeff = 2.0 * cos(omega)
|
||||
var s0 = 0.0; var s1 = 0.0; var s2 = 0.0
|
||||
for (sample in buffer) {
|
||||
s0 = sample.toDouble() + coeff * s1 - s2
|
||||
s2 = s1; s1 = s0
|
||||
}
|
||||
val power = s2 * s2 + s1 * s1 - coeff * s1 * s2
|
||||
return sqrt(kotlin.math.abs(power)).toFloat()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Radix-2 FFT for real-valued input.
|
||||
* Produces magnitude spectrum for the first N/2+1 bins.
|
||||
* Used by CwSpectrogram for time-frequency analysis.
|
||||
*/
|
||||
internal class CwFFT(private val n: Int) {
|
||||
init {
|
||||
require(n > 0 && n and (n - 1) == 0) { "FFT size must be power of 2, got $n" }
|
||||
}
|
||||
|
||||
private val cosTable = FloatArray(n / 2)
|
||||
private val sinTable = FloatArray(n / 2)
|
||||
|
||||
init {
|
||||
for (i in 0 until n / 2) {
|
||||
val angle = -2.0 * kotlin.math.PI * i / n
|
||||
cosTable[i] = cos(angle).toFloat()
|
||||
sinTable[i] = kotlin.math.sin(angle).toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute magnitude spectrum for real input. Returns array of size n/2+1. */
|
||||
fun magnitudeSpectrum(input: FloatArray): FloatArray {
|
||||
require(input.size == n) { "Input size must be $n, got ${input.size}" }
|
||||
|
||||
val real = input.copyOf()
|
||||
val imag = FloatArray(n)
|
||||
|
||||
// Bit-reversal permutation
|
||||
var j = 0
|
||||
for (i in 1 until n) {
|
||||
var bit = n shr 1
|
||||
while (j and bit != 0) { j = j xor bit; bit = bit shr 1 }
|
||||
j = j xor bit
|
||||
if (i < j) {
|
||||
var tmp = real[i]; real[i] = real[j]; real[j] = tmp
|
||||
}
|
||||
}
|
||||
|
||||
// Radix-2 Cooley-Tukey FFT
|
||||
var len = 2
|
||||
while (len <= n) {
|
||||
val half = len / 2
|
||||
val step = n / len
|
||||
for (i in 0 until n step len) {
|
||||
for (k in 0 until half) {
|
||||
val tReal = real[i + k + half] * cosTable[k * step] - imag[i + k + half] * sinTable[k * step]
|
||||
val tImag = real[i + k + half] * sinTable[k * step] + imag[i + k + half] * cosTable[k * step]
|
||||
real[i + k + half] = real[i + k] - tReal
|
||||
imag[i + k + half] = imag[i + k] - tImag
|
||||
real[i + k] += tReal
|
||||
imag[i + k] += tImag
|
||||
}
|
||||
}
|
||||
len = len shl 1
|
||||
}
|
||||
|
||||
// Magnitude spectrum (first N/2+1 bins)
|
||||
val mag = FloatArray(n / 2 + 1)
|
||||
for (i in 0..n / 2) {
|
||||
mag[i] = sqrt(real[i] * real[i] + imag[i] * imag[i]) / n
|
||||
}
|
||||
return mag
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
/**
|
||||
* First-order IIR filters.
|
||||
* Ported from ggmorse/src/filter.h
|
||||
*/
|
||||
internal class CwFilter {
|
||||
private var z1 = 0f
|
||||
|
||||
companion object {
|
||||
private const val PI_F = 3.141592653589793f
|
||||
}
|
||||
|
||||
fun highPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
|
||||
val rc = 1.0f / (2f * PI_F * cutoffHz)
|
||||
val dt = 1.0f / sampleRate
|
||||
val alpha = dt / (rc + dt)
|
||||
z1 = alpha * (z1 + sample - z1)
|
||||
return sample - z1
|
||||
}
|
||||
|
||||
fun lowPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
|
||||
val rc = 1.0f / (2f * PI_F * cutoffHz)
|
||||
val dt = 1.0f / sampleRate
|
||||
val alpha = dt / (rc + dt)
|
||||
z1 += alpha * (sample - z1)
|
||||
return z1
|
||||
}
|
||||
|
||||
fun reset() { z1 = 0f }
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Running Goertzel filter for CW tone detection.
|
||||
* Tracks a specific frequency over time with a sliding window.
|
||||
* Ported from ggmorse/src/goertzel.h
|
||||
*/
|
||||
internal class CwGoertzel {
|
||||
private var s1 = 0.0
|
||||
private var s2 = 0.0
|
||||
private var coeff = 0.0
|
||||
|
||||
fun init(sampleRate: Float, targetFreq: Float) {
|
||||
val omega = 2.0 * kotlin.math.PI * targetFreq / sampleRate
|
||||
coeff = 2.0 * cos(omega)
|
||||
s1 = 0.0
|
||||
s2 = 0.0
|
||||
}
|
||||
|
||||
fun process(sample: Float) {
|
||||
val s0 = sample.toDouble() + coeff * s1 - s2
|
||||
s2 = s1
|
||||
s1 = s0
|
||||
}
|
||||
|
||||
fun getPower(): Float {
|
||||
return sqrt(s2 * s2 + s1 * s1 - coeff * s1 * s2).toFloat()
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
s1 = 0.0
|
||||
s2 = 0.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
/**
|
||||
* Simple linear resampler.
|
||||
* Downsamples from input sample rate to output sample rate.
|
||||
* Ported from ggmorse/src/resampler.h
|
||||
*/
|
||||
internal class CwResampler(private val inputRate: Float, private val outputRate: Float) {
|
||||
private val ratio = inputRate / outputRate
|
||||
private var lastSample = 0f
|
||||
|
||||
fun process(input: FloatArray): FloatArray {
|
||||
if (ratio <= 0f || input.isEmpty()) return input
|
||||
val outputLen = (input.size / ratio).toInt() + 1
|
||||
val output = FloatArray(outputLen)
|
||||
var idx = 0f
|
||||
for (i in output.indices) {
|
||||
val intIdx = idx.toInt()
|
||||
val frac = idx - intIdx
|
||||
if (intIdx + 1 < input.size) {
|
||||
output[i] = input[intIdx] * (1 - frac) + input[intIdx + 1] * frac
|
||||
} else if (intIdx < input.size) {
|
||||
output[i] = input[intIdx] * (1 - frac) + lastSample * frac
|
||||
} else {
|
||||
output[i] = lastSample
|
||||
}
|
||||
idx += ratio
|
||||
}
|
||||
lastSample = input.lastOrNull() ?: lastSample
|
||||
return output
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
lastSample = 0f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Lightweight pitch detector using DFT at specific frequency bins.
|
||||
* Only scans [200, 1200] Hz in configurable steps — much faster than full FFT.
|
||||
* Ported from ggmorse/src/stfft.h (simplified for CW use case).
|
||||
*/
|
||||
internal class CwPitchDetector(
|
||||
private val sampleRate: Float,
|
||||
private val minFreq: Float = 200f,
|
||||
private val maxFreq: Float = 1200f,
|
||||
private val stepHz: Float = 10f
|
||||
) {
|
||||
/**
|
||||
* Find the dominant pitch frequency in the buffer.
|
||||
* Returns null if no significant pitch found.
|
||||
*/
|
||||
fun findPitch(buffer: FloatArray): Float? {
|
||||
if (buffer.isEmpty()) return null
|
||||
var bestFreq = 0f
|
||||
var bestPower = 0f
|
||||
var freq = minFreq
|
||||
while (freq <= maxFreq) {
|
||||
var real = 0.0
|
||||
var imag = 0.0
|
||||
val omega = 2.0 * kotlin.math.PI * freq / sampleRate
|
||||
for (i in buffer.indices) {
|
||||
real += buffer[i] * cos(omega * i)
|
||||
imag += buffer[i] * -sin(omega * i)
|
||||
}
|
||||
val power = (real * real + imag * imag).toFloat()
|
||||
if (power > bestPower) {
|
||||
bestPower = power
|
||||
bestFreq = freq
|
||||
}
|
||||
freq += stepHz
|
||||
}
|
||||
return if (bestPower > 0.001f) bestFreq else null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
/**
|
||||
* Sliding-window spectrogram for CW decoding.
|
||||
* Maintains a time-frequency matrix updated with each audio frame.
|
||||
*
|
||||
* FFT size: 256, hop size: 64, sample rate: 4000 (or native)
|
||||
* Frequency bins: 6..38 (187-1187 Hz, covers typical CW range)
|
||||
* History: 40 columns (320 ms window)
|
||||
* Time resolution: 64/4000 = 16 ms, Frequency resolution: 4000/256 = 15.625 Hz
|
||||
*/
|
||||
internal class CwSpectrogram(
|
||||
private val fftSize: Int = 256,
|
||||
private val hopSize: Int = 64,
|
||||
private val sampleRate: Int = 4000,
|
||||
private val minBin: Int = 6,
|
||||
private val maxBin: Int = 38,
|
||||
val historyCols: Int = 40
|
||||
) {
|
||||
private val fft = CwFFT(fftSize)
|
||||
val numBins: Int get() = maxBin - minBin + 1
|
||||
|
||||
// Hanning window
|
||||
private val hanning = FloatArray(fftSize) {
|
||||
(0.5 - 0.5 * kotlin.math.cos(2.0 * kotlin.math.PI * it / (fftSize - 1))).toFloat()
|
||||
}
|
||||
|
||||
// Spectrogram data: [timeCol][freqBin]
|
||||
private val spectrogram = Array(historyCols) { FloatArray(numBins) }
|
||||
private var currentCol = 0
|
||||
private var samplesBuffered = 0
|
||||
private val buffer = FloatArray(fftSize)
|
||||
|
||||
// Per-bin running energy for normalization
|
||||
private val binEnergy = FloatArray(numBins) { 1f }
|
||||
private val alpha = 0.95f
|
||||
|
||||
// Counter for new columns generated since last check
|
||||
private var newColumnCount = 0
|
||||
|
||||
/** Add audio samples, compute FFTs for each complete hop. */
|
||||
fun addSamples(samples: FloatArray) {
|
||||
var offset = 0
|
||||
while (offset < samples.size) {
|
||||
val needed = fftSize - samplesBuffered
|
||||
val copyLen = minOf(needed, samples.size - offset)
|
||||
System.arraycopy(samples, offset, buffer, samplesBuffered, copyLen)
|
||||
samplesBuffered += copyLen
|
||||
offset += copyLen
|
||||
|
||||
if (samplesBuffered >= fftSize) {
|
||||
processFrame()
|
||||
newColumnCount++
|
||||
// Shift buffer: keep last (fftSize - hopSize) samples
|
||||
System.arraycopy(buffer, hopSize, buffer, 0, fftSize - hopSize)
|
||||
samplesBuffered = fftSize - hopSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Get number of new columns generated since the last call to this method. */
|
||||
fun getNewColumns(): Int {
|
||||
val count = newColumnCount
|
||||
newColumnCount = 0
|
||||
return count
|
||||
}
|
||||
|
||||
private fun processFrame() {
|
||||
// Apply Hanning window
|
||||
val windowed = FloatArray(fftSize) { buffer[it] * hanning[it] }
|
||||
|
||||
// Compute FFT magnitude spectrum
|
||||
val mag = fft.magnitudeSpectrum(windowed)
|
||||
|
||||
// Update spectrogram column
|
||||
val col = spectrogram[currentCol]
|
||||
for (b in 0 until numBins) {
|
||||
val binIdx = minBin + b
|
||||
val rawMag = mag[binIdx]
|
||||
// Running energy normalization
|
||||
binEnergy[b] = alpha * binEnergy[b] + (1 - alpha) * rawMag
|
||||
col[b] = if (binEnergy[b] > 1e-6f) rawMag / binEnergy[b] else 0f
|
||||
}
|
||||
|
||||
currentCol = (currentCol + 1) % historyCols
|
||||
}
|
||||
|
||||
/** Get the current spectrogram as a 2D array in chronological order. */
|
||||
fun getSpectrogram(): Array<FloatArray> {
|
||||
val result = Array(historyCols) { i ->
|
||||
val srcIdx = (currentCol + i) % historyCols
|
||||
spectrogram[srcIdx].copyOf()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Get the most recent column (current energy across all frequencies). */
|
||||
fun getCurrentColumn(): FloatArray {
|
||||
val prevCol = (currentCol - 1 + historyCols) % historyCols
|
||||
return spectrogram[prevCol].copyOf()
|
||||
}
|
||||
|
||||
/** Get a column by index from the history (0 = oldest, historyCols-1 = newest). */
|
||||
fun getColumn(index: Int): FloatArray {
|
||||
val clamped = index.coerceIn(0, historyCols - 1)
|
||||
val srcIdx = (currentCol - historyCols + clamped + historyCols) % historyCols
|
||||
return spectrogram[srcIdx].copyOf()
|
||||
}
|
||||
|
||||
/** Find the frequency bin with peak energy. Returns -1 if no significant signal. */
|
||||
fun findPeakBin(): Int {
|
||||
val col = getCurrentColumn()
|
||||
var maxBin = -1
|
||||
var maxVal = 0f
|
||||
for (i in col.indices) {
|
||||
if (col[i] > maxVal) {
|
||||
maxVal = col[i]
|
||||
maxBin = i
|
||||
}
|
||||
}
|
||||
return if (maxVal > 0.3f) maxBin else -1
|
||||
}
|
||||
|
||||
/** Get energy at a specific bin over the last N columns in chronological order. */
|
||||
fun getBinEnergy(bin: Int, numCols: Int): FloatArray {
|
||||
val clamped = minOf(numCols, historyCols)
|
||||
val result = FloatArray(clamped)
|
||||
for (i in 0 until clamped) {
|
||||
val colIdx = (currentCol - clamped + i + historyCols) % historyCols
|
||||
result[i] = spectrogram[colIdx][bin]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Get the bin index for a frequency in Hz. */
|
||||
fun freqToBin(freqHz: Float): Int {
|
||||
val bin = (freqHz * fftSize / sampleRate).toInt()
|
||||
return (bin - minBin).coerceIn(0, numBins - 1)
|
||||
}
|
||||
|
||||
/** Get the center frequency for a bin. */
|
||||
fun binToFreq(bin: Int): Float {
|
||||
return (minBin + bin).toFloat() * sampleRate / fftSize
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
for (col in spectrogram) col.fill(0f)
|
||||
currentCol = 0
|
||||
samplesBuffered = 0
|
||||
buffer.fill(0f)
|
||||
binEnergy.fill(1f)
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
/** One public AMSAT satellite status report submission. */
|
||||
data class AmSatReportSubmission(
|
||||
val name: String,
|
||||
val report: String,
|
||||
val callsign: String,
|
||||
val gridSquare: String,
|
||||
val reportedAtUtcMillis: Long
|
||||
)
|
||||
|
||||
/** Result of submitting an AMSAT satellite status report. */
|
||||
data class AmSatReportSubmitResult(
|
||||
val success: Boolean,
|
||||
val message: String = "",
|
||||
val reportId: String? = null
|
||||
)
|
||||
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
/**
|
||||
* Award progress computed from the confirmed-QSO store.
|
||||
*
|
||||
* @param type which award
|
||||
* @param workedKeys matching keys for the boundary assets: each is compared
|
||||
* against the region "code" field of the award's GeoJSON
|
||||
* (e.g. WAPC -> "GD", WAJA -> "34", WAZ -> "24",
|
||||
* WAS -> "CA", DXCC -> "318", VUCC -> "OL62").
|
||||
* @param count distinct worked units (grids / entities / provinces / ...)
|
||||
* @param target units required for the award
|
||||
*/
|
||||
data class AwardProgress(
|
||||
val type: AwardType,
|
||||
val workedKeys: Set<String>,
|
||||
val count: Int,
|
||||
val target: Int
|
||||
)
|
||||
|
||||
enum class AwardType { DXCC, VUCC, WAPC, WAJA, WAZ, WAS }
|
||||
|
||||
object AwardTargets {
|
||||
const val DXCC = 100
|
||||
const val VUCC = 100
|
||||
const val WAPC = 34
|
||||
const val WAJA = 47
|
||||
const val WAZ = 40
|
||||
const val WAS = 50
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives six-award progress from the confirmed QSO store.
|
||||
*
|
||||
* Award rules (ADIF / award-program semantics):
|
||||
* - VUCC: distinct 4-char gridsquares.
|
||||
* - DXCC: distinct ARRL DXCC entities (from the QSO's dxcc field; falls back
|
||||
* to a callsign-prefix guess when LoTW omitted it).
|
||||
* - WAPC: distinct Chinese provinces (31 mainland STATE codes) plus the
|
||||
* Hong Kong / Macao / Taiwan entities (counted as their own provinces).
|
||||
* - WAJA: distinct Japanese prefectures (STATE code "01".."47").
|
||||
* - WAZ: distinct CQ zones.
|
||||
* - WAS: distinct US states (STATE abbreviations).
|
||||
*
|
||||
* The ADIF STATE field's meaning depends on the DXCC entity
|
||||
* ("STATE depends on DXCC"), so every QSO is first classified by entity.
|
||||
*/
|
||||
object AwardCalculator {
|
||||
|
||||
fun calculate(qsosByGrid: Map<String, List<GridQso>>): List<AwardProgress> {
|
||||
val all = qsosByGrid.values.flatten()
|
||||
return listOf(
|
||||
AwardProgress(
|
||||
AwardType.VUCC,
|
||||
workedKeys = qsosByGrid.keys,
|
||||
count = qsosByGrid.size,
|
||||
target = AwardTargets.VUCC
|
||||
),
|
||||
calculateDxcc(all),
|
||||
calculateWapc(all),
|
||||
calculateWaja(all),
|
||||
calculateWaz(all),
|
||||
calculateWas(all)
|
||||
)
|
||||
}
|
||||
|
||||
private fun calculateDxcc(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
val code = q.dxcc ?: prefixEntityCode(q.call)
|
||||
if (code != null) keys.add(code.toString())
|
||||
}
|
||||
return AwardProgress(AwardType.DXCC, keys, keys.size, AwardTargets.DXCC)
|
||||
}
|
||||
|
||||
private fun calculateWapc(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
when (entityOf(q)) {
|
||||
Entity.CHINA -> q.state?.trim()?.uppercase()?.takeIf { it in CN_STATES }?.let { keys.add(it) }
|
||||
Entity.HONG_KONG -> keys.add(HK_CODE)
|
||||
Entity.MACAO -> keys.add(MO_CODE)
|
||||
Entity.TAIWAN -> keys.add(TW_CODE)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
return AwardProgress(AwardType.WAPC, keys, keys.size, AwardTargets.WAPC)
|
||||
}
|
||||
|
||||
private fun calculateWaja(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
if (entityOf(q) != Entity.JAPAN) continue
|
||||
q.state?.trim()?.takeIf { it.length == 2 && it.all(Char::isDigit) }?.let { keys.add(it) }
|
||||
}
|
||||
return AwardProgress(AwardType.WAJA, keys, keys.size, AwardTargets.WAJA)
|
||||
}
|
||||
|
||||
private fun calculateWaz(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
q.cqz?.takeIf { it in 1..40 }?.let { keys.add(it.toString()) }
|
||||
}
|
||||
return AwardProgress(AwardType.WAZ, keys, keys.size, AwardTargets.WAZ)
|
||||
}
|
||||
|
||||
private fun calculateWas(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
if (entityOf(q) != Entity.USA) continue
|
||||
q.state?.trim()?.uppercase()?.takeIf { it in US_STATES }?.let { keys.add(it) }
|
||||
}
|
||||
return AwardProgress(AwardType.WAS, keys, keys.size, AwardTargets.WAS)
|
||||
}
|
||||
|
||||
private enum class Entity { CHINA, HONG_KONG, MACAO, TAIWAN, JAPAN, USA, OTHER }
|
||||
|
||||
private fun entityOf(q: GridQso): Entity = when (q.dxcc) {
|
||||
318 -> Entity.CHINA
|
||||
321 -> Entity.HONG_KONG
|
||||
152 -> Entity.MACAO
|
||||
386 -> Entity.TAIWAN
|
||||
339 -> Entity.JAPAN
|
||||
291 -> Entity.USA
|
||||
else -> prefixEntity(q.call)
|
||||
}
|
||||
|
||||
/** Coarse entity from callsign prefix, used when LoTW omitted the DXCC field. */
|
||||
private fun prefixEntity(call: String): Entity {
|
||||
val c = call.uppercase().substringBefore('/')
|
||||
return when {
|
||||
CN_PREFIXES.any { c.startsWith(it) } -> Entity.CHINA
|
||||
c.startsWith("VR") -> Entity.HONG_KONG
|
||||
c.startsWith("XX9") -> Entity.MACAO
|
||||
TW_PREFIXES.any { c.startsWith(it) } -> Entity.TAIWAN
|
||||
JP_PREFIXES.any { c.startsWith(it) } -> Entity.JAPAN
|
||||
US_PREFIXES.any { c.startsWith(it) } -> Entity.USA
|
||||
else -> Entity.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/** ARRL DXCC entity code guessed from callsign prefix (very coarse). */
|
||||
private fun prefixEntityCode(call: String): Int? {
|
||||
return when (prefixEntity(call)) {
|
||||
Entity.CHINA -> 318
|
||||
Entity.HONG_KONG -> 321
|
||||
Entity.MACAO -> 152
|
||||
Entity.TAIWAN -> 386
|
||||
Entity.JAPAN -> 339
|
||||
Entity.USA -> 291
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private val CN_PREFIXES = listOf("BA", "BD", "BG", "BH", "BI", "BY", "BJ", "BK", "BL", "BQ", "BR", "BS")
|
||||
private val TW_PREFIXES = listOf("BM", "BV", "BU", "BW", "BX", "BN", "BO", "BT", "BZ")
|
||||
private val JP_PREFIXES =
|
||||
listOf("7J", "7K", "7L", "7M", "7N") + ('A'..'Z').map { "J$it" }
|
||||
private val US_PREFIXES =
|
||||
listOf("AA", "AB", "AC", "AD", "AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "K", "N", "W")
|
||||
|
||||
private val CN_STATES = setOf(
|
||||
"AH", "BJ", "CQ", "FJ", "GD", "GS", "GX", "GZ", "HA", "HB", "HE", "HI", "HL", "HN", "JL",
|
||||
"JS", "JX", "LN", "NM", "NX", "QH", "SC", "SD", "SH", "SN", "SX", "TJ", "XJ", "XZ", "YN", "ZJ"
|
||||
)
|
||||
|
||||
private val US_STATES = setOf(
|
||||
"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", "HI", "ID", "IL", "IN", "IA",
|
||||
"KS", "KY", "LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ",
|
||||
"NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT",
|
||||
"VA", "WA", "WV", "WI", "WY"
|
||||
)
|
||||
|
||||
private const val HK_CODE = "HK"
|
||||
private const val MO_CODE = "MO"
|
||||
private const val TW_CODE = "TW"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
object Constants {
|
||||
const val FREQ_OFFSET_MIN_HZ = -50_000L
|
||||
const val FREQ_OFFSET_MAX_HZ = 50_000L
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
/**
|
||||
* One confirmed satellite QSO as reported by LoTW, kept per worked gridsquare
|
||||
* so the map can show, for any worked grid, which callsigns were worked there
|
||||
* and when.
|
||||
*
|
||||
* @param call opposite station callsign (as logged by the opposite op)
|
||||
* @param epochMs QSO date+time in UTC milliseconds
|
||||
* @param satName satellite name (e.g. "FO-29")
|
||||
* @param mode ADIF mode (FM / CW / SSB ...)
|
||||
* @param bandUp uplink band as reported by LoTW (BAND_RX: "70CM", "2M", "10M"...)
|
||||
* @param bandDown downlink band (BAND: "2M", "70CM"...) — may be empty when
|
||||
* LoTW did not include it
|
||||
* @param dxcc ARRL DXCC entity code of the opposite station (from ADIF
|
||||
* <DXCC>), or null when LoTW omitted it
|
||||
* @param country DXCC entity name (ADIF <COUNTRY>), or null
|
||||
* @param cqz CQ zone of the opposite station (ADIF <CQZ>), or null
|
||||
* @param state Primary administrative subdivision of the opposite station
|
||||
* (ADIF <STATE>). Interpretation depends on the DXCC entity:
|
||||
* China -> province code ("GD"), Japan -> prefecture number
|
||||
* ("34"), USA -> state abbreviation ("CA"). Null when absent.
|
||||
*/
|
||||
data class GridQso(
|
||||
val call: String,
|
||||
val epochMs: Long,
|
||||
val satName: String,
|
||||
val mode: String,
|
||||
val bandUp: String,
|
||||
val bandDown: String,
|
||||
val dxcc: Int? = null,
|
||||
val country: String? = null,
|
||||
val cqz: Int? = null,
|
||||
val state: String? = null
|
||||
) {
|
||||
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
|
||||
val bandLabel: String
|
||||
get() {
|
||||
val up = bandAbbrev(bandUp)
|
||||
val down = bandAbbrev(bandDown)
|
||||
return when {
|
||||
up.isNotEmpty() && down.isNotEmpty() -> "$up/$down"
|
||||
up.isNotEmpty() -> up
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun bandAbbrev(band: String): String = when (band.trim().uppercase()) {
|
||||
"70CM" -> "U"
|
||||
"2M" -> "V"
|
||||
"10M" -> "A"
|
||||
"6M" -> "V"
|
||||
"1.2G" -> "L"
|
||||
"2.4G" -> "S"
|
||||
"23CM" -> "L"
|
||||
"13CM" -> "S"
|
||||
else -> band.trim().uppercase()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
/** Latest release info fetched from the fork's GitHub releases API. */
|
||||
data class LatestRelease(
|
||||
val versionTag: String, // e.g. "v4.4.6-ba7opf.6"
|
||||
val title: String, // release name, may be empty
|
||||
val body: String, // release notes / update description
|
||||
val apkUrl: String? // first .apk asset download URL, null if absent
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
/** One satellite status report (AMSAT site tooltip data) */
|
||||
data class SatReport(
|
||||
val id: String, // 报告 ID(a885153)
|
||||
val statusText: String, // Heard / Telemetry Only / Not Heard ...
|
||||
val call: String, // 呼号
|
||||
val grid: String, // 网格坐标(可为空)
|
||||
val dateUtc: String, // 2026-08-04
|
||||
val timeUtc: String // 2:46-:59 UTC
|
||||
)
|
||||
|
||||
/** State of one 2-hour slot */
|
||||
data class SatSlot(
|
||||
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
|
||||
val count: Int, // 报告数量(0 = 无)
|
||||
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
|
||||
)
|
||||
|
||||
/** One satellite day (12 two-hour slots) */
|
||||
data class SatDay(
|
||||
val dateLabel: String, // "Aug 4"
|
||||
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
||||
)
|
||||
|
||||
/** One satellite, 3 days of state */
|
||||
data class SatStatus(
|
||||
val name: String, // "AO-123_[FM]"
|
||||
val days: List<SatDay> // 3 days (newest first)
|
||||
)
|
||||
|
||||
/** 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,6 +42,7 @@ data class RCSettings(
|
||||
val frequencyAddress: String,
|
||||
val frequencyPort: String,
|
||||
val frequencyFormat: String,
|
||||
val frequencyOffsetHz: Long = 0L,
|
||||
val bluetoothRotatorState: Boolean,
|
||||
val bluetoothRotatorFormat: String,
|
||||
val bluetoothRotatorName: String,
|
||||
@@ -57,21 +59,37 @@ data class OtherSettings(
|
||||
val stateOfUtc: Boolean,
|
||||
val stateOfLightTheme: Boolean,
|
||||
val stateOfNightMode: Boolean = false,
|
||||
val stateOfMapGrid: Boolean = false,
|
||||
val shouldSeeWarning: Boolean,
|
||||
val shouldSeeWhatsNew: Boolean,
|
||||
val sstvMode: String = "Auto",
|
||||
val lowElevation: Double = 15.0,
|
||||
val highElevation: Double = 45.0,
|
||||
val radarCompassOffset: Float = 0f,
|
||||
val radarCompassOffsetElev: Float = 0f
|
||||
val highElevation: Double = 45.0
|
||||
)
|
||||
|
||||
data class DataSourcesSettings(
|
||||
val useCustomTLE: Boolean,
|
||||
val useCustomTransceivers: Boolean,
|
||||
val tleUrl: String,
|
||||
val transceiversUrl: String
|
||||
)
|
||||
val satelliteUrls: List<String>,
|
||||
val transceiversUrls: List<String>,
|
||||
val satelliteEnabled: List<Boolean> = emptyList(),
|
||||
val transceiversEnabled: List<Boolean> = emptyList()
|
||||
) {
|
||||
fun isSatelliteEnabled(index: Int): Boolean = satelliteEnabled.getOrElse(index) { true }
|
||||
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
|
||||
}
|
||||
|
||||
data class WavelogSettings(
|
||||
val url: String = "",
|
||||
val token: String = ""
|
||||
) {
|
||||
val isConfigured: Boolean get() = url.isNotBlank() && token.isNotBlank()
|
||||
}
|
||||
|
||||
data class LoTWSettings(
|
||||
val callsign: String = "",
|
||||
val password: String = ""
|
||||
) {
|
||||
val isConfigured: Boolean get() = callsign.isNotBlank() && password.isNotBlank()
|
||||
}
|
||||
|
||||
data class RadioControlSettings(
|
||||
val enabled: Boolean,
|
||||
|
||||
+7
-3
@@ -424,11 +424,15 @@ object CelestialComputer {
|
||||
}
|
||||
if (sunrise == 0.0) sunrise = daynum
|
||||
|
||||
// Phase 4: fast-forward through the day until sun drops back below threshold
|
||||
daynum = sunrise
|
||||
// Phase 4: fast-forward through the day until sun drops back below threshold.
|
||||
// Start from just after sunrise (small offset) so the sun is clearly above
|
||||
// the threshold. This prevents a bug where Phase 3 converges to a point
|
||||
// slightly below -threshold, causing Phase 4 to skip and Phase 5 to converge
|
||||
// to the same time as sunrise, producing identical sunrise/sunset times.
|
||||
daynum = sunrise + 0.001
|
||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||
guard = 0
|
||||
while (sunPos.elevation > -threshold && guard++ < 500) {
|
||||
while (sunPos.elevation >= -threshold && guard++ < 500) {
|
||||
daynum += 0.008
|
||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
|
||||
/** AMSAT satellite status data source */
|
||||
interface IAmSatRepository {
|
||||
/** Cached AMSAT status page for the current foreground session, if any. */
|
||||
fun getCachedStatus(): SatStatusPage?
|
||||
|
||||
/** Fetch and parse the AMSAT status page; null on failure. */
|
||||
suspend fun fetchStatus(forceRefresh: Boolean = false): SatStatusPage?
|
||||
|
||||
/** Warm the foreground-session cache without forcing a network reload. */
|
||||
suspend fun prefetchStatus()
|
||||
|
||||
/** Clear the foreground-session status cache. */
|
||||
fun clearStatusCache()
|
||||
|
||||
/** Submit a public AMSAT satellite status report. */
|
||||
suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
/** Fetches confirmed gridsquares directly from ARRL LoTW. */
|
||||
interface ILoTWRepository {
|
||||
|
||||
/**
|
||||
* Fetch all confirmed (QSL_RCVD=Y) gridsquares for the given LoTW account.
|
||||
* Returns the 4-char grid set with an explicit failure cause on error.
|
||||
*/
|
||||
suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult
|
||||
|
||||
/**
|
||||
* Same report, but keeps the per-QSO detail of every confirmed satellite
|
||||
* QSO (call / time / satellite / mode / bands), grouped by worked 4-char
|
||||
* gridsquare. Failure cause is kept explicit.
|
||||
*/
|
||||
suspend fun fetchConfirmedGridQsos(
|
||||
callsign: String,
|
||||
password: String
|
||||
): LoTWResult
|
||||
}
|
||||
+27
-2
@@ -16,12 +16,13 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface IMainContainer {
|
||||
val appScope: CoroutineScope
|
||||
@@ -29,7 +30,13 @@ interface IMainContainer {
|
||||
val selectionRepo: ISelectionRepo
|
||||
val satelliteRepo: ISatelliteRepo
|
||||
val databaseRepo: IDatabaseRepo
|
||||
val amSatRepo: IAmSatRepository
|
||||
val updateRepo: IUpdateRepository
|
||||
val wavelogRepo: IWavelogRepository
|
||||
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||
val radioTrackingService: IRadioTrackingService
|
||||
val mutualPassData: StateFlow<MutualPassData>
|
||||
fun setMutualPassData(data: MutualPassData)
|
||||
fun provideAddToCalendar(): IAddToCalendar
|
||||
fun provideShowToast(): IShowToast
|
||||
fun provideBluetoothReporter(): IReporter
|
||||
@@ -39,9 +46,27 @@ interface IMainContainer {
|
||||
fun provideRxRadioController(): IRadioController
|
||||
fun provideAudioCapture(): IAudioCapture
|
||||
fun provideSaveImage(): ISaveImage
|
||||
fun providePairedBluetoothDevices(): List<Pair<String, String>>
|
||||
}
|
||||
|
||||
data class MutualPassData(
|
||||
val samples: List<Pair<Long, Pair<Double, Double>>> = emptyList(),
|
||||
val trackSamples: List<TrackSampleData> = emptyList(),
|
||||
val startTime: Long = 0L,
|
||||
val endTime: Long = 0L,
|
||||
val maxElev: Double = 10.0,
|
||||
val labelA: String = "你",
|
||||
val labelB: String = "友台"
|
||||
)
|
||||
|
||||
/** Minimal track sample for cross-module sharing (angles in degrees). */
|
||||
data class TrackSampleData(
|
||||
val time: Long = 0L,
|
||||
val azimuthA: Double,
|
||||
val elevationA: Double,
|
||||
val azimuthB: Double,
|
||||
val elevationB: Double
|
||||
)
|
||||
|
||||
interface IContainerProvider {
|
||||
fun getMainContainer(): IMainContainer
|
||||
}
|
||||
+3
-3
@@ -21,10 +21,10 @@ import com.rtbishop.look4sat.core.domain.model.SatItem
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface ISelectionRepo {
|
||||
fun getCurrentModes(): List<String>
|
||||
fun getModesList(): List<String>
|
||||
fun getCurrentTypes(): List<String>
|
||||
fun getTypesList(): List<String>
|
||||
suspend fun getEntriesFlow(): Flow<List<SatItem>>
|
||||
suspend fun setModes(modes: List<String>)
|
||||
suspend fun setTypes(types: List<String>)
|
||||
suspend fun setQuery(query: String)
|
||||
suspend fun setSelection(selectAll: Boolean)
|
||||
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
|
||||
|
||||
+38
-2
@@ -23,6 +23,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@@ -32,9 +33,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
|
||||
@@ -51,6 +52,8 @@ interface ISettingsRepo {
|
||||
|
||||
//region # Database update settings
|
||||
val databaseState: StateFlow<DatabaseState>
|
||||
fun getSatelliteTypesIds(types: List<String>): List<Int>
|
||||
fun setSatelliteTypeIds(type: String, ids: List<Int>)
|
||||
fun updateDatabaseState(state: DatabaseState)
|
||||
//endregion
|
||||
|
||||
@@ -71,8 +74,41 @@ interface ISettingsRepo {
|
||||
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
||||
//endregion
|
||||
|
||||
//region # Data sources status
|
||||
val dataSourcesStatus: StateFlow<Map<String, Int>>
|
||||
fun updateDataSourcesStatus(status: Map<String, Int>)
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
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
|
||||
|
||||
//region # AMSAT status report settings
|
||||
fun getAmSatCallsign(): String
|
||||
fun setAmSatCallsign(callsign: String)
|
||||
//endregion
|
||||
|
||||
//region # Wavelog worked-grids settings
|
||||
val wavelogSettings: StateFlow<WavelogSettings>
|
||||
fun updateWavelogSettings(settings: WavelogSettings)
|
||||
fun getWorkedGrids(): Set<String>
|
||||
fun setWorkedGrids(grids: Set<String>)
|
||||
/** Confirmed satellite QSOs grouped by worked 4-char gridsquare (map tap detail). */
|
||||
fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>
|
||||
fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>)
|
||||
/** Distinct 4-char gridsquares the account operated from (LoTW <MY_GRIDSQUARE>). */
|
||||
fun getRoamedGrids(): Set<String>
|
||||
fun setRoamedGrids(grids: Set<String>)
|
||||
//endregion
|
||||
|
||||
//region # LoTW confirmed-grids settings
|
||||
val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
|
||||
fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings)
|
||||
//endregion
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.LatestRelease
|
||||
import java.io.File
|
||||
|
||||
interface IUpdateRepository {
|
||||
/** Fetch the latest release of the fork repo from GitHub; null on failure. */
|
||||
suspend fun getLatestRelease(): LatestRelease?
|
||||
|
||||
/** Download the release APK from [url] into [dest]; true on success. */
|
||||
suspend fun downloadApk(url: String, dest: File): Boolean
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
/** Fetches worked gridsquares from a self-hosted Wavelog instance (API v2). */
|
||||
interface IWavelogRepository {
|
||||
|
||||
/**
|
||||
* Fetch worked grids for the configured Wavelog URL/token.
|
||||
* Returns the grid set, or null on any failure.
|
||||
*/
|
||||
suspend fun fetchWorkedGrids(url: String, token: String): Set<String>?
|
||||
}
|
||||
@@ -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.repository
|
||||
|
||||
/** Result of a LoTW report fetch, with the failure cause kept explicit. */
|
||||
sealed class LoTWResult {
|
||||
|
||||
/** Report downloaded and parsed successfully. */
|
||||
data class Success(
|
||||
val grids: Set<String>,
|
||||
val qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>,
|
||||
/** Distinct 4-char gridsquares the account itself operated from
|
||||
* (ADIF <MY_GRIDSQUARE>, satellite QSOs only) — "roamed/activated" grids. */
|
||||
val roamedGrids: Set<String> = emptySet()
|
||||
) : LoTWResult()
|
||||
|
||||
/** HTTP 200 but LoTW replied with its login-error page (bad callsign/password). */
|
||||
data object BadCredentials : LoTWResult()
|
||||
|
||||
/**
|
||||
* Report endpoint refused the request without a usable error page. LoTW
|
||||
* limits downloads to one in progress per user id and flags accounts that
|
||||
* pull the full report too often; the server answers those with a bare
|
||||
* non-ADIF body (no <eoh>).
|
||||
*/
|
||||
data object RateLimited : LoTWResult()
|
||||
|
||||
/** Connect/read timed out — typically a slow route to the ARRL servers. */
|
||||
data object Timeout : LoTWResult()
|
||||
|
||||
/** Any other network-level failure (DNS, TLS, connection reset, HTTP 5xx). */
|
||||
data class NetworkError(val detail: String) : LoTWResult()
|
||||
}
|
||||
+12
-1
@@ -19,7 +19,18 @@ package com.rtbishop.look4sat.core.domain.source
|
||||
|
||||
import java.io.InputStream
|
||||
|
||||
/** Result of a network download: the HTTP status code and the response body stream. */
|
||||
data class NetworkResult(val code: Int, val stream: InputStream?) {
|
||||
companion object {
|
||||
/** Code used when a request fails before any HTTP response is received. */
|
||||
const val CONNECTION_ERROR = -1
|
||||
}
|
||||
}
|
||||
|
||||
interface IRemoteSource {
|
||||
suspend fun getFileStream(uri: String): InputStream?
|
||||
suspend fun getNetworkStream(url: String): InputStream?
|
||||
suspend fun getNetworkStream(url: String): NetworkResult
|
||||
suspend fun getAmSatCatalog(): String?
|
||||
suspend fun getAmSatReports(hours: Int, limit: Int): String?
|
||||
suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>?
|
||||
}
|
||||
@@ -19,25 +19,40 @@ package com.rtbishop.look4sat.core.domain.source
|
||||
|
||||
object Sources {
|
||||
val satelliteDataUrls = mapOf(
|
||||
"CelesTrak" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||
"AMSAT" to "https://amsat.org/tle/current/nasabare.txt",
|
||||
"McCants Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
||||
"McCants Integrated" to "https://www.mmccants.org/tles/inttles.zip",
|
||||
"LAPAN-A2" to "https://www.kaggle.com/api/v1/datasets/download/muazamnugroho/lapan-a2-satellite-two-line-element-tle-dataset/LAPAN-A2_TLE_latest.txt",
|
||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
|
||||
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
||||
"Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv",
|
||||
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
|
||||
"Education" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=education&FORMAT=csv",
|
||||
"Engineer" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=engineering&FORMAT=csv",
|
||||
"Geostationary" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=geo&FORMAT=csv",
|
||||
"Globalstar" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=globalstar&FORMAT=csv",
|
||||
"GNSS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=gnss&FORMAT=csv",
|
||||
"Intelsat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=intelsat&FORMAT=csv",
|
||||
"Iridium" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=iridium-NEXT&FORMAT=csv",
|
||||
"Military" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=military&FORMAT=csv",
|
||||
"New" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=last-30-days&FORMAT=csv",
|
||||
"OneWeb" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=oneweb&FORMAT=csv",
|
||||
"Orbcomm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=orbcomm&FORMAT=csv",
|
||||
"Resource" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=resource&FORMAT=csv",
|
||||
"CelesTrak SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv",
|
||||
"Science" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=science&FORMAT=csv",
|
||||
"Spire" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=spire&FORMAT=csv",
|
||||
"Starlink" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=csv",
|
||||
"Swarm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=swarm&FORMAT=csv",
|
||||
"Weather" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=csv",
|
||||
"X-Comm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=x-comm&FORMAT=csv",
|
||||
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
|
||||
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
||||
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
|
||||
"ARISS" to "https://live.ariss.org/iss.txt",
|
||||
"Other" to "" // key for sats filter
|
||||
)
|
||||
val transceiversDataUrls = mapOf(
|
||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||
)
|
||||
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"
|
||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
|
||||
"R4UAB" to "https://r4uab.ru/transmitters.json"
|
||||
)
|
||||
}
|
||||
@@ -91,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,
|
||||
|
||||
+79
-28
@@ -16,18 +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, this computes 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,
|
||||
@@ -35,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,
|
||||
@@ -54,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,
|
||||
@@ -68,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,
|
||||
@@ -84,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. */
|
||||
@@ -102,8 +134,8 @@ object DopplerFrequencyCalculator {
|
||||
* True for the radio entry that should drive the standalone Calculator page.
|
||||
*
|
||||
* A frequency range alone is not enough: some non-user-facing or drifting data entries
|
||||
* can also have low/high frequencies. The calculator is meant for named linear
|
||||
* transponders, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
|
||||
* can also have low/high frequencies. The calculator is meant for the named linear
|
||||
* transponder entry, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
|
||||
*/
|
||||
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
|
||||
if (!isLinearTransponder(transponder)) return false
|
||||
@@ -112,11 +144,30 @@ object DopplerFrequencyCalculator {
|
||||
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
||||
.joinToString(separator = " ")
|
||||
.lowercase(Locale.ENGLISH)
|
||||
val hasLinearName = info.contains("linear")
|
||||
val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
|
||||
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
|
||||
info.contains("xponder") || info.contains("xpdr")
|
||||
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
|
||||
|
||||
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode)
|
||||
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) ||
|
||||
(hasLinearName && hasLinearMode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes duplicate transponder entries that describe the same physical
|
||||
* transponder with different mode labels (e.g. SatNOGS lists AO-7's Mode A
|
||||
* as both "Lin SSB" and "Lin CW", and JO-97's U/V transponder as both
|
||||
* "CW Transponder" and "SSB Transponder").
|
||||
*
|
||||
* Entries sharing the same uplink/downlink frequency range are considered
|
||||
* the same transponder. The non-CW entry is preferred because its invert
|
||||
* flag is more reliable (e.g. JO-97's CW entry wrongly has invert=false).
|
||||
*/
|
||||
fun deduplicateTransponders(radios: List<SatRadio>): List<SatRadio> {
|
||||
return radios.groupBy { radio ->
|
||||
listOf(radio.uplinkLow, radio.uplinkHigh, radio.downlinkLow, radio.downlinkHigh)
|
||||
}.values.map { group ->
|
||||
group.firstOrNull { it.downlinkMode?.equals("CW", ignoreCase = true) != true } ?: group.first()
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
-12
@@ -19,20 +19,43 @@ package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
|
||||
/**
|
||||
* Convert a Maidenhead locator (4, 6 or 8 chars) to the position of its
|
||||
* square / subsquare / extended-square centre. Longer input is truncated to
|
||||
* 8 chars (the Maidenhead maximum); malformed or out-of-range input yields
|
||||
* null.
|
||||
*/
|
||||
fun qthToPosition(locator: String): GeoPos? {
|
||||
val trimmedQth = locator.take(6)
|
||||
if (!isValidLocator(trimmedQth)) return null
|
||||
val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20
|
||||
val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10
|
||||
val trimmedQth = locator.trim().uppercase().take(8)
|
||||
if (trimmedQth.length !in listOf(4, 6, 8) || !isValidLocator(trimmedQth)) return null
|
||||
val lonFirst = (trimmedQth[0].code - 65) * 20
|
||||
val latFirst = (trimmedQth[1].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) + (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)
|
||||
// Start from the 4-char square centre, refine for 6/8-char subsquares.
|
||||
var longitude = lonFirst + lonSecond + 1.0
|
||||
var latitude = latFirst + latSecond + 0.5
|
||||
if (trimmedQth.length >= 6) {
|
||||
val lonSub = (trimmedQth[4].code - 65) * 5.0 / 60.0
|
||||
val latSub = (trimmedQth[5].code - 65) * 2.5 / 60.0
|
||||
if (lonSub < 0.0 || lonSub > 115.0 / 60.0 || latSub < 0.0 || latSub > 57.5 / 60.0) return null
|
||||
longitude = lonFirst + lonSecond + lonSub + 2.5 / 60.0
|
||||
latitude = latFirst + latSecond + latSub + 1.25 / 60.0
|
||||
if (trimmedQth.length >= 8) {
|
||||
val lonExt = (trimmedQth[6].code - 48) * 30.0 / 3600.0
|
||||
val latExt = (trimmedQth[7].code - 48) * 15.0 / 3600.0
|
||||
if (lonExt < 0.0 || lonExt > 270.0 / 3600.0 || latExt < 0.0 || latExt > 135.0 / 3600.0) return null
|
||||
longitude += lonExt + 15.0 / 3600.0
|
||||
latitude += latExt + 7.5 / 3600.0
|
||||
}
|
||||
}
|
||||
return GeoPos((latitude - 90.0).round(4), (longitude - 180.0).round(4))
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a position to its 6-char Maidenhead locator, upper-cased (field,
|
||||
* square and subsquare letters). Returns null for out-of-range positions.
|
||||
*/
|
||||
fun positionToQth(latitude: Double, longitude: Double): String? {
|
||||
if (!isValidPosition(latitude, longitude)) return null
|
||||
val newLongitude = if (longitude > 180.0) longitude else longitude + 180
|
||||
@@ -41,8 +64,8 @@ fun positionToQth(latitude: Double, longitude: Double): String? {
|
||||
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 lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().uppercaseChar()
|
||||
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().uppercaseChar()
|
||||
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
|
||||
}
|
||||
|
||||
@@ -51,5 +74,11 @@ private fun isValidPosition(lat: Double, lon: Double): Boolean {
|
||||
}
|
||||
|
||||
private fun isValidLocator(locator: String): Boolean {
|
||||
return locator.matches("[a-xA-X][a-xA-X]\\d\\d[a-xA-X][a-xA-X]".toRegex())
|
||||
// 4 chars: [A-X][A-X]\d\d ; 6 chars: + [A-X][A-X] ; 8 chars: + \d\d
|
||||
return when (locator.length) {
|
||||
4 -> Regex("[A-X]{2}\\d{2}").matches(locator)
|
||||
6 -> Regex("[A-X]{2}\\d{2}[A-X]{2}").matches(locator)
|
||||
8 -> Regex("[A-X]{2}\\d{2}[A-X]{2}\\d{2}").matches(locator)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
/**
|
||||
* Compares two version strings of the form "<major>.<minor>.<patch>[-<build>]" used by the
|
||||
* BA7OPF fork releases (e.g. "4.4.6-ba7opf.6" or "v4.4.6-ba7opf.9.1").
|
||||
*
|
||||
* The comparison is done on the numeric version segments first; when the base versions are
|
||||
* equal, the trailing build numbers of the suffix decide. All numeric segments of the suffix
|
||||
* are compared as a sequence, so multi-level suffixes work correctly:
|
||||
* "4.4.6-ba7opf.9.1" > "4.4.6-ba7opf.9" > "4.4.6-ba7opf.8".
|
||||
*/
|
||||
object VersionComparator {
|
||||
|
||||
/** True when [candidate] is a newer version than [current]. */
|
||||
fun isNewer(candidate: String, current: String): Boolean {
|
||||
val cand = parse(candidate)
|
||||
val curr = parse(current)
|
||||
val maxLen = maxOf(cand.first.size, curr.first.size)
|
||||
for (i in 0 until maxLen) {
|
||||
val c = cand.first.getOrElse(i) { 0 }
|
||||
val k = curr.first.getOrElse(i) { 0 }
|
||||
if (c != k) return c > k
|
||||
}
|
||||
val maxBuild = maxOf(cand.second.size, curr.second.size)
|
||||
for (i in 0 until maxBuild) {
|
||||
val c = cand.second.getOrElse(i) { 0 }
|
||||
val k = curr.second.getOrElse(i) { 0 }
|
||||
if (c != k) return c > k
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a version string into (numeric base segments, numeric suffix segments).
|
||||
* "v4.4.6-ba7opf.9.1" -> ([4,4,6], [9,1]); "4.4.6-ba7opf.8" -> ([4,4,6], [8]);
|
||||
* "4.4.6" -> ([4,4,6], []); "4.4.6-ba7opf" -> ([4,4,6], []).
|
||||
*/
|
||||
fun parse(version: String): Pair<List<Int>, List<Int>> {
|
||||
val cleaned = version.trim().removePrefix("v")
|
||||
val dashIndex = cleaned.indexOf('-')
|
||||
val basePart = if (dashIndex >= 0) cleaned.substring(0, dashIndex) else cleaned
|
||||
val suffixPart = if (dashIndex >= 0) cleaned.substring(dashIndex + 1) else ""
|
||||
val base = basePart.split('.').mapNotNull { it.toIntOrNull() }
|
||||
// All numeric segments of the suffix, in order (e.g. "ba7opf.9.1" -> [9, 1]).
|
||||
val build = suffixPart.split('.').mapNotNull { it.toIntOrNull() }
|
||||
return base to build
|
||||
}
|
||||
}
|
||||
+95
-10
@@ -3,16 +3,13 @@ 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,
|
||||
@@ -22,7 +19,7 @@ class DopplerFrequencyCalculatorTest {
|
||||
downlinkMode: String? = "USB",
|
||||
uplinkMode: String? = "LSB"
|
||||
) = SatRadio(
|
||||
uuid = "linear", info = info, isAlive = true,
|
||||
uuid = uuid, info = info, isAlive = true,
|
||||
downlinkLow = downLow, downlinkHigh = downHigh,
|
||||
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
|
||||
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
|
||||
@@ -72,17 +69,101 @@ 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)
|
||||
}
|
||||
|
||||
@@ -97,23 +178,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)
|
||||
@@ -172,11 +254,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
|
||||
@@ -184,6 +268,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)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,3 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
@@ -22,25 +5,34 @@ import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||
import org.junit.Test
|
||||
|
||||
class QthConverterTest {
|
||||
|
||||
@Test
|
||||
fun `Given valid QTH returns correct POS`() {
|
||||
// 8-char locator -> centre of the extended square (higher precision)
|
||||
var result = qthToPosition("io91VL39FX")
|
||||
assert(result?.latitude == 51.4792 && result.longitude == -0.2083)
|
||||
assert(result?.latitude == 51.5188 && result.longitude == -0.1792)
|
||||
result = qthToPosition("gf15vc")
|
||||
assert(result?.latitude == -34.8958 && result.longitude == -56.2083)
|
||||
// 4-char locator -> centre of the 2deg x 1deg square
|
||||
result = qthToPosition("JN58")
|
||||
assert(result?.latitude == 48.5 && result.longitude == 11.0)
|
||||
// lowercase input is accepted and normalised
|
||||
result = qthToPosition("ol63pd")
|
||||
assert(result?.latitude == 23.1458 && result.longitude == 113.2917)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given invalid QTH returns null`() {
|
||||
assert(qthToPosition("ZZ00zz") == null)
|
||||
assert(qthToPosition("JN58") == null)
|
||||
assert(qthToPosition("JN5") == null) // odd length
|
||||
assert(qthToPosition("JN58Z") == null) // 5 chars
|
||||
assert(qthToPosition("JN58ZA") == null) // Z out of A-X range
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid POS returns correct QTH`() {
|
||||
assert(positionToQth(51.4878, -0.2146) == "IO91vl")
|
||||
assert(positionToQth(48.1466, 11.6083) == "JN58td")
|
||||
assert(positionToQth(51.4878, -0.2146) == "IO91VL")
|
||||
assert(positionToQth(48.1466, 11.6083) == "JN58TD")
|
||||
assert(positionToQth(23.13, 113.26) == "OL63PD")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.rtbishop.look4sat.core.domain
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class VersionComparatorTest {
|
||||
|
||||
@Test
|
||||
fun `newer build number wins when base version equal`() {
|
||||
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.7", "4.4.6-ba7opf.6"))
|
||||
assertTrue(VersionComparator.isNewer("v4.4.6-ba7opf.6", "4.4.6-ba7opf.5"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newer base version wins regardless of build number`() {
|
||||
assertTrue(VersionComparator.isNewer("4.5.0", "4.4.6-ba7opf.99"))
|
||||
assertTrue(VersionComparator.isNewer("4.4.7-ba7opf.1", "4.4.6-ba7opf.99"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `older versions are not newer`() {
|
||||
assertFalse(VersionComparator.isNewer("4.4.6-ba7opf.5", "4.4.6-ba7opf.6"))
|
||||
assertFalse(VersionComparator.isNewer("4.4.5", "4.4.6-ba7opf.1"))
|
||||
assertFalse(VersionComparator.isNewer("4.4.6-ba7opf.6", "4.4.6-ba7opf.6"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `v prefix is ignored`() {
|
||||
assertTrue(VersionComparator.isNewer("v4.4.7", "4.4.6"))
|
||||
assertFalse(VersionComparator.isNewer("v4.4.6", "4.4.6-ba7opf.1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non numeric suffix without number treated as zero build`() {
|
||||
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.1", "4.4.6-ba7opf"))
|
||||
assertFalse(VersionComparator.isNewer("4.4.6-ba7opf", "4.4.6-ba7opf.1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multi level suffixes compare correctly`() {
|
||||
// The .9.1 hotfix case that exposed the old single-build-number logic.
|
||||
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.9.1", "4.4.6-ba7opf.9"))
|
||||
assertTrue(VersionComparator.isNewer("v4.4.6-ba7opf.9.1", "4.4.6-ba7opf.8"))
|
||||
assertFalse(VersionComparator.isNewer("4.4.6-ba7opf.9", "4.4.6-ba7opf.9.1"))
|
||||
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.10", "4.4.6-ba7opf.9.1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse extracts base and suffix segments`() {
|
||||
assertEquals(listOf(4, 4, 6) to listOf(7), VersionComparator.parse("v4.4.6-ba7opf.7"))
|
||||
assertEquals(listOf(4, 4, 6) to listOf(9, 1), VersionComparator.parse("v4.4.6-ba7opf.9.1"))
|
||||
assertEquals(listOf(4, 4, 6) to emptyList<Int>(), VersionComparator.parse("4.4.6"))
|
||||
assertEquals(listOf(4, 4, 6) to emptyList<Int>(), VersionComparator.parse("4.4.6-ba7opf"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AwardCalculatorTest {
|
||||
|
||||
private fun qso(
|
||||
call: String,
|
||||
dxcc: Int? = null,
|
||||
cqz: Int? = null,
|
||||
state: String? = null,
|
||||
grid: String = "OL62"
|
||||
) = GridQso(
|
||||
call = call, epochMs = 0L, satName = "SO-50", mode = "FM",
|
||||
bandUp = "70CM", bandDown = "2M", dxcc = dxcc, cqz = cqz, state = state
|
||||
)
|
||||
|
||||
private fun byGrid(vararg qsos: GridQso) =
|
||||
qsos.groupBy { "OL62" } // all share one grid for award-math tests
|
||||
|
||||
@Test
|
||||
fun `vucc counts distinct grid keys`() {
|
||||
val qsos = mapOf(
|
||||
"OL62" to listOf(qso("BG7XYZ")),
|
||||
"PM95" to listOf(qso("JH0ABC")),
|
||||
"OL62" to listOf(qso("BA7AAA"))
|
||||
)
|
||||
// map literal dedupes keys: OL62/PM95 -> 2 grids
|
||||
val progress = AwardCalculator.calculate(qsos).first { it.type == AwardType.VUCC }
|
||||
assertEquals(2, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("OL62", "PM95")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dxcc counts distinct entities from dxcc field`() {
|
||||
val all = listOf(
|
||||
qso("BG7XYZ", dxcc = 318),
|
||||
qso("JH0ABC", dxcc = 339),
|
||||
qso("YD1XYZ", dxcc = 327),
|
||||
qso("BG7ABC", dxcc = 318)
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.DXCC }
|
||||
assertEquals(3, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("318", "339", "327")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dxcc falls back to prefix when dxcc missing`() {
|
||||
val all = listOf(qso("JH0ABC"), qso("BG7XYZ"), qso("VR2XYZ"), qso("XX9A"))
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.DXCC }
|
||||
assertEquals(4, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("339", "318", "321", "152")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wapc counts mainland provinces plus hk mo tw`() {
|
||||
val all = listOf(
|
||||
qso("BG7XYZ", dxcc = 318, state = "GD"), // Guangdong
|
||||
qso("BD5ABC", dxcc = 318, state = "ZJ"), // Zhejiang
|
||||
qso("BD5ABC", dxcc = 318, state = "ZJ"), // dup
|
||||
qso("VR2X", dxcc = 321), // Hong Kong
|
||||
qso("XX9A", dxcc = 152), // Macao
|
||||
qso("BM4X", dxcc = 386) // Taiwan
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAPC }
|
||||
assertEquals(5, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("GD", "ZJ", "HK", "MO", "TW")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wapc ignores non-china states and missing states`() {
|
||||
val all = listOf(
|
||||
qso("JH0ABC", dxcc = 339, state = "34"), // Japan state ignored
|
||||
qso("BG7XYZ", dxcc = 318, state = null), // missing province
|
||||
qso("K1ABC", dxcc = 291, state = "HI") // Hawaii != Hainan
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAPC }
|
||||
assertEquals(0, progress.count)
|
||||
assertTrue(progress.workedKeys.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `waja counts japanese prefectures only`() {
|
||||
val all = listOf(
|
||||
qso("JH0ABC", dxcc = 339, state = "10"), // Tokyo
|
||||
qso("JH0ABC", dxcc = 339, state = "47"), // Okinawa
|
||||
qso("JH0ABC", dxcc = 339, state = "10"), // dup
|
||||
qso("BG7XYZ", dxcc = 318, state = "10"), // Chinese call, state=10 must NOT count
|
||||
qso("JE2XYZ", dxcc = 339, state = null) // missing
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAJA }
|
||||
assertEquals(2, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("10", "47")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `waz counts distinct cq zones in range`() {
|
||||
val all = listOf(
|
||||
qso("BG7XYZ", cqz = 24),
|
||||
qso("JH0ABC", cqz = 25),
|
||||
qso("YD1XYZ", cqz = 28),
|
||||
qso("BG7AAA", cqz = 24),
|
||||
qso("BG7BBB", cqz = null),
|
||||
qso("BG7CCC", cqz = 0)
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAZ }
|
||||
assertEquals(3, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("24", "25", "28")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `was counts us states only`() {
|
||||
val all = listOf(
|
||||
qso("K1ABC", dxcc = 291, state = "CA"),
|
||||
qso("W2XYZ", dxcc = 291, state = "NY"),
|
||||
qso("N3QRS", dxcc = 291, state = "ca"), // lowercase normalized
|
||||
qso("BG7XYZ", dxcc = 318, state = "CA"), // not US
|
||||
qso("K1AAA", dxcc = 291, state = "XX") // invalid state
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAS }
|
||||
assertEquals(2, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("CA", "NY")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all six awards present with correct targets`() {
|
||||
val progress = AwardCalculator.calculate(emptyMap())
|
||||
assertEquals(6, progress.size)
|
||||
assertEquals(100, progress.first { it.type == AwardType.DXCC }.target)
|
||||
assertEquals(100, progress.first { it.type == AwardType.VUCC }.target)
|
||||
assertEquals(34, progress.first { it.type == AwardType.WAPC }.target)
|
||||
assertEquals(47, progress.first { it.type == AwardType.WAJA }.target)
|
||||
assertEquals(40, progress.first { it.type == AwardType.WAZ }.target)
|
||||
assertEquals(50, progress.first { it.type == AwardType.WAS }.target)
|
||||
}
|
||||
}
|
||||
+72
-27
@@ -34,7 +34,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -47,7 +46,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -145,7 +143,7 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
||||
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||
}
|
||||
val sdfTime = remember(isUtc) {
|
||||
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
|
||||
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
|
||||
}
|
||||
ElevatedCard(
|
||||
modifier = modifier
|
||||
@@ -159,16 +157,13 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
||||
.padding(start = 6.dp, top = 1.dp, end = 6.dp, bottom = 0.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "${stringResource(R.string.pass_satId, pass.catNum)} - ",
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = pass.name,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 6.dp)
|
||||
.infiniteMarquee(),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@@ -214,18 +209,21 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayLocale(): Locale {
|
||||
val locale = Locale.getDefault()
|
||||
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
||||
fun CardButton(
|
||||
onClick: () -> Unit,
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
isEnabled: Boolean = true
|
||||
) {
|
||||
ElevatedButton(
|
||||
onClick = onClick,
|
||||
enabled = isEnabled,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||
),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = modifier,
|
||||
@@ -291,10 +289,56 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass(
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SharedDialog(
|
||||
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
||||
fun InfoDialog(
|
||||
title: String,
|
||||
onDismiss: () -> Unit,
|
||||
onAccept: () -> Unit,
|
||||
extraAction: (@Composable () -> Unit)? = null,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
SharedDialog(title = title, onDismissRequest = onCancel, onCancel = onCancel, onAccept = onAccept) { _ ->
|
||||
DialogShell(onDismissRequest = onDismiss) { padding ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = padding, top = padding, end = padding)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
extraAction?.let {
|
||||
it()
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedDialog(
|
||||
title: String,
|
||||
onCancel: () -> Unit,
|
||||
onAccept: () -> Unit,
|
||||
acceptText: String? = null,
|
||||
acceptEnabled: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
SharedDialog(
|
||||
title = title,
|
||||
onDismissRequest = onCancel,
|
||||
onCancel = onCancel,
|
||||
onAccept = onAccept,
|
||||
acceptText = acceptText,
|
||||
acceptEnabled = acceptEnabled
|
||||
) { _ ->
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -305,12 +349,13 @@ fun SharedDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
onCancel: (() -> Unit)? = null,
|
||||
onAccept: (() -> Unit)? = null,
|
||||
acceptText: String? = null,
|
||||
acceptEnabled: Boolean = true,
|
||||
titleFontSize: Int = 16,
|
||||
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
|
||||
content: @Composable (padding: Dp) -> Unit
|
||||
) {
|
||||
val dismissible = onCancel != null
|
||||
DialogShell(onDismissRequest = onDismissRequest, dismissible = dismissible) { padding ->
|
||||
DialogShell(onDismissRequest = onDismissRequest) { padding ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
@@ -333,7 +378,11 @@ fun SharedDialog(
|
||||
.padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp)
|
||||
)
|
||||
if (onAccept != null) {
|
||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||
CardButton(
|
||||
onClick = onAccept,
|
||||
text = acceptText ?: stringResource(R.string.btn_accept),
|
||||
isEnabled = acceptEnabled
|
||||
)
|
||||
}
|
||||
}
|
||||
content(padding)
|
||||
@@ -344,14 +393,10 @@ fun SharedDialog(
|
||||
@Composable
|
||||
private fun DialogShell(
|
||||
onDismissRequest: () -> Unit,
|
||||
dismissible: Boolean = true,
|
||||
content: @Composable (padding: Dp) -> Unit
|
||||
) {
|
||||
val padding = LocalSpacing.current.large
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true,
|
||||
confirmValueChange = { if (dismissible) true else it != SheetValue.Hidden }
|
||||
)
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val stopSheetFling = remember {
|
||||
object : NestedScrollConnection {
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
@@ -366,10 +411,10 @@ private fun DialogShell(
|
||||
}
|
||||
}
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = if (dismissible) onDismissRequest else { {} },
|
||||
onDismissRequest = onDismissRequest,
|
||||
sheetState = sheetState,
|
||||
dragHandle = null,
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
scrimColor = Color.Black.copy(alpha = 0.64f)
|
||||
) {
|
||||
Column(
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package com.rtbishop.look4sat.core.presentation
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Format a Maidenhead locator for display. Per IARU convention and the user's
|
||||
* preference: field letters (1-2) and square digits (3-4) uppercase, sub-square
|
||||
* letters (5-6 of a 6/8-char locator) lowercase, e.g. "OL62" / "OL62kl".
|
||||
*/
|
||||
fun formatGridDisplay(grid: String): String? {
|
||||
val g = grid.trim().uppercase()
|
||||
if (g.length !in listOf(4, 6, 8) || g.any { !it.isLetterOrDigit() }) return null
|
||||
if (g.length == 4) return g
|
||||
if (g.length == 6) return g.take(4) + g.drop(4).lowercase()
|
||||
return g.take(4) + g.drop(4).take(2).lowercase() + g.drop(6)
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-bar chip for the mutual-match screen: navigation needle (rotated by the
|
||||
* great-circle bearing to the target grid, 0 deg = north), grid locator and
|
||||
* distance. Shows a dimmed placeholder when no target grid is entered.
|
||||
*/
|
||||
@Composable
|
||||
fun GridTargetChip(
|
||||
grid: String?,
|
||||
distanceKm: Double?,
|
||||
bearingDeg: Double?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val configured = grid != null && distanceKm != null && bearingDeg != null
|
||||
// The app's signature accent yellow (MaterialTheme primary in both themes).
|
||||
val accent = MaterialTheme.colorScheme.primary
|
||||
val placeholder = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier
|
||||
.height(36.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = if (configured) 0.9f else 0.45f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
NavigationNeedle(
|
||||
bearingDeg = bearingDeg ?: 0.0,
|
||||
color = if (configured) accent else placeholder,
|
||||
dimmed = !configured,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
Text(
|
||||
text = grid?.let { formatGridDisplay(it) } ?: "----",
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = if (configured) accent else placeholder,
|
||||
letterSpacing = 1.sp
|
||||
)
|
||||
Text(
|
||||
text = distanceKm?.let { "${"%,.0f".format(it)} km" } ?: "-- km",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map-style navigation needle: slim triangular pointer whose tip points at
|
||||
* [bearingDeg] (0 = north/up, clockwise positive).
|
||||
*/
|
||||
@Composable
|
||||
private fun NavigationNeedle(
|
||||
bearingDeg: Double,
|
||||
color: Color,
|
||||
dimmed: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Canvas(modifier = modifier) {
|
||||
val cx = size.width / 2f
|
||||
val cy = size.height / 2f
|
||||
val r = size.minDimension / 2f
|
||||
if (dimmed) {
|
||||
drawCircle(color = color, radius = r * 0.92f, style = Stroke(width = 1.5f))
|
||||
}
|
||||
rotate(degrees = bearingDeg.toFloat(), pivot = Offset(cx, cy)) {
|
||||
val path = Path().apply {
|
||||
moveTo(cx, cy - r * 0.9f) // tip (north before rotation)
|
||||
lineTo(cx - r * 0.42f, cy + r * 0.55f)
|
||||
lineTo(cx, cy + r * 0.28f)
|
||||
lineTo(cx + r * 0.42f, cy + r * 0.55f)
|
||||
close()
|
||||
}
|
||||
drawPath(path, color = color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Great-circle distance in km between two positions. */
|
||||
fun greatCircleDistanceKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
|
||||
val rad = Math.PI / 180.0
|
||||
val dLat = (lat2 - lat1) * rad
|
||||
val dLon = (lon2 - lon1) * rad
|
||||
val a = sin(dLat / 2) * sin(dLat / 2) +
|
||||
cos(lat1 * rad) * cos(lat2 * rad) * sin(dLon / 2) * sin(dLon / 2)
|
||||
return 6371.0 * 2 * kotlin.math.atan2(kotlin.math.sqrt(a), kotlin.math.sqrt(1 - a))
|
||||
}
|
||||
|
||||
/** Initial great-circle bearing in degrees (0 = north, clockwise). */
|
||||
fun greatCircleBearingDeg(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
|
||||
val rad = Math.PI / 180.0
|
||||
val dLon = (lon2 - lon1) * rad
|
||||
val y = sin(dLon) * cos(lat2 * rad)
|
||||
val x = cos(lat1 * rad) * sin(lat2 * rad) -
|
||||
sin(lat1 * rad) * cos(lat2 * rad) * cos(dLon)
|
||||
return (Math.atan2(y, x) / rad + 360.0) % 360.0
|
||||
}
|
||||
+8
-4
@@ -79,6 +79,10 @@ private val lightScheme = lightColorScheme(
|
||||
onSecondary = Color(0xFFFFFFFF),
|
||||
secondaryContainer = Color(0xFFF1E1BB),
|
||||
onSecondaryContainer = Color(0xFF221B04),
|
||||
tertiary = Color(0xFF3C6FE0), // AMSAT Active (darker for light theme)
|
||||
onTertiary = Color(0xFFFFFFFF),
|
||||
tertiaryContainer = Color(0xFFE09800), // AMSAT Telemetry (darker)
|
||||
onTertiaryContainer = Color(0xFF000000),
|
||||
background = Color(0xFFFFF8F0),
|
||||
onBackground = Color(0xFF1E1B13),
|
||||
surface = Color(0xFFFFF8F0),
|
||||
@@ -101,10 +105,10 @@ private val darkScheme = darkColorScheme(
|
||||
onSecondary = Color(0xFF000000),
|
||||
secondaryContainer = Color(0xFF404040), // navBar indicator,
|
||||
onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon
|
||||
// tertiary = Color(0xFF121212),
|
||||
// onTertiary = Color(0xFF121212),
|
||||
// tertiaryContainer = Color(0xFF121212),
|
||||
// onTertiaryContainer = Color(0xFF121212),
|
||||
tertiary = Color(0xFF648FFF), // AMSAT Active (from amsat.org/status)
|
||||
onTertiary = Color(0xFF000000),
|
||||
tertiaryContainer = Color(0xFFFFB000), // AMSAT Telemetry
|
||||
onTertiaryContainer = Color(0xFF000000),
|
||||
background = Color(0xFF121212),
|
||||
onBackground = Color(0xFFE0E0E0),
|
||||
surface = Color(0xFF202020), // card background
|
||||
|
||||
+5
-2
@@ -30,10 +30,13 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
||||
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
||||
|
||||
@Serializable
|
||||
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
|
||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
||||
|
||||
@Serializable
|
||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
||||
data object Mutual : Screen(R.drawable.ic_radio_tower, R.string.nav_mutual)
|
||||
|
||||
@Serializable
|
||||
data object AMSAT : Screen(R.drawable.ic_satellite_alt, R.string.nav_amsat)
|
||||
|
||||
@Serializable
|
||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<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="@android:color/white" android:pathData="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M240,320q-33,0 -56.5,-23.5T160,240q0,-33 23.5,-56.5T240,160q33,0 56.5,23.5T320,240q0,33 -23.5,56.5T240,320zM240,560q-33,0 -56.5,-23.5T160,480q0,-33 23.5,-56.5T240,400q33,0 56.5,23.5T320,480q0,33 -23.5,56.5T240,560zM240,800q-33,0 -56.5,-23.5T160,720q0,-33 23.5,-56.5T240,640q33,0 56.5,23.5T320,720q0,33 -23.5,56.5T240,800zM720,320q-33,0 -56.5,-23.5T640,240q0,-33 23.5,-56.5T720,160q33,0 56.5,23.5T800,240q0,33 -23.5,56.5T720,320zM720,560q-33,0 -56.5,-23.5T640,480q0,-33 23.5,-56.5T720,400q33,0 56.5,23.5T800,480q0,33 -23.5,56.5T720,560zM720,800q-33,0 -56.5,-23.5T640,720q0,-33 23.5,-56.5T720,640q33,0 56.5,23.5T800,720q0,33 -23.5,56.5T720,800z" />
|
||||
</vector>
|
||||
@@ -1,9 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="15"
|
||||
android:viewportHeight="15">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M13.91,6.75c-1.17,2.25 -4.3,5.31 -6.07,6.94c-0.19,0.172 -0.48,0.172 -0.67,0C5.39,12.06 2.26,9 1.09,6.75C-1.48,1.8 5,-1.5 7.5,3.45C10,-1.5 16.48,1.8 13.91,6.75z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,22 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="96"
|
||||
android:viewportHeight="96">
|
||||
|
||||
<path
|
||||
android:fillColor="@android:color/transparent"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeWidth="6"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M 8 76 C 8 44, 21 24, 39 24 C 57 24, 70 44, 70 76" />
|
||||
|
||||
<path
|
||||
android:fillColor="@android:color/transparent"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeWidth="6"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M 30 76 C 30 54, 44 42, 62 42 C 80 42, 94 54, 94 76" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M14,21c1.93,0 3.62,-1.17 4,-3l-1.75,-0.88C16,18.21 15.33,19 14,19l-4.9,0c0.83,-1 1.5,-2.34 1.5,-4c0,-0.35 -0.03,-0.69 -0.08,-1L14,14v-2l-4.18,0C9,10.42 8,9.6 8,8c0,-1.93 1.57,-3.5 3.5,-3.5c1.5,0 2.79,0.95 3.28,2.28L16.63,6c-0.8,-2.05 -2.79,-3.5 -5.13,-3.5C8.46,2.5 6,4.96 6,8c0,1.78 0.79,2.9 1.49,4L6,12v2l2.47,0c0.08,0.31 0.13,0.64 0.13,1c0,2.7 -2.6,4 -2.6,4v2H14z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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,10A2,2 0 0,1 14,12C14,12.5 13.82,12.94 13.53,13.29L16.7,22H14.57L12,14.93L9.43,22H7.3L10.47,13.29C10.18,12.94 10,12.5 10,12A2,2 0 0,1 12,10M12,8A4,4 0 0,0 8,12C8,12.5 8.1,13 8.28,13.46L7.4,15.86C6.53,14.81 6,13.47 6,12A6,6 0 0,1 12,6A6,6 0 0,1 18,12C18,13.47 17.47,14.81 16.6,15.86L15.72,13.46C15.9,13 16,12.5 16,12A4,4 0 0,0 12,8M12,4A8,8 0 0,0 4,12C4,14.36 5,16.5 6.64,17.94L5.92,19.94C3.54,18.11 2,15.23 2,12A10,10 0 0,1 12,2A10,10 0 0,1 22,12C22,15.23 20.46,18.11 18.08,19.94L17.36,17.94C19,16.5 20,14.36 20,12A8,8 0 0,0 12,4Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Standard Material refresh icon (24dp viewport), centered geometry -->
|
||||
<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="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- AMSAT tab icon provided by rt-bishop (upstream author), original SVG asset -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<group
|
||||
android:scaleX="0.242"
|
||||
android:scaleY="0.242"
|
||||
android:translateX="0.3"
|
||||
android:translateY="-230.5">
|
||||
<path android:fillColor="#FF000000" android:pathData="m 21.275553,967.35933 a 2.0000048,2.0000048 0 0 0 -1.1875,0.59375 l -13.5000003,13.5 a 2.0000048,2.0000048 0 0 0 0,2.8125 l 18.5625003,18.59382 a 2.0000048,2.0000048 0 0 0 1.2813,0.5625 l 5.5312,0.4375 -4.0312,4.0312 a 2.0000048,2.0000048 0 0 0 0,2.8126 l 13.6874,13.7187 a 2.0000048,2.0000048 0 0 0 2.8438,0 l 4.0312,-4.0312 0.4376,5.5312 a 2.0000048,2.0000048 0 0 0 0.5624,1.2813 l 18.5938,18.5625 a 2.0000048,2.0000048 0 0 0 2.8438,0 l 13.468705,-13.4688 a 2.0000048,2.0000048 0 0 0 0,-2.8437 l -18.562505,-18.5938 a 2.0000048,2.0000048 0 0 0 -1.2812,-0.5625 l -5.5313,-0.4375 4.0313,-4.0313 a 2.0000048,2.0000048 0 0 0 0,-2.8437 l -13.6876,-13.68757 a 2.0000048,2.0000048 0 0 0 -2.8437,0 l -4.0313,4.03125 -0.4374,-5.53125 a 2.0000048,2.0000048 0 0 0 -0.5626,-1.28125 l -18.5937,-18.5625 a 2.0000048,2.0000048 0 0 0 -1.4375,-0.59375 2.0000048,2.0000048 0 0 0 -0.1875,0 z m 0.2187,4.8125 16.6563,16.65625 0.5625,7.46875 -3.7812,3.78127 -7.4688,-0.59377 -16.6562,-16.625 z m 34.5626,41.46887 7.4687,0.5937 16.656305,16.625 -10.687605,10.6875 -16.625,-16.6562 -0.5937,-7.4688 z" />
|
||||
<path android:fillColor="#FF000000" android:pathData="m 50.940553,966.18283 a 2.000005,2.000005 0 1 0 0,3.99969 c 7.9999,0 15.9734,3.03851 22.0884,9.15344 6.103605,6.10359 9.143805,14.08909 9.143805,22.07844 a 2.000005,2.000005 0 1 0 3.9994,0 c 0,-9.00692 -3.4462,-18.03775 -10.315,-24.90657 -6.877605,-6.8775 -15.900305,-10.325 -24.916605,-10.325 z" />
|
||||
<path android:fillColor="#FF000000" android:pathData="m 50.940553,958.36533 a 2.000005,2.000005 0 1 0 0,3.99969 c 10.0021,0 19.9781,3.79987 27.613705,11.43562 7.6352,7.63517 11.4357,17.62207 11.4357,27.61376 a 2.000005,2.000005 0 1 0 3.9997,0 c 0,-11.00842 -4.207,-22.04171 -12.6072,-30.44188 -8.399505,-8.3996 -19.424005,-12.60719 -30.441905,-12.60719 z" />
|
||||
<path android:fillColor="#FF000000" android:pathData="m 50.940553,973.94033 a 2.000005,2.000005 0 1 0 0,3.99938 c 6.0191,0 12.0105,2.279 16.6031,6.87156 4.5926,4.59255 6.881605,10.59683 6.881605,16.60313 a 2.000005,2.000005 0 1 0 3.9997,0 c 0,-7.02618 -2.6961,-14.07429 -8.053105,-19.43126 -5.3571,-5.35696 -12.398,-8.04281 -19.4313,-8.04281 z" />
|
||||
<path android:fillColor="#FF000000" android:pathData="m 50.940553,981.77783 a 2.000005,2.000005 0 1 0 0,3.99938 c 4.0058,0 7.9968,1.51836 11.0578,4.57937 3.061,3.06099 4.5794,7.05193 4.5794,11.05782 a 2.000005,2.000005 0 1 0 3.9994,0 c 0,-5.02276 -1.9251,-10.06035 -5.7507,-13.88594 -3.8256,-3.82559 -8.8631,-5.75063 -13.8859,-5.75063 z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M240,800L280,640L120,640L140,560L300,560L340,400L180,400L200,320L360,320L400,160L480,160L440,320L600,320L640,160L720,160L680,320L840,320L820,400L660,400L620,560L780,560L760,640L600,640L560,800L480,800L520,640L360,640L320,800L240,800ZM380,560L540,560L580,400L420,400L380,560Z" />
|
||||
</vector>
|
||||
@@ -13,16 +13,12 @@
|
||||
<string name="nav_prefs">Ajustes</string>
|
||||
|
||||
<!-- Satellites screen -->
|
||||
<string name="sat_type_hint">Modos: %s</string>
|
||||
<string name="sat_type_title">Seleccionar modos</string>
|
||||
<string name="sat_type_hint">Tipo: %s</string>
|
||||
<string name="sat_type_title">Seleccionar tipo de satélite</string>
|
||||
<string name="sat_search_hint">Id - Nombre</string>
|
||||
<string name="sat_search_clear">Vaciar</string>
|
||||
<string name="sat_clear_all">Vaciar todo</string>
|
||||
<string name="sat_select_all">Seleccionar todo</string>
|
||||
<string name="sat_group_selected">Seleccionados</string>
|
||||
<string name="sat_group_available">Disponibles</string>
|
||||
<string name="sat_group_selected_count">Seleccionados (%1$d)</string>
|
||||
<string name="sat_group_available_count">Disponibles (%1$d)</string>
|
||||
<string name="sat_empty_list_message">
|
||||
Asegúrese de que su consulta de búsqueda sea correcta y que la base de datos esté actualizada</string>
|
||||
<string name="sat_warning_title">Alarma\!</string>
|
||||
@@ -30,7 +26,7 @@
|
||||
Esta aplicación incluye más de 9000 satélites.
|
||||
No tiene sentido rastrearlos todos a la vez.
|
||||
\n\nIntenta siempre limitar la lista a los que te
|
||||
interesen mediante la búsqueda y el selector de modos.</string>
|
||||
interesen mediante la búsqueda y el selector de tipos.</string>
|
||||
|
||||
<!-- Passes screen -->
|
||||
<string name="pass_filter_title">Filtrar pases</string>
|
||||
@@ -39,7 +35,7 @@
|
||||
<string name="pass_filter_aos_time">Ventana AOS</string>
|
||||
<string name="pass_filter_invert_time">Invertir ventana AOS</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (período >225min)</string>
|
||||
<string name="pass_modes_title">Seleccionar modos</string>
|
||||
<string name="pass_modes_title">Tipo de modulación</string>
|
||||
<string name="pass_elevation">Elevación: %.1f°</string>
|
||||
<string name="pass_altitude">Altitud: %.0f km</string>
|
||||
<string name="pass_empty_list_message">
|
||||
@@ -100,11 +96,11 @@
|
||||
<string name="prefs_loc_qth_title">QTH</string>
|
||||
<string name="prefs_loc_qth_error">Ubicación QTH no válida</string>
|
||||
<string name="prefs_loc_success">Ubicación actualizada con éxito</string>
|
||||
<string name="prefs_station_title">Ubicación de la estación</string>
|
||||
<string name="prefs_station_lat_text">Latitud de estación</string>
|
||||
<string name="prefs_station_lon_text">Longitud de estación</string>
|
||||
<string name="prefs_locator_title">Localizador QTH</string>
|
||||
<string name="prefs_locator_text">Posición por localizador</string>
|
||||
<string name="prefs_station_title">Ajustar ubicaicón de la estación</string>
|
||||
<string name="prefs_station_lat_text">Latitud de tu estación terrestre</string>
|
||||
<string name="prefs_station_lon_text">Longitud de tu estación terrestre</string>
|
||||
<string name="prefs_locator_title">Ajustes ubicación QTH</string>
|
||||
<string name="prefs_locator_text">Ajustar posición de la estación con tu ubicación</string>
|
||||
|
||||
<string name="prefs_data_title">Datos satelitales</string>
|
||||
<string name="prefs_data_entries">Satélites: %s</string>
|
||||
@@ -114,52 +110,47 @@
|
||||
<string name="prefs_data_clear">Vaciar</string>
|
||||
<string name="prefs_data_clear_success">Datos eliminados con éxito</string>
|
||||
<string name="prefs_data_update_success">Actualización completada con éxito</string>
|
||||
<string name="prefs_data_import_satellites_error">No se importaron satélites. Usa un TLE/3LE (.txt) u OMM (.csv) válido.</string>
|
||||
<string name="prefs_data_import_transceivers_error">No se importaron transceptores. Usa un SatNOGS (.json) válido.</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Fuentes personalizadas</string>
|
||||
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">URL transceptores</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
||||
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</string>
|
||||
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||
|
||||
<string name="prefs_data_output_title">Salida de datos</string>
|
||||
<string name="prefs_net_output">Red</string>
|
||||
<string name="prefs_data_output_title">Data output</string>
|
||||
<string name="prefs_net_output">Network</string>
|
||||
<string name="prefs_bt_output">Bluetooth</string>
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
|
||||
<string name="nav_radiocontrol">Control de radio</string>
|
||||
<string name="rc_settings_title">Control CAT</string>
|
||||
<string name="rc_radio_model">Modelo de radio</string>
|
||||
<string name="rc_tx_device_hint">Dirección BT TX</string>
|
||||
<string name="rc_rx_device_hint">Dirección BT RX</string>
|
||||
<string name="rc_tx_name_hint">Nombre radio TX</string>
|
||||
<string name="rc_rx_name_hint">Nombre radio RX</string>
|
||||
<string name="rc_enable_switch">Activar CAT</string>
|
||||
|
||||
<string name="prefs_net_title">Salida de red</string>
|
||||
<string name="prefs_net_rotator_switch">Activar salida de rotación</string>
|
||||
<string name="prefs_net_title">Salida de datos de red</string>
|
||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
||||
<string name="prefs_net_rotator_address_hint">IP:Puerto</string>
|
||||
<string name="prefs_net_rotator_format_hint">Formato de datos</string>
|
||||
<string name="prefs_net_frequency_switch">Activar salida de frecuencia</string>
|
||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||
<string name="prefs_net_frequency_address_hint">IP:Puerto</string>
|
||||
<string name="prefs_net_frequency_format_hint">Formato de datos</string>
|
||||
|
||||
<string name="prefs_bt_title">Salida Bluetooth</string>
|
||||
<string name="prefs_bt_rotator_switch">Activar salida de rotación</string>
|
||||
<string name="prefs_bt_rotator_device_hint">ID dispositivo</string>
|
||||
<string name="prefs_bt_rotator_output_hint">Formato</string>
|
||||
<string name="prefs_bt_frequency_switch">Activar salida de frecuencia</string>
|
||||
<string name="prefs_bt_frequency_device_hint">ID dispositivo</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Formato</string>
|
||||
<string name="prefs_bt_title">Salida por Bluetooth</string>
|
||||
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
||||
<string name="prefs_bt_rotator_device_hint">Id dispositivo</string>
|
||||
<string name="prefs_bt_rotator_output_hint">Formato de datos</string>
|
||||
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
||||
<string name="prefs_bt_frequency_device_hint">Id dispositivo</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Formato de datos</string>
|
||||
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</string>
|
||||
<string name="prefs_net_perm_error">Revisa los permisos de red</string>
|
||||
|
||||
<string name="prefs_other_title">Otros</string>
|
||||
<string name="prefs_other_switch_utc">Hora UTC</string>
|
||||
<string name="prefs_other_switch_update">Actualización automática</string>
|
||||
<string name="prefs_other_switch_sweep">Animación radar</string>
|
||||
<string name="prefs_other_switch_sensors">Sensores</string>
|
||||
<string name="prefs_other_switch_night_mode">Filtro nocturno</string>
|
||||
<string name="prefs_other_title">Otros ajustes</string>
|
||||
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
|
||||
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
|
||||
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
|
||||
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
|
||||
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
|
||||
<string name="prefs_other_switch_map_grid">Mostrar cuadrícula Maidenhead en el mapa</string>
|
||||
|
||||
<string name="prefs_highlight_title">Resaltado de elevación</string>
|
||||
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
||||
|
||||
@@ -13,23 +13,19 @@
|
||||
<string name="nav_prefs">Настройки</string>
|
||||
|
||||
<!-- Satellites screen -->
|
||||
<string name="sat_type_hint">Режимы: %s</string>
|
||||
<string name="sat_type_title">Выберите режимы</string>
|
||||
<string name="sat_type_hint">Тип: %s</string>
|
||||
<string name="sat_type_title">Выберите тип спутника</string>
|
||||
<string name="sat_search_hint">Id - Название</string>
|
||||
<string name="sat_search_clear">Очистить</string>
|
||||
<string name="sat_clear_all">Очистить</string>
|
||||
<string name="sat_select_all">Выбрать</string>
|
||||
<string name="sat_group_selected">Выбранные</string>
|
||||
<string name="sat_group_available">Доступные</string>
|
||||
<string name="sat_group_selected_count">Выбранные (%1$d)</string>
|
||||
<string name="sat_group_available_count">Доступные (%1$d)</string>
|
||||
<string name="sat_empty_list_message">Убедитесь, что ваш поисковый запрос верен и база данных обновлена</string>
|
||||
<string name="sat_warning_title">Внимание\!</string>
|
||||
<string name="sat_warning_message">
|
||||
В этом приложении перечислено более 9000 спутников.
|
||||
Отслеживать их все одновременно бессмысленно.
|
||||
\n\nВсегда старайтесь сузить список до тех,
|
||||
которые вас интересуют, используя поиск и выбор режимов.</string>
|
||||
которые вас интересуют, используя поиск и селектор типов.</string>
|
||||
|
||||
<!-- Passes screen -->
|
||||
<string name="pass_filter_title">Фильтровать пролеты</string>
|
||||
@@ -38,7 +34,7 @@
|
||||
<string name="pass_filter_aos_time">Окно AOS</string>
|
||||
<string name="pass_filter_invert_time">Инвертировать окно AOS</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (период >225мин)</string>
|
||||
<string name="pass_modes_title">Выберите режимы</string>
|
||||
<string name="pass_modes_title">Выберите тип модуляции</string>
|
||||
<string name="pass_elevation">Элевация: %.1f°</string>
|
||||
<string name="pass_altitude">Высота: %d км</string>
|
||||
<string name="pass_empty_list_message">
|
||||
@@ -99,11 +95,11 @@
|
||||
<string name="prefs_loc_qth_title">QTH</string>
|
||||
<string name="prefs_loc_qth_error">Неправильный локатор QTH</string>
|
||||
<string name="prefs_loc_success">Позиция успешно обновлена</string>
|
||||
<string name="prefs_station_title">Положение станции</string>
|
||||
<string name="prefs_station_lat_text">Широта станции</string>
|
||||
<string name="prefs_station_lon_text">Долгота станции</string>
|
||||
<string name="prefs_locator_title">Локатор QTH</string>
|
||||
<string name="prefs_locator_text">Позиция по локатору</string>
|
||||
<string name="prefs_station_title">Настройки местоположения</string>
|
||||
<string name="prefs_station_lat_text">Введите широту наземной станции</string>
|
||||
<string name="prefs_station_lon_text">Введите долготу наземной станции</string>
|
||||
<string name="prefs_locator_title">Настройки локатора QTH</string>
|
||||
<string name="prefs_locator_text">Введите позицию через локатор</string>
|
||||
|
||||
<string name="prefs_data_title">Данные спутников</string>
|
||||
<string name="prefs_data_entries">Спутников: %s</string>
|
||||
@@ -113,30 +109,23 @@
|
||||
<string name="prefs_data_clear">Очистить</string>
|
||||
<string name="prefs_data_clear_success">Очистка прошла успешно</string>
|
||||
<string name="prefs_data_update_success">Обновление прошло успешно</string>
|
||||
<string name="prefs_data_import_satellites_error">Спутники не импортированы. Нужен TLE/3LE (.txt) или OMM (.csv).</string>
|
||||
<string name="prefs_data_import_transceivers_error">Трансиверы не импортированы. Нужен SatNOGS (.json).</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Свои источники</string>
|
||||
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">URL трансиверов</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
<string name="prefs_data_sources_title">Свои источники данных</string>
|
||||
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
||||
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</string>
|
||||
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Custom Transceivers URL</string>
|
||||
|
||||
<string name="prefs_data_output_title">Вывод</string>
|
||||
<string name="prefs_data_output_title">Вывод данных</string>
|
||||
<string name="prefs_net_output">Сеть</string>
|
||||
<string name="prefs_bt_output">Bluetooth</string>
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
|
||||
<string name="nav_radiocontrol">Управление радио</string>
|
||||
|
||||
<string name="rc_settings_title">CAT управление</string>
|
||||
<string name="rc_radio_model">Модель радио</string>
|
||||
<string name="rc_tx_device_hint">BT адрес TX</string>
|
||||
<string name="rc_rx_device_hint">BT адрес RX</string>
|
||||
<string name="rc_tx_name_hint">Имя радио TX</string>
|
||||
<string name="rc_rx_name_hint">Имя радио RX</string>
|
||||
<string name="rc_enable_switch">Включить CAT</string>
|
||||
|
||||
<string name="prefs_net_title">Сетевой вывод</string>
|
||||
<string name="prefs_net_title">Вывод сетевых данных</string>
|
||||
<string name="prefs_net_rotator_switch">Включить вывод ротатора</string>
|
||||
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
|
||||
<string name="prefs_net_rotator_format_hint">Формат</string>
|
||||
@@ -144,22 +133,23 @@
|
||||
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
||||
<string name="prefs_net_frequency_format_hint">Формат</string>
|
||||
|
||||
<string name="prefs_bt_title">Вывод Bluetooth</string>
|
||||
<string name="prefs_bt_title">Вывод данных Bluetooth</string>
|
||||
<string name="prefs_bt_rotator_switch">Включить вывод ротатора</string>
|
||||
<string name="prefs_bt_rotator_device_hint">ID устройства</string>
|
||||
<string name="prefs_bt_rotator_device_hint">Id устройства</string>
|
||||
<string name="prefs_bt_rotator_output_hint">Формат</string>
|
||||
<string name="prefs_bt_frequency_switch">Включить вывод частоты</string>
|
||||
<string name="prefs_bt_frequency_device_hint">ID устройства</string>
|
||||
<string name="prefs_bt_frequency_device_hint">Id устройства</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Формат</string>
|
||||
<string name="prefs_bt_perm_error">Нет разрешения использовать bluetooth</string>
|
||||
<string name="prefs_net_perm_error">Нет разрешения использовать сеть</string>
|
||||
|
||||
<string name="prefs_other_title">Прочее</string>
|
||||
<string name="prefs_other_switch_utc">Время в UTC</string>
|
||||
<string name="prefs_other_switch_update">Автообновление данных</string>
|
||||
<string name="prefs_other_switch_sweep">Анимация радара</string>
|
||||
<string name="prefs_other_switch_sensors">Сенсоры</string>
|
||||
<string name="prefs_other_switch_night_mode">Ночной фильтр</string>
|
||||
<string name="prefs_other_title">Другие настройки</string>
|
||||
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
|
||||
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
|
||||
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
|
||||
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
|
||||
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
|
||||
<string name="prefs_other_switch_map_grid">Показывать сетку Maidenhead на карте</string>
|
||||
|
||||
<string name="prefs_highlight_title">Подсветка высоты</string>
|
||||
<string name="prefs_highlight_low">Низко < %1$d°</string>
|
||||
|
||||
@@ -13,23 +13,19 @@
|
||||
<string name="nav_prefs">පසුතල</string>
|
||||
|
||||
<!-- Satellites screen -->
|
||||
<string name="sat_type_hint">මාදිලි: %s</string>
|
||||
<string name="sat_type_title">මාදිලි තෝරන්න</string>
|
||||
<string name="sat_type_hint">වර්ගය: %s</string>
|
||||
<string name="sat_type_title">චන්ද්රිකා ආකාරය තෝරන්න</string>
|
||||
<string name="sat_search_hint">Id - නම</string>
|
||||
<string name="sat_search_clear">පිරිසිදු ක°</string>
|
||||
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
|
||||
<string name="sat_select_all">සියල්ල තෝරන්න</string>
|
||||
<string name="sat_group_selected">තෝරාගත්</string>
|
||||
<string name="sat_group_available">තිබෙන</string>
|
||||
<string name="sat_group_selected_count">තෝරාගත් (%1$d)</string>
|
||||
<string name="sat_group_available_count">තිබෙන (%1$d)</string>
|
||||
<string name="sat_empty_list_message">
|
||||
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
|
||||
<string name="sat_warning_title">අවවාදයයි\!</string>
|
||||
<string name="sat_warning_message">
|
||||
මෙම යෙදුමේ ලැයිස්තුගත කර ඇති චන්ද්රිකා 9000 කට වඩා තිබේ.
|
||||
ඒවා සියල්ලම එකවර නිරීක්ෂණය කිරීම තේරුමක් නැති දෙයක්.
|
||||
\n\nසෙවීම සහ මාදිලි තේරීම හරහා ඔබ උනන්දුවක් දක්වන
|
||||
\n\nසෙවීම සහ වර්ග තේරීම හරහා ඔබ උනන්දුවක් දක්වන
|
||||
ඒවාට පමණක් ලැයිස්තුව පටු කිරීමට සැමවිටම උත්සාහ කරන්න.</string>
|
||||
|
||||
<!-- Passes screen -->
|
||||
@@ -39,7 +35,7 @@
|
||||
<string name="pass_filter_aos_time">AOS කවුළුව</string>
|
||||
<string name="pass_filter_invert_time">AOS කවුළුව ප්රතිවිරුද්ධ කරන්න</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (කාලය >225min)</string>
|
||||
<string name="pass_modes_title">මූර්ජන තෝරන්න</string>
|
||||
<string name="pass_modes_title">මූර්ජන ආකාරය තෝරන්න</string>
|
||||
<string name="pass_elevation">උත්තෝලනය: %.1f°</string>
|
||||
<string name="pass_altitude">උන්නතාශය: %d km</string>
|
||||
<string name="pass_empty_list_message">
|
||||
@@ -100,11 +96,11 @@
|
||||
<string name="prefs_loc_qth_title">QTH</string>
|
||||
<string name="prefs_loc_qth_error">QTH locator වලංගු නොවේ</string>
|
||||
<string name="prefs_loc_success">පිහිටීම යාවත්කාලීනය සාර්ථකයි</string>
|
||||
<string name="prefs_station_title">පොළ පිහිටීම</string>
|
||||
<string name="prefs_station_lat_text">පොළ අක්ෂාංශය</string>
|
||||
<string name="prefs_station_lon_text">පොළ දේශාංශය</string>
|
||||
<string name="prefs_locator_title">QTH locator</string>
|
||||
<string name="prefs_locator_text">locator මඟින් පොළ පිහිටීම සකසන්න</string>
|
||||
<string name="prefs_station_title">පොළෙහි පිහිටීම් පසුතල</string>
|
||||
<string name="prefs_station_lat_text">ඔබගේ පොළෙහි අක්ෂාංශය සකසන්න</string>
|
||||
<string name="prefs_station_lon_text">ඔබගේ පොළෙහි දේශාංශය සකසන්න</string>
|
||||
<string name="prefs_locator_title">QTH locator පසුතල</string>
|
||||
<string name="prefs_locator_text"> ඔබගේ locator භාවිතා කර පොළහි පිහිටීම සකසන්න</string>
|
||||
|
||||
<string name="prefs_data_title">චන්ද්රිකා දත්ත</string>
|
||||
<string name="prefs_data_entries">චන්ද්රිකා: %s</string>
|
||||
@@ -114,52 +110,47 @@
|
||||
<string name="prefs_data_clear">පිරිසිදු ක°</string>
|
||||
<string name="prefs_data_clear_success">දත්ත ඉවත් කිරීම සාර්ථකයි</string>
|
||||
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
|
||||
<string name="prefs_data_import_satellites_error">චන්ද්රිකා ආයාත නොවීය. වලංගු TLE/3LE (.txt) හෝ OMM (.csv) ගොනුවක් තෝරන්න.</string>
|
||||
<string name="prefs_data_import_transceivers_error">සම්ප්රේෂක ආයාත නොවීය. වලංගු SatNOGS (.json) ගොනුවක් තෝරන්න.</string>
|
||||
|
||||
<string name="prefs_data_sources_title">අභිරුචි මූලාශ්ර</string>
|
||||
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
||||
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</string>
|
||||
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||
|
||||
<string name="prefs_data_output_title">දත්ත ප්රතිදානය</string>
|
||||
<string name="prefs_net_output">ජාලය</string>
|
||||
<string name="prefs_data_output_title">Data output</string>
|
||||
<string name="prefs_net_output">Network</string>
|
||||
<string name="prefs_bt_output">Bluetooth</string>
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
|
||||
<string name="nav_radiocontrol">රේඩියෝ පාලනය</string>
|
||||
<string name="rc_settings_title">CAT පාලනය</string>
|
||||
<string name="rc_radio_model">රේඩියෝ මාදිලිය</string>
|
||||
<string name="rc_tx_device_hint">TX BT ලිපිනය</string>
|
||||
<string name="rc_rx_device_hint">RX BT ලිපිනය</string>
|
||||
<string name="rc_tx_name_hint">TX රේඩියෝ නම</string>
|
||||
<string name="rc_rx_name_hint">RX රේඩියෝ නම</string>
|
||||
<string name="rc_enable_switch">CAT සබල කරන්න</string>
|
||||
|
||||
<string name="prefs_net_title">ජාල ප්රතිදානය</string>
|
||||
<string name="prefs_net_rotator_switch">Rotation ප්රතිදානය සබල කරන්න</string>
|
||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
||||
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||
<string name="prefs_net_rotator_format_hint">දත්ත ආකෘතිය</string>
|
||||
<string name="prefs_net_frequency_switch">Frequency ප්රතිදානය සබල කරන්න</string>
|
||||
<string name="prefs_net_rotator_format_hint">දත්ත අකාරය</string>
|
||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||
<string name="prefs_net_frequency_format_hint">දත්ත ආකෘතිය</string>
|
||||
<string name="prefs_net_frequency_format_hint">දත්ත අකාරය</string>
|
||||
|
||||
<string name="prefs_bt_title">Bluetooth ප්රතිදානය</string>
|
||||
<string name="prefs_bt_rotator_switch">Rotation ප්රතිදානය සබල කරන්න</string>
|
||||
<string name="prefs_bt_rotator_device_hint">උපාංග ID</string>
|
||||
<string name="prefs_bt_rotator_output_hint">දත්ත ආකෘතිය</string>
|
||||
<string name="prefs_bt_frequency_switch">Frequency ප්රතිදානය සබල කරන්න</string>
|
||||
<string name="prefs_bt_frequency_device_hint">උපාංග ID</string>
|
||||
<string name="prefs_bt_frequency_output_hint">දත්ත ආකෘතිය</string>
|
||||
<string name="prefs_bt_title">Bluetooth දත්ත ප්රතිදානය</string>
|
||||
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
||||
<string name="prefs_bt_rotator_device_hint">උපාංග id</string>
|
||||
<string name="prefs_bt_rotator_output_hint">දත්ත අකාරය</string>
|
||||
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
||||
<string name="prefs_bt_frequency_device_hint">උපාංග id</string>
|
||||
<string name="prefs_bt_frequency_output_hint">දත්ත අකාරය</string>
|
||||
<string name="prefs_bt_perm_error">Bluetooth අවසර පරික්ෂා ක°</string>
|
||||
<string name="prefs_net_perm_error">Network අවසර පරික්ෂා කරන්න</string>
|
||||
|
||||
<string name="prefs_other_title">වෙනත්</string>
|
||||
<string name="prefs_other_switch_utc">UTC වේලාව</string>
|
||||
<string name="prefs_other_switch_update">දත්ත ස්වයං යාවත්කාලීන</string>
|
||||
<string name="prefs_other_switch_sweep">Radar sweep</string>
|
||||
<string name="prefs_other_switch_sensors">සංවේදක</string>
|
||||
<string name="prefs_other_switch_night_mode">රාත්රී පෙරහන</string>
|
||||
<string name="prefs_other_title">වෙනත් සැකසුම්</string>
|
||||
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
|
||||
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
|
||||
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
|
||||
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
|
||||
<string name="prefs_other_switch_night_mode">රතු රාත්රී පෙරහන සබල කරන්න</string>
|
||||
<string name="prefs_other_switch_map_grid">සිතියමේ Maidenhead ජාලකය පෙන්වන්න</string>
|
||||
|
||||
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
|
||||
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
||||
|
||||
@@ -14,23 +14,19 @@
|
||||
<string name="nav_prefs">Ayarlar</string>
|
||||
|
||||
<!-- Satellites screen -->
|
||||
<string name="sat_type_hint">Modlar: %s</string>
|
||||
<string name="sat_type_title">Modları seçin</string>
|
||||
<string name="sat_type_hint">Uydu türü: %s</string>
|
||||
<string name="sat_type_title">Uydu türü seçin</string>
|
||||
<string name="sat_search_hint">Id - Ad</string>
|
||||
<string name="sat_search_clear">Temizle</string>
|
||||
<string name="sat_clear_all">Tümünü temizle</string>
|
||||
<string name="sat_select_all">Tümünü seç</string>
|
||||
<string name="sat_group_selected">Seçilenler</string>
|
||||
<string name="sat_group_available">Kullanılabilir</string>
|
||||
<string name="sat_group_selected_count">Seçilenler (%1$d)</string>
|
||||
<string name="sat_group_available_count">Kullanılabilir (%1$d)</string>
|
||||
<string name="sat_empty_list_message">"Arama sorgunuzun doğru olduğundan ve veritabanının güncellendiğinden emin olun"</string>
|
||||
<string name="sat_warning_title">Uyarı\!</string>
|
||||
<string name="sat_warning_message">
|
||||
Bu uygulamada 9000\'den fazla uydu listelenmiştir.
|
||||
Hepsini aynı anda takip etmek anlamsızdır.
|
||||
\n\nListeyi yalnızca ilgilendiğiniz uydularla
|
||||
daraltmak için arama ve mod seçiciyi kullanın.</string>
|
||||
daraltmak için arama ve tür seçiciyi kullanın.</string>
|
||||
|
||||
<!-- Passes screen -->
|
||||
<string name="pass_filter_title">Geçişleri filtrele</string>
|
||||
@@ -40,7 +36,7 @@
|
||||
<string name="pass_filter_invert_time">AOS aralığını tersine çevir</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (periyot >225dk)</string>
|
||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||
<string name="pass_modes_title">Modları seçin</string>
|
||||
<string name="pass_modes_title">Modulasyon türü seçin</string>
|
||||
<string name="pass_satId" translatable="false">%05d</string>
|
||||
<string name="pass_elevation">Yükseklik açısı: %.1f°</string>
|
||||
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
||||
@@ -131,11 +127,11 @@
|
||||
<string name="prefs_loc_qth_title">QTH</string>
|
||||
<string name="prefs_loc_qth_error">Geçersiz QTH konumlandırıcısı</string>
|
||||
<string name="prefs_loc_success">Konum başarıyla güncellendi</string>
|
||||
<string name="prefs_station_title">İstasyon konumu</string>
|
||||
<string name="prefs_station_lat_text">İstasyon enlemi</string>
|
||||
<string name="prefs_station_lon_text">İstasyon boylamı</string>
|
||||
<string name="prefs_locator_title">QTH konumlandırıcı</string>
|
||||
<string name="prefs_locator_text">Konumu lokatörle ayarla</string>
|
||||
<string name="prefs_station_title">İstasyon konumu ayarları</string>
|
||||
<string name="prefs_station_lat_text">Yer istasyonunuzun enlemini girin</string>
|
||||
<string name="prefs_station_lon_text">Yer istasyonunuzun boylamını girin</string>
|
||||
<string name="prefs_locator_title">QTH konumlandırıcı ayarları</string>
|
||||
<string name="prefs_locator_text">İstasyon konumunu konumlandırıcı ile ayarlayın</string>
|
||||
|
||||
<string name="prefs_data_title">Uydu verisi</string>
|
||||
<string name="prefs_data_entries">Uydular: %s</string>
|
||||
@@ -145,30 +141,34 @@
|
||||
<string name="prefs_data_clear">Temizle</string>
|
||||
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
|
||||
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
|
||||
<string name="prefs_data_import_satellites_error">Uydu içe aktarılmadı. Geçerli bir TLE/3LE (.txt) veya OMM (.csv) dosyası seçin.</string>
|
||||
<string name="prefs_data_import_transceivers_error">Transceiver içe aktarılmadı. Geçerli bir SatNOGS (.json) dosyası seçin.</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Özel kaynaklar</string>
|
||||
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Transceiver URL</string>
|
||||
<string name="prefs_data_sources_title">Özel veri kaynakları</string>
|
||||
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
||||
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</string>
|
||||
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Özel transceiver URL</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
|
||||
<string name="prefs_data_output_title">Veri çıkışı</string>
|
||||
<string name="prefs_data_output_title">Veri aktarımı</string>
|
||||
<string name="prefs_net_output">Ağ</string>
|
||||
<string name="prefs_bt_output">Bluetooth</string>
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
|
||||
<!-- Radio Control -->
|
||||
<string name="nav_radiocontrol">Radyo Kontrolü</string>
|
||||
<string name="rc_settings_title">CAT kontrolü</string>
|
||||
<string name="rc_settings_title">CAT Radyo Kontrolü</string>
|
||||
<string name="rc_radio_model">Radyo Modeli</string>
|
||||
<string name="rc_tx_device_hint">TX BT adresi</string>
|
||||
<string name="rc_rx_device_hint">RX BT adresi</string>
|
||||
<string name="rc_tx_device_hint">TX Radyo BT Adresi</string>
|
||||
<string name="rc_rx_device_hint">RX Radyo BT Adresi</string>
|
||||
<string name="rc_tx_name_hint">TX Radyo Adı</string>
|
||||
<string name="rc_rx_name_hint">RX Radyo Adı</string>
|
||||
<string name="rc_enable_switch">CAT\'i etkinleştir</string>
|
||||
<string name="rc_enable_switch">CAT Kontrolünü Etkinleştir</string>
|
||||
|
||||
<string name="prefs_net_title">Ağ çıkışı</string>
|
||||
<string name="prefs_net_title">Ağ veri çıkışı</string>
|
||||
<string name="prefs_net_rotator_switch">Döndürme çıkışını etkinleştir</string>
|
||||
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||
<string name="prefs_net_rotator_format_hint">Biçim</string>
|
||||
@@ -176,22 +176,23 @@
|
||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||
<string name="prefs_net_frequency_format_hint">Biçim</string>
|
||||
|
||||
<string name="prefs_bt_title">Bluetooth çıkışı</string>
|
||||
<string name="prefs_bt_title">Bluetooth veri çıkışı</string>
|
||||
<string name="prefs_bt_rotator_switch">Döndürme çıkışını etkinleştir</string>
|
||||
<string name="prefs_bt_rotator_device_hint">Cihaz kimliği</string>
|
||||
<string name="prefs_bt_rotator_output_hint">Biçim</string>
|
||||
<string name="prefs_bt_rotator_output_hint">Veri biçimi</string>
|
||||
<string name="prefs_bt_frequency_switch">Frekans çıkışını etkinleştir</string>
|
||||
<string name="prefs_bt_frequency_device_hint">Cihaz kimliği</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Biçim</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Veri biçimi</string>
|
||||
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
|
||||
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
|
||||
|
||||
<string name="prefs_other_title">Diğer</string>
|
||||
<string name="prefs_other_switch_utc">UTC saati</string>
|
||||
<string name="prefs_other_switch_update">Veriyi otomatik güncelle</string>
|
||||
<string name="prefs_other_switch_sweep">Radar taraması</string>
|
||||
<string name="prefs_other_switch_sensors">Sensör</string>
|
||||
<string name="prefs_other_switch_night_mode">Gece filtresi</string>
|
||||
<string name="prefs_other_title">Diğer ayarlar</string>
|
||||
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
|
||||
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
|
||||
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
|
||||
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
|
||||
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string>
|
||||
<string name="prefs_other_switch_map_grid">Haritada Maidenhead ızgarasını göster</string>
|
||||
|
||||
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
|
||||
<string name="prefs_highlight_low">Düşük < %1$d°</string>
|
||||
@@ -205,7 +206,8 @@
|
||||
\n• Dave Moten (predict4java)
|
||||
\n• Alexandru Csete (Gpredict)
|
||||
\n• Dr T.S. Kelso (Celestrak)
|
||||
\n• Libre Space Foundation (SatNOGS)</string>
|
||||
\n• Libre Space Foundation (SatNOGS)
|
||||
\n• BG7NTA (CW decoding feature)</string>
|
||||
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
|
||||
|
||||
</resources>
|
||||
@@ -13,16 +13,12 @@
|
||||
<string name="nav_prefs">Налаштування</string>
|
||||
|
||||
<!-- Satellites screen -->
|
||||
<string name="sat_type_hint">Режими: %s</string>
|
||||
<string name="sat_type_title">Виберіть режими</string>
|
||||
<string name="sat_type_hint">Тип: %s</string>
|
||||
<string name="sat_type_title">Виберіть тип супутника</string>
|
||||
<string name="sat_search_hint">Id - Назва</string>
|
||||
<string name="sat_search_clear">Очистити</string>
|
||||
<string name="sat_clear_all">Очистити всі</string>
|
||||
<string name="sat_select_all">Вибрати всі</string>
|
||||
<string name="sat_group_selected">Обрані</string>
|
||||
<string name="sat_group_available">Доступні</string>
|
||||
<string name="sat_group_selected_count">Обрані (%1$d)</string>
|
||||
<string name="sat_group_available_count">Доступні (%1$d)</string>
|
||||
<string name="sat_empty_list_message">
|
||||
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
|
||||
<string name="sat_warning_title">Увага\!</string>
|
||||
@@ -30,7 +26,7 @@
|
||||
У цьому додатку перелічено понад 9000 супутників.
|
||||
Немає сенсу відстежувати їх усі одночасно.
|
||||
\n\nЗавжди намагайтеся звузити список лише до тих,
|
||||
які вас цікавлять, за допомогою пошуку та вибору режимів.</string>
|
||||
які вас цікавлять, за допомогою пошуку та селектора типів.</string>
|
||||
|
||||
<!-- Passes screen -->
|
||||
<string name="pass_filter_title">Фільтр прольотів</string>
|
||||
@@ -39,7 +35,7 @@
|
||||
<string name="pass_filter_aos_time">Вікно AOS</string>
|
||||
<string name="pass_filter_invert_time">Інвертувати вікно AOS</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (період >225хв)</string>
|
||||
<string name="pass_modes_title">Виберіть режими</string>
|
||||
<string name="pass_modes_title">Виберіть тип модуляції</string>
|
||||
<string name="pass_elevation">Піднес.: %.1f°</string>
|
||||
<string name="pass_altitude">Висота: %.0f км</string>
|
||||
<string name="pass_empty_list_message">
|
||||
@@ -100,11 +96,11 @@
|
||||
<string name="prefs_loc_qth_title">QTH</string>
|
||||
<string name="prefs_loc_qth_error">Некоректний локатор QTH</string>
|
||||
<string name="prefs_loc_success">Позицію успішно оновлено</string>
|
||||
<string name="prefs_station_title">Позиція станції</string>
|
||||
<string name="prefs_station_lat_text">Широта станції</string>
|
||||
<string name="prefs_station_lon_text">Довгота станції</string>
|
||||
<string name="prefs_locator_title">Локатор QTH</string>
|
||||
<string name="prefs_locator_text">Позиція за локатором</string>
|
||||
<string name="prefs_station_title">Налаштування місцезнаходження станції</string>
|
||||
<string name="prefs_station_lat_text">Широта місцезнаходження станції</string>
|
||||
<string name="prefs_station_lon_text">Довгота місцезнаходження станції</string>
|
||||
<string name="prefs_locator_title">Налаштування QTH локатора</string>
|
||||
<string name="prefs_locator_text">Задати позицію за QTH локатором</string>
|
||||
|
||||
<string name="prefs_data_title">Супутникові дані</string>
|
||||
<string name="prefs_data_entries">Супутників: %s</string>
|
||||
@@ -114,52 +110,47 @@
|
||||
<string name="prefs_data_clear">Очистити</string>
|
||||
<string name="prefs_data_clear_success">Дані успішно очищено</string>
|
||||
<string name="prefs_data_update_success">Оновлення успішне</string>
|
||||
<string name="prefs_data_import_satellites_error">Супутники не імпортовано. Потрібен TLE/3LE (.txt) або OMM (.csv).</string>
|
||||
<string name="prefs_data_import_transceivers_error">Трансивери не імпортовано. Потрібен SatNOGS (.json).</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Власні джерела</string>
|
||||
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">URL трансиверів</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
||||
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</string>
|
||||
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||
|
||||
<string name="prefs_data_output_title">Вивід даних</string>
|
||||
<string name="prefs_net_output">Мережа</string>
|
||||
<string name="prefs_data_output_title">Data output</string>
|
||||
<string name="prefs_net_output">Network</string>
|
||||
<string name="prefs_bt_output">Bluetooth</string>
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
|
||||
<string name="nav_radiocontrol">Керування радіо</string>
|
||||
<string name="rc_settings_title">CAT керування</string>
|
||||
<string name="rc_radio_model">Модель радіо</string>
|
||||
<string name="rc_tx_device_hint">BT адреса TX</string>
|
||||
<string name="rc_rx_device_hint">BT адреса RX</string>
|
||||
<string name="rc_tx_name_hint">Назва радіо TX</string>
|
||||
<string name="rc_rx_name_hint">Назва радіо RX</string>
|
||||
<string name="rc_enable_switch">Увімкнути CAT</string>
|
||||
|
||||
<string name="prefs_net_title">Мережевий вивід</string>
|
||||
<string name="prefs_net_rotator_switch">Увімкнути вивід повороту</string>
|
||||
<string name="prefs_net_title">Вивід мережевих даних</string>
|
||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
||||
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
|
||||
<string name="prefs_net_rotator_format_hint">Формат даних</string>
|
||||
<string name="prefs_net_frequency_switch">Увімкнути вивід частоти</string>
|
||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
||||
<string name="prefs_net_frequency_format_hint">Формат даних</string>
|
||||
|
||||
<string name="prefs_bt_title">Вивід Bluetooth</string>
|
||||
<string name="prefs_bt_rotator_switch">Увімкнути вивід повороту</string>
|
||||
<string name="prefs_bt_rotator_device_hint">ID пристрою</string>
|
||||
<string name="prefs_bt_title">Вивід даних Bluetooth</string>
|
||||
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
||||
<string name="prefs_bt_rotator_device_hint">Id пристрою</string>
|
||||
<string name="prefs_bt_rotator_output_hint">Формат даних</string>
|
||||
<string name="prefs_bt_frequency_switch">Увімкнути вивід частоти</string>
|
||||
<string name="prefs_bt_frequency_device_hint">ID пристрою</string>
|
||||
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
||||
<string name="prefs_bt_frequency_device_hint">Id пристрою</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Формат даних</string>
|
||||
<string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</string>
|
||||
<string name="prefs_net_perm_error">Перевірте дозвіл мережі</string>
|
||||
|
||||
<string name="prefs_other_title">Інше</string>
|
||||
<string name="prefs_other_switch_utc">Час в UTC</string>
|
||||
<string name="prefs_other_switch_update">Автооновлення даних</string>
|
||||
<string name="prefs_other_switch_sweep">Анімація радара</string>
|
||||
<string name="prefs_other_switch_sensors">Сенсори</string>
|
||||
<string name="prefs_other_switch_night_mode">Нічний фільтр</string>
|
||||
<string name="prefs_other_title">Інші налаштування</string>
|
||||
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
|
||||
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
|
||||
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
|
||||
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
|
||||
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
|
||||
<string name="prefs_other_switch_map_grid">Показувати сітку Maidenhead на мапі</string>
|
||||
|
||||
<string name="prefs_highlight_title">Підсвічування висоти</string>
|
||||
<string name="prefs_highlight_low">Низько < %1$d°</string>
|
||||
|
||||
@@ -9,23 +9,20 @@
|
||||
<string name="nav_sat">卫星</string>
|
||||
<string name="nav_pass">过境</string>
|
||||
<string name="nav_radar">雷达</string>
|
||||
<string name="nav_mutual">匹配</string>
|
||||
<string name="nav_map">地图</string>
|
||||
<string name="nav_prefs">设置</string>
|
||||
|
||||
<!-- Satellites screen -->
|
||||
<string name="sat_type_hint">模式:%s</string>
|
||||
<string name="sat_type_title">选择模式</string>
|
||||
<string name="sat_type_hint">类型:%s</string>
|
||||
<string name="sat_type_title">选择卫星类型</string>
|
||||
<string name="sat_search_hint">Id - 名称</string>
|
||||
<string name="sat_search_clear">清空</string>
|
||||
<string name="sat_clear_all">全部清空</string>
|
||||
<string name="sat_select_all">全选</string>
|
||||
<string name="sat_group_selected">已选择</string>
|
||||
<string name="sat_group_available">可用</string>
|
||||
<string name="sat_group_selected_count">已选择(%1$d)</string>
|
||||
<string name="sat_group_available_count">可用(%1$d)</string>
|
||||
<string name="sat_empty_list_message">请确保您的搜索查询正确且数据库已更新</string>
|
||||
<string name="sat_warning_title">警告\!</string>
|
||||
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和模式筛选功能仅勾选您感兴趣的卫星</string>
|
||||
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星</string>
|
||||
|
||||
<!-- Passes screen -->
|
||||
<string name="pass_filter_title">筛选过境</string>
|
||||
@@ -34,7 +31,7 @@
|
||||
<string name="pass_filter_aos_time">AOS 时间段</string>
|
||||
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace(周期 >225分钟)</string>
|
||||
<string name="pass_modes_title">选择模式</string>
|
||||
<string name="pass_modes_title">选择调制类型</string>
|
||||
<string name="pass_elevation">仰角:%.1f°</string>
|
||||
<string name="pass_altitude">高度:%d km</string>
|
||||
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
|
||||
@@ -42,6 +39,41 @@
|
||||
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</string>
|
||||
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
||||
|
||||
<!-- AMSAT Status screen -->
|
||||
<string name="nav_amsat">AMSAT</string>
|
||||
<string name="amsat_title">AMSAT 卫星状态</string>
|
||||
<string name="amsat_refresh">刷新</string>
|
||||
<string name="amsat_retry">重试</string>
|
||||
<string name="amsat_updated">更新于:</string>
|
||||
<string name="amsat_active">活跃</string>
|
||||
<string name="amsat_tlm">遥测/信标</string>
|
||||
<string name="amsat_not_heard">未听到</string>
|
||||
<string name="amsat_conflict">冲突</string>
|
||||
<string name="amsat_name">名称</string>
|
||||
<string name="amsat_load_failed">加载失败 - 检查网络后重试</string>
|
||||
<string name="amsat_no_reports">此时间段无报告</string>
|
||||
<string name="amsat_close">关闭</string>
|
||||
<string name="amsat_upload">上传</string>
|
||||
<string name="amsat_upload_back">返回</string>
|
||||
<string name="amsat_upload_report">上传报告到 AMSAT</string>
|
||||
<string name="amsat_upload_status">状态</string>
|
||||
<string name="amsat_upload_callsign">呼号</string>
|
||||
<string name="amsat_upload_grid">网格</string>
|
||||
<string name="amsat_upload_time_now">时间:当前 UTC %s</string>
|
||||
<string name="amsat_upload_submit">提交到 AMSAT</string>
|
||||
<string name="amsat_upload_confirm">确认上传</string>
|
||||
<string name="amsat_upload_confirm_title">确认上传到 AMSAT</string>
|
||||
<string name="amsat_upload_confirm_message">这条公开报告将上传到 AMSAT。\n卫星:%1$s\n状态:%2$s\n呼号:%3$s\n网格:%4$s</string>
|
||||
<string name="amsat_upload_grid_none">未填写</string>
|
||||
<string name="amsat_upload_success">报告已上传</string>
|
||||
<string name="amsat_upload_failed">上传失败:%s</string>
|
||||
<string name="amsat_upload_callsign_required">请填写呼号</string>
|
||||
<string name="amsat_upload_grid_invalid">网格应为 4 或 6 位 Maidenhead 定位符</string>
|
||||
<string name="amsat_upload_active">活跃</string>
|
||||
<string name="amsat_upload_tlm">遥测/信标</string>
|
||||
<string name="amsat_upload_not_heard">未听到</string>
|
||||
<string name="amsat_upload_crew_active">ISS 语音</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">后退</string>
|
||||
<string name="radar_notify">通知</string>
|
||||
@@ -60,13 +92,23 @@
|
||||
<string name="radar_string_no">否</string>
|
||||
<string name="radar_string_yes">是</string>
|
||||
<string name="radar_visible">日照中</string>
|
||||
|
||||
<string name="radar_doppler_calc">多普勒频率计算器</string>
|
||||
<string name="radar_doppler_tx_hint">输入上行频率 (MHz)</string>
|
||||
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
|
||||
<string name="radar_doppler_offset_hint">偏移 (kHz)</string>
|
||||
<string name="radar_doppler_info">线性转发器:输入一个频率即可算出另一个</string>
|
||||
|
||||
<string name="radar_cw_decoder">CW 解码器</string>
|
||||
<string name="radar_cw_start">开始</string>
|
||||
<string name="radar_cw_stop">停止</string>
|
||||
<string name="radar_cw_reset">清空</string>
|
||||
|
||||
<!-- Map screen -->
|
||||
<string name="map_prev">上一个</string>
|
||||
<string name="map_next">下一个</string>
|
||||
<string name="map_grid_mode">网格模式</string>
|
||||
<string name="map_satellite_mode">卫星模式</string>
|
||||
<string name="map_azimuth">方位角:%.1f°</string>
|
||||
<string name="map_elevation">仰角:%.1f°</string>
|
||||
<string name="map_altitude">高度:%.0f km</string>
|
||||
@@ -78,6 +120,10 @@
|
||||
<string name="map_period">周期:% .0f min</string>
|
||||
<string name="map_velocity">速度:%.2f km/s</string>
|
||||
<string name="map_visibility">能见度:%s</string>
|
||||
<!-- Worked-grid QSO dialog -->
|
||||
<string name="grid_qso_calls_count">%d 个呼号 · %d 次通联</string>
|
||||
<string name="grid_qso_first">首通</string>
|
||||
<string name="grid_qso_no_sat">卫星未知</string>
|
||||
<string name="map_visible">日照中</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
@@ -95,11 +141,29 @@
|
||||
<string name="prefs_loc_qth_title">QTH 网格</string>
|
||||
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
|
||||
<string name="prefs_loc_success">位置更新成功</string>
|
||||
<string name="prefs_station_title">站点位置</string>
|
||||
<string name="prefs_station_lat_text">站点纬度</string>
|
||||
<string name="prefs_station_lon_text">站点经度</string>
|
||||
<string name="prefs_locator_title">QTH 定位</string>
|
||||
<string name="prefs_locator_text">通过 QTH 设置站点位置</string>
|
||||
<string name="prefs_station_title">站点位置设置</string>
|
||||
<string name="prefs_station_lat_text">输入站点的纬度</string>
|
||||
<string name="prefs_station_lon_text">输入站点的经度</string>
|
||||
<string name="prefs_locator_title">QTH 网格</string>
|
||||
<string name="prefs_wavelog_title">Wavelog 已通联网格</string>
|
||||
<string name="prefs_wavelog_configure">配置</string>
|
||||
<string name="prefs_wavelog_configured">已从 Wavelog 同步 — 通联 %1$d 个网格</string>
|
||||
<string name="prefs_wavelog_not_configured">未配置 — 连接你的 Wavelog 日志以在地图上高亮已通联网格</string>
|
||||
<string name="prefs_wavelog_url">Wavelog 地址</string>
|
||||
<string name="prefs_wavelog_token">API key :日志 ID</string>
|
||||
<string name="prefs_wavelog_hint">已同步网格数:%1$d。格式:API key:日志ID。填好直接点同步,会自动保存并拉取。</string>
|
||||
<string name="prefs_wavelog_sync">立即同步</string>
|
||||
<string name="prefs_wavelog_syncing">同步中…</string>
|
||||
<string name="prefs_lotw_title">LoTW 已确认网格</string>
|
||||
<string name="prefs_lotw_callsign">LoTW 呼号</string>
|
||||
<string name="prefs_lotw_password">LoTW 密码</string>
|
||||
<string name="prefs_lotw_hint">直接从 ARRL LoTW 拉取该呼号已确认 QSO 的网格并覆盖本地已存集合(当前 %1$d 个)。密码仅保存在手机本地。点"同步"会自动保存凭据并拉取。</string>
|
||||
<string name="prefs_lotw_sync_duration_hint">同步通常约需 3–10 秒;首次同步或 ARRL 服务器繁忙时可能更久。</string>
|
||||
<string name="prefs_lotw_sync">同步</string>
|
||||
<string name="prefs_lotw_syncing">同步中…</string>
|
||||
<string name="prefs_lotw_configured">已从 LoTW 同步 — 共 %1$d 个网格</string>
|
||||
<string name="prefs_lotw_not_configured">未配置 — 直接登录 ARRL LoTW 拉取已确认 QSO 的网格,无需自建服务器</string>
|
||||
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
|
||||
|
||||
<string name="prefs_data_title">卫星数据更新</string>
|
||||
<string name="prefs_data_entries">卫星:%s</string>
|
||||
@@ -109,37 +173,34 @@
|
||||
<string name="prefs_data_clear">清空</string>
|
||||
<string name="prefs_data_clear_success">数据清空成功</string>
|
||||
<string name="prefs_data_update_success">更新成功</string>
|
||||
|
||||
<string name="prefs_data_import_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</string>
|
||||
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
|
||||
|
||||
<string name="prefs_data_sources_title">自定义源</string>
|
||||
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">收发器 URL</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
<string name="prefs_data_sources_title">自定义数据源</string>
|
||||
<string name="prefs_data_sources_restore">恢复默认数据源</string>
|
||||
<string name="prefs_data_sources_tle_switch">自定义TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||
<string name="prefs_data_sources_satellites_label">卫星数据</string>
|
||||
<string name="prefs_data_sources_transceivers_label">收发器数据</string>
|
||||
|
||||
<string name="prefs_data_output_title">数据输出</string>
|
||||
<string name="prefs_net_output">网络</string>
|
||||
<string name="prefs_bt_output">蓝牙</string>
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
|
||||
<string name="nav_radiocontrol">电台控制</string>
|
||||
<string name="rc_settings_title">CAT 控制</string>
|
||||
<string name="rc_radio_model">电台型号</string>
|
||||
<string name="rc_tx_device_hint">TX 电台蓝牙地址</string>
|
||||
<string name="rc_rx_device_hint">RX 电台蓝牙地址</string>
|
||||
<string name="rc_tx_name_hint">TX 电台名称</string>
|
||||
<string name="rc_rx_name_hint">RX 电台名称</string>
|
||||
<string name="rc_enable_switch">启用 CAT</string>
|
||||
|
||||
<string name="prefs_net_title">网络输出</string>
|
||||
<string name="prefs_net_title">网络数据输出</string>
|
||||
<string name="prefs_net_rotator_switch">启用方位俯仰角输出</string>
|
||||
<string name="prefs_net_rotator_address_hint">IP:端口</string>
|
||||
<string name="prefs_net_rotator_format_hint">数据格式</string>
|
||||
<string name="prefs_net_frequency_switch">启用多普勒频率输出</string>
|
||||
<string name="prefs_net_frequency_address_hint">IP:端口</string>
|
||||
<string name="prefs_net_frequency_format_hint">数据格式</string>
|
||||
<string name="prefs_net_frequency_offset_hint" translatable="false">频率偏移 (Hz)</string>
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">范围:-50000 到 50000 Hz。正值提高上报频率,负值降低上报频率。</string>
|
||||
|
||||
<string name="prefs_bt_title">蓝牙输出</string>
|
||||
<string name="prefs_bt_title">蓝牙数据输出</string>
|
||||
<string name="prefs_bt_rotator_switch">启用方位俯仰角输出</string>
|
||||
<string name="prefs_bt_rotator_device_hint">蓝牙设备 ID</string>
|
||||
<string name="prefs_bt_rotator_output_hint">数据格式</string>
|
||||
@@ -149,14 +210,23 @@
|
||||
<string name="prefs_bt_perm_error">请检查蓝牙权限</string>
|
||||
<string name="prefs_net_perm_error">请检查网络权限</string>
|
||||
|
||||
<string name="prefs_other_title">其他</string>
|
||||
<string name="prefs_other_switch_utc">UTC 时间</string>
|
||||
<string name="prefs_other_switch_update">自动更新数据</string>
|
||||
<string name="prefs_other_switch_sweep">雷达扫描动画</string>
|
||||
<string name="prefs_other_switch_sensors">传感器控制雷达</string>
|
||||
<string name="prefs_other_switch_night_mode">红色夜间模式</string>
|
||||
<string name="prefs_other_compass_offset">雷达罗盘水平偏移量</string>
|
||||
<string name="prefs_other_compass_offset_elev">雷达罗盘俯仰偏移量</string>
|
||||
<!-- Radio Control -->
|
||||
<string name="nav_radiocontrol">电台控制</string>
|
||||
<string name="rc_settings_title">CAT 电台控制</string>
|
||||
<string name="rc_radio_model">电台型号</string>
|
||||
<string name="rc_tx_device_hint">发射电台蓝牙地址</string>
|
||||
<string name="rc_rx_device_hint">接收电台蓝牙地址</string>
|
||||
<string name="rc_tx_name_hint">发射电台名称</string>
|
||||
<string name="rc_rx_name_hint">接收电台名称</string>
|
||||
<string name="rc_enable_switch">启用 CAT 控制</string>
|
||||
|
||||
<string name="prefs_other_title">其他设置</string>
|
||||
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
||||
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
||||
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
|
||||
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
|
||||
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
|
||||
<string name="prefs_other_switch_map_grid">地图显示 Maidenhead 网格</string>
|
||||
|
||||
<string name="prefs_highlight_title">仰角高亮</string>
|
||||
<string name="prefs_highlight_low">低 < %1$d°</string>
|
||||
@@ -170,7 +240,53 @@
|
||||
\n• Dave Moten (predict4java)
|
||||
\n• Alexandru Csete (Gpredict)
|
||||
\n• Dr T.S. Kelso (Celestrak)
|
||||
\n• Libre Space Foundation (SatNOGS)</string>
|
||||
\n• Libre Space Foundation (SatNOGS)
|
||||
\n• xdsopl 和 Robot36 贡献者!
|
||||
\n• BG7NTA(CW解码功能)</string>
|
||||
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
||||
|
||||
<!-- 匹配页 Mutual match page -->
|
||||
<string name="mutual_title">过境匹配</string>
|
||||
<string name="mutual_station_you">你的位置</string>
|
||||
<string name="mutual_station_opposite">友台位置</string>
|
||||
<string name="mutual_label_you">你</string>
|
||||
<string name="mutual_label_opposite">友台</string>
|
||||
<string name="mutual_status_calculating">正在计算双方共同可见窗口…</string>
|
||||
<string name="mutual_status_error">请修正提示后重新查询</string>
|
||||
<string name="mutual_status_matches">找到 %d 个匹配 · 点击卡片展开曲线</string>
|
||||
<string name="mutual_status_none">未找到共同可见窗口,调整时间或仰角后重试</string>
|
||||
<string name="mutual_status_idle">输入双方位置后点击查询</string>
|
||||
<string name="mutual_chip_calculating">计算中</string>
|
||||
<string name="mutual_chip_error">有错误</string>
|
||||
<string name="mutual_chip_matches">%d 个匹配</string>
|
||||
<string name="mutual_chip_none">无匹配</string>
|
||||
<string name="mutual_chip_idle">待查询</string>
|
||||
<string name="mutual_use_current">填入当前位置</string>
|
||||
<string name="mutual_lat">纬度</string>
|
||||
<string name="mutual_lon">经度</string>
|
||||
<string name="mutual_grid">网格</string>
|
||||
<string name="mutual_grid_hint">支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度</string>
|
||||
<string name="mutual_min_elevation">最小仰角:%d°</string>
|
||||
<string name="mutual_time_range">时间范围</string>
|
||||
<string name="mutual_calculating">计算中…</string>
|
||||
<string name="mutual_query">查询匹配</string>
|
||||
<string name="mutual_elevation_you">你:%d°</string>
|
||||
<string name="mutual_elevation_opposite">友台:%d°</string>
|
||||
<string name="mutual_both_tracks">双方轨迹</string>
|
||||
<string name="mutual_open_radar">查看雷达</string>
|
||||
<string name="mutual_station_a">站点A</string>
|
||||
<string name="mutual_station_b">站点B</string>
|
||||
<string name="update_check_title">检查更新</string>
|
||||
<string name="update_check_button">检查更新</string>
|
||||
<string name="update_check_current">当前版本:%s</string>
|
||||
<string name="update_check_latest">最新版本:%s</string>
|
||||
<string name="update_check_up_to_date">已是最新版本</string>
|
||||
<string name="update_check_available">发现新版本</string>
|
||||
<string name="update_check_release_notes">更新内容</string>
|
||||
<string name="update_check_download">下载并安装</string>
|
||||
<string name="update_check_downloading">正在下载…</string>
|
||||
<string name="update_check_error">检查更新失败,请检查网络连接。</string>
|
||||
<string name="update_check_download_error">下载失败,请重试。</string>
|
||||
<string name="update_check_no_apk">该版本未附带 APK 安装包。</string>
|
||||
|
||||
</resources>
|
||||
@@ -10,27 +10,24 @@
|
||||
<string name="nav_sat">Satellites</string>
|
||||
<string name="nav_pass">Passes</string>
|
||||
<string name="nav_radar">Radar</string>
|
||||
<string name="nav_mutual">Match</string>
|
||||
<string name="nav_map">Map</string>
|
||||
<string name="nav_prefs">Settings</string>
|
||||
|
||||
<!-- Satellites screen -->
|
||||
<string name="sat_type_hint">Modes: %s</string>
|
||||
<string name="sat_type_title">Select modes</string>
|
||||
<string name="sat_type_hint">Type: %s</string>
|
||||
<string name="sat_type_title">Select satellite type</string>
|
||||
<string name="sat_search_hint">Id - Name</string>
|
||||
<string name="sat_search_clear">Clear</string>
|
||||
<string name="sat_clear_all">Clear all</string>
|
||||
<string name="sat_select_all">Select all</string>
|
||||
<string name="sat_group_selected">Selected</string>
|
||||
<string name="sat_group_available">Available</string>
|
||||
<string name="sat_group_selected_count">Selected (%1$d)</string>
|
||||
<string name="sat_group_available_count">Available (%1$d)</string>
|
||||
<string name="sat_empty_list_message">"Make sure your search query is correct and the DB is updated"</string>
|
||||
<string name="sat_warning_title">Warning\!</string>
|
||||
<string name="sat_warning_message">
|
||||
There are over 9000 satellites listed in this app.
|
||||
It makes no sense to track them all at the same time.
|
||||
\n\nAlways try to narrow down the list to only the ones
|
||||
you\'re interested in via search and the modes selector.</string>
|
||||
you\'re interested in via search and types selector.</string>
|
||||
|
||||
<!-- Passes screen -->
|
||||
<string name="pass_filter_title">Filter passes</string>
|
||||
@@ -40,7 +37,7 @@
|
||||
<string name="pass_filter_invert_time">Invert AOS window</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (period >225min)</string>
|
||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||
<string name="pass_modes_title">Select modes</string>
|
||||
<string name="pass_modes_title">Select modulation type</string>
|
||||
<string name="pass_satId" translatable="false">%05d</string>
|
||||
<string name="pass_elevation">Elevation: %.1f°</string>
|
||||
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
||||
@@ -55,18 +52,45 @@
|
||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* Added small tweaks to Sources and strings. Added official ISS source (#225)
|
||||
\n* Implemented AOS window and elevation highlight filters (#226)
|
||||
\n* Added cleartext traffic support for custom TLE URLs (#227)
|
||||
\n* Fixed manual OMM (.csv) data import, tweaked error messaging (#228)
|
||||
\n* Added support for Icom IC-705 CAT by ruilvo (#229)
|
||||
\n* Added configurable Radar offset to the sensors output by qwqtoday (#230)
|
||||
\n* Localized pass date and time formats for Chinese by atsunatsu (#231)
|
||||
\n* Added linear transponder Doppler calculator by atsunatsu (#232)
|
||||
\n* Added a few tweaks to SSTV sensitivity and reception, similar to Robot36
|
||||
\n* Replaced the types selection dialog with modes selection to avoid confusion
|
||||
* Added required tweaks to support Android 17 (API 37)
|
||||
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
||||
</string>
|
||||
|
||||
<!-- AMSAT Status screen -->
|
||||
<string name="nav_amsat">AMSAT</string>
|
||||
<string name="amsat_title">AMSAT Satellite Status</string>
|
||||
<string name="amsat_refresh">Refresh</string>
|
||||
<string name="amsat_retry">Retry</string>
|
||||
<string name="amsat_updated">Updated:</string>
|
||||
<string name="amsat_active">Active</string>
|
||||
<string name="amsat_tlm">TLM/Beacon</string>
|
||||
<string name="amsat_not_heard">Not Heard</string>
|
||||
<string name="amsat_conflict">Conflicting</string>
|
||||
<string name="amsat_name">Name</string>
|
||||
<string name="amsat_load_failed">Load failed - check network and retry</string>
|
||||
<string name="amsat_no_reports">No reports for this period</string>
|
||||
<string name="amsat_close">Close</string>
|
||||
<string name="amsat_upload">Upload</string>
|
||||
<string name="amsat_upload_back">Back</string>
|
||||
<string name="amsat_upload_report">Upload report to AMSAT</string>
|
||||
<string name="amsat_upload_status">Status</string>
|
||||
<string name="amsat_upload_callsign">Callsign</string>
|
||||
<string name="amsat_upload_grid">Grid</string>
|
||||
<string name="amsat_upload_time_now">Time: current UTC %s</string>
|
||||
<string name="amsat_upload_submit">Submit to AMSAT</string>
|
||||
<string name="amsat_upload_confirm">Upload</string>
|
||||
<string name="amsat_upload_confirm_title">Confirm AMSAT upload</string>
|
||||
<string name="amsat_upload_confirm_message">This public report will be uploaded to AMSAT.\nSatellite: %1$s\nStatus: %2$s\nCallsign: %3$s\nGrid: %4$s</string>
|
||||
<string name="amsat_upload_grid_none">Not provided</string>
|
||||
<string name="amsat_upload_success">Report uploaded</string>
|
||||
<string name="amsat_upload_failed">Upload failed: %s</string>
|
||||
<string name="amsat_upload_callsign_required">Callsign is required</string>
|
||||
<string name="amsat_upload_grid_invalid">Grid must be a 4 or 6 character Maidenhead locator</string>
|
||||
<string name="amsat_upload_active">Active</string>
|
||||
<string name="amsat_upload_tlm">TLM/Beacon</string>
|
||||
<string name="amsat_upload_not_heard">Not Heard</string>
|
||||
<string name="amsat_upload_crew_active">Crew Active</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">Back</string>
|
||||
<string name="radar_notify">Notify</string>
|
||||
@@ -92,14 +116,23 @@
|
||||
<string name="radar_string_no">No</string>
|
||||
<string name="radar_string_yes">Yes</string>
|
||||
<string name="radar_visible">Visible</string>
|
||||
<string name="radar_doppler_calc">Doppler Frequency Calculator</string>
|
||||
<string name="radar_doppler_tx_hint">Enter TX freq (MHz)</string>
|
||||
<string name="radar_doppler_rx_hint">Enter RX freq (MHz)</string>
|
||||
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
|
||||
<string name="radar_doppler_info">For linear transponders, type one frequency to see the other</string>
|
||||
|
||||
<string name="radar_cw_decoder">CW Decoder</string>
|
||||
<string name="radar_cw_start">Start</string>
|
||||
<string name="radar_cw_stop">Stop</string>
|
||||
<string name="radar_cw_reset">Clear</string>
|
||||
|
||||
<!-- Map screen -->
|
||||
<string name="map_prev">Prev</string>
|
||||
<string name="map_next">Next</string>
|
||||
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
|
||||
<string name="map_grid_mode">Grid mode</string>
|
||||
<string name="map_satellite_mode">Satellite mode</string>
|
||||
<string name="map_azimuth">Azimuth: %.1f°</string>
|
||||
<string name="map_elevation">Elevation: %.1f°</string>
|
||||
<string name="map_altitude">Altitude: %.0f km</string>
|
||||
@@ -112,6 +145,10 @@
|
||||
<string name="map_period">Period: %.0f min</string>
|
||||
<string name="map_velocity">Velocity: %.2f km/s</string>
|
||||
<string name="map_visibility">Visibility: %s</string>
|
||||
<!-- Worked-grid QSO dialog -->
|
||||
<string name="grid_qso_calls_count">%d callsigns · %d QSOs</string>
|
||||
<string name="grid_qso_first">First</string>
|
||||
<string name="grid_qso_no_sat">satellite unknown</string>
|
||||
<string name="map_visible">Visible</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
@@ -139,11 +176,29 @@
|
||||
<string name="prefs_loc_qth_title">QTH</string>
|
||||
<string name="prefs_loc_qth_error">Invalid QTH locator</string>
|
||||
<string name="prefs_loc_success">Position updated successfully</string>
|
||||
<string name="prefs_station_title">Station position</string>
|
||||
<string name="prefs_station_lat_text">Station latitude</string>
|
||||
<string name="prefs_station_lon_text">Station longitude</string>
|
||||
<string name="prefs_locator_title">QTH locator</string>
|
||||
<string name="prefs_locator_text">Set position via locator</string>
|
||||
<string name="prefs_station_title">Station position settings</string>
|
||||
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
|
||||
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
|
||||
<string name="prefs_locator_title">QTH locator settings</string>
|
||||
<string name="prefs_wavelog_title">Wavelog worked grids</string>
|
||||
<string name="prefs_wavelog_configure">Configure</string>
|
||||
<string name="prefs_wavelog_configured">Synced from Wavelog — %1$d grids worked</string>
|
||||
<string name="prefs_wavelog_not_configured">Not configured — connect your Wavelog logbook to highlight worked grids on the map</string>
|
||||
<string name="prefs_wavelog_url">Wavelog URL</string>
|
||||
<string name="prefs_wavelog_token">API key : logbook ID</string>
|
||||
<string name="prefs_wavelog_hint">Worked grids synced: %1$d. Format: API key, colon, logbook ID. Just tap Sync — credentials are saved automatically.</string>
|
||||
<string name="prefs_wavelog_sync">Sync now</string>
|
||||
<string name="prefs_wavelog_syncing">Syncing…</string>
|
||||
<string name="prefs_lotw_title">LoTW confirmed grids</string>
|
||||
<string name="prefs_lotw_callsign">LoTW callsign</string>
|
||||
<string name="prefs_lotw_password">LoTW password</string>
|
||||
<string name="prefs_lotw_hint">Fetch confirmed-QSO grids directly from ARRL LoTW for this callsign and replace the stored set (currently %1$d). The password is stored on this device only. Tapping Sync saves the credentials and fetches in one step.</string>
|
||||
<string name="prefs_lotw_sync_duration_hint">A sync usually takes about 3–10 seconds; the first one or a busy ARRL server can take longer.</string>
|
||||
<string name="prefs_lotw_sync">Sync</string>
|
||||
<string name="prefs_lotw_syncing">Syncing…</string>
|
||||
<string name="prefs_lotw_configured">Synced from LoTW — %1$d grids total</string>
|
||||
<string name="prefs_lotw_not_configured">Not configured — pull confirmed grids directly from ARRL LoTW, no self-hosted server needed</string>
|
||||
<string name="prefs_locator_text">Set station\'s position using locator</string>
|
||||
|
||||
<string name="prefs_data_title">Satellite data</string>
|
||||
<string name="prefs_data_entries">Satellites: %s</string>
|
||||
@@ -156,10 +211,13 @@
|
||||
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Custom sources</string>
|
||||
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
|
||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||
<string name="prefs_data_sources_restore">Restore default sources</string>
|
||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
<string name="prefs_data_sources_satellites_label">Satellites data</string>
|
||||
<string name="prefs_data_sources_transceivers_label">Transceivers data</string>
|
||||
|
||||
<string name="prefs_data_output_title">Data output</string>
|
||||
<string name="prefs_net_output">Network</string>
|
||||
@@ -168,40 +226,41 @@
|
||||
|
||||
<!-- Radio Control -->
|
||||
<string name="nav_radiocontrol">Radio Control</string>
|
||||
<string name="rc_settings_title">CAT control</string>
|
||||
<string name="rc_settings_title">CAT Radio Control</string>
|
||||
<string name="rc_radio_model">Radio Model</string>
|
||||
<string name="rc_tx_device_hint">TX BT address</string>
|
||||
<string name="rc_rx_device_hint">RX BT address</string>
|
||||
<string name="rc_tx_device_hint">TX Radio BT Address</string>
|
||||
<string name="rc_rx_device_hint">RX Radio BT Address</string>
|
||||
<string name="rc_tx_name_hint">TX Radio Name</string>
|
||||
<string name="rc_rx_name_hint">RX Radio Name</string>
|
||||
<string name="rc_enable_switch">Enable CAT</string>
|
||||
<string name="rc_enable_switch">Enable CAT Control</string>
|
||||
|
||||
<string name="prefs_net_title">Network output</string>
|
||||
<string name="prefs_net_title">Network data output</string>
|
||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
||||
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||
<string name="prefs_net_rotator_format_hint">Format</string>
|
||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||
<string name="prefs_net_frequency_format_hint">Format</string>
|
||||
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||
|
||||
<string name="prefs_bt_title">Bluetooth output</string>
|
||||
<string name="prefs_bt_title">Bluetooth data output</string>
|
||||
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
||||
<string name="prefs_bt_rotator_device_hint">Device ID</string>
|
||||
<string name="prefs_bt_rotator_output_hint">Format</string>
|
||||
<string name="prefs_bt_rotator_device_hint">Device id</string>
|
||||
<string name="prefs_bt_rotator_output_hint">Data format</string>
|
||||
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
||||
<string name="prefs_bt_frequency_device_hint">Device ID</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Format</string>
|
||||
<string name="prefs_bt_frequency_device_hint">Device id</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Data format</string>
|
||||
<string name="prefs_bt_perm_error">Check your bluetooth permission</string>
|
||||
<string name="prefs_net_perm_error">Check your network permission</string>
|
||||
|
||||
<string name="prefs_other_title">Other</string>
|
||||
<string name="prefs_other_switch_utc">UTC time</string>
|
||||
<string name="prefs_other_switch_update">Auto-update data</string>
|
||||
<string name="prefs_other_switch_sweep">Radar sweep</string>
|
||||
<string name="prefs_other_switch_sensors">Use sensors</string>
|
||||
<string name="prefs_other_switch_night_mode">Night filter</string>
|
||||
<string name="prefs_other_compass_offset">Radar compass offset</string>
|
||||
<string name="prefs_other_compass_offset_elev">Radar compass offset (elev)</string>
|
||||
<string name="prefs_other_title">Other settings</string>
|
||||
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
|
||||
<string name="prefs_other_switch_update">Enable automatic data update</string>
|
||||
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
|
||||
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
|
||||
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
|
||||
<string name="prefs_other_switch_map_grid">Show Maidenhead grid on map</string>
|
||||
|
||||
<string name="prefs_highlight_title">Elevation highlight</string>
|
||||
<string name="prefs_highlight_low">Low < %1$d°</string>
|
||||
@@ -217,7 +276,52 @@
|
||||
\n* Dr T.S. Kelso (Celestrak)
|
||||
\n* Libre Space Foundation (SatNOGS)
|
||||
\n* xdsopl and Robot36 contributors!
|
||||
\n* BG7NTA (CW decoding feature)
|
||||
</string>
|
||||
<string name="prefs_outro_license">The app comes with no warranty</string>
|
||||
|
||||
<!-- Mutual match page -->
|
||||
<string name="mutual_title">Pass Match</string>
|
||||
<string name="mutual_station_you">Your Station</string>
|
||||
<string name="mutual_station_opposite">Opposite Station</string>
|
||||
<string name="mutual_label_you">You</string>
|
||||
<string name="mutual_label_opposite">Opposite</string>
|
||||
<string name="mutual_status_calculating">Calculating mutual visibility windows…</string>
|
||||
<string name="mutual_status_error">Fix the reported issues and retry</string>
|
||||
<string name="mutual_status_matches">%d matches found · tap a card to expand</string>
|
||||
<string name="mutual_status_none">No mutual windows found. Adjust time or min elevation and retry</string>
|
||||
<string name="mutual_status_idle">Enter both stations, then tap Query</string>
|
||||
<string name="mutual_chip_calculating">Calculating</string>
|
||||
<string name="mutual_chip_error">Error</string>
|
||||
<string name="mutual_chip_matches">%d matches</string>
|
||||
<string name="mutual_chip_none">No matches</string>
|
||||
<string name="mutual_chip_idle">Idle</string>
|
||||
<string name="mutual_use_current">Use current</string>
|
||||
<string name="mutual_lat">Lat</string>
|
||||
<string name="mutual_lon">Lon</string>
|
||||
<string name="mutual_grid">Grid</string>
|
||||
<string name="mutual_grid_hint">Supports 4/6/8-char Maidenhead grid; or enter lat/lon directly</string>
|
||||
<string name="mutual_min_elevation">Min elevation: %d°</string>
|
||||
<string name="mutual_time_range">Time range</string>
|
||||
<string name="mutual_calculating">Calculating…</string>
|
||||
<string name="mutual_query">Query Match</string>
|
||||
<string name="mutual_elevation_you">You: %d°</string>
|
||||
<string name="mutual_elevation_opposite">Opposite: %d°</string>
|
||||
<string name="mutual_both_tracks">Both tracks</string>
|
||||
<string name="mutual_open_radar">Open Radar</string>
|
||||
<string name="mutual_station_a">Station A</string>
|
||||
<string name="mutual_station_b">Station B</string>
|
||||
<string name="update_check_title">Check for updates</string>
|
||||
<string name="update_check_button">Check for updates</string>
|
||||
<string name="update_check_current">Current version: %s</string>
|
||||
<string name="update_check_latest">Latest version: %s</string>
|
||||
<string name="update_check_up_to_date">You are up to date</string>
|
||||
<string name="update_check_available">New version available</string>
|
||||
<string name="update_check_release_notes">Release notes</string>
|
||||
<string name="update_check_download">Download & install</string>
|
||||
<string name="update_check_downloading">Downloading…</string>
|
||||
<string name="update_check_error">Failed to check for updates. Check your connection.</string>
|
||||
<string name="update_check_download_error">Download failed. Try again.</string>
|
||||
<string name="update_check_no_apk">No APK attached to this release.</string>
|
||||
|
||||
</resources>
|
||||
@@ -1,8 +1,2 @@
|
||||
* Added ARISS source, tweaked SSTV sensitivity #225
|
||||
* Implemented AOS window and elev filter #226
|
||||
* Added cleartext traffic support for TLEs #227
|
||||
* Fixed OMM import, tweaked error messages #228
|
||||
* Added support for Icom CAT by ruilvo #229
|
||||
* Added tweakable Radar offset by qwqtoday #230
|
||||
* Localized date/time formats by atsunatsu #231
|
||||
* Added linear radio Doppler calc by atsunatsu #232
|
||||
* Added required tweaks to support Android 17 (API 37)
|
||||
* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
||||
@@ -0,0 +1,39 @@
|
||||
import com.android.build.api.dsl.LibraryExtension
|
||||
import com.android.build.api.variant.CanProduceConsumerProguardFiles
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.convention.featurePlugin)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.rtbishop.look4sat.feature.cw"
|
||||
compileOptions {
|
||||
encoding = "UTF-8"
|
||||
}
|
||||
// Morse Expert 1.15 官方 Play 分包提供 ARM32/ARM64 原生解码器
|
||||
// (jniLibs/armeabi-v7a + arm64-v8a), 两个 ABI 都打进 APK
|
||||
defaultConfig {
|
||||
ndk {
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CW 类保持规则(JNI 按类名注册 + 照搬混淆类保逻辑), 由 app 的 R8 消费
|
||||
// AGP 9: consumer 规则走 variant 级 CanProduceConsumerProguardFiles
|
||||
androidComponents {
|
||||
onVariants(selector().all()) { variant ->
|
||||
(variant as? CanProduceConsumerProguardFiles)?.consumerProguardFiles?.add(
|
||||
project.layout.projectDirectory.file("proguard-rules.pro")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
|
||||
}
|
||||
|
||||
// 照搬的 Java 源码含中文注释, 强制 UTF-8 编译(compileOptions 在部分 AGP 版本不生效)
|
||||
tasks.withType<JavaCompile>().configureEach {
|
||||
options.encoding = "UTF-8"
|
||||
}
|
||||
Vendored
+31
@@ -0,0 +1,31 @@
|
||||
# Look4Sat Pro — CW 解码模块(照搬 Morse Expert 1.15)混淆规则
|
||||
# 所有 CW 类保持原名: pas.* 通过 RegisterNatives 按类名注册 JNI(混淆即崩);
|
||||
# 其余为照搬的混淆名类(命名已无混淆意义, 保持以保逻辑完整)。
|
||||
|
||||
# JNI 引擎(RegisterNatives 按类名/方法名查表)
|
||||
-keep class pas.** { *; }
|
||||
|
||||
# 照搬的解码/UI 类(全部保持)
|
||||
-keep class com.ve3nea.morse_expert.** { *; }
|
||||
-keep class H2.** { *; }
|
||||
-keep class I2.b { *; }
|
||||
-keep class J2.** { *; }
|
||||
-keep class k3.** { *; }
|
||||
-keep class i3.** { *; }
|
||||
-keep class g3.** { *; }
|
||||
-keep class j3.** { *; }
|
||||
-keep class s.** { *; }
|
||||
-keep class d1.AbstractC1518b { *; }
|
||||
-keep class B0.b { *; }
|
||||
-keep class B.RunnableC0001b { *; }
|
||||
-keep class D.n { *; }
|
||||
-keep class E2.g { *; }
|
||||
-keep class F2.a { *; }
|
||||
-keep class I0.d { *; }
|
||||
-keep class K1.a { *; }
|
||||
-keep class j1.C1646n { *; }
|
||||
|
||||
# sun.misc stub(Android 无此类, 仅编译期; 防止 R8 异常处理)
|
||||
-dontwarn sun.misc.**
|
||||
-keep class sun.misc.Unsafe { *; }
|
||||
-keep class sun.misc.Cleaner { *; }
|
||||
@@ -0,0 +1,63 @@
|
||||
package B;
|
||||
|
||||
import android.text.SpannableStringBuilder;
|
||||
import com.ve3nea.morse_expert.DecodedTextView;
|
||||
import com.ve3nea.morse_expert.MainActivity;
|
||||
import H2.b;
|
||||
|
||||
/**
|
||||
* 照搬自 Morse Expert 1.15 B.RunnableC0001b, 类名保持混淆原名。
|
||||
* R8 合并 Runnable 手术: 构造按 smali 真实签名 5 参 (Object,Object,int,int,boolean),
|
||||
* 字段 = i(Object)/h(Object)/g(int)/f(int); run() 仅保留 case 4(f==2 对应 pswitch_4:
|
||||
* 解码文本 UI 更新, H2.b 调用), 其余 case(权限/Intent/通知等库代码)已裁剪。
|
||||
* 注: jadx 4 参构造为错误还原, 已按 smali 修正。
|
||||
*/
|
||||
public final class RunnableC0001b implements Runnable {
|
||||
public final /* synthetic */ int f;
|
||||
|
||||
/* renamed from: g, reason: collision with root package name */
|
||||
public final int g;
|
||||
|
||||
/* renamed from: h, reason: collision with root package name */
|
||||
public final Object h;
|
||||
|
||||
/* renamed from: i, reason: collision with root package name */
|
||||
public final Object i;
|
||||
|
||||
public /* synthetic */ RunnableC0001b(Object obj, Object obj2, int i4, int i5, boolean z) {
|
||||
this.i = obj;
|
||||
this.h = obj2;
|
||||
this.g = i4;
|
||||
this.f = i5;
|
||||
}
|
||||
|
||||
@Override // java.lang.Runnable
|
||||
public final void run() {
|
||||
if (this.f == 2) {
|
||||
MainActivity mainActivity = ((b) this.i).f620p;
|
||||
if (mainActivity != null) {
|
||||
SpannableStringBuilder spannableStringBuilder = (SpannableStringBuilder) this.h;
|
||||
int i5 = this.g;
|
||||
DecodedTextView decodedTextView = (DecodedTextView) mainActivity.f11035D.f11888g;
|
||||
if (!decodedTextView.f11032n && !decodedTextView.f11033o.get()) {
|
||||
decodedTextView.beginBatchEdit();
|
||||
try {
|
||||
int length2 = decodedTextView.length();
|
||||
int min = Math.min(length2, i5);
|
||||
if (min > 0) {
|
||||
decodedTextView.getEditableText().delete(length2 - min, length2);
|
||||
}
|
||||
decodedTextView.q(spannableStringBuilder);
|
||||
decodedTextView.endBatchEdit();
|
||||
return;
|
||||
} catch (Throwable th) {
|
||||
decodedTextView.endBatchEdit();
|
||||
throw th;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package B0;
|
||||
|
||||
import android.widget.TextView;
|
||||
import com.ve3nea.morse_expert.MainActivity;
|
||||
import com.ve3nea.morse_expert.ScaleView;
|
||||
import java.util.Locale;
|
||||
import d1.AbstractC1518b;
|
||||
|
||||
/**
|
||||
* 照搬自 Morse Expert 1.15 B0.b, 类名保持混淆原名。
|
||||
* R8 合并 Runnable 手术: 构造按 smali 真实签名 (H2/b, String, [I) -> f=4;
|
||||
* 只保留 case 4(状态栏更新: dBFS/Hz/dB/WPM + ScaleView 频率标记, H2.b 调用),
|
||||
* 其余 case(WorkManager/动画等库代码)已裁剪。
|
||||
* r3 = scaleView.getHeight()(smali 证实, jadx 寄存器未展开)。
|
||||
*/
|
||||
public final class b implements Runnable {
|
||||
public final /* synthetic */ int f;
|
||||
|
||||
/* renamed from: g, reason: collision with root package name */
|
||||
public final Object g;
|
||||
|
||||
/* renamed from: h, reason: collision with root package name */
|
||||
public final Object h;
|
||||
|
||||
/* renamed from: i, reason: collision with root package name */
|
||||
public final Object i;
|
||||
|
||||
public b(H2.b bVar, String str, int[] iArr) {
|
||||
this.f = 4;
|
||||
this.g = bVar;
|
||||
this.h = iArr;
|
||||
this.i = str;
|
||||
}
|
||||
|
||||
@Override // java.lang.Runnable
|
||||
public final void run() {
|
||||
if (this.f == 4) {
|
||||
MainActivity mainActivity = ((H2.b) this.g).f621q;
|
||||
if (mainActivity != null) {
|
||||
String str2 = (String) this.i;
|
||||
int[] iArr = (int[]) this.h;
|
||||
String concat;
|
||||
String format = String.format(Locale.US, " %3d dBFS", Integer.valueOf(iArr[0]));
|
||||
if (iArr[5] == 0) {
|
||||
concat = format.concat(" Idle ");
|
||||
} else {
|
||||
concat = format.concat(String.format(Locale.US, " %4d Hz %2d dB %2d WPM ", Integer.valueOf(iArr[1]), Integer.valueOf(iArr[2]), Integer.valueOf(iArr[3])));
|
||||
}
|
||||
((TextView) mainActivity.f11035D.f11890i).setText(AbstractC1518b.e(concat, str2));
|
||||
ScaleView scaleView = (ScaleView) mainActivity.f11035D.f11889h;
|
||||
int i4 = iArr[1];
|
||||
scaleView.f11041g = iArr[5];
|
||||
// r3 = scaleView.getHeight()(smali 证实)
|
||||
scaleView.f = (scaleView.getHeight() - 1) - Math.round(((Math.round(i4 * 0.128f) - H2.b.f606z) * scaleView.getHeight()) / H2.b.f603B);
|
||||
scaleView.invalidate();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package D;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.opengl.GLES20;
|
||||
import android.opengl.GLUtils;
|
||||
import com.ve3nea.morse_expert.WaterfallSurfaceView;
|
||||
|
||||
/**
|
||||
* 照搬自 Morse Expert 1.15, 类名保持混淆原名。
|
||||
* R8 合并 Runnable 手术: 只保留 case 1(瀑布图频谱->OpenGL 纹理, H2.b 调用),
|
||||
* 其余 case(库/广告)已裁剪。
|
||||
*/
|
||||
public final /* synthetic */ class n implements Runnable {
|
||||
public final /* synthetic */ int f;
|
||||
|
||||
/* renamed from: g, reason: collision with root package name */
|
||||
public final /* synthetic */ Object f224g;
|
||||
|
||||
/* renamed from: h, reason: collision with root package name */
|
||||
public final /* synthetic */ Object f225h;
|
||||
|
||||
public /* synthetic */ n(Object obj, int i4, Object obj2) {
|
||||
this.f = i4;
|
||||
this.f224g = obj;
|
||||
this.f225h = obj2;
|
||||
}
|
||||
|
||||
@Override // java.lang.Runnable
|
||||
public final void run() {
|
||||
int i4 = this.f;
|
||||
Object obj = this.f225h;
|
||||
Object obj2 = this.f224g;
|
||||
if (i4 == 1) {
|
||||
float[] fArr = (float[]) obj;
|
||||
J2.b bVar = ((WaterfallSurfaceView) obj2).f;
|
||||
Bitmap bitmap = bVar.f733h;
|
||||
int width = bVar.f730d.getWidth();
|
||||
double d4 = width;
|
||||
double b4 = (bVar.b() + 4.0d) % d4;
|
||||
double d5 = bVar.f737l - b4;
|
||||
if (d5 > d4 / 2.0d) {
|
||||
d5 -= d4;
|
||||
} else if (d5 < (-width) / 2.0d) {
|
||||
d5 += d4;
|
||||
}
|
||||
if (Math.abs(d5) > 8.0d) {
|
||||
bVar.f737l = (int) Math.floor(b4);
|
||||
} else {
|
||||
bVar.f732g = (d5 * 1.0E-5d) + bVar.f732g;
|
||||
}
|
||||
int height = bitmap.getHeight() - 1;
|
||||
for (int i5 = 0; i5 <= height; i5++) {
|
||||
J2.a aVar = bVar.f734i;
|
||||
int round = Math.round((fArr[i5] * 4.0f) + 30.0f);
|
||||
aVar.getClass();
|
||||
bitmap.setPixel(0, height - i5, aVar.f727a[Math.max(0, Math.min(255, round))]);
|
||||
}
|
||||
int i6 = bVar.f737l;
|
||||
GLES20.glBindTexture(3553, bVar.f728a[0]);
|
||||
J2.b.a();
|
||||
GLUtils.texSubImage2D(3553, 0, i6, 0, bitmap);
|
||||
J2.b.a();
|
||||
int i7 = bVar.f737l + 1;
|
||||
bVar.f737l = i7;
|
||||
bVar.f737l = i7 % width;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package E2;
|
||||
|
||||
import android.util.Log;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import com.ve3nea.morse_expert.MainActivity;
|
||||
import com.ve3nea.morse_expert.ScaleView;
|
||||
|
||||
/**
|
||||
* 照搬自 Morse Expert 1.15 (com.ve3nea.morse_expert), 类名保持混淆原名。
|
||||
* R8 合并类手术: 只保留 case 1(瀑布图触摸选频率=应用逻辑),
|
||||
* 其余 case(广告 P4 / appcompat 焦点 / 弹窗)为库代码, 已裁剪。
|
||||
*/
|
||||
public final class g implements View.OnTouchListener {
|
||||
public final /* synthetic */ int f;
|
||||
|
||||
/* renamed from: g, reason: collision with root package name */
|
||||
public final /* synthetic */ Object f411g;
|
||||
|
||||
public /* synthetic */ g(Object obj, int i4) {
|
||||
this.f = i4;
|
||||
this.f411g = obj;
|
||||
}
|
||||
|
||||
@Override // android.view.View.OnTouchListener
|
||||
public final boolean onTouch(View view, MotionEvent motionEvent) {
|
||||
int i4 = this.f;
|
||||
Object obj = this.f411g;
|
||||
if (i4 == 1) {
|
||||
MainActivity mainActivity = (MainActivity) obj;
|
||||
if (motionEvent.getActionMasked() == 0 && mainActivity.f11037F != null) {
|
||||
// smali 证实: p1 先被重赋值为 f11035D.h(ScaleView)再 getHeight;
|
||||
// 公式 = round((f606z + ((scaleH-1-y)*f603B/scaleH)) / 0.128f)
|
||||
ScaleView scaleView = (ScaleView) mainActivity.f11035D.f11889h;
|
||||
float y3 = motionEvent.getY();
|
||||
int round = Math.round((H2.b.f606z + ((((scaleView.getHeight() - 1) - y3) * H2.b.f603B) / scaleView.getHeight())) / 0.128f);
|
||||
mainActivity.f11037F.f622r.set(round);
|
||||
Log.i("Waterfall", String.format("Touch at %d Hz`", Integer.valueOf(round)));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package F2;
|
||||
|
||||
import com.rtbishop.look4sat.feature.cw.R;
|
||||
import android.view.ActionMode;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import com.ve3nea.morse_expert.DecodedTextView;
|
||||
|
||||
/* loaded from: classes.dex */
|
||||
public final class a implements ActionMode.Callback {
|
||||
|
||||
/* renamed from: a, reason: collision with root package name */
|
||||
public final /* synthetic */ DecodedTextView f564a;
|
||||
|
||||
public a(DecodedTextView decodedTextView) {
|
||||
this.f564a = decodedTextView;
|
||||
}
|
||||
|
||||
@Override // android.view.ActionMode.Callback
|
||||
public final boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) {
|
||||
int itemId = menuItem.getItemId();
|
||||
DecodedTextView decodedTextView = this.f564a;
|
||||
if (itemId != 73) {
|
||||
if (itemId != 74) {
|
||||
return false;
|
||||
}
|
||||
decodedTextView.s();
|
||||
return true;
|
||||
}
|
||||
decodedTextView.r();
|
||||
actionMode.finish();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override // android.view.ActionMode.Callback
|
||||
public final boolean onCreateActionMode(ActionMode actionMode, Menu menu) {
|
||||
this.f564a.f11032n = true;
|
||||
// 原: if (menu.findItem(R.id.shareText) == null) 检查(shareText 为原 APK 资源, 简化后始终添加)
|
||||
menu.add(0, 74, 0, "Share").setIcon(R.drawable.ic_baseline_share_24);
|
||||
menu.add(0, 73, 0, "Save").setIcon(R.drawable.ic_baseline_save_24);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override // android.view.ActionMode.Callback
|
||||
public final void onDestroyActionMode(ActionMode actionMode) {
|
||||
this.f564a.f11032n = false;
|
||||
}
|
||||
|
||||
@Override // android.view.ActionMode.Callback
|
||||
public final boolean onPrepareActionMode(ActionMode actionMode, Menu menu) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package H2;
|
||||
|
||||
import android.media.AudioRecord;
|
||||
import android.util.Log;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/* loaded from: classes.dex */
|
||||
public final class a implements Runnable {
|
||||
public final int f;
|
||||
|
||||
/* renamed from: g, reason: collision with root package name */
|
||||
public final AudioRecord f596g;
|
||||
|
||||
/* renamed from: h, reason: collision with root package name */
|
||||
public final ExecutorService f597h;
|
||||
|
||||
/* renamed from: i, reason: collision with root package name */
|
||||
public final AtomicBoolean f598i;
|
||||
|
||||
/* renamed from: j, reason: collision with root package name */
|
||||
public final short[] f599j;
|
||||
|
||||
/* renamed from: k, reason: collision with root package name */
|
||||
public final float[] f600k;
|
||||
|
||||
/* renamed from: l, reason: collision with root package name */
|
||||
public b f601l;
|
||||
|
||||
public a() {
|
||||
int minBufferSize = AudioRecord.getMinBufferSize(8000, 16, 2);
|
||||
int i4 = b.f604C;
|
||||
this.f = i4;
|
||||
ExecutorService newSingleThreadExecutor = Executors.newSingleThreadExecutor();
|
||||
this.f597h = newSingleThreadExecutor;
|
||||
AtomicBoolean atomicBoolean = new AtomicBoolean(false);
|
||||
this.f598i = atomicBoolean;
|
||||
this.f599j = new short[i4];
|
||||
this.f600k = new float[i4];
|
||||
AudioRecord audioRecord = new AudioRecord(1, 8000, 16, 2, minBufferSize * 4);
|
||||
this.f596g = audioRecord;
|
||||
if (audioRecord.getState() == 1) {
|
||||
audioRecord.startRecording();
|
||||
if (audioRecord.getState() != 1) {
|
||||
Log.e("AudioRecord", "cannot start");
|
||||
}
|
||||
atomicBoolean.set(true);
|
||||
newSingleThreadExecutor.execute(this);
|
||||
return;
|
||||
}
|
||||
Log.e("AudioInput", "Failed to initialize");
|
||||
}
|
||||
|
||||
@Override // java.lang.Runnable
|
||||
public final void run() {
|
||||
while (true) {
|
||||
try {
|
||||
if (this.f598i.get()) {
|
||||
float[] fArr = this.f600k;
|
||||
AudioRecord audioRecord = this.f596g;
|
||||
short[] sArr = this.f599j;
|
||||
int i4 = this.f;
|
||||
if (audioRecord.read(sArr, 0, i4) == i4) {
|
||||
for (int i5 = 0; i5 < i4; i5++) {
|
||||
fArr[i5] = sArr[i5] / 32768.0f;
|
||||
}
|
||||
b bVar = this.f601l;
|
||||
if (bVar != null) {
|
||||
bVar.a(fArr);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
TimeUnit.MILLISECONDS.sleep(100L);
|
||||
}
|
||||
} catch (Exception e4) {
|
||||
Log.e("Audio thread", "Exception: ", e4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
package H2;
|
||||
|
||||
import B.RunnableC0001b;
|
||||
import D.n;
|
||||
import android.content.Context;
|
||||
import android.media.MediaScannerConnection;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.format.DateFormat;
|
||||
import android.text.style.ForegroundColorSpan;
|
||||
import android.util.Log;
|
||||
import com.ve3nea.morse_expert.MainActivity;
|
||||
import com.ve3nea.morse_expert.WaterfallSurfaceView;
|
||||
import i3.d;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import k3.s;
|
||||
import pas.decoder;
|
||||
import pas.nativedecoder;
|
||||
|
||||
/* loaded from: classes.dex */
|
||||
public final class b {
|
||||
|
||||
/* renamed from: A, reason: collision with root package name */
|
||||
public static final int f602A;
|
||||
|
||||
/* renamed from: B, reason: collision with root package name */
|
||||
public static final int f603B;
|
||||
|
||||
/* renamed from: C, reason: collision with root package name */
|
||||
public static final int f604C;
|
||||
|
||||
/* renamed from: D, reason: collision with root package name */
|
||||
public static final d f605D;
|
||||
|
||||
/* renamed from: z, reason: collision with root package name */
|
||||
public static final int f606z;
|
||||
|
||||
/* renamed from: a, reason: collision with root package name */
|
||||
public final float[] f607a;
|
||||
|
||||
/* renamed from: b, reason: collision with root package name */
|
||||
public final float[] f608b;
|
||||
public final float[] c;
|
||||
|
||||
/* renamed from: d, reason: collision with root package name */
|
||||
public final float[] f609d;
|
||||
|
||||
/* renamed from: e, reason: collision with root package name */
|
||||
public final nativedecoder.TNativeDecoder f610e;
|
||||
public int f;
|
||||
|
||||
/* renamed from: g, reason: collision with root package name */
|
||||
public int f611g;
|
||||
|
||||
/* renamed from: h, reason: collision with root package name */
|
||||
public int f612h;
|
||||
|
||||
/* renamed from: i, reason: collision with root package name */
|
||||
public decoder.TDecoderState f613i;
|
||||
|
||||
/* renamed from: j, reason: collision with root package name */
|
||||
public String f614j;
|
||||
|
||||
/* renamed from: k, reason: collision with root package name */
|
||||
public final c f615k;
|
||||
|
||||
/* renamed from: l, reason: collision with root package name */
|
||||
public int f616l;
|
||||
|
||||
/* renamed from: m, reason: collision with root package name */
|
||||
public final I2.b f617m;
|
||||
|
||||
/* renamed from: n, reason: collision with root package name */
|
||||
public final Handler f618n;
|
||||
|
||||
/* renamed from: o, reason: collision with root package name */
|
||||
public WaterfallSurfaceView f619o;
|
||||
|
||||
/* renamed from: p, reason: collision with root package name */
|
||||
public MainActivity f620p;
|
||||
|
||||
/* renamed from: q, reason: collision with root package name */
|
||||
public MainActivity f621q;
|
||||
|
||||
/* renamed from: r, reason: collision with root package name */
|
||||
public final AtomicInteger f622r;
|
||||
|
||||
/* renamed from: s, reason: collision with root package name */
|
||||
public final AtomicBoolean f623s;
|
||||
|
||||
/* renamed from: t, reason: collision with root package name */
|
||||
public final Context f624t;
|
||||
|
||||
/* renamed from: u, reason: collision with root package name */
|
||||
public int f625u;
|
||||
|
||||
/* renamed from: v, reason: collision with root package name */
|
||||
public final AtomicBoolean f626v;
|
||||
|
||||
/* renamed from: w, reason: collision with root package name */
|
||||
public DataOutputStream f627w;
|
||||
|
||||
/* renamed from: x, reason: collision with root package name */
|
||||
public int f628x;
|
||||
|
||||
/* renamed from: y, reason: collision with root package name */
|
||||
public String f629y;
|
||||
|
||||
/* JADX WARN: Removed duplicated region for block: B:14:0x012d */
|
||||
/* JADX WARN: Type inference failed for: r0v4, types: [i3.d, java.lang.Object] */
|
||||
static {
|
||||
boolean z3;
|
||||
float f;
|
||||
float f4;
|
||||
float f5;
|
||||
int round = Math.round(25.6f);
|
||||
f606z = round;
|
||||
int round2 = Math.round(153.6f);
|
||||
f602A = round2;
|
||||
f603B = round2 - round;
|
||||
f604C = Math.round(85.333336f);
|
||||
i3.d obj = new i3.d();
|
||||
if (2048 > 1073741824) {
|
||||
z3 = true;
|
||||
} else {
|
||||
z3 = false;
|
||||
}
|
||||
obj.f11709l = z3;
|
||||
int i4 = (int) 1024;
|
||||
obj.f11700a = i4;
|
||||
obj.f11701b = 1024L;
|
||||
if (!z3) {
|
||||
obj.f11708k = 1;
|
||||
int[] iArr = new int[((int) g3.c.a((1 << (((int) (g3.c.d(((float) 1024) + 0.5f) / g3.c.d(2.0d))) / 2)) + 2)) + 2];
|
||||
obj.c = iArr;
|
||||
float[] fArr = new float[i4];
|
||||
obj.f11703e = fArr;
|
||||
int i5 = (i4 * 2) >> 2;
|
||||
obj.f11704g = i5;
|
||||
iArr[0] = i5;
|
||||
iArr[1] = 1;
|
||||
if (i5 > 2) {
|
||||
int i6 = i5 >> 1;
|
||||
float f6 = 0.7853982f / i6;
|
||||
float f7 = 2.0f * f6;
|
||||
float b4 = (float) g3.c.b(i6 * f6); // r9 = i6 (smali)
|
||||
fArr[0] = 1.0f;
|
||||
fArr[1] = b4;
|
||||
if (i6 == 4) {
|
||||
double d4 = f7;
|
||||
f4 = 0.5f;
|
||||
fArr[2] = (float) g3.c.b(d4);
|
||||
fArr[3] = (float) g3.c.f(d4);
|
||||
} else {
|
||||
f4 = 0.5f;
|
||||
if (i6 > 4) {
|
||||
iArr[2] = 0;
|
||||
iArr[3] = 16;
|
||||
int i7 = i5;
|
||||
int i8 = 2;
|
||||
f5 = 1.0f;
|
||||
while (i7 > 32) {
|
||||
int i9 = i8 << 1;
|
||||
int i10 = i8 << 4;
|
||||
for (int i11 = i8; i11 < i9; i11++) {
|
||||
int i12 = iArr[i11] << 2;
|
||||
iArr[i8 + i11] = i12;
|
||||
iArr[i9 + i11] = i12 + i10;
|
||||
}
|
||||
i7 >>= 2;
|
||||
i8 = i9;
|
||||
}
|
||||
fArr[2] = 0.5f / ((float) g3.c.b(f7));
|
||||
fArr[3] = 0.5f / ((float) g3.c.b(6.0f * f6));
|
||||
for (int i13 = 4; i13 < i6; i13 += 4) {
|
||||
float f8 = i13 * f6;
|
||||
double d5 = f8;
|
||||
fArr[i13] = (float) g3.c.b(d5);
|
||||
fArr[i13 + 1] = (float) g3.c.f(d5);
|
||||
double d6 = 3.0f * f8;
|
||||
fArr[i13 + 2] = (float) g3.c.b(d6);
|
||||
fArr[i13 + 3] = -((float) g3.c.f(d6));
|
||||
}
|
||||
int i14 = 0;
|
||||
while (i6 > 2) {
|
||||
int i15 = i14 + i6;
|
||||
i6 >>= 1;
|
||||
fArr[i15] = f5;
|
||||
fArr[i15 + 1] = b4;
|
||||
if (i6 == 4) {
|
||||
float f9 = fArr[i14 + 4];
|
||||
float f10 = fArr[i14 + 5];
|
||||
fArr[i15 + 2] = f9;
|
||||
fArr[i15 + 3] = f10;
|
||||
} else if (i6 > 4) {
|
||||
float f11 = fArr[i14 + 4];
|
||||
float f12 = fArr[i14 + 6];
|
||||
fArr[i15 + 2] = f4 / f11;
|
||||
fArr[i15 + 3] = f4 / f12;
|
||||
for (int i16 = 4; i16 < i6; i16 += 4) {
|
||||
int i17 = (i16 * 2) + i14;
|
||||
int i18 = i15 + i16;
|
||||
float f13 = fArr[i17];
|
||||
float f14 = fArr[i17 + 1];
|
||||
float f15 = fArr[i17 + 2];
|
||||
float f16 = fArr[i17 + 3];
|
||||
fArr[i18] = f13;
|
||||
fArr[i18 + 1] = f14;
|
||||
fArr[i18 + 2] = f15;
|
||||
fArr[i18 + 3] = f16;
|
||||
}
|
||||
}
|
||||
i14 = i15;
|
||||
}
|
||||
}
|
||||
}
|
||||
f5 = 1.0f;
|
||||
int i142 = 0;
|
||||
while (i6 > 2) {
|
||||
}
|
||||
}
|
||||
int i19 = i4 >> 2;
|
||||
obj.f11706i = i19;
|
||||
iArr[1] = i19;
|
||||
if (i19 > 1) {
|
||||
int i20 = i19 >> 1;
|
||||
float f17 = 0.7853982f / i20;
|
||||
float b5 = (float) g3.c.b(i20 * f17); // r6 = i20 (smali)
|
||||
fArr[i5] = b5;
|
||||
fArr[i5 + i20] = b5 * 0.5f;
|
||||
for (int i21 = 1; i21 < i20; i21++) {
|
||||
double d7 = i21 * f17;
|
||||
fArr[i5 + i21] = ((float) g3.c.b(d7)) * 0.5f;
|
||||
fArr[(i5 + i19) - i21] = ((float) g3.c.f(d7)) * 0.5f;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
obj.f11708k = 1;
|
||||
s sVar = new s(((long) g3.c.a((1 << ((int) (((long) (g3.c.d(((float) 1024) + 0.5f) / g3.c.d(2.0d))) / 2))) + 2)) + 2, true);
|
||||
obj.f11702d = sVar;
|
||||
k3.d dVar = new k3.d(1024L, true);
|
||||
obj.f = dVar;
|
||||
obj.f11705h = 512L;
|
||||
sVar.c(0L, 512L);
|
||||
sVar.c(1L, 1L);
|
||||
if (512 > 2) {
|
||||
long j4 = 512 >> 1;
|
||||
float f18 = 0.7853982f / ((float) j4);
|
||||
float f19 = f18 * 2.0f;
|
||||
float b6 = (float) g3.c.b(j4 * f18); // r14 = j4 (smali 同模式)
|
||||
dVar.c(0L, 1.0f);
|
||||
dVar.c(1L, b6);
|
||||
if (j4 == 4) {
|
||||
f = 0.5f;
|
||||
double d8 = f19;
|
||||
dVar.c(2L, (float) g3.c.b(d8));
|
||||
dVar.c(3L, (float) g3.c.f(d8));
|
||||
} else {
|
||||
f = 0.5f;
|
||||
if (j4 > 4) {
|
||||
sVar.c(2L, 0L);
|
||||
sVar.c(3L, 16L);
|
||||
long j5 = 2;
|
||||
long j6 = 512;
|
||||
while (j6 > 32) {
|
||||
long j7 = j5 << 1;
|
||||
long j8 = j5 << 4;
|
||||
long j9 = j5;
|
||||
while (j9 < j7) {
|
||||
long b7 = sVar.b(j9) << 2;
|
||||
sVar.c(j5 + j9, b7);
|
||||
sVar.c(j7 + j9, b7 + j8);
|
||||
j9++;
|
||||
f19 = f19;
|
||||
}
|
||||
j6 >>= 2;
|
||||
j5 = j7;
|
||||
}
|
||||
dVar.c(2L, 0.5f / ((float) g3.c.b(f19)));
|
||||
dVar.c(3L, 0.5f / ((float) g3.c.b(6.0f * f18)));
|
||||
long j10 = 4;
|
||||
while (j10 < j4) {
|
||||
float f20 = ((float) j10) * f18;
|
||||
double d9 = f20;
|
||||
dVar.c(j10, (float) g3.c.b(d9));
|
||||
dVar.c(j10 + 1, (float) g3.c.f(d9));
|
||||
double d10 = 3.0f * f20;
|
||||
long j11 = j10;
|
||||
dVar.c(j10 + 2, (float) g3.c.b(d10));
|
||||
dVar.c(j11 + 3, -((float) g3.c.f(d10)));
|
||||
j10 = j11 + 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
long j12 = 0;
|
||||
long j13 = 2;
|
||||
while (j4 > j13) {
|
||||
long j14 = j12 + j4;
|
||||
j4 >>= 1;
|
||||
dVar.c(j14, 1.0f);
|
||||
dVar.c(j14 + 1, b6);
|
||||
if (j4 == 4) {
|
||||
float b8 = dVar.b(j12 + 4);
|
||||
float b9 = dVar.b(j12 + 5);
|
||||
j13 = 2;
|
||||
dVar.c(j14 + 2, b8);
|
||||
dVar.c(j14 + 3, b9);
|
||||
} else {
|
||||
j13 = 2;
|
||||
if (j4 > 4) {
|
||||
float b10 = dVar.b(j12 + 4);
|
||||
float b11 = dVar.b(6 + j12);
|
||||
dVar.c(j14 + 2, f / b10);
|
||||
dVar.c(j14 + 3, f / b11);
|
||||
long j15 = 4;
|
||||
while (j15 < j4) {
|
||||
long j16 = (j15 * 2) + j12;
|
||||
float f21 = b6;
|
||||
long j17 = j14 + j15;
|
||||
long j18 = j12;
|
||||
float b12 = dVar.b(j16);
|
||||
long j19 = j14;
|
||||
float b13 = dVar.b(j16 + 1);
|
||||
float b14 = dVar.b(j16 + 2);
|
||||
float b15 = dVar.b(j16 + 3);
|
||||
dVar.c(j17, b12);
|
||||
dVar.c(j17 + 1, b13);
|
||||
dVar.c(j17 + 2, b14);
|
||||
dVar.c(j17 + 3, b15);
|
||||
j15 += 4;
|
||||
b6 = f21;
|
||||
j12 = j18;
|
||||
j14 = j19;
|
||||
}
|
||||
}
|
||||
}
|
||||
b6 = b6;
|
||||
j12 = j14;
|
||||
}
|
||||
}
|
||||
long j20 = 256;
|
||||
obj.f11707j = 256L;
|
||||
long j21 = 1;
|
||||
sVar.c(1L, 256L);
|
||||
if (256 > 1) {
|
||||
long j22 = 256 >> 1;
|
||||
float f22 = 0.7853982f / ((float) j22);
|
||||
long j23 = 512;
|
||||
dVar.c(512L, (float) g3.c.b(j22 * f22)); // r1 = j22 (smali)
|
||||
dVar.c(512 + j22, dVar.b(512L) * 0.5f);
|
||||
long j24 = 1;
|
||||
while (j24 < j22) {
|
||||
long j25 = j20;
|
||||
long j26 = j21;
|
||||
double d11 = ((float) j24) * f22;
|
||||
long j27 = j23;
|
||||
dVar.c(j23 + j24, ((float) g3.c.b(d11)) * 0.5f);
|
||||
dVar.c((j27 + j25) - j24, ((float) g3.c.f(d11)) * 0.5f);
|
||||
j24 += j26;
|
||||
j20 = j25;
|
||||
j21 = j26;
|
||||
j23 = j27;
|
||||
}
|
||||
}
|
||||
}
|
||||
f605D = obj;
|
||||
}
|
||||
|
||||
public b(Context context) {
|
||||
float[] fArr = new float[1024];
|
||||
fArr[512] = 1.0f;
|
||||
for (int i4 = 1; i4 < 512; i4++) {
|
||||
double d4 = (i4 * 6.283185307179586d) / 1024;
|
||||
float cos = (float) ((Math.cos(d4 * 2.0d) * 0.08d) + (0.42d - (Math.cos(d4) * 0.5d)));
|
||||
fArr[i4] = cos;
|
||||
double d5 = 0.003125f * 6.283185307179586d * (i4 - 512);
|
||||
float sin = (float) ((Math.sin(d5) / d5) * cos);
|
||||
fArr[i4] = sin;
|
||||
fArr[1024 - i4] = sin;
|
||||
}
|
||||
this.f607a = fArr;
|
||||
this.f608b = new float[1024];
|
||||
this.c = new float[1024];
|
||||
this.f609d = new float[f603B];
|
||||
this.f610e = nativedecoder.TNativeDecoder.Create();
|
||||
this.f614j = "";
|
||||
this.f615k = new c(4000, 0);
|
||||
I2.b bVar = new I2.b();
|
||||
this.f617m = bVar;
|
||||
this.f618n = new Handler(Looper.getMainLooper());
|
||||
this.f622r = new AtomicInteger();
|
||||
this.f623s = new AtomicBoolean(false);
|
||||
this.f625u = 0;
|
||||
this.f626v = new AtomicBoolean(false);
|
||||
this.f628x = 0;
|
||||
this.f624t = context;
|
||||
bVar.b(context);
|
||||
}
|
||||
|
||||
/* JADX WARN: Removed duplicated region for block: B:57:0x01e2 */
|
||||
/* JADX WARN: Removed duplicated region for block: B:63:0x020a A[LOOP:6: B:62:0x0208->B:63:0x020a, LOOP_END] */
|
||||
/* JADX WARN: Removed duplicated region for block: B:66:0x0216 A[LOOP:7: B:65:0x0214->B:66:0x0216, LOOP_END] */
|
||||
/* JADX WARN: Removed duplicated region for block: B:70:0x0232 */
|
||||
/* JADX WARN: Removed duplicated region for block: B:73:? A[RETURN, SYNTHETIC] */
|
||||
/*
|
||||
Code decompiled incorrectly, please refer to instructions dump.
|
||||
*/
|
||||
public final void a(float[] fArr) {
|
||||
c cVar;
|
||||
float[] fArr2;
|
||||
float[] fArr3;
|
||||
b bVar;
|
||||
Exception exc;
|
||||
int i4;
|
||||
float f;
|
||||
int i5;
|
||||
WaterfallSurfaceView waterfallSurfaceView;
|
||||
int length = fArr.length;
|
||||
int i6 = 0;
|
||||
while (true) {
|
||||
cVar = this.f615k;
|
||||
if (i6 >= length) {
|
||||
break;
|
||||
}
|
||||
cVar.c(Math.abs(fArr[i6]));
|
||||
i6++;
|
||||
}
|
||||
this.f616l = Math.round(((float) Math.log10(Math.max(-0.5d, cVar.f633e))) * 20.0f);
|
||||
int i7 = f604C;
|
||||
int i8 = 1024 - i7;
|
||||
float[] fArr4 = this.f608b;
|
||||
System.arraycopy(fArr4, i7, fArr4, 0, i8);
|
||||
System.arraycopy(fArr, 0, fArr4, i8, i7);
|
||||
int i9 = 0;
|
||||
while (true) {
|
||||
fArr2 = this.c;
|
||||
if (i9 >= 1024) {
|
||||
break;
|
||||
}
|
||||
fArr2[i9] = fArr4[i9] * this.f607a[i9];
|
||||
i9++;
|
||||
}
|
||||
f605D.k(fArr2, 0);
|
||||
int i10 = f606z;
|
||||
int i11 = i10;
|
||||
while (true) {
|
||||
int i12 = f602A;
|
||||
float f4 = 0.0f;
|
||||
fArr3 = this.f609d;
|
||||
if (i11 < i12) {
|
||||
int i13 = i11 * 2;
|
||||
float f5 = fArr2[i13];
|
||||
float f6 = fArr2[i13 + 1];
|
||||
float f7 = ((f6 * f6) + (f5 * f5)) * 0.001f;
|
||||
if (!Float.isNaN(f7)) {
|
||||
f4 = f7;
|
||||
}
|
||||
fArr3[i11 - i10] = f4;
|
||||
i11++;
|
||||
} else {
|
||||
// smali: 循环内无 try; 录音写入 try 在循环后(见下)
|
||||
break;
|
||||
}
|
||||
}
|
||||
DataOutputStream dataOutputStream = this.f627w;
|
||||
Context context = this.f624t;
|
||||
AtomicBoolean atomicBoolean = this.f626v;
|
||||
try { // smali try_start_0: 录音写入整块(原 jadx 误把 try 放 break 处)
|
||||
if (dataOutputStream == null && atomicBoolean.get()) {
|
||||
File file = new File(context.getExternalFilesDir(null), ((String) DateFormat.format("yyyy-MM-dd_HH_mm_ss", new Date())) + "_spectra.bin");
|
||||
this.f629y = file.getAbsoluteFile().toString();
|
||||
this.f627w = new DataOutputStream(new BufferedOutputStream(new FileOutputStream(file, false)));
|
||||
this.f628x = 0;
|
||||
this.f614j = " Recording...";
|
||||
}
|
||||
if (this.f627w != null) {
|
||||
for (float f8 : fArr3) {
|
||||
this.f627w.writeFloat(f8);
|
||||
}
|
||||
}
|
||||
if (atomicBoolean.get()) {
|
||||
int i14 = this.f628x + 1;
|
||||
this.f628x = i14;
|
||||
if (i14 >= 2048) {
|
||||
atomicBoolean.set(false);
|
||||
this.f627w.close();
|
||||
this.f627w = null;
|
||||
this.f614j = "";
|
||||
MediaScannerConnection.scanFile(context, new String[]{this.f629y}, new String[]{"application/x-binary"}, null);
|
||||
}
|
||||
}
|
||||
} catch (IOException e4) {
|
||||
Log.e("File write", "Exception: ", e4);
|
||||
}
|
||||
nativedecoder.TNativeDecoder tNativeDecoder = this.f610e;
|
||||
try {
|
||||
this.f613i = tNativeDecoder.ProcessSpectrum(fArr3);
|
||||
int i15 = this.f625u + 1;
|
||||
this.f625u = i15;
|
||||
Handler handler = this.f618n;
|
||||
if (i15 == 15) {
|
||||
try {
|
||||
this.f625u = 0;
|
||||
this.f = tNativeDecoder.getPitch();
|
||||
this.f611g = tNativeDecoder.getSnr();
|
||||
this.f612h = tNativeDecoder.getWpm();
|
||||
handler.post(new B0.b(this, this.f614j, new int[]{this.f616l, this.f, this.f611g, this.f612h, tNativeDecoder.getSignalCount(), this.f613i.Value}));
|
||||
AtomicInteger atomicInteger = this.f622r;
|
||||
int i16 = atomicInteger.get();
|
||||
if (i16 != 0) {
|
||||
tNativeDecoder.LockToPitch(i16);
|
||||
atomicInteger.set(0);
|
||||
}
|
||||
tNativeDecoder.setHamMode(this.f623s.get());
|
||||
} catch (Exception e5) {
|
||||
// jadx 幻觉区(空循环/空 if 为 catch 错位还原)已裁剪; 原 smali: 记录异常后跳回正常流程尾部
|
||||
exc = e5;
|
||||
bVar = this;
|
||||
Log.e("Audio Processor", "Exception", exc);
|
||||
}
|
||||
}
|
||||
int charCount = tNativeDecoder.getCharCount();
|
||||
if (charCount > 0) {
|
||||
SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder();
|
||||
int i18 = 0;
|
||||
while (i18 < charCount) {
|
||||
decoder.TCharInfo GetChar = tNativeDecoder.GetChar(i18);
|
||||
spannableStringBuilder.append(GetChar.getChar());
|
||||
ForegroundColorSpan foregroundColorSpan = new ForegroundColorSpan(this.f617m.a(GetChar));
|
||||
int i19 = i18 + 1;
|
||||
spannableStringBuilder.setSpan(foregroundColorSpan, i18, i19, 33);
|
||||
i18 = i19;
|
||||
}
|
||||
bVar = this;
|
||||
try {
|
||||
handler.post(new RunnableC0001b(bVar, spannableStringBuilder, tNativeDecoder.getDeleteCount(), 2, false));
|
||||
} catch (Exception e6) {
|
||||
// jadx 幻觉区已裁剪(同 catch e5); 原 smali: 记录异常后跳回正常流程尾部
|
||||
exc = e6;
|
||||
Log.e("Audio Processor", "Exception", exc);
|
||||
}
|
||||
} else {
|
||||
bVar = this;
|
||||
}
|
||||
} catch (Exception e7) {
|
||||
// jadx 幻觉(e 变量)已裁剪
|
||||
bVar = this;
|
||||
}
|
||||
i4 = f603B;
|
||||
int i1722 = i4 / 2;
|
||||
float[] copyOfRange22 = Arrays.copyOfRange(fArr3, i1722 - 20, i1722 + 20);
|
||||
Arrays.sort(copyOfRange22);
|
||||
f = copyOfRange22[20];
|
||||
if (f > 0.0f) {
|
||||
for (int i20 = 0; i20 < i4; i20++) {
|
||||
fArr3[i20] = fArr3[i20] / f;
|
||||
}
|
||||
}
|
||||
float[] fArr522 = (float[]) fArr3.clone();
|
||||
new c(12, 1).a(fArr522);
|
||||
new c(12, 0).a(fArr522);
|
||||
for (i5 = 0; i5 < i4; i5++) {
|
||||
fArr3[i5] = fArr3[i5] - fArr522[i5];
|
||||
}
|
||||
for (int i21 = 0; i21 < i4; i21++) {
|
||||
fArr3[i21] = ((float) Math.log10(Math.max(-0.5d, fArr3[i21]) + 1.0d)) * 10.0f;
|
||||
}
|
||||
waterfallSurfaceView = bVar.f619o;
|
||||
if (waterfallSurfaceView != null) {
|
||||
waterfallSurfaceView.queueEvent(new n(waterfallSurfaceView, 1, (float[]) fArr3.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package H2;
|
||||
|
||||
/* loaded from: classes.dex */
|
||||
public final class c {
|
||||
|
||||
/* renamed from: a, reason: collision with root package name */
|
||||
public final int f630a;
|
||||
|
||||
/* renamed from: b, reason: collision with root package name */
|
||||
public float[] f631b;
|
||||
public int c;
|
||||
|
||||
/* renamed from: d, reason: collision with root package name */
|
||||
public float f632d;
|
||||
|
||||
/* renamed from: e, reason: collision with root package name */
|
||||
public float f633e;
|
||||
public final /* synthetic */ int f;
|
||||
|
||||
public c(int i4, int i5) {
|
||||
this.f = i5;
|
||||
this.f630a = i4 - 1;
|
||||
}
|
||||
|
||||
public final void a(float[] fArr) {
|
||||
int i4 = this.f630a / 2;
|
||||
int length = fArr.length;
|
||||
if (length > i4) {
|
||||
this.f631b = null;
|
||||
for (int i5 = 0; i5 < i4; i5++) {
|
||||
c(fArr[i5]);
|
||||
}
|
||||
for (int i6 = i4; i6 < length; i6++) {
|
||||
fArr[i6 - i4] = c(fArr[i6]);
|
||||
}
|
||||
for (int i7 = length; i7 < length + i4; i7++) {
|
||||
fArr[i7 - i4] = c(fArr[length - 1]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new IllegalArgumentException("Sliding Filter error: input too short");
|
||||
}
|
||||
|
||||
public final float b(float f, float f4) {
|
||||
switch (this.f) {
|
||||
case 0:
|
||||
return Math.max(f, f4);
|
||||
default:
|
||||
return Math.min(f, f4);
|
||||
}
|
||||
}
|
||||
|
||||
public final float c(float f) {
|
||||
float[] fArr = this.f631b;
|
||||
int i4 = this.f630a;
|
||||
if (fArr == null) {
|
||||
this.f631b = new float[i4];
|
||||
for (int i5 = 0; i5 < i4; i5++) {
|
||||
this.f631b[i5] = f;
|
||||
}
|
||||
this.f632d = f;
|
||||
return f;
|
||||
}
|
||||
float b4 = b(this.f632d, f);
|
||||
this.f632d = b4;
|
||||
this.f633e = b(this.f631b[this.c], b4);
|
||||
float[] fArr2 = this.f631b;
|
||||
int i6 = this.c;
|
||||
int i7 = i6 + 1;
|
||||
this.c = i7;
|
||||
fArr2[i6] = f;
|
||||
if (i7 == i4) {
|
||||
int length = fArr2.length;
|
||||
while (true) {
|
||||
this.c = length - 1;
|
||||
int i8 = this.c;
|
||||
if (i8 <= 0) {
|
||||
break;
|
||||
}
|
||||
float[] fArr3 = this.f631b;
|
||||
fArr3[i8 - 1] = b(fArr3[i8 - 1], fArr3[i8]);
|
||||
length = this.c;
|
||||
}
|
||||
this.c = 0;
|
||||
this.f632d = f;
|
||||
}
|
||||
return this.f633e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package I0;
|
||||
|
||||
/**
|
||||
* 照搬自 Morse Expert 1.15 I0.d, 类名保持混淆原名。
|
||||
* R8 合并类手术: 只保留 (i3.d, int, int, float[]) FFT 任务构造;
|
||||
* run() 按 smali 原样还原: R8 已将任务体优化为死代码(aget 后 throw null,
|
||||
* 永不执行), 保留原语义(行为与原 APK 完全一致)。
|
||||
* SystemForegroundService(work 库)case 已裁剪。
|
||||
*/
|
||||
public final class d implements Runnable {
|
||||
public final /* synthetic */ int f = 0;
|
||||
|
||||
/* renamed from: g, reason: collision with root package name */
|
||||
public final /* synthetic */ int f653g;
|
||||
|
||||
/* renamed from: h, reason: collision with root package name */
|
||||
public final /* synthetic */ int f654h;
|
||||
|
||||
/* renamed from: i, reason: collision with root package name */
|
||||
public final /* synthetic */ Object f655i;
|
||||
|
||||
/* renamed from: j, reason: collision with root package name */
|
||||
public final /* synthetic */ Object f656j;
|
||||
|
||||
public d(i3.d dVar, int i4, int i5, float[] fArr) {
|
||||
this.f656j = dVar;
|
||||
this.f653g = i4;
|
||||
this.f654h = i5;
|
||||
this.f655i = fArr;
|
||||
}
|
||||
|
||||
@Override // java.lang.Runnable
|
||||
public final void run() {
|
||||
int i6 = this.f654h;
|
||||
int i7 = this.f653g;
|
||||
if (i7 >= i6) {
|
||||
return;
|
||||
}
|
||||
float f = ((float[]) this.f655i)[i7 * 2];
|
||||
// R8 原代码: aget 后 throw null(死代码, 永不执行) —— 照搬语义, 不执行任何计算
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package I2;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import pas.decoder;
|
||||
|
||||
/* loaded from: classes.dex */
|
||||
public final class b {
|
||||
|
||||
/* renamed from: b, reason: collision with root package name */
|
||||
public static final String[] f663b = {"bg_color", "text_color", "text_color_weak", "call_color", "call_color_weak", "cq_color", "cq_color_weak", "rst_color", "rst_color_weak"};
|
||||
public static final String[] c = {"Background color", "Text color", "Text color, weak", "Callsign color", "Callsign color, weak", "CQ color", "CQ color, weak", "RST color", "RST color, weak"};
|
||||
|
||||
/* renamed from: d, reason: collision with root package name */
|
||||
public static final int[] f664d = {-3084048, -16777216, -5592406, -65536, -30584, -16776961, -7829249, -65281, -30465};
|
||||
|
||||
/* renamed from: a, reason: collision with root package name */
|
||||
public final int[] f665a = (int[]) f664d.clone();
|
||||
|
||||
public final int a(decoder.TCharInfo tCharInfo) {
|
||||
boolean lowSnr = tCharInfo.getLowSnr();
|
||||
int[] iArr = this.f665a;
|
||||
if (lowSnr) {
|
||||
int i4 = tCharInfo.getWordType().Value;
|
||||
if (i4 != 0) {
|
||||
if (i4 != 1) {
|
||||
if (i4 != 2) {
|
||||
if (i4 != 3) {
|
||||
if (i4 != 4) {
|
||||
if (i4 == 5) {
|
||||
return iArr[4];
|
||||
}
|
||||
return -16777216;
|
||||
}
|
||||
return iArr[2];
|
||||
}
|
||||
return iArr[6];
|
||||
}
|
||||
return iArr[8];
|
||||
}
|
||||
return iArr[2];
|
||||
}
|
||||
return iArr[2];
|
||||
}
|
||||
int i5 = tCharInfo.getWordType().Value;
|
||||
if (i5 != 0) {
|
||||
if (i5 != 1) {
|
||||
if (i5 != 2) {
|
||||
if (i5 != 3) {
|
||||
if (i5 != 4) {
|
||||
if (i5 != 5) {
|
||||
return -16777216;
|
||||
}
|
||||
return iArr[3];
|
||||
}
|
||||
return iArr[1];
|
||||
}
|
||||
return iArr[5];
|
||||
}
|
||||
return iArr[7];
|
||||
}
|
||||
return iArr[1];
|
||||
}
|
||||
return iArr[1];
|
||||
}
|
||||
|
||||
public final void b(Context context) {
|
||||
SharedPreferences sharedPreferences = context.getSharedPreferences(context.getPackageName() + "_preferences", 0); // 原 c0.x.a(context)
|
||||
for (int i4 = 0; i4 < 9; i4++) {
|
||||
this.f665a[i4] = sharedPreferences.getInt(f663b[i4], f664d[i4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package J2;
|
||||
|
||||
/* loaded from: classes.dex */
|
||||
public final class a {
|
||||
|
||||
/* renamed from: b, reason: collision with root package name */
|
||||
public static final int[] f726b = {16, 17749, 65535, 16776960, 16711680};
|
||||
|
||||
/* renamed from: a, reason: collision with root package name */
|
||||
public final int[] f727a;
|
||||
|
||||
public a() {
|
||||
int[] iArr = new int[256];
|
||||
this.f727a = iArr;
|
||||
int i4 = 4;
|
||||
float f = 255.0f / 4;
|
||||
int[] iArr2 = f726b;
|
||||
int i5 = 0;
|
||||
int i6 = -16777216;
|
||||
iArr[0] = iArr2[0] | (-16777216);
|
||||
int i7 = 1;
|
||||
while (i7 <= i4) {
|
||||
int round = Math.round(i7 * f);
|
||||
iArr[round] = iArr2[i7] | i6;
|
||||
int i8 = round - i5;
|
||||
int i9 = iArr[i5];
|
||||
int i10 = (i9 >> 16) & 255;
|
||||
int i11 = (i9 >> 8) & 255;
|
||||
int i12 = i9 & 255;
|
||||
float f4 = i8;
|
||||
// r10 = iArr2[i7] (下一个锚点色, smali: aget v10, v4, v8)
|
||||
float f5 = (((iArr2[i7] >> 16) & 255) - i10) / f4;
|
||||
float f6 = (((iArr2[i7] >> 8) & 255) - i11) / f4;
|
||||
float f7 = ((iArr2[i7] & 255) - i12) / f4;
|
||||
for (int i13 = 1; i13 < i8; i13++) {
|
||||
float f8 = i13;
|
||||
iArr[i5 + i13] = ((Math.round(f5 * f8) + i10) << 16) | (-16777216) | ((Math.round(f6 * f8) + i11) << 8) | (Math.round(f8 * f7) + i12);
|
||||
}
|
||||
i7++;
|
||||
i5 = round;
|
||||
i4 = 4;
|
||||
i6 = -16777216;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package J2;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.opengl.GLES20;
|
||||
import android.opengl.GLSurfaceView;
|
||||
import android.opengl.GLUtils;
|
||||
import android.os.SystemClock;
|
||||
import android.util.Log;
|
||||
import java.nio.Buffer;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.FloatBuffer;
|
||||
import javax.microedition.khronos.egl.EGLConfig;
|
||||
import javax.microedition.khronos.opengles.GL10;
|
||||
|
||||
/* loaded from: classes.dex */
|
||||
public final class b implements GLSurfaceView.Renderer {
|
||||
|
||||
/* renamed from: b, reason: collision with root package name */
|
||||
public FloatBuffer f729b;
|
||||
public int c;
|
||||
|
||||
/* renamed from: d, reason: collision with root package name */
|
||||
public final Bitmap f730d;
|
||||
public final Context f;
|
||||
|
||||
/* renamed from: g, reason: collision with root package name */
|
||||
public double f732g;
|
||||
|
||||
/* renamed from: h, reason: collision with root package name */
|
||||
public final Bitmap f733h;
|
||||
|
||||
/* renamed from: i, reason: collision with root package name */
|
||||
public final a f734i;
|
||||
|
||||
/* renamed from: j, reason: collision with root package name */
|
||||
public long f735j;
|
||||
|
||||
/* renamed from: k, reason: collision with root package name */
|
||||
public double f736k;
|
||||
|
||||
/* renamed from: l, reason: collision with root package name */
|
||||
public int f737l;
|
||||
|
||||
/* renamed from: a, reason: collision with root package name */
|
||||
public final int[] f728a = new int[1];
|
||||
|
||||
/* renamed from: e, reason: collision with root package name */
|
||||
public final float[] f731e = {-1.0f, -1.0f, 0.0f, 1.0f, -1.0f, 1.0f, 0.0f, 0.0f, 1.0f, -1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 0.0f};
|
||||
|
||||
public b(Context context, int i4) {
|
||||
Bitmap.Config config = Bitmap.Config.ARGB_8888;
|
||||
Bitmap createBitmap = Bitmap.createBitmap(2048, i4, config);
|
||||
this.f730d = createBitmap;
|
||||
createBitmap.eraseColor(16);
|
||||
this.f732g = 0.09375d;
|
||||
this.f734i = new a();
|
||||
this.f735j = 0L;
|
||||
this.f736k = 0.0d;
|
||||
this.f737l = -1;
|
||||
this.f = context;
|
||||
this.f733h = Bitmap.createBitmap(1, i4, config);
|
||||
}
|
||||
|
||||
public static void a() {
|
||||
int glGetError = GLES20.glGetError();
|
||||
if (glGetError != 0) {
|
||||
StackTraceElement stackTraceElement = Thread.currentThread().getStackTrace()[3];
|
||||
Log.e(stackTraceElement.getFileName() + ":" + stackTraceElement.getLineNumber(), ", GLES20 error: " + glGetError);
|
||||
}
|
||||
}
|
||||
|
||||
public final double b() {
|
||||
long uptimeMillis = SystemClock.uptimeMillis();
|
||||
double width = (((uptimeMillis - this.f735j) * this.f732g) + this.f736k) % this.f730d.getWidth();
|
||||
this.f735j = uptimeMillis;
|
||||
this.f736k = width;
|
||||
return width;
|
||||
}
|
||||
|
||||
public final void c(GL10 gl10) {
|
||||
GLES20.glUseProgram(this.c);
|
||||
a();
|
||||
GLES20.glActiveTexture(33984);
|
||||
a();
|
||||
GLES20.glBindTexture(3553, this.f728a[0]);
|
||||
a();
|
||||
GLES20.glUniform1i(GLES20.glGetUniformLocation(this.c, "uTexture"), 0);
|
||||
a();
|
||||
int glGetAttribLocation = GLES20.glGetAttribLocation(this.c, "aPosition");
|
||||
a();
|
||||
int glGetAttribLocation2 = GLES20.glGetAttribLocation(this.c, "aTexPos");
|
||||
a();
|
||||
this.f729b.position(0);
|
||||
a();
|
||||
GLES20.glVertexAttribPointer(glGetAttribLocation, 2, 5126, false, 16, (Buffer) this.f729b);
|
||||
a();
|
||||
GLES20.glEnableVertexAttribArray(glGetAttribLocation);
|
||||
a();
|
||||
this.f729b.position(2);
|
||||
a();
|
||||
GLES20.glVertexAttribPointer(glGetAttribLocation2, 2, 5126, false, 16, (Buffer) this.f729b);
|
||||
a();
|
||||
GLES20.glEnableVertexAttribArray(glGetAttribLocation2);
|
||||
a();
|
||||
GLES20.glDrawArrays(5, 0, 4);
|
||||
a();
|
||||
}
|
||||
|
||||
public final void d(GL10 gl10, int i4, int i5) {
|
||||
GLES20.glViewport(0, 0, i4, i5);
|
||||
a();
|
||||
}
|
||||
|
||||
@Override // android.opengl.GLSurfaceView.Renderer
|
||||
public final void onDrawFrame(GL10 gl10) {
|
||||
float b4 = (float) (b() / this.f730d.getWidth());
|
||||
float f = 0.5f + b4;
|
||||
float[] fArr = this.f731e;
|
||||
fArr[6] = f;
|
||||
fArr[2] = f;
|
||||
float f4 = b4 + 1.0f;
|
||||
fArr[14] = f4;
|
||||
fArr[10] = f4;
|
||||
this.f729b.position(0);
|
||||
this.f729b.put(this.f731e);
|
||||
c(gl10);
|
||||
}
|
||||
|
||||
@Override // android.opengl.GLSurfaceView.Renderer
|
||||
public final void onSurfaceChanged(GL10 gl10, int i4, int i5) {
|
||||
d(gl10, i4, i5);
|
||||
float width = 1.0f - (((this.f730d.getWidth() * 0.7f) / (i4 / this.f.getResources().getDisplayMetrics().density)) * 2.0f);
|
||||
float[] fArr = this.f731e;
|
||||
fArr[4] = width;
|
||||
fArr[0] = width;
|
||||
this.f729b.position(0);
|
||||
this.f729b.put(this.f731e);
|
||||
}
|
||||
|
||||
@Override // android.opengl.GLSurfaceView.Renderer
|
||||
public final void onSurfaceCreated(GL10 gl10, EGLConfig eGLConfig) {
|
||||
int glCreateShader = GLES20.glCreateShader(35633);
|
||||
a();
|
||||
GLES20.glShaderSource(glCreateShader, "attribute vec2 aPosition;\nattribute vec2 aTexPos;\nvarying vec2 vTexPos;\nvoid main() {\n vTexPos = aTexPos;\n gl_Position = vec4(aPosition.xy, 0.0, 1.0);\n}");
|
||||
a();
|
||||
GLES20.glCompileShader(glCreateShader);
|
||||
a();
|
||||
int glCreateShader2 = GLES20.glCreateShader(35632);
|
||||
a();
|
||||
GLES20.glShaderSource(glCreateShader2, "precision mediump float;\nuniform sampler2D uTexture;\nvarying vec2 vTexPos;\nvoid main(void)\n{\n gl_FragColor = texture2D(uTexture, vTexPos);\n}");
|
||||
a();
|
||||
GLES20.glCompileShader(glCreateShader2);
|
||||
a();
|
||||
this.c = GLES20.glCreateProgram();
|
||||
a();
|
||||
GLES20.glAttachShader(this.c, glCreateShader);
|
||||
a();
|
||||
GLES20.glAttachShader(this.c, glCreateShader2);
|
||||
a();
|
||||
GLES20.glLinkProgram(this.c);
|
||||
a();
|
||||
int[] iArr = this.f728a;
|
||||
GLES20.glGenTextures(1, iArr, 0);
|
||||
a();
|
||||
GLES20.glBindTexture(3553, iArr[0]);
|
||||
a();
|
||||
GLES20.glTexParameteri(3553, 10240, 9729);
|
||||
GLES20.glTexParameteri(3553, 10242, 10497);
|
||||
GLES20.glTexParameteri(3553, 10241, 9729);
|
||||
GLES20.glTexParameteri(3553, 10243, 10497);
|
||||
a();
|
||||
GLUtils.texImage2D(3553, 0, this.f730d, 0);
|
||||
a();
|
||||
float[] fArr = this.f731e;
|
||||
ByteBuffer allocateDirect = ByteBuffer.allocateDirect(fArr.length * 4);
|
||||
allocateDirect.order(ByteOrder.nativeOrder());
|
||||
FloatBuffer asFloatBuffer = allocateDirect.asFloatBuffer();
|
||||
this.f729b = asFloatBuffer;
|
||||
asFloatBuffer.put(fArr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package K1;
|
||||
|
||||
/**
|
||||
* 照搬自 Morse Expert 1.15 K1.a 精简版: 仅保留 k3 FFT 库引用的内存统计静态字段
|
||||
* (K1.a.c 长整型记账)。原 K1.a(828 行)的其余方法为状态栏/库代码, 已裁剪
|
||||
* (状态栏逻辑已在 MainActivity 内联)。
|
||||
*/
|
||||
public class a {
|
||||
public static long c = 0;
|
||||
}
|
||||
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user