mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
144
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
f585372594 | ||
|
|
4585332b1a | ||
|
|
6d2b0ced49 | ||
|
|
e86bc2c700 | ||
|
|
9a52fdfde5 | ||
|
|
07504a2a95 | ||
|
|
81525b6dd3 | ||
|
|
c7089ab314 | ||
|
|
77dfb7bb05 | ||
|
|
58fb688593 | ||
|
|
dc596b4348 | ||
|
|
f9b1a97a37 | ||
|
|
d700432829 | ||
|
|
b17ea2d918 | ||
|
|
976fdfd949 | ||
|
|
d8db258bd8 | ||
|
|
7b6fb5eb8f | ||
|
|
f4aef4f4f0 | ||
|
|
9147323db2 | ||
|
|
81d397a09d | ||
|
|
ec1a55c93d | ||
|
|
e5033cb2e9 | ||
|
|
39b786ae82 | ||
|
|
79809aaec0 | ||
|
|
f7f5a73c4c | ||
|
|
18c8ac1822 | ||
|
|
22607e7710 | ||
|
|
ad0ff6859e | ||
|
|
a39b1716e7 | ||
|
|
8a2339cfbf | ||
|
|
821fa0dfe7 | ||
|
|
ce8c2a4834 | ||
|
|
8cc4ef5610 | ||
|
|
dde22faf46 | ||
|
|
7ad81e32e1 | ||
|
|
5af963f3c9 | ||
|
|
bfb9fb6ca8 | ||
|
|
df24c66ef3 | ||
|
|
b166e896c2 | ||
|
|
18f8ab517d | ||
|
|
d3b8e951dd | ||
|
|
6e5a3cb0bb | ||
|
|
31d329a19b | ||
|
|
3df358b88c | ||
|
|
7d5bca300d | ||
|
|
fb2cd85b11 | ||
|
|
99158e74fa | ||
|
|
8265b73d75 | ||
|
|
d2b3184084 | ||
|
|
0c5d3699c0 | ||
|
|
a94f95874b | ||
|
|
4a3a01729b | ||
|
|
aadc285d7f | ||
|
|
2df0b9ba3d | ||
|
|
868155c532 | ||
|
|
3f981c09d5 | ||
|
|
b79fec13f4 | ||
|
|
6588291997 | ||
|
|
be96602c76 | ||
|
|
b59f9a6c17 | ||
|
|
9afe2ab69e | ||
|
|
2edc892dc2 | ||
|
|
45eb6915c2 | ||
|
|
706c912b13 | ||
|
|
e523eb5517 | ||
|
|
0bef2ffddc | ||
|
|
279594c425 | ||
|
|
8c1df334d8 | ||
|
|
e2bf2a09a4 | ||
|
|
1d48ce144f | ||
|
|
082b52c58b | ||
|
|
c1966442a7 | ||
|
|
4d88214c1d | ||
|
|
de99e09f5e | ||
|
|
e717d75324 | ||
|
|
a545863473 | ||
|
|
85ffe94266 | ||
|
|
7adbf951bc | ||
|
|
78dc217642 | ||
|
|
64137473b3 | ||
|
|
c08effb445 | ||
|
|
65410defe2 |
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
# GitHub Copilot Instructions
|
||||||
|
|
||||||
|
Read `AGENTS.md` first, then `CLAUDE.md`.
|
||||||
|
|
||||||
|
`AGENTS.md` contains the architecture, module boundaries, implementation details, conventions, and gotchas.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
@@ -16,61 +16,64 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '17'
|
java-version: '21'
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v5
|
uses: gradle/actions/setup-gradle@v6
|
||||||
|
|
||||||
- name: Assemble Artifacts
|
- name: Assemble Artifacts
|
||||||
run: |
|
run: ./gradlew assembleRelease bundleRelease
|
||||||
./gradlew assembleRelease
|
|
||||||
./gradlew bundleRelease
|
|
||||||
|
|
||||||
- name: Sign APK
|
- name: Sign APK
|
||||||
uses: r0adkll/sign-android-release@v1
|
run: |
|
||||||
id: sign_apk
|
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||||
with:
|
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
|
||||||
releaseDirectory: app/build/outputs/apk/release
|
BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
|
||||||
signingKeyBase64: ${{ secrets.KEY_STORE }}
|
${BUILD_TOOLS}apksigner sign \
|
||||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
--ks keystore.jks \
|
||||||
alias: ${{ secrets.KEY_ALIAS }}
|
--ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
|
||||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
--ks-key-alias ${{ secrets.KEY_ALIAS }} \
|
||||||
env:
|
--key-pass pass:${{ secrets.KEY_PASSWORD }} \
|
||||||
BUILD_TOOLS_VERSION: "36.0.0"
|
--out app/build/outputs/apk/release/look4sat.apk \
|
||||||
|
"$APK"
|
||||||
|
rm keystore.jks
|
||||||
|
|
||||||
- name: Sign Bundle
|
- name: Sign Bundle
|
||||||
uses: r0adkll/sign-android-release@v1
|
|
||||||
id: sign_bundle
|
|
||||||
with:
|
|
||||||
releaseDirectory: app/build/outputs/bundle/release
|
|
||||||
signingKeyBase64: ${{ secrets.KEY_STORE }}
|
|
||||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
|
||||||
alias: ${{ secrets.KEY_ALIAS }}
|
|
||||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
|
||||||
env:
|
|
||||||
BUILD_TOOLS_VERSION: "36.0.0"
|
|
||||||
|
|
||||||
- name: Rename Artifacts
|
|
||||||
run: |
|
run: |
|
||||||
mv ${{steps.sign_apk.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.apk
|
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||||
mv ${{steps.sign_bundle.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.aab
|
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
||||||
|
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
|
||||||
|
-keystore keystore.jks \
|
||||||
|
-storepass ${{ secrets.KEY_STORE_PASSWORD }} \
|
||||||
|
-keypass ${{ secrets.KEY_PASSWORD }} \
|
||||||
|
"$AAB" ${{ secrets.KEY_ALIAS }}
|
||||||
|
rm keystore.jks
|
||||||
|
|
||||||
- name: Deploy Bundle
|
- name: Setup Ruby
|
||||||
uses: r0adkll/upload-google-play@v1
|
uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
serviceAccountJsonPlainText: ${{secrets.SERVICE_ACCOUNT_JSON}}
|
ruby-version: '3.4'
|
||||||
packageName: com.rtbishop.look4sat
|
|
||||||
releaseFiles: app/build/outputs/apk/release/look4sat.aab
|
- name: Deploy Bundle to Google Play
|
||||||
track: production
|
run: |
|
||||||
whatsNewDirectory: fastlane/metadata/android/en-US/whatsnew
|
gem install multi_json
|
||||||
|
gem install fastlane --no-document
|
||||||
|
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
||||||
|
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
|
||||||
|
fastlane supply \
|
||||||
|
--aab "$AAB" \
|
||||||
|
--json_key service_account.json \
|
||||||
|
--package_name com.rtbishop.look4sat \
|
||||||
|
--track production \
|
||||||
|
--skip_upload_images true \
|
||||||
|
--skip_upload_screenshots true \
|
||||||
|
rm service_account.json
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
# Built application files
|
# Built application files
|
||||||
*.ap_
|
*.ap_
|
||||||
|
|
||||||
|
# Hermes AI plans and metadata
|
||||||
|
.hermes/
|
||||||
|
|
||||||
# Files for the ART/Dalvik VM
|
# Files for the ART/Dalvik VM
|
||||||
*.dex
|
*.dex
|
||||||
|
|
||||||
@@ -15,6 +18,7 @@ out/
|
|||||||
# Gradle files
|
# Gradle files
|
||||||
.gradle/
|
.gradle/
|
||||||
build/
|
build/
|
||||||
|
.cxx/
|
||||||
|
|
||||||
# Local configuration file (sdk path, etc)
|
# Local configuration file (sdk path, etc)
|
||||||
local.properties
|
local.properties
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Look4Sat AI Agent Instructions
|
||||||
|
|
||||||
|
This is the canonical project guide for all AI assistants working on Look4Sat.
|
||||||
|
All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) point here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
||||||
|
satellites using TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays
|
||||||
|
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
|
||||||
|
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
|
||||||
|
data download.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**MVI (Model-View-Intent)** with unidirectional data flow:
|
||||||
|
- `State` data class → exposed via `StateFlow` from ViewModel
|
||||||
|
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
|
||||||
|
- Jetpack Compose UI observes state and recomposes reactively
|
||||||
|
|
||||||
|
**Clean Architecture layers:**
|
||||||
|
|
||||||
|
| Module | Responsibility |
|
||||||
|
|----------------------|---------------------------------------------------------------------|
|
||||||
|
| `app` | Entry point. Aggregates all modules |
|
||||||
|
| `core:data` | Android library. Room DB, OkHttp networking, repo implementations |
|
||||||
|
| `core:domain` | Pure Kotlin (JVM). Orbital math (SGP4/SDP4), models, repo contracts |
|
||||||
|
| `core:presentation` | Android library. Compose theme, shared UI components, NavKeys |
|
||||||
|
| `feature:map` | OSMDroid map with ground tracks |
|
||||||
|
| `feature:passes` | Pass predictions and upcoming events |
|
||||||
|
| `feature:radar` | Polar radar view of satellite positions, SSTV image decoding |
|
||||||
|
| `feature:satellites` | Satellite list, filtering, selection |
|
||||||
|
| `feature:settings` | User preferences |
|
||||||
|
|
||||||
|
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
|
||||||
|
|
||||||
|
## Build & Run
|
||||||
|
|
||||||
|
```shell
|
||||||
|
# Debug build
|
||||||
|
./gradlew assembleDebug
|
||||||
|
|
||||||
|
# Release build (minified, shrunk resources)
|
||||||
|
./gradlew assembleRelease
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
./gradlew test
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
||||||
|
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
|
||||||
|
|
||||||
|
## Key Libraries
|
||||||
|
|
||||||
|
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
||||||
|
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
|
||||||
|
- **Room** (KSP code generation) for local satellite/TLE storage
|
||||||
|
- **OkHttp** 5.x for TLE downloads
|
||||||
|
- **OSMDroid** for map rendering
|
||||||
|
- **Kotlin Serialization** for navigation args and data parsing
|
||||||
|
- **Coroutines** + `StateFlow` for async/reactive patterns
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Minimal dependencies**: Avoid adding libraries when a simple manual solution exists. Fewer deps = less maintenance.
|
||||||
|
- **DI**: Manual — ViewModels use companion `factory()` methods with `IMainContainer` interface.
|
||||||
|
- **Navigation**: Type-safe Compose Navigation3 with `@Serializable` data classes as nav keys.
|
||||||
|
- **State naming**: `<Feature>State` data class + `<Feature>Action` sealed interface per feature.
|
||||||
|
- **No feature-to-feature deps**: All cross-feature communication goes through core layers.
|
||||||
|
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh).
|
||||||
|
|
||||||
|
## Data Formats & Migration
|
||||||
|
|
||||||
|
**TLE vs. OMM/CSV format:**
|
||||||
|
|
||||||
|
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- **KMP migration**: `core:domain` is to become a fully shareable KMM module. Keep it pure Kotlin/JVM.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Orbital math lives in `core:domain/predict/` — it's dense vector math (SGP4/SDP4). Tread carefully.
|
||||||
|
- TLE/OMM data must be refreshed weekly for accurate predictions (satellite orbits decay). TLE format is legacy and
|
||||||
|
will eventually be deprecated in favor of OMM/CSV as NORAD catalog numbers approach the 5-digit limit.
|
||||||
|
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
||||||
|
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
|
||||||
|
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
|
||||||
|
|
||||||
|
## Copilot Working Mode: Code-Only
|
||||||
|
|
||||||
|
- Default to code changes only. Provide explanations in chat only.
|
||||||
|
- If documentation seems useful, ask first before creating files.
|
||||||
|
- Do NOT create any `.md` documentation files unless explicitly requested.
|
||||||
|
- Do NOT add README, guides, summaries, migration notes, or how-to files unless asked.
|
||||||
|
- Prefer minimal diffs focused on requested implementation.
|
||||||
|
- Default validation is static checks (`get_errors`). Do NOT run Gradle compile/test tasks unless explicitly requested.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Read `AGENTS.md` first, then follow the instructions there.
|
||||||
|
|
||||||
|
`AGENTS.md` contains the architecture, module boundaries, implementation details, conventions, and gotchas.
|
||||||
@@ -1,35 +1,26 @@
|
|||||||
# 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)
|
**BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/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/)
|
|
||||||
|
|
||||||
### Radio satellite tracker and pass predictor for Android, inspired by Gpredict
|
## 本仓库特色功能
|
||||||
|
|
||||||
<p float="left">
|
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="192"/>
|
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="192"/>
|
- **Passband 模式** — 支持通过位置滑块(Passband)自动计算 TX/RX 频率,避免切换时跳变
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="192"/>
|
- **中文界面优化** — 翻译修正、UI 布局调整
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="192">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
### Track satellite passes with ease!
|
## 上游仓库
|
||||||
|
|
||||||
Thanks to [Celestrak](https://celestrak.com/) and [SatNOGS](https://satnogs.org/) you have access to over 9000 active satellites.\
|
本仓库是 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的分支,上游仓库的原始功能包括:
|
||||||
You can search the entire database by NORAD Catalog Number or the satellite's name.
|
|
||||||
|
|
||||||
Orbital positions and passes are calculated relative to your location.\
|
- 基于 Celestrak / SatNOGS 数据的 9000+ 活跃卫星追踪
|
||||||
To get reliable data make sure to set the station position via the app Settings.
|
- 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:
|
GNU General Public License v3.0。详见 [LICENSE](LICENSE)。
|
||||||
|
|
||||||
* 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.
|
|
||||||
+13
-39
@@ -1,50 +1,24 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.convention.applicationPlugin)
|
||||||
alias(libs.plugins.compose.compiler)
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
jvmToolchain(17)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.rtbishop.look4sat"
|
namespace = libs.versions.packageName.get()
|
||||||
compileSdk = 36
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.rtbishop.look4sat"
|
applicationId = "cn.ba7opf.look4sat"
|
||||||
minSdk = 24
|
ndk { abiFilters.add("armeabi-v7a") }
|
||||||
versionCode = 402
|
|
||||||
versionName = "4.0.2"
|
|
||||||
}
|
}
|
||||||
buildFeatures {
|
signingConfigs {
|
||||||
compose = true
|
create("release") {
|
||||||
|
storeFile = file(System.getProperty("user.home") + "/my-release-key.jks")
|
||||||
|
storePassword = "look4sat123"
|
||||||
|
keyAlias = "look4sat"
|
||||||
|
keyPassword = "look4sat123"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
|
||||||
applicationIdSuffix = ".debug"
|
|
||||||
}
|
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
signingConfig = signingConfigs.getByName("release")
|
||||||
isShrinkResources = true
|
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
androidResources {
|
}
|
||||||
generateLocaleConfig = true
|
|
||||||
localeFilters.addAll(listOf("en", "es", "ru", "si", "uk", "zh"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation(project(":data"))
|
|
||||||
implementation(project(":domain"))
|
|
||||||
|
|
||||||
implementation(libs.androidx.core.splashscreen)
|
|
||||||
implementation(platform(libs.compose.bom))
|
|
||||||
implementation(libs.bundles.composeAll)
|
|
||||||
implementation(libs.other.osmdroid)
|
|
||||||
|
|
||||||
debugImplementation(libs.bundles.composeDebug)
|
|
||||||
testImplementation(libs.bundles.unitTest)
|
|
||||||
androidTestImplementation(libs.bundles.androidTest)
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
@@ -11,21 +12,33 @@
|
|||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MainApplication"
|
android:name=".MainApplication"
|
||||||
|
android:allowBackup="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round">
|
android:roundIcon="@mipmap/ic_launcher_round">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
android:theme="@style/Theme.Look4Sat.SplashScreen">
|
android:theme="@style/Theme.Look4Sat.SplashScreen">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- <intent-filter android:autoVerify="true">-->
|
||||||
|
<!-- <action android:name="android.intent.action.VIEW" />-->
|
||||||
|
<!-- <category android:name="android.intent.category.DEFAULT" />-->
|
||||||
|
<!-- <category android:name="android.intent.category.BROWSABLE" />-->
|
||||||
|
<!-- <data android:scheme="https" />-->
|
||||||
|
<!-- <data android:host="github.com" />-->
|
||||||
|
<!-- <data android:pathPattern="/rt-bishop/Look4Sat/passes.*" />-->
|
||||||
|
<!-- </intent-filter>-->
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|||||||
@@ -19,13 +19,21 @@ package com.rtbishop.look4sat
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.ColorMatrix
|
||||||
|
import android.graphics.ColorMatrixColorFilter
|
||||||
|
import android.graphics.Paint
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import com.rtbishop.look4sat.presentation.MainScreen
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.rtbishop.look4sat.presentation.MainTheme
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
@@ -39,8 +47,37 @@ class MainActivity : ComponentActivity() {
|
|||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
observeNightFilterState()
|
||||||
setContent {
|
setContent {
|
||||||
MainTheme(isDarkTheme = true) { MainScreen() }
|
MainTheme(isDarkTheme = true) { MainScreen() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun observeNightFilterState() {
|
||||||
|
val mainContainer = (applicationContext as IContainerProvider).getMainContainer()
|
||||||
|
lifecycleScope.launch {
|
||||||
|
mainContainer.settingsRepo.otherSettings
|
||||||
|
.map { it.stateOfNightMode }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { nightMode -> applyNightFilter(nightMode) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyNightFilter(enabled: Boolean) {
|
||||||
|
if (enabled) {
|
||||||
|
val nightMatrix = ColorMatrix(
|
||||||
|
floatArrayOf(
|
||||||
|
1f, 0f, 0f, 0f, 0f, // R → R
|
||||||
|
0f, 0f, 0f, 0f, 0f, // G → 0
|
||||||
|
0f, 0f, 0f, 0f, 0f, // B → 0
|
||||||
|
0f, 0f, 0f, 1f, 0f // A → A
|
||||||
|
)
|
||||||
|
)
|
||||||
|
window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, Paint().apply {
|
||||||
|
colorFilter = ColorMatrixColorFilter(nightMatrix)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
window.decorView.setLayerType(View.LAYER_TYPE_NONE, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -18,15 +18,19 @@
|
|||||||
package com.rtbishop.look4sat
|
package com.rtbishop.look4sat
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.rtbishop.look4sat.data.injection.MainContainer
|
import com.rtbishop.look4sat.core.data.injection.MainContainer
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class MainApplication : Application() {
|
class MainApplication : Application(), IContainerProvider {
|
||||||
|
|
||||||
lateinit var container: MainContainer
|
private lateinit var container: IMainContainer
|
||||||
|
|
||||||
|
override fun getMainContainer(): IMainContainer = container
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||||
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||||
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.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
|
||||||
|
import com.rtbishop.look4sat.core.presentation.RadarDestination
|
||||||
|
import com.rtbishop.look4sat.core.presentation.Screen
|
||||||
|
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||||
|
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
||||||
|
import com.rtbishop.look4sat.feature.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
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 })
|
||||||
|
NavDisplay(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
backStack = rootBackStack,
|
||||||
|
onBack = navigateBack,
|
||||||
|
transitionSpec = { slideInTransition },
|
||||||
|
popTransitionSpec = { slideOutTransition },
|
||||||
|
predictivePopTransitionSpec = { slideOutTransition },
|
||||||
|
entryDecorators = listOf(
|
||||||
|
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
|
||||||
|
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
|
||||||
|
),
|
||||||
|
entryProvider = entryProvider {
|
||||||
|
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
|
||||||
|
entry<RadarDestination> {
|
||||||
|
Scaffold { innerPadding ->
|
||||||
|
RadarDestination(navigateUp = navigateBack)
|
||||||
|
innerPadding.calculateTopPadding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||||
|
val backStack = rememberNavBackStack(Screen.Passes)
|
||||||
|
val currentKey = backStack.lastOrNull()
|
||||||
|
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||||
|
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
||||||
|
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Map, Screen.Settings)
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
|
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||||
|
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||||
|
// 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(
|
||||||
|
low = otherSettings.lowElevation,
|
||||||
|
high = otherSettings.highElevation
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
NavigationSuiteScaffold(
|
||||||
|
navigationSuiteItems = {
|
||||||
|
navItems.forEach { screen ->
|
||||||
|
val isSelected = when (currentKey) {
|
||||||
|
is Screen.Satellites -> screen is Screen.Satellites
|
||||||
|
is Screen.Passes -> screen is Screen.Passes
|
||||||
|
is Screen.Radar -> screen is Screen.Radar
|
||||||
|
is Screen.Mutual -> screen is Screen.Mutual
|
||||||
|
is Screen.Map -> screen is Screen.Map
|
||||||
|
is Screen.Settings -> screen is Screen.Settings
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
item(
|
||||||
|
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
|
||||||
|
label = { Text(stringResource(screen.titleResId)) },
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = {
|
||||||
|
if (isSelected) return@item
|
||||||
|
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||||
|
if (screen !is Screen.Passes) backStack.add(screen)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
||||||
|
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
),
|
||||||
|
layoutType = when {
|
||||||
|
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
|
||||||
|
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
|
||||||
|
else -> NavigationSuiteType.ShortNavigationBarMedium
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
NavDisplay(
|
||||||
|
backStack = backStack,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
onBack = navigateBack,
|
||||||
|
transitionSpec = { fadeTransition },
|
||||||
|
popTransitionSpec = { fadeTransition },
|
||||||
|
predictivePopTransitionSpec = { fadeTransition },
|
||||||
|
entryDecorators = listOf(
|
||||||
|
// Required for saving Compose state per entry
|
||||||
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
|
// Required for ViewModel scoping per entry
|
||||||
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
|
),
|
||||||
|
entryProvider = entryProvider {
|
||||||
|
entry<Screen.Satellites> {
|
||||||
|
SatellitesDestination(navigateUp = navigateBack)
|
||||||
|
}
|
||||||
|
entry<Screen.Passes> {
|
||||||
|
PassesDestination { catNum, aosTime ->
|
||||||
|
container.setMutualPassData(MutualPassData())
|
||||||
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
|
backStack.add(Screen.Radar)
|
||||||
|
// navigateToRadar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry<Screen.Radar> {
|
||||||
|
RadarDestination(navigateUp = navigateBack)
|
||||||
|
}
|
||||||
|
entry<Screen.Map> {
|
||||||
|
MapDestination()
|
||||||
|
}
|
||||||
|
entry<Screen.Mutual> {
|
||||||
|
MutualScreen(
|
||||||
|
viewModel = mutualViewModel,
|
||||||
|
navigateUp = navigateBack,
|
||||||
|
navigateToRadar = { catNum, aosTime, pass ->
|
||||||
|
container.setMutualPassData(pass ?: MutualPassData())
|
||||||
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
|
backStack.add(Screen.Radar)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
entry<Screen.Settings> {
|
||||||
|
SettingsDestination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// Radio tracking status banner
|
||||||
|
if (trackingState.isActive) {
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
||||||
|
val alpha by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 1f, targetValue = 0.4f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(1000, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
), label = "pulseAlpha"
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
|
.clickable {
|
||||||
|
val pass = trackingState.currentPass
|
||||||
|
if (pass != null) {
|
||||||
|
container.setMutualPassData(MutualPassData())
|
||||||
|
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||||
|
backStack.add(Screen.Radar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
val txOk = if (trackingState.txConnected) "TX" else ""
|
||||||
|
val rxOk = if (trackingState.rxConnected) "RX" else ""
|
||||||
|
Text(
|
||||||
|
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,98 +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.presentation
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.navigation.NavHostController
|
|
||||||
import androidx.navigation.compose.NavHost
|
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
|
||||||
import com.rtbishop.look4sat.R
|
|
||||||
import com.rtbishop.look4sat.presentation.common.hasEnoughHeight
|
|
||||||
import com.rtbishop.look4sat.presentation.common.hasEnoughWidth
|
|
||||||
import com.rtbishop.look4sat.presentation.map.mapDestination
|
|
||||||
import com.rtbishop.look4sat.presentation.passes.passesDestination
|
|
||||||
import com.rtbishop.look4sat.presentation.radar.radarDestination
|
|
||||||
import com.rtbishop.look4sat.presentation.satellites.satellitesDestination
|
|
||||||
import com.rtbishop.look4sat.presentation.settings.settingsDestination
|
|
||||||
|
|
||||||
sealed class Screen(val route: String, val iconResId: Int, val titleResId: Int) {
|
|
||||||
data object Satellites : Screen("satellites", R.drawable.ic_satellites, R.string.nav_sat)
|
|
||||||
data object Passes : Screen("passes", R.drawable.ic_passes, R.string.nav_pass)
|
|
||||||
data object Radar : Screen("radar", R.drawable.ic_radar, R.string.nav_radar)
|
|
||||||
data object Map : Screen("map", R.drawable.ic_map, R.string.nav_map)
|
|
||||||
data object Settings : Screen("settings", R.drawable.ic_settings, R.string.nav_prefs)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MainScreen(navController: NavHostController = rememberNavController()) {
|
|
||||||
val items = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
|
|
||||||
val currentDestination = navController.currentBackStackEntryAsState().value?.destination?.route
|
|
||||||
val startDestination = Screen.Passes.route
|
|
||||||
NavigationSuiteScaffold(
|
|
||||||
navigationSuiteItems = {
|
|
||||||
items.forEach {
|
|
||||||
item(
|
|
||||||
icon = { Icon(painterResource(it.iconResId), stringResource(it.titleResId)) },
|
|
||||||
label = { Text(stringResource(it.titleResId)) },
|
|
||||||
selected = currentDestination?.contains(it.route) ?: false,
|
|
||||||
onClick = {
|
|
||||||
if (currentDestination?.contains(it.route) ?: false) return@item
|
|
||||||
navController.navigate(it.route) {
|
|
||||||
popUpTo(startDestination) { saveState = false }
|
|
||||||
launchSingleTop = true
|
|
||||||
restoreState = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, navigationSuiteColors = NavigationSuiteDefaults.colors(
|
|
||||||
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
), layoutType = when {
|
|
||||||
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
|
|
||||||
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
|
|
||||||
else -> NavigationSuiteType.ShortNavigationBarMedium
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
NavHost(
|
|
||||||
navController = navController,
|
|
||||||
startDestination = startDestination,
|
|
||||||
enterTransition = { fadeIn(animationSpec = tween(350)) },
|
|
||||||
exitTransition = { fadeOut(animationSpec = tween(350)) }
|
|
||||||
) {
|
|
||||||
satellitesDestination { navController.navigateUp() }
|
|
||||||
passesDestination { catNum: Int, aosTime: Long ->
|
|
||||||
val radarRoute = "${Screen.Radar.route}?catNum=${catNum}&aosTime=${aosTime}"
|
|
||||||
navController.navigate(radarRoute)
|
|
||||||
}
|
|
||||||
radarDestination { navController.navigateUp() }
|
|
||||||
mapDestination()
|
|
||||||
settingsDestination()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,161 +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.presentation
|
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Shapes
|
|
||||||
import androidx.compose.material3.Typography
|
|
||||||
import androidx.compose.material3.darkColorScheme
|
|
||||||
import androidx.compose.material3.lightColorScheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.SideEffect
|
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalView
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
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 androidx.core.view.WindowCompat
|
|
||||||
|
|
||||||
val LocalSpacing = compositionLocalOf { Spacing }
|
|
||||||
|
|
||||||
data object Spacing {
|
|
||||||
val extraExtraSmall = 4.dp
|
|
||||||
val extraSmall = 6.dp
|
|
||||||
val small = 8.dp
|
|
||||||
val medium = 12.dp
|
|
||||||
val large = 16.dp
|
|
||||||
val extraLarge = 24.dp
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MainTheme(isDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
|
|
||||||
val view = LocalView.current
|
|
||||||
if (view.isInEditMode) {
|
|
||||||
MaterialTheme(darkScheme, shapes, typography, content)
|
|
||||||
} else {
|
|
||||||
CompositionLocalProvider(LocalSpacing provides Spacing) {
|
|
||||||
val colorScheme = if (isDarkTheme) darkScheme else lightScheme
|
|
||||||
SideEffect {
|
|
||||||
val window = (view.context as ComponentActivity).window
|
|
||||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
|
||||||
insetsController.isAppearanceLightStatusBars = false
|
|
||||||
insetsController.isAppearanceLightNavigationBars = false
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
window.isNavigationBarContrastEnforced = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MaterialTheme(colorScheme, shapes, typography, content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val lightScheme = lightColorScheme(
|
|
||||||
primary = Color(0xFF715C0C),
|
|
||||||
onPrimary = Color(0xFFFFFFFF),
|
|
||||||
secondary = Color(0xFF685E40),
|
|
||||||
onSecondary = Color(0xFFFFFFFF),
|
|
||||||
secondaryContainer = Color(0xFFF1E1BB),
|
|
||||||
onSecondaryContainer = Color(0xFF221B04),
|
|
||||||
background = Color(0xFFFFF8F0),
|
|
||||||
onBackground = Color(0xFF1E1B13),
|
|
||||||
surface = Color(0xFFFFF8F0),
|
|
||||||
onSurface = Color(0xFF1E1B13),
|
|
||||||
surfaceTint = Color(0x00000000),
|
|
||||||
surfaceVariant = Color(0xFFEBE2CF),
|
|
||||||
onSurfaceVariant = Color(0xFF4C4639),
|
|
||||||
error = Color(0xFFDC0000),
|
|
||||||
outline = Color(0xFF7D7667),
|
|
||||||
scrim = Color(0xFF000000)
|
|
||||||
)
|
|
||||||
|
|
||||||
private val darkScheme = darkColorScheme(
|
|
||||||
primary = Color(0xFFFFE082), // main accent, switch background, active progress
|
|
||||||
onPrimary = Color(0xFF000000), // on main accent, switch knob
|
|
||||||
// primaryContainer = Color(0xFF121212),
|
|
||||||
// onPrimaryContainer = Color(0xFF121212),
|
|
||||||
// inversePrimary = Color(0xFF121212),
|
|
||||||
secondary = Color(0xFFE0E0E0),
|
|
||||||
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),
|
|
||||||
background = Color(0xFF121212),
|
|
||||||
onBackground = Color(0xFFE0E0E0),
|
|
||||||
surface = Color(0xFF202020), // card background
|
|
||||||
onSurface = Color(0xFFE0E0E0), // on card background
|
|
||||||
surfaceVariant = Color(0xFF404040), // buttons background
|
|
||||||
onSurfaceVariant = Color(0xFFE0E0E0), // navBar inactive icon
|
|
||||||
surfaceTint = Color(0x00000000),
|
|
||||||
// inverseSurface = Color(0xFF121212),
|
|
||||||
// inverseOnSurface = Color(0xFF121212),
|
|
||||||
error = Color(0xFFDC0000),
|
|
||||||
// onError = Color(0xFFFFFFFF),
|
|
||||||
// errorContainer = Color(0xFF121212),
|
|
||||||
// onErrorContainer = Color(0xFF121212),
|
|
||||||
outline = Color(0xA3E0E0E0),
|
|
||||||
// outlineVariant = Color(0xFF121212),
|
|
||||||
scrim = Color(0xFF000000),
|
|
||||||
// surfaceBright = Color(0xFF121212),
|
|
||||||
surfaceContainer = Color(0xFF121212), // navBar background
|
|
||||||
// surfaceContainerHigh = Color(0xFF121212),
|
|
||||||
surfaceContainerHighest = Color(0xFF242424), // filled card background
|
|
||||||
surfaceContainerLow = Color(0xFF202020), // elevated card background
|
|
||||||
surfaceContainerLowest = Color(0xCC121212),
|
|
||||||
// surfaceDim = Color(0xFF121212),
|
|
||||||
)
|
|
||||||
|
|
||||||
private val shapes = Shapes(
|
|
||||||
extraSmall = RoundedCornerShape(4.dp), // menus, snackbars, text fields
|
|
||||||
small = RoundedCornerShape(8.dp), // buttons, chips
|
|
||||||
medium = RoundedCornerShape(12.dp), // cards, small FABs
|
|
||||||
large = RoundedCornerShape(24.dp), // extended FABs, FABs, nav drawers
|
|
||||||
extraLarge = RoundedCornerShape(32.dp) // large FABs
|
|
||||||
)
|
|
||||||
|
|
||||||
private val typography = Typography(
|
|
||||||
bodyLarge = TextStyle(
|
|
||||||
fontFamily = FontFamily.Default,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
lineHeight = 24.sp,
|
|
||||||
letterSpacing = 0.5.sp
|
|
||||||
), titleLarge = TextStyle(
|
|
||||||
fontFamily = FontFamily.Default,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontSize = 22.sp,
|
|
||||||
lineHeight = 28.sp,
|
|
||||||
letterSpacing = 0.sp
|
|
||||||
), labelSmall = TextStyle(
|
|
||||||
fontFamily = FontFamily.Default,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
lineHeight = 16.sp,
|
|
||||||
letterSpacing = 0.5.sp
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -1,402 +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.presentation.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.MarqueeSpacing
|
|
||||||
import androidx.compose.foundation.basicMarquee
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.RowScope
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
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.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ElevatedButton
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import com.rtbishop.look4sat.R
|
|
||||||
import com.rtbishop.look4sat.domain.predict.NearEarthObject
|
|
||||||
import com.rtbishop.look4sat.domain.predict.OrbitalData
|
|
||||||
import com.rtbishop.look4sat.domain.predict.OrbitalPass
|
|
||||||
import com.rtbishop.look4sat.presentation.LocalSpacing
|
|
||||||
import com.rtbishop.look4sat.presentation.MainTheme
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
private val sdfTime = SimpleDateFormat("HH:mm:ss", Locale.ENGLISH)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
private fun TopBarPreview() = MainTheme {
|
|
||||||
TopBar {
|
|
||||||
IconCard(action = {}, resId = R.drawable.ic_filter)
|
|
||||||
TimerRow(timeString = "88:88:88", isTimeAos = true)
|
|
||||||
IconCard(action = {}, resId = R.drawable.ic_radios)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TopBar(content: @Composable (RowScope.() -> Unit)) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.height(48.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) { content() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RowScope.TimerRow(timeString: String, isTimeAos: Boolean) {
|
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
|
||||||
val aosColor = if (isTimeAos) colorScheme.primary else colorScheme.onSurface
|
|
||||||
val losColor = if (!isTimeAos) colorScheme.primary else colorScheme.onSurface
|
|
||||||
ElevatedCard(modifier = Modifier.weight(1f)) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
) {
|
|
||||||
Text(text = "AOS", fontSize = 16.sp, fontWeight = FontWeight.Medium, color = aosColor)
|
|
||||||
Text(
|
|
||||||
text = timeString,
|
|
||||||
fontSize = 32.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(text = "LOS", fontSize = 16.sp, fontWeight = FontWeight.Medium, color = losColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
private fun NextPassRowPreview() = MainTheme {
|
|
||||||
TopBar { NextPassRow(pass = getDefaultPass()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier) {
|
|
||||||
ElevatedCard(modifier = modifier.height(48.dp).weight(1f)) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy((-2).dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
// .background(color = MaterialTheme.colorScheme.background)
|
|
||||||
.padding(start = 6.dp, top = 1.dp, end = 6.dp, bottom = 0.dp)
|
|
||||||
) {
|
|
||||||
val passSatId = stringResource(id = R.string.pass_satId, pass.catNum)
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = "$passSatId - ",
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = pass.name,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 6.dp)
|
|
||||||
.infiniteMarquee(),
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.ic_elevation),
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "${pass.maxElevation}°",
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
horizontalArrangement = Arrangement.Start,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = sdfTime.format(Date(pass.aosTime)),
|
|
||||||
fontSize = 15.sp,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.ic_altitude),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "${pass.altitude} km",
|
|
||||||
fontSize = 15.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
horizontalArrangement = Arrangement.End,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(
|
|
||||||
id = R.string.pass_aosLos,
|
|
||||||
pass.aosAzimuth.toInt(),
|
|
||||||
pass.losAzimuth.toInt()
|
|
||||||
),
|
|
||||||
fontSize = 15.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
|
||||||
ElevatedButton(
|
|
||||||
onClick = { onClick() }, colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
), shape = MaterialTheme.shapes.small, modifier = modifier,
|
|
||||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)
|
|
||||||
) { Text(text = text, fontSize = 16.sp, textAlign = TextAlign.Center) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier) {
|
|
||||||
val clickableMod = Modifier.clickable { action() }
|
|
||||||
ElevatedCard(modifier = Modifier.size(48.dp)) {
|
|
||||||
Box(modifier = clickableMod.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PrimaryIconCard(modifier: Modifier = Modifier, resId: Int, onClick: () -> Unit) {
|
|
||||||
val clickableMod = modifier.clickable { onClick() }
|
|
||||||
ElevatedCard(
|
|
||||||
modifier = Modifier.size(102.dp, 48.dp),
|
|
||||||
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.primary)
|
|
||||||
) {
|
|
||||||
Box(modifier = clickableMod.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
Icon(painter = painterResource(id = resId), contentDescription = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CardLoadingIndicator() {
|
|
||||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.size(80.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun EmptyListCard(message: String) {
|
|
||||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
|
||||||
modifier = Modifier.padding(32.dp)
|
|
||||||
) {
|
|
||||||
Text(text = """¯\_(ツ)_/¯""", fontSize = 32.sp)
|
|
||||||
Text(text = stringResource(R.string.empty_list_message), fontSize = 21.sp, textAlign = TextAlign.Center)
|
|
||||||
Text(text = message, fontSize = 18.sp, textAlign = TextAlign.Center)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getDefaultPass(): OrbitalPass {
|
|
||||||
val orbitalData = OrbitalData(""" ¯\_(ツ)_/¯ ⚠️""", 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0, 0.0)
|
|
||||||
val satellite = NearEarthObject(orbitalData)
|
|
||||||
return OrbitalPass(0L, 0.0, Long.MAX_VALUE, 0.0, 0, 0.0, satellite, 0f)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SharedDialog(
|
|
||||||
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
val padding = LocalSpacing.current.large
|
|
||||||
Dialog(onDismissRequest = { onCancel() }) {
|
|
||||||
ElevatedCard {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(padding)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(start = padding, top = padding, end = padding)
|
|
||||||
)
|
|
||||||
content()
|
|
||||||
Row(modifier = Modifier.padding(start = padding, bottom = padding, end = padding)) {
|
|
||||||
CardButton(onClick = onCancel, text = stringResource(id = R.string.btn_cancel))
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
CardButton(onClick = onAccept, text = stringResource(id = R.string.btn_accept))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun InfoDialog(title: String, text: String, onDismiss: () -> Unit) {
|
|
||||||
val padding = LocalSpacing.current.large
|
|
||||||
Dialog(onDismissRequest = onDismiss) {
|
|
||||||
ElevatedCard {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(padding)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
fontSize = 18.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(start = padding, top = padding, end = padding)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier.padding(horizontal = padding)
|
|
||||||
)
|
|
||||||
Row(modifier = Modifier.padding(start = padding, bottom = padding, end = padding)) {
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
CardButton(
|
|
||||||
onClick = onDismiss,
|
|
||||||
text = stringResource(id = R.string.btn_accept)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun hasEnoughHeight(): Boolean {
|
|
||||||
return currentWindowAdaptiveInfo().windowSizeClass.isHeightAtLeastBreakpoint(480)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun hasEnoughWidth(): Boolean {
|
|
||||||
return currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun isVerticalLayout(): Boolean {
|
|
||||||
return currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600).not()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun Modifier.infiniteMarquee(): Modifier {
|
|
||||||
return basicMarquee(iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(16.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun Modifier.layoutPadding(): Modifier {
|
|
||||||
val statusBarMod = this.statusBarsPadding()
|
|
||||||
val spacing = LocalSpacing.current.extraSmall
|
|
||||||
return statusBarMod.padding(start = spacing, top = 0.dp, end = spacing, bottom = spacing)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ScreenColumn(
|
|
||||||
topBar: @Composable (Boolean) -> Unit = {},
|
|
||||||
floatingBar: @Composable () -> Unit = {},
|
|
||||||
content: @Composable (Boolean) -> Unit = {}
|
|
||||||
) {
|
|
||||||
val isVerticalLayout = isVerticalLayout()
|
|
||||||
Surface(color = MaterialTheme.colorScheme.background) {
|
|
||||||
Box(modifier = Modifier.layoutPadding(), contentAlignment = Alignment.BottomCenter) {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(LocalSpacing.current.extraSmall)) {
|
|
||||||
topBar(isVerticalLayout)
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
) { content(isVerticalLayout) }
|
|
||||||
}
|
|
||||||
floatingBar()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TopBar(
|
|
||||||
isVerticalLayout: Boolean,
|
|
||||||
startAction: @Composable () -> Unit,
|
|
||||||
topInfo: @Composable (RowScope.() -> Unit),
|
|
||||||
bottomInfo: @Composable (RowScope.() -> Unit),
|
|
||||||
endAction: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
val topBarRow = @Composable { content: @Composable RowScope.() -> Unit ->
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.height(48.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) { content() }
|
|
||||||
}
|
|
||||||
if (isVerticalLayout) {
|
|
||||||
topBarRow { startAction().also { topInfo() }.also { endAction() } }
|
|
||||||
topBarRow { bottomInfo() }
|
|
||||||
} else {
|
|
||||||
topBarRow { startAction().also { topInfo() }.also { bottomInfo() }.also { endAction() } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,367 +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.presentation.map
|
|
||||||
|
|
||||||
import android.graphics.Canvas
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.graphics.ColorMatrix
|
|
||||||
import android.graphics.ColorMatrixColorFilter
|
|
||||||
import android.graphics.Paint
|
|
||||||
import android.graphics.Rect
|
|
||||||
import android.graphics.drawable.Drawable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.State
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.rotate
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import androidx.navigation.NavGraphBuilder
|
|
||||||
import androidx.navigation.compose.composable
|
|
||||||
import com.rtbishop.look4sat.R
|
|
||||||
import com.rtbishop.look4sat.domain.predict.GeoPos
|
|
||||||
import com.rtbishop.look4sat.domain.predict.OrbitalObject
|
|
||||||
import com.rtbishop.look4sat.domain.predict.OrbitalPos
|
|
||||||
import com.rtbishop.look4sat.presentation.Screen
|
|
||||||
import com.rtbishop.look4sat.presentation.common.IconCard
|
|
||||||
import com.rtbishop.look4sat.presentation.common.NextPassRow
|
|
||||||
import com.rtbishop.look4sat.presentation.common.TimerRow
|
|
||||||
import com.rtbishop.look4sat.presentation.common.TopBar
|
|
||||||
import org.osmdroid.tileprovider.tilesource.XYTileSource
|
|
||||||
import org.osmdroid.util.GeoPoint
|
|
||||||
import org.osmdroid.views.CustomZoomButtonsController
|
|
||||||
import org.osmdroid.views.MapView
|
|
||||||
import org.osmdroid.views.overlay.FolderOverlay
|
|
||||||
import org.osmdroid.views.overlay.Marker
|
|
||||||
import org.osmdroid.views.overlay.Polyline
|
|
||||||
import androidx.core.graphics.toColorInt
|
|
||||||
import androidx.core.graphics.createBitmap
|
|
||||||
import androidx.core.graphics.drawable.toDrawable
|
|
||||||
import com.rtbishop.look4sat.presentation.common.isVerticalLayout
|
|
||||||
import com.rtbishop.look4sat.presentation.common.layoutPadding
|
|
||||||
|
|
||||||
private val minLat = MapView.getTileSystem().minLatitude
|
|
||||||
private val maxLat = MapView.getTileSystem().maxLatitude
|
|
||||||
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
||||||
strokeWidth = 3f
|
|
||||||
style = Paint.Style.STROKE
|
|
||||||
color = Color.RED
|
|
||||||
strokeCap = Paint.Cap.ROUND
|
|
||||||
strokeJoin = Paint.Join.ROUND
|
|
||||||
}
|
|
||||||
private val footprintPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
||||||
strokeWidth = 3f
|
|
||||||
style = Paint.Style.FILL_AND_STROKE
|
|
||||||
color = "#FFE082".toColorInt()
|
|
||||||
}
|
|
||||||
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
||||||
textSize = 36f
|
|
||||||
style = Paint.Style.FILL
|
|
||||||
color = "#FFE082".toColorInt()
|
|
||||||
setShadowLayer(3f, 3f, 3f, Color.BLACK)
|
|
||||||
}
|
|
||||||
private val labelRect = Rect()
|
|
||||||
|
|
||||||
fun NavGraphBuilder.mapDestination() {
|
|
||||||
composable(Screen.Map.route) {
|
|
||||||
val viewModel = viewModel(MapViewModel::class.java, factory = MapViewModel.Factory)
|
|
||||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle()
|
|
||||||
val mapView = rememberMapViewWithLifecycle()
|
|
||||||
MapScreen(uiState, mapView)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MapScreen(uiState: State<MapState>, mapView: MapView) {
|
|
||||||
val onItemClick = { item: OrbitalObject -> uiState.value.sendAction(MapAction.SelectItem(item)) }
|
|
||||||
val selectPrev = { uiState.value.sendAction(MapAction.SelectPrev) }
|
|
||||||
val selectNext = { uiState.value.sendAction(MapAction.SelectNext) }
|
|
||||||
val rotateMod = Modifier.rotate(180f)
|
|
||||||
val timeString = uiState.value.mapData?.aosTime ?: "00:00:00"
|
|
||||||
val isTimeAos = uiState.value.mapData?.isTimeAos ?: true
|
|
||||||
LaunchedEffect(uiState.value.track) {
|
|
||||||
val latitude = uiState.value.track?.get(0)?.get(0)?.latitude ?: 0.0
|
|
||||||
val longitude = uiState.value.track?.get(0)?.get(0)?.longitude ?: 0.0
|
|
||||||
mapView.controller.animateTo(GeoPoint(latitude, longitude))
|
|
||||||
}
|
|
||||||
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
if (isVerticalLayout()) {
|
|
||||||
TopBar {
|
|
||||||
IconCard(action = selectPrev, resId = R.drawable.ic_arrow, modifier = rotateMod)
|
|
||||||
TimerRow(timeString = timeString, isTimeAos = isTimeAos)
|
|
||||||
IconCard(action = selectNext, resId = R.drawable.ic_arrow)
|
|
||||||
}
|
|
||||||
TopBar { NextPassRow(pass = uiState.value.orbitalPass) }
|
|
||||||
} else {
|
|
||||||
TopBar {
|
|
||||||
IconCard(action = selectPrev, resId = R.drawable.ic_arrow, modifier = rotateMod)
|
|
||||||
TimerRow(timeString = timeString, isTimeAos = isTimeAos)
|
|
||||||
NextPassRow(pass = uiState.value.orbitalPass, modifier = Modifier.weight(1f))
|
|
||||||
IconCard(action = selectNext, resId = R.drawable.ic_arrow)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ElevatedCard(modifier = Modifier.weight(1f)) {
|
|
||||||
Box(contentAlignment = Alignment.BottomCenter) {
|
|
||||||
AndroidView({ mapView }) { mapView ->
|
|
||||||
uiState.value.stationPosition?.let { setStationPosition(it, mapView) }
|
|
||||||
uiState.value.positions?.let { setPositions(it, mapView, onItemClick) }
|
|
||||||
uiState.value.track?.let { setSatelliteTrack(it, mapView) }
|
|
||||||
uiState.value.footprint?.let { setFootprint(it, mapView) }
|
|
||||||
}
|
|
||||||
uiState.value.mapData?.let { mapData ->
|
|
||||||
if (isVerticalLayout()) MapDataCard(mapData) else MapDataCards(mapData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MapDataCard(data: MapData) {
|
|
||||||
val textColor = MaterialTheme.colorScheme.primary
|
|
||||||
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween) {
|
|
||||||
Text(text = stringResource(R.string.map_copyright), fontSize = 14.sp)
|
|
||||||
Card(colors = cardColors) {
|
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
|
||||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(text = stringResource(R.string.map_azimuth, data.azimuth), color = textColor)
|
|
||||||
Text(text = stringResource(R.string.map_elevation, data.elevation), color = textColor)
|
|
||||||
}
|
|
||||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(text = stringResource(R.string.map_altitude, data.altitude))
|
|
||||||
Text(text = stringResource(R.string.map_distance, data.range))
|
|
||||||
}
|
|
||||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(text = stringResource(R.string.map_latitude, data.osmPos.latitude), color = textColor)
|
|
||||||
Text(text = stringResource(R.string.map_longitude, data.osmPos.longitude), color = textColor)
|
|
||||||
}
|
|
||||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(text = stringResource(R.string.map_qth, data.qthLoc))
|
|
||||||
Text(text = stringResource(R.string.map_phase, data.phase))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MapDataCards(data: MapData) {
|
|
||||||
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
|
|
||||||
val paddingMod = Modifier.padding(horizontal = 8.dp, vertical = 4.dp).width(160.dp)
|
|
||||||
val osmText = stringResource(R.string.map_copyright)
|
|
||||||
val textColor = MaterialTheme.colorScheme.primary
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
|
||||||
Card(colors = cardColors, modifier = Modifier.align(Alignment.TopStart)) {
|
|
||||||
Column(horizontalAlignment = Alignment.Start, modifier = paddingMod) {
|
|
||||||
Text(text = stringResource(R.string.map_azimuth, data.azimuth), color = textColor)
|
|
||||||
Text(text = stringResource(R.string.map_elevation, data.elevation))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Card(colors = cardColors, modifier = Modifier.align(Alignment.TopEnd)) {
|
|
||||||
Column(horizontalAlignment = Alignment.End, modifier = paddingMod) {
|
|
||||||
Text(text = stringResource(R.string.map_altitude, data.altitude), color = textColor)
|
|
||||||
Text(text = stringResource(R.string.map_distance, data.range))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Card(colors = cardColors, modifier = Modifier.align(Alignment.BottomStart)) {
|
|
||||||
Column(horizontalAlignment = Alignment.Start, modifier = paddingMod) {
|
|
||||||
Text(text = stringResource(R.string.map_phase, data.phase), color = textColor)
|
|
||||||
Text(text = stringResource(R.string.map_qth, data.qthLoc))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(text = osmText, fontSize = 14.sp, modifier = Modifier.align(Alignment.BottomCenter))
|
|
||||||
Card(colors = cardColors, modifier = Modifier.align(Alignment.BottomEnd)) {
|
|
||||||
Column(horizontalAlignment = Alignment.End, modifier = paddingMod) {
|
|
||||||
Text(text = stringResource(R.string.map_latitude, data.osmPos.latitude), color = textColor)
|
|
||||||
Text(text = stringResource(R.string.map_longitude, data.osmPos.longitude))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
|
||||||
try {
|
|
||||||
Marker(mapView).apply {
|
|
||||||
setInfoWindow(null)
|
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
|
||||||
icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position)
|
|
||||||
position = GeoPoint(stationPos.latitude, stationPos.longitude)
|
|
||||||
mapView.overlays[0] = this
|
|
||||||
mapView.invalidate()
|
|
||||||
}
|
|
||||||
} catch (exception: Exception) {
|
|
||||||
println(exception)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setPositions(
|
|
||||||
posMap: Map<OrbitalObject, GeoPos>,
|
|
||||||
mapView: MapView,
|
|
||||||
action: (OrbitalObject) -> Unit
|
|
||||||
) {
|
|
||||||
val markers = FolderOverlay()
|
|
||||||
try {
|
|
||||||
posMap.entries.forEach {
|
|
||||||
Marker(mapView).apply {
|
|
||||||
setInfoWindow(null)
|
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
|
||||||
icon = getCustomTextIcon(it.key.data.name, mapView)
|
|
||||||
try {
|
|
||||||
position = GeoPoint(it.value.latitude, it.value.longitude)
|
|
||||||
} catch (exception: IllegalArgumentException) {
|
|
||||||
println(exception.stackTraceToString())
|
|
||||||
}
|
|
||||||
setOnMarkerClickListener { _, _ ->
|
|
||||||
action(it.key)
|
|
||||||
return@setOnMarkerClickListener true
|
|
||||||
}
|
|
||||||
markers.add(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mapView.overlays[3] = markers
|
|
||||||
mapView.invalidate()
|
|
||||||
} catch (exception: Exception) {
|
|
||||||
println(exception)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getCustomTextIcon(textLabel: String, mapView: MapView): Drawable {
|
|
||||||
textPaint.getTextBounds(textLabel, 0, textLabel.length, labelRect)
|
|
||||||
val iconSize = 10f
|
|
||||||
val width = labelRect.width() + iconSize * 2f
|
|
||||||
val height = textPaint.textSize * 3f + iconSize * 2f
|
|
||||||
val bitmap = createBitmap(width.toInt(), height.toInt())
|
|
||||||
Canvas(bitmap).run {
|
|
||||||
drawCircle(width / 2f, height / 2f, iconSize, textPaint)
|
|
||||||
drawText(textLabel, iconSize / 2f, height - iconSize, textPaint)
|
|
||||||
}
|
|
||||||
return bitmap.toDrawable(mapView.context.resources)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
|
|
||||||
val trackOverlay = FolderOverlay()
|
|
||||||
try {
|
|
||||||
satTrack.forEach { track ->
|
|
||||||
val trackPoints = track.map { GeoPoint(it.latitude, it.longitude) }
|
|
||||||
Polyline().apply {
|
|
||||||
setPoints(trackPoints)
|
|
||||||
outlinePaint.set(trackPaint)
|
|
||||||
trackOverlay.add(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mapView.overlays[1] = trackOverlay
|
|
||||||
} catch (exception: Exception) {
|
|
||||||
println(exception)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
|
|
||||||
val footprintPoints = orbitalPos.getRangeCircle().map { GeoPoint(it.latitude, it.longitude) }
|
|
||||||
try {
|
|
||||||
val footprintOverlay = Polyline().apply {
|
|
||||||
outlinePaint.set(footprintPaint)
|
|
||||||
setPoints(footprintPoints)
|
|
||||||
}
|
|
||||||
mapView.overlays[2] = footprintOverlay
|
|
||||||
} catch (exception: Exception) {
|
|
||||||
println(exception)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun rememberMapViewWithLifecycle(): MapView {
|
|
||||||
val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
|
|
||||||
val context = LocalContext.current
|
|
||||||
val mapView = remember { MapView(context) }.apply {
|
|
||||||
setMultiTouchControls(true)
|
|
||||||
setUseDataConnection(false)
|
|
||||||
setTileSource(tileSource)
|
|
||||||
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels)
|
|
||||||
maxZoomLevel = 7.0
|
|
||||||
controller.setCenter(GeoPoint(48.8575, 6.3514))
|
|
||||||
controller.setZoom(minZoomLevel + 2)
|
|
||||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
|
||||||
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
|
|
||||||
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
|
|
||||||
overlayManager.tilesOverlay.setColorFilter(getColorFilter())
|
|
||||||
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
|
|
||||||
// add overlays: 0 - GSP, 1 - SatTrack, 2 - SatFootprint, 3 - SatIcons
|
|
||||||
overlays.addAll(Array(4) { FolderOverlay() })
|
|
||||||
}
|
|
||||||
// Makes MapView follow the lifecycle of this composable
|
|
||||||
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
|
|
||||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
|
||||||
DisposableEffect(lifecycle) {
|
|
||||||
lifecycle.addObserver(lifecycleObserver)
|
|
||||||
onDispose { lifecycle.removeObserver(lifecycleObserver) }
|
|
||||||
}
|
|
||||||
return mapView
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
|
|
||||||
LifecycleEventObserver { _, event ->
|
|
||||||
when (event) {
|
|
||||||
Lifecycle.Event.ON_RESUME -> mapView.onResume()
|
|
||||||
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun getColorFilter(): ColorMatrixColorFilter {
|
|
||||||
val grayScale = ColorMatrix().apply { setSaturation(0f) }
|
|
||||||
val negative = ColorMatrix(
|
|
||||||
floatArrayOf(-1f, 0f, 0f, 0f, 260f, 0f, -1f, 0f, 0f, 260f, 0f, 0f, -1f, 0f, 260f, 0f, 0f, 0f, 1f, 0f)
|
|
||||||
)
|
|
||||||
negative.preConcat(grayScale)
|
|
||||||
return ColorMatrixColorFilter(negative)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun getMinZoom(screenHeight: Int): Double {
|
|
||||||
if (!isVerticalLayout()) return 3.5
|
|
||||||
return MapView.getTileSystem().getLatitudeZoom(maxLat, minLat, screenHeight)
|
|
||||||
}
|
|
||||||
@@ -1,58 +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.presentation.map
|
|
||||||
|
|
||||||
import com.rtbishop.look4sat.domain.predict.GeoPos
|
|
||||||
import com.rtbishop.look4sat.domain.predict.OrbitalObject
|
|
||||||
import com.rtbishop.look4sat.domain.predict.OrbitalPass
|
|
||||||
import com.rtbishop.look4sat.domain.predict.OrbitalPos
|
|
||||||
|
|
||||||
data class MapState(
|
|
||||||
val mapData: MapData?,
|
|
||||||
val isLightUi: Boolean,
|
|
||||||
val stationPosition: GeoPos?,
|
|
||||||
val orbitalPass: OrbitalPass,
|
|
||||||
val track: List<List<GeoPos>>?,
|
|
||||||
val footprint: OrbitalPos?,
|
|
||||||
val positions: Map<OrbitalObject, GeoPos>?,
|
|
||||||
val sendAction: (MapAction) -> Unit
|
|
||||||
)
|
|
||||||
|
|
||||||
sealed class MapAction {
|
|
||||||
data object SelectPrev: MapAction()
|
|
||||||
data object SelectNext: MapAction()
|
|
||||||
data class SelectItem(val item: OrbitalObject): MapAction()
|
|
||||||
data class SelectDefaultItem(val catnum: Int): MapAction()
|
|
||||||
}
|
|
||||||
|
|
||||||
data class MapData(
|
|
||||||
val catNum: Int,
|
|
||||||
val name: String,
|
|
||||||
val aosTime: String,
|
|
||||||
val isTimeAos: Boolean,
|
|
||||||
val azimuth: Double,
|
|
||||||
val elevation: Double,
|
|
||||||
val range: Double,
|
|
||||||
val altitude: Double,
|
|
||||||
val velocity: Double,
|
|
||||||
val qthLoc: String,
|
|
||||||
val osmPos: GeoPos,
|
|
||||||
val period: Double,
|
|
||||||
val phase: Double,
|
|
||||||
val eclipsed: Boolean
|
|
||||||
)
|
|
||||||
@@ -1,235 +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.presentation.map
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.ViewModelProvider
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
|
||||||
import com.rtbishop.look4sat.MainApplication
|
|
||||||
import com.rtbishop.look4sat.domain.predict.GeoPos
|
|
||||||
import com.rtbishop.look4sat.domain.predict.OrbitalObject
|
|
||||||
import com.rtbishop.look4sat.domain.repository.ISatelliteRepo
|
|
||||||
import com.rtbishop.look4sat.domain.repository.ISettingsRepo
|
|
||||||
import com.rtbishop.look4sat.domain.utility.clipLat
|
|
||||||
import com.rtbishop.look4sat.domain.utility.clipLon
|
|
||||||
import com.rtbishop.look4sat.domain.utility.positionToQth
|
|
||||||
import com.rtbishop.look4sat.domain.utility.toDegrees
|
|
||||||
import com.rtbishop.look4sat.domain.utility.toTimerString
|
|
||||||
import com.rtbishop.look4sat.presentation.common.getDefaultPass
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.util.Date
|
|
||||||
import kotlin.collections.set
|
|
||||||
|
|
||||||
class MapViewModel(private val satelliteRepo: ISatelliteRepo, settingsRepo: ISettingsRepo) :
|
|
||||||
ViewModel() {
|
|
||||||
|
|
||||||
private val stationPos = settingsRepo.stationPosition.value
|
|
||||||
private val defaultPass = getDefaultPass()
|
|
||||||
private val _uiState = MutableStateFlow(
|
|
||||||
MapState(
|
|
||||||
mapData = null,
|
|
||||||
isLightUi = settingsRepo.otherSettings.value.stateOfLightTheme,
|
|
||||||
stationPosition = null,
|
|
||||||
orbitalPass = defaultPass,
|
|
||||||
track = null,
|
|
||||||
footprint = null,
|
|
||||||
positions = null,
|
|
||||||
sendAction = ::handleAction
|
|
||||||
)
|
|
||||||
)
|
|
||||||
private var allPasses = satelliteRepo.passes.value
|
|
||||||
private var allSatellites = satelliteRepo.satellites.value
|
|
||||||
private var dataUpdateJob: Job? = null
|
|
||||||
private var dataUpdateRate = 1000L
|
|
||||||
private var selectedOrbitalObject: OrbitalObject? = null
|
|
||||||
val uiState: StateFlow<MapState> = _uiState
|
|
||||||
|
|
||||||
init {
|
|
||||||
selectDefaultSatellite(-1)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleAction(action: MapAction) {
|
|
||||||
when (action) {
|
|
||||||
MapAction.SelectPrev -> scrollSelection(true)
|
|
||||||
MapAction.SelectNext -> scrollSelection(false)
|
|
||||||
is MapAction.SelectItem -> selectSatellite(action.item)
|
|
||||||
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getStationPosition() {
|
|
||||||
val osmLat = clipLat(stationPos.latitude)
|
|
||||||
val osmLon = clipLon(stationPos.longitude)
|
|
||||||
_uiState.update { it.copy(stationPosition = GeoPos(osmLat, osmLon)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun selectDefaultSatellite(catnum: Int) {
|
|
||||||
if (allSatellites.isNotEmpty()) {
|
|
||||||
if (catnum == -1) {
|
|
||||||
allPasses.find { pass -> pass.progress < 100 && !pass.isDeepSpace }
|
|
||||||
?.let { pass -> selectSatellite(pass.orbitalObject) }
|
|
||||||
} else {
|
|
||||||
allSatellites.find { it.data.catnum == catnum }?.let { selectSatellite(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scrollSelection(decrement: Boolean) {
|
|
||||||
if (allSatellites.isNotEmpty()) {
|
|
||||||
val index = allSatellites.indexOf(selectedOrbitalObject)
|
|
||||||
if (decrement) {
|
|
||||||
if (index > 0) selectSatellite(allSatellites[index - 1])
|
|
||||||
else selectSatellite(allSatellites[allSatellites.size - 1])
|
|
||||||
} else {
|
|
||||||
if (index < allSatellites.size - 1) selectSatellite(allSatellites[index + 1])
|
|
||||||
else selectSatellite(allSatellites[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun selectSatellite(orbitalObject: OrbitalObject, updateFreq: Long = dataUpdateRate) {
|
|
||||||
selectedOrbitalObject = orbitalObject
|
|
||||||
viewModelScope.launch {
|
|
||||||
dataUpdateJob?.cancelAndJoin()
|
|
||||||
dataUpdateJob = launch {
|
|
||||||
val dateNow = Date()
|
|
||||||
getStationPosition()
|
|
||||||
getSatTrack(orbitalObject, stationPos, dateNow)
|
|
||||||
while (isActive) {
|
|
||||||
dateNow.time = System.currentTimeMillis()
|
|
||||||
getPositions(allSatellites, stationPos, dateNow)
|
|
||||||
getSatFootprint(orbitalObject, stationPos, dateNow)
|
|
||||||
getSatData(orbitalObject, stationPos, dateNow)
|
|
||||||
delay(updateFreq)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getSatTrack(orbitalObject: OrbitalObject, pos: GeoPos, date: Date) {
|
|
||||||
val satTracks = mutableListOf<List<GeoPos>>()
|
|
||||||
val currentTrack = mutableListOf<GeoPos>()
|
|
||||||
val endDate = Date(date.time + (orbitalObject.data.orbitalPeriod * 2.4 * 60000L).toLong())
|
|
||||||
var oldLongitude = 0.0
|
|
||||||
satelliteRepo.getTrack(orbitalObject, pos, date.time, endDate.time).forEach { satPos ->
|
|
||||||
val osmLat = clipLat(satPos.latitude.toDegrees())
|
|
||||||
val osmLon = clipLon(satPos.longitude.toDegrees())
|
|
||||||
val currentPosition = GeoPos(osmLat, osmLon)
|
|
||||||
if (oldLongitude < -170.0 && currentPosition.longitude > 170.0) {
|
|
||||||
// adding left terminal position
|
|
||||||
currentTrack.add(GeoPos(osmLat, -180.0))
|
|
||||||
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
|
|
||||||
satTracks.add(finishedTrack)
|
|
||||||
currentTrack.clear()
|
|
||||||
} else if (oldLongitude > 170.0 && currentPosition.longitude < -170.0) {
|
|
||||||
// adding right terminal position
|
|
||||||
currentTrack.add(GeoPos(osmLat, 180.0))
|
|
||||||
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
|
|
||||||
satTracks.add(finishedTrack)
|
|
||||||
currentTrack.clear()
|
|
||||||
}
|
|
||||||
oldLongitude = currentPosition.longitude
|
|
||||||
currentTrack.add(currentPosition)
|
|
||||||
}
|
|
||||||
satTracks.add(currentTrack)
|
|
||||||
_uiState.update { it.copy(track = satTracks) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getPositions(orbitalObjects: List<OrbitalObject>, pos: GeoPos, date: Date) {
|
|
||||||
val positions = mutableMapOf<OrbitalObject, GeoPos>()
|
|
||||||
orbitalObjects.forEach { satellite ->
|
|
||||||
val satPos = satelliteRepo.getPosition(satellite, pos, date.time)
|
|
||||||
val osmLat = clipLat(satPos.latitude.toDegrees())
|
|
||||||
val osmLon = clipLon(satPos.longitude.toDegrees())
|
|
||||||
positions[satellite] = GeoPos(osmLat, osmLon)
|
|
||||||
}
|
|
||||||
_uiState.update { it.copy(positions = positions) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getSatFootprint(orbitalObject: OrbitalObject, pos: GeoPos, date: Date) {
|
|
||||||
val satPos = satelliteRepo.getPosition(orbitalObject, pos, date.time)
|
|
||||||
_uiState.update { it.copy(footprint = satPos) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getSatData(sat: OrbitalObject, pos: GeoPos, date: Date) {
|
|
||||||
var orbitalPass = defaultPass
|
|
||||||
var aosTime = 0L.toTimerString()
|
|
||||||
var isTimeAos = true
|
|
||||||
allPasses.find { pass -> pass.catNum == sat.data.catnum && pass.progress < 1 }
|
|
||||||
?.let { satPass ->
|
|
||||||
orbitalPass = satPass
|
|
||||||
if (!satPass.isDeepSpace) {
|
|
||||||
aosTime = if (date.time < satPass.aosTime) {
|
|
||||||
val millisBeforeStart = satPass.aosTime.minus(date.time)
|
|
||||||
isTimeAos = true
|
|
||||||
millisBeforeStart.toTimerString()
|
|
||||||
} else {
|
|
||||||
val millisBeforeEnd = satPass.losTime.minus(date.time)
|
|
||||||
isTimeAos = false
|
|
||||||
millisBeforeEnd.toTimerString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val satPos = satelliteRepo.getPosition(sat, pos, date.time)
|
|
||||||
val azimuth = satPos.azimuth.toDegrees()
|
|
||||||
val elevation = satPos.elevation.toDegrees()
|
|
||||||
val osmLat = clipLat(satPos.latitude.toDegrees())
|
|
||||||
val osmLon = clipLon(satPos.longitude.toDegrees())
|
|
||||||
val osmPos = GeoPos(osmLat, osmLon)
|
|
||||||
val qthLoc = positionToQth(osmPos.latitude, osmPos.longitude) ?: "-- --"
|
|
||||||
val velocity = satPos.getOrbitalVelocity()
|
|
||||||
val phase = satPos.phase.toDegrees()
|
|
||||||
val visibility = satPos.eclipsed
|
|
||||||
val satData = MapData(
|
|
||||||
sat.data.catnum,
|
|
||||||
sat.data.name,
|
|
||||||
aosTime,
|
|
||||||
isTimeAos,
|
|
||||||
azimuth,
|
|
||||||
elevation,
|
|
||||||
satPos.distance,
|
|
||||||
satPos.altitude,
|
|
||||||
velocity,
|
|
||||||
qthLoc,
|
|
||||||
osmPos,
|
|
||||||
sat.data.orbitalPeriod,
|
|
||||||
phase,
|
|
||||||
visibility
|
|
||||||
)
|
|
||||||
_uiState.update { it.copy(mapData = satData, orbitalPass = orbitalPass) }
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
|
||||||
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
|
|
||||||
initializer {
|
|
||||||
val container = (this[applicationKey] as MainApplication).container
|
|
||||||
MapViewModel(container.satelliteRepo, container.settingsRepo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,189 +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.presentation.passes
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
||||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
|
||||||
import androidx.compose.material3.Checkbox
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Slider
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.mutableDoubleStateOf
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.rtbishop.look4sat.R
|
|
||||||
import com.rtbishop.look4sat.presentation.LocalSpacing
|
|
||||||
import com.rtbishop.look4sat.presentation.MainTheme
|
|
||||||
import com.rtbishop.look4sat.presentation.common.SharedDialog
|
|
||||||
|
|
||||||
private val allModes = listOf(
|
|
||||||
"AFSK", "AFSK S-Net", "AFSK SALSAT", "AHRPT", "AM", "APT", "BPSK", "BPSK PMT-A3",
|
|
||||||
"CERTO", "CW", "DQPSK", "DSTAR", "DUV", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5",
|
|
||||||
"FSK AX.100 Mode 6", "FSK AX.25 G3RUH", "GFSK", "GFSK Rktr", "GMSK", "HRPT", "LoRa",
|
|
||||||
"LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5", "MSK AX.100 Mode 6", "OFDM", "OQPSK",
|
|
||||||
"PSK", "PSK31", "PSK63", "QPSK", "QPSK31", "QPSK63", "SSTV", "USB", "WSJT"
|
|
||||||
)
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
private fun PassesDialogPreview() {
|
|
||||||
MainTheme { PassesDialog(24, 16.0, {}) { _, _ -> } }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PassesDialog(hours: Int, elevation: Double, cancel: () -> Unit, accept: (Int, Double) -> Unit) {
|
|
||||||
val hoursValue = remember { mutableIntStateOf(hours) }
|
|
||||||
val elevationValueNew = remember { mutableDoubleStateOf(elevation) }
|
|
||||||
val onAccept = {
|
|
||||||
accept(hoursValue.intValue, elevationValueNew.doubleValue).also { cancel() }
|
|
||||||
}
|
|
||||||
SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
|
|
||||||
SliderRow(
|
|
||||||
title = stringResource(R.string.pass_filter_elev),
|
|
||||||
value = elevationValueNew.doubleValue,
|
|
||||||
valuePostfix = "°",
|
|
||||||
valueResId = R.drawable.ic_elevation,
|
|
||||||
valueRange = 0f..60f
|
|
||||||
) { elevationValueNew.doubleValue = it.toDouble() }
|
|
||||||
SliderRow(
|
|
||||||
title = stringResource(R.string.pass_filter_hours),
|
|
||||||
value = hoursValue.intValue.toDouble(),
|
|
||||||
valuePostfix = "h",
|
|
||||||
valueResId = R.drawable.ic_clock,
|
|
||||||
valueRange = 1f..240f
|
|
||||||
) { hoursValue.intValue = it.toInt() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SliderRow(
|
|
||||||
title: String,
|
|
||||||
value: Double,
|
|
||||||
valuePostfix: String,
|
|
||||||
valueResId: Int,
|
|
||||||
valueRange: ClosedFloatingPointRange<Float>,
|
|
||||||
onChange: (Float) -> Unit
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(LocalSpacing.current.small),
|
|
||||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(LocalSpacing.current.small),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = valueResId),
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "${value.toInt()}$valuePostfix",
|
|
||||||
fontSize = 18.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Slider(value = value.toFloat(), onValueChange = onChange, valueRange = valueRange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun RadiosDialogPreview() {
|
|
||||||
MainTheme { RadiosDialog(emptyList(), {}) { _ -> } }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit) {
|
|
||||||
val selected = remember { mutableStateListOf<String>().apply { addAll(modes) } }
|
|
||||||
val select = { mode: String ->
|
|
||||||
if (selected.contains(mode)) selected.remove(mode) else selected.add(mode)
|
|
||||||
}
|
|
||||||
val onAccept = { accept(selected.toList()).also { cancel() } }
|
|
||||||
SharedDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) {
|
|
||||||
LazyVerticalGrid(
|
|
||||||
columns = GridCells.Adaptive(240.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxHeight(0.69f)
|
|
||||||
.background(MaterialTheme.colorScheme.background),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
|
||||||
) {
|
|
||||||
itemsIndexed(allModes) { index, item ->
|
|
||||||
Surface {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.clickable { select(item) }
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "${index + 1}).",
|
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 8.dp),
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = item,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Checkbox(
|
|
||||||
checked = selected.contains(item),
|
|
||||||
onCheckedChange = null,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,336 +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.presentation.passes
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
|
||||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import androidx.navigation.NavGraphBuilder
|
|
||||||
import androidx.navigation.compose.composable
|
|
||||||
import com.rtbishop.look4sat.R
|
|
||||||
import com.rtbishop.look4sat.domain.predict.DeepSpaceObject
|
|
||||||
import com.rtbishop.look4sat.domain.predict.NearEarthObject
|
|
||||||
import com.rtbishop.look4sat.domain.predict.OrbitalData
|
|
||||||
import com.rtbishop.look4sat.domain.predict.OrbitalPass
|
|
||||||
import com.rtbishop.look4sat.presentation.MainTheme
|
|
||||||
import com.rtbishop.look4sat.presentation.Screen
|
|
||||||
import com.rtbishop.look4sat.presentation.common.EmptyListCard
|
|
||||||
import com.rtbishop.look4sat.presentation.common.IconCard
|
|
||||||
import com.rtbishop.look4sat.presentation.common.InfoDialog
|
|
||||||
import com.rtbishop.look4sat.presentation.common.NextPassRow
|
|
||||||
import com.rtbishop.look4sat.presentation.common.ScreenColumn
|
|
||||||
import com.rtbishop.look4sat.presentation.common.TimerRow
|
|
||||||
import com.rtbishop.look4sat.presentation.common.TopBar
|
|
||||||
import com.rtbishop.look4sat.presentation.common.infiniteMarquee
|
|
||||||
import com.rtbishop.look4sat.presentation.common.isVerticalLayout
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
private val sdfDate = SimpleDateFormat("EEE dd MMM", Locale.ENGLISH)
|
|
||||||
private val sdfTime = SimpleDateFormat("HH:mm:ss", Locale.ENGLISH)
|
|
||||||
|
|
||||||
fun NavGraphBuilder.passesDestination(navigateToRadar: (Int, Long) -> Unit) {
|
|
||||||
composable(Screen.Passes.route) {
|
|
||||||
val viewModel = viewModel(
|
|
||||||
modelClass = PassesViewModel::class.java,
|
|
||||||
factory = PassesViewModel.Factory
|
|
||||||
)
|
|
||||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
|
||||||
PassesScreen(uiState, navigateToRadar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PassesScreen(uiState: PassesState, navigateToRadar: (Int, Long) -> Unit) {
|
|
||||||
val refreshList = { uiState.takeAction(PassesAction.RefreshPasses) }
|
|
||||||
val showPassesDialog = { uiState.takeAction(PassesAction.TogglePassesDialog) }
|
|
||||||
val showRadiosDialog = { uiState.takeAction(PassesAction.ToggleRadiosDialog) }
|
|
||||||
if (uiState.isPassesDialogShown) {
|
|
||||||
PassesDialog(
|
|
||||||
hours = uiState.hours,
|
|
||||||
elevation = uiState.elevation,
|
|
||||||
cancel = showPassesDialog
|
|
||||||
) { hours, elevation ->
|
|
||||||
uiState.takeAction(PassesAction.FilterPasses(hours, elevation))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (uiState.isRadiosDialogShown) {
|
|
||||||
RadiosDialog(modes = uiState.modes, cancel = showRadiosDialog) { modes ->
|
|
||||||
uiState.takeAction(PassesAction.FilterRadios(modes))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (uiState.shouldSeeWelcome) {
|
|
||||||
InfoDialog(
|
|
||||||
title = stringResource(R.string.pass_welcome_title),
|
|
||||||
text = stringResource(R.string.pass_welcome_message)
|
|
||||||
) {
|
|
||||||
uiState.takeAction(PassesAction.DismissWelcome)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val gridState = rememberLazyGridState()
|
|
||||||
ScreenColumn(
|
|
||||||
topBar = { isVerticalLayout ->
|
|
||||||
TopBar(
|
|
||||||
isVerticalLayout = isVerticalLayout,
|
|
||||||
startAction = {
|
|
||||||
IconCard(action = showPassesDialog, resId = R.drawable.ic_filter)
|
|
||||||
},
|
|
||||||
topInfo = {
|
|
||||||
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
|
|
||||||
},
|
|
||||||
bottomInfo = {
|
|
||||||
NextPassRow(pass = uiState.nextPass)
|
|
||||||
},
|
|
||||||
endAction = {
|
|
||||||
IconCard(action = showRadiosDialog, resId = R.drawable.ic_radios)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) { _ ->
|
|
||||||
PassesList(
|
|
||||||
isRefreshing = uiState.isRefreshing,
|
|
||||||
passes = uiState.itemsList,
|
|
||||||
navigateToRadar = navigateToRadar,
|
|
||||||
refreshPasses = refreshList,
|
|
||||||
gridState = gridState
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun PassesList(
|
|
||||||
isRefreshing: Boolean,
|
|
||||||
passes: List<OrbitalPass>,
|
|
||||||
navigateToRadar: (Int, Long) -> Unit,
|
|
||||||
refreshPasses: () -> Unit,
|
|
||||||
gridState: LazyGridState
|
|
||||||
) {
|
|
||||||
val isVerticalLayout = isVerticalLayout()
|
|
||||||
val refreshState = rememberPullToRefreshState()
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxSize()) {
|
|
||||||
PullToRefreshBox(
|
|
||||||
isRefreshing = isRefreshing,
|
|
||||||
state = refreshState,
|
|
||||||
onRefresh = refreshPasses,
|
|
||||||
indicator = {
|
|
||||||
PullToRefreshDefaults.Indicator(
|
|
||||||
state = refreshState,
|
|
||||||
isRefreshing = isRefreshing,
|
|
||||||
color = MaterialTheme.colorScheme.background,
|
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.align(Alignment.TopCenter),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
if (passes.isEmpty()) {
|
|
||||||
EmptyListCard(message = stringResource(R.string.pass_empty_list_message))
|
|
||||||
} else {
|
|
||||||
LazyVerticalGrid(
|
|
||||||
state = gridState,
|
|
||||||
columns = GridCells.Adaptive(320.dp),
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
) {
|
|
||||||
items(items = passes, key = { item -> item.catNum + item.aosTime }) { pass ->
|
|
||||||
NearEarthPass(
|
|
||||||
pass = pass,
|
|
||||||
navigateToRadar = navigateToRadar,
|
|
||||||
modifier = Modifier.animateItem(),
|
|
||||||
isVerticalLayout = isVerticalLayout
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun DeepSpacePassPreview() {
|
|
||||||
val data = OrbitalData("Satellite", 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 45000, 0.0)
|
|
||||||
val satellite = DeepSpaceObject(data)
|
|
||||||
val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f)
|
|
||||||
MainTheme { NearEarthPass(pass = pass, { _, _ -> }) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun NearEarthPassPreview() {
|
|
||||||
val data = OrbitalData("Satellite", 0.0, 15.0, 0.0, 0.0, 0.0, 0.0, 0.0, 45000, 0.0)
|
|
||||||
val satellite = NearEarthObject(data)
|
|
||||||
val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f)
|
|
||||||
MainTheme { NearEarthPass(pass = pass, { _, _ -> }) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun NearEarthPass(
|
|
||||||
pass: OrbitalPass,
|
|
||||||
navigateToRadar: (Int, Long) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
isVerticalLayout: Boolean = true
|
|
||||||
) {
|
|
||||||
val passSatId = stringResource(id = R.string.pass_satId, pass.catNum)
|
|
||||||
val horizontalPadding = if (isVerticalLayout) 6.dp else 10.dp
|
|
||||||
Surface(color = MaterialTheme.colorScheme.background, modifier = modifier) {
|
|
||||||
Surface(modifier = Modifier
|
|
||||||
.padding(bottom = 2.dp)
|
|
||||||
.clickable { navigateToRadar(pass.catNum, pass.aosTime) }) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.background(color = MaterialTheme.colorScheme.surface)
|
|
||||||
.padding(horizontal = horizontalPadding, vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = "$passSatId - ",
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = pass.name,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 6.dp)
|
|
||||||
.infiniteMarquee(),
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.ic_elevation),
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "${pass.maxElevation}°",
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
horizontalArrangement = Arrangement.Start,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
if (pass.isDeepSpace) {
|
|
||||||
Text(text = stringResource(R.string.pass_deep_space), fontSize = 15.sp)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = sdfDate.format(Date(pass.aosTime)),
|
|
||||||
fontSize = 15.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.ic_altitude),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "${pass.altitude} km",
|
|
||||||
fontSize = 15.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
horizontalArrangement = Arrangement.End,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(
|
|
||||||
id = R.string.pass_aosLos,
|
|
||||||
pass.aosAzimuth.toInt(),
|
|
||||||
pass.losAzimuth.toInt()
|
|
||||||
),
|
|
||||||
fontSize = 15.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
val defaultTime = " - - : - - "
|
|
||||||
Text(
|
|
||||||
text = if (pass.isDeepSpace) defaultTime else sdfTime.format(Date(pass.aosTime)),
|
|
||||||
fontSize = 15.sp
|
|
||||||
)
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { if (pass.isDeepSpace) 100f else pass.progress },
|
|
||||||
drawStopIndicator = {},
|
|
||||||
modifier = modifier.fillMaxWidth(0.75f)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = if (pass.isDeepSpace) defaultTime else sdfTime.format(Date(pass.losTime)),
|
|
||||||
fontSize = 15.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +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.presentation.passes
|
|
||||||
|
|
||||||
import com.rtbishop.look4sat.domain.predict.OrbitalPass
|
|
||||||
|
|
||||||
data class PassesState(
|
|
||||||
val isPassesDialogShown: Boolean,
|
|
||||||
val isRadiosDialogShown: Boolean,
|
|
||||||
val isRefreshing: Boolean,
|
|
||||||
val isUtc: Boolean,
|
|
||||||
val nextPass: OrbitalPass,
|
|
||||||
val nextTime: String,
|
|
||||||
val isNextTimeAos: Boolean,
|
|
||||||
val hours: Int,
|
|
||||||
val elevation: Double,
|
|
||||||
val modes: List<String>,
|
|
||||||
val itemsList: List<OrbitalPass>,
|
|
||||||
val shouldSeeWelcome: Boolean,
|
|
||||||
val takeAction: (PassesAction) -> Unit
|
|
||||||
)
|
|
||||||
|
|
||||||
sealed class PassesAction {
|
|
||||||
data object DismissWelcome : PassesAction()
|
|
||||||
data class FilterPasses(val hoursAhead: Int, val minElevation: Double) : PassesAction()
|
|
||||||
data class FilterRadios(val modes: List<String>) : PassesAction()
|
|
||||||
data object RefreshPasses : PassesAction()
|
|
||||||
data object TogglePassesDialog : PassesAction()
|
|
||||||
data object ToggleRadiosDialog : PassesAction()
|
|
||||||
}
|
|
||||||
@@ -1,147 +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.presentation.passes
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.ViewModelProvider
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
|
||||||
import com.rtbishop.look4sat.MainApplication
|
|
||||||
import com.rtbishop.look4sat.domain.model.PassesSettings
|
|
||||||
import com.rtbishop.look4sat.domain.predict.OrbitalPass
|
|
||||||
import com.rtbishop.look4sat.domain.repository.ISatelliteRepo
|
|
||||||
import com.rtbishop.look4sat.domain.repository.ISettingsRepo
|
|
||||||
import com.rtbishop.look4sat.domain.utility.toTimerString
|
|
||||||
import com.rtbishop.look4sat.presentation.common.getDefaultPass
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class PassesViewModel(
|
|
||||||
private val satelliteRepo: ISatelliteRepo, private val settingsRepo: ISettingsRepo
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(
|
|
||||||
PassesState(
|
|
||||||
isPassesDialogShown = false,
|
|
||||||
isRadiosDialogShown = false,
|
|
||||||
isRefreshing = true,
|
|
||||||
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
|
||||||
nextTime = "00:00:00",
|
|
||||||
isNextTimeAos = true,
|
|
||||||
nextPass = getDefaultPass(),
|
|
||||||
hours = settingsRepo.passesSettings.value.hoursAhead,
|
|
||||||
elevation = settingsRepo.passesSettings.value.minElevation,
|
|
||||||
modes = settingsRepo.passesSettings.value.selectedModes,
|
|
||||||
itemsList = emptyList(),
|
|
||||||
shouldSeeWelcome = settingsRepo.otherSettings.value.shouldSeeWelcome,
|
|
||||||
takeAction = ::handleAction
|
|
||||||
)
|
|
||||||
)
|
|
||||||
private var processing: Job? = null
|
|
||||||
val uiState: StateFlow<PassesState> = _uiState
|
|
||||||
|
|
||||||
init {
|
|
||||||
viewModelScope.launch {
|
|
||||||
delay(1000)
|
|
||||||
satelliteRepo.passes.collectLatest { passes ->
|
|
||||||
processing?.cancelAndJoin()
|
|
||||||
processing = viewModelScope.launch {
|
|
||||||
while (isActive) {
|
|
||||||
val timeNow = System.currentTimeMillis()
|
|
||||||
val newPasses = satelliteRepo.processPasses(passes, timeNow)
|
|
||||||
setPassInfo(newPasses, timeNow)
|
|
||||||
_uiState.update { it.copy(isRefreshing = false, itemsList = newPasses) }
|
|
||||||
delay(1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
|
||||||
settingsRepo.otherSettings.collectLatest { settings ->
|
|
||||||
_uiState.update { it.copy(shouldSeeWelcome = settings.shouldSeeWelcome) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleAction(action: PassesAction) {
|
|
||||||
when (action) {
|
|
||||||
PassesAction.DismissWelcome -> settingsRepo.setWelcomeDismissed()
|
|
||||||
is PassesAction.FilterPasses -> applyFilter(action.hoursAhead, action.minElevation, uiState.value.modes)
|
|
||||||
is PassesAction.FilterRadios -> applyFilter(uiState.value.hours, uiState.value.elevation, action.modes)
|
|
||||||
PassesAction.RefreshPasses -> refreshPasses()
|
|
||||||
PassesAction.TogglePassesDialog -> toggleFilterDialog()
|
|
||||||
PassesAction.ToggleRadiosDialog -> toggleRadiosDialog()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun applyFilter(hoursAhead: Int, minElevation: Double, modes: List<String>) = viewModelScope.launch {
|
|
||||||
_uiState.update { it.copy(isRefreshing = true) }
|
|
||||||
processing?.cancelAndJoin()
|
|
||||||
settingsRepo.setPassesSettings(PassesSettings(hoursAhead, minElevation, modes))
|
|
||||||
_uiState.update { it.copy(hours = hoursAhead, elevation = minElevation, modes = modes) }
|
|
||||||
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshPasses() = viewModelScope.launch {
|
|
||||||
_uiState.update { it.copy(isRefreshing = true) }
|
|
||||||
processing?.cancelAndJoin()
|
|
||||||
val (hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
|
|
||||||
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setPassInfo(passes: List<OrbitalPass>, timeNow: Long) {
|
|
||||||
if (passes.isEmpty()) return
|
|
||||||
try {
|
|
||||||
val nextPass = passes.first { it.aosTime.minus(timeNow) > 0 }
|
|
||||||
val time = nextPass.aosTime.minus(timeNow).toTimerString()
|
|
||||||
_uiState.update { it.copy(nextPass = nextPass, nextTime = time, isNextTimeAos = true) }
|
|
||||||
} catch (_: NoSuchElementException) {
|
|
||||||
val lastPass = passes.last()
|
|
||||||
val time = lastPass.losTime.minus(timeNow).toTimerString()
|
|
||||||
_uiState.update { it.copy(nextPass = lastPass, nextTime = time, isNextTimeAos = false) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toggleFilterDialog() {
|
|
||||||
val currentDialogState = _uiState.value.isPassesDialogShown
|
|
||||||
_uiState.update { it.copy(isPassesDialogShown = currentDialogState.not()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toggleRadiosDialog() {
|
|
||||||
val currentDialogState = _uiState.value.isRadiosDialogShown
|
|
||||||
_uiState.update { it.copy(isRadiosDialogShown = currentDialogState.not()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
|
||||||
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
|
|
||||||
initializer {
|
|
||||||
val container = (this[applicationKey] as MainApplication).container
|
|
||||||
PassesViewModel(container.satelliteRepo, container.settingsRepo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,453 +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.presentation.radar
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.LinearEasing
|
|
||||||
import androidx.compose.animation.core.RepeatMode
|
|
||||||
import androidx.compose.animation.core.animateFloat
|
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.rotate
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import androidx.navigation.NavGraphBuilder
|
|
||||||
import androidx.navigation.compose.composable
|
|
||||||
import androidx.navigation.navArgument
|
|
||||||
import com.rtbishop.look4sat.R
|
|
||||||
import com.rtbishop.look4sat.domain.model.SatRadio
|
|
||||||
import com.rtbishop.look4sat.domain.utility.toDegrees
|
|
||||||
import com.rtbishop.look4sat.presentation.MainTheme
|
|
||||||
import com.rtbishop.look4sat.presentation.Screen
|
|
||||||
import com.rtbishop.look4sat.presentation.common.EmptyListCard
|
|
||||||
import com.rtbishop.look4sat.presentation.common.IconCard
|
|
||||||
import com.rtbishop.look4sat.presentation.common.NextPassRow
|
|
||||||
import com.rtbishop.look4sat.presentation.common.TimerRow
|
|
||||||
import com.rtbishop.look4sat.presentation.common.TopBar
|
|
||||||
import com.rtbishop.look4sat.presentation.common.getDefaultPass
|
|
||||||
import com.rtbishop.look4sat.presentation.common.infiniteMarquee
|
|
||||||
import com.rtbishop.look4sat.presentation.common.isVerticalLayout
|
|
||||||
import com.rtbishop.look4sat.presentation.common.layoutPadding
|
|
||||||
|
|
||||||
fun NavGraphBuilder.radarDestination(navigateUp: () -> Unit) {
|
|
||||||
val radarRoute = "${Screen.Radar.route}?catNum={catNum}&aosTime={aosTime}"
|
|
||||||
val radarArgs = listOf(
|
|
||||||
navArgument("catNum") { defaultValue = 0 },
|
|
||||||
navArgument("aosTime") { defaultValue = 0L }
|
|
||||||
)
|
|
||||||
composable(radarRoute, radarArgs) {
|
|
||||||
val viewModel = viewModel(RadarViewModel::class.java, factory = RadarViewModel.Factory)
|
|
||||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
|
||||||
RadarScreen(uiState, navigateUp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) {
|
|
||||||
// BluetoothCIV.init(LocalContext.current)
|
|
||||||
val addToCalendar: () -> Unit = {
|
|
||||||
uiState.currentPass?.let { pass ->
|
|
||||||
uiState.sendAction(RadarAction.AddToCalendar(pass.name, pass.aosTime, pass.losTime))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
|
||||||
if (upcomingPass.losTime < System.currentTimeMillis()) navigateUp()
|
|
||||||
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
if (isVerticalLayout()) {
|
|
||||||
TopBar {
|
|
||||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
|
||||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
|
|
||||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
|
||||||
}
|
|
||||||
TopBar { NextPassRow(pass = upcomingPass) }
|
|
||||||
} else {
|
|
||||||
TopBar {
|
|
||||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
|
||||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
|
|
||||||
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f))
|
|
||||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(isVerticalLayout()) {
|
|
||||||
ElevatedCard(modifier = Modifier.weight(1f)) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
if (uiState.orbitalPos == null) {
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxSize()) {
|
|
||||||
EmptyListCard(message = "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
uiState.orbitalPos?.let { position ->
|
|
||||||
RadarViewCompose(
|
|
||||||
item = position,
|
|
||||||
items = uiState.satTrack,
|
|
||||||
azimElev = uiState.orientationValues,
|
|
||||||
shouldShowSweep = uiState.shouldShowSweep,
|
|
||||||
shouldUseCompass = uiState.shouldUseCompass,
|
|
||||||
modifier = Modifier.align(Alignment.Center)
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(horizontal = 6.dp, vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
RadarTextTop(
|
|
||||||
position.azimuth,
|
|
||||||
stringResource(R.string.radar_az_text),
|
|
||||||
true
|
|
||||||
)
|
|
||||||
RadarTextTop(
|
|
||||||
position.elevation,
|
|
||||||
stringResource(R.string.radar_el_text),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
RadarTextBottom(
|
|
||||||
position.altitude,
|
|
||||||
stringResource(R.string.radar_alt_text),
|
|
||||||
true
|
|
||||||
)
|
|
||||||
RadarTextBottom(
|
|
||||||
position.distance,
|
|
||||||
stringResource(R.string.radar_dist_text),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ElevatedCard(modifier = Modifier.weight(1f)) {
|
|
||||||
if (uiState.transmitters.isEmpty()) {
|
|
||||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
|
||||||
modifier = Modifier.padding(32.dp)
|
|
||||||
) {
|
|
||||||
Text(text = """¯\_(ツ)_/¯""", fontSize = 32.sp)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.empty_list_message),
|
|
||||||
fontSize = 21.sp,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.radar_no_data),
|
|
||||||
fontSize = 18.sp,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
TransmittersList(transmitters = uiState.transmitters)
|
|
||||||
if (uiState.orbitalPos?.eclipsed == true) {
|
|
||||||
EclipsedIndicator()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
ElevatedCard(modifier = Modifier.weight(1f)) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
if (uiState.orbitalPos == null) {
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxSize()) {
|
|
||||||
EmptyListCard(message = "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
uiState.orbitalPos?.let { position ->
|
|
||||||
RadarViewCompose(
|
|
||||||
item = position,
|
|
||||||
items = uiState.satTrack,
|
|
||||||
azimElev = uiState.orientationValues,
|
|
||||||
shouldShowSweep = uiState.shouldShowSweep,
|
|
||||||
shouldUseCompass = false,
|
|
||||||
modifier = Modifier.align(Alignment.Center)
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(horizontal = 6.dp, vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
RadarTextTop(
|
|
||||||
position.azimuth,
|
|
||||||
stringResource(R.string.radar_az_text),
|
|
||||||
true
|
|
||||||
)
|
|
||||||
RadarTextTop(
|
|
||||||
position.elevation,
|
|
||||||
stringResource(R.string.radar_el_text),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
RadarTextBottom(
|
|
||||||
position.altitude,
|
|
||||||
stringResource(R.string.radar_alt_text),
|
|
||||||
true
|
|
||||||
)
|
|
||||||
RadarTextBottom(
|
|
||||||
position.distance,
|
|
||||||
stringResource(R.string.radar_dist_text),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ElevatedCard(modifier = Modifier.weight(1f)) {
|
|
||||||
if (uiState.transmitters.isEmpty()) {
|
|
||||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
|
||||||
modifier = Modifier.padding(32.dp)
|
|
||||||
) {
|
|
||||||
Text(text = """¯\_(ツ)_/¯""", fontSize = 32.sp)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.empty_list_message),
|
|
||||||
fontSize = 21.sp,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.radar_no_data),
|
|
||||||
fontSize = 18.sp,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
TransmittersList(transmitters = uiState.transmitters)
|
|
||||||
if (uiState.orbitalPos?.eclipsed == true) {
|
|
||||||
EclipsedIndicator()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RadarTextTop(value: Double, text: String, isLeft: Boolean) {
|
|
||||||
val alignment = if (isLeft) Alignment.Start else Alignment.End
|
|
||||||
val degValue = stringResource(R.string.radar_az_value, value.toDegrees())
|
|
||||||
Column(horizontalAlignment = alignment) {
|
|
||||||
Text(text = degValue, fontSize = 18.sp)
|
|
||||||
Text(text = text, fontSize = 15.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RadarTextBottom(value: Double, text: String, isLeft: Boolean) {
|
|
||||||
val alignment = if (isLeft) Alignment.Start else Alignment.End
|
|
||||||
val degValue = stringResource(R.string.radar_alt_value, value)
|
|
||||||
Column(horizontalAlignment = alignment) {
|
|
||||||
Text(text = text, fontSize = 15.sp)
|
|
||||||
Text(text = degValue, fontSize = 18.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TransmittersList(transmitters: List<SatRadio>) {
|
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
|
||||||
items(items = transmitters, key = { item -> item.uuid }) { radio ->
|
|
||||||
TransmitterItem(radio)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
private fun TransmitterItemPreview() {
|
|
||||||
val transmitter = SatRadio(
|
|
||||||
"", "Extremely powerful transmitter", true, 10000000000L, 10000000000L,
|
|
||||||
null, 10000000000L, 10000000000L, "FSK AX.100 Mode 5", true, 0
|
|
||||||
)
|
|
||||||
MainTheme { TransmitterItem(transmitter) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EclipsedIndicator() {
|
|
||||||
val bgColor = MaterialTheme.colorScheme.primary
|
|
||||||
val bgShape = MaterialTheme.shapes.medium
|
|
||||||
val infiniteTransition = rememberInfiniteTransition()
|
|
||||||
val textAlpha = infiniteTransition.animateFloat(
|
|
||||||
initialValue = 1.0f,
|
|
||||||
targetValue = 0.0f,
|
|
||||||
animationSpec = infiniteRepeatable(
|
|
||||||
animation = tween(
|
|
||||||
durationMillis = 1000,
|
|
||||||
delayMillis = 25,
|
|
||||||
easing = LinearEasing
|
|
||||||
),
|
|
||||||
repeatMode = RepeatMode.Reverse
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.alpha(textAlpha.value)
|
|
||||||
.border(width = 2.dp, color = bgColor, shape = bgShape),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.radar_eclipsed),
|
|
||||||
fontSize = 18.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.background,
|
|
||||||
modifier = Modifier
|
|
||||||
.background(color = bgColor, shape = bgShape)
|
|
||||||
.padding(12.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TransmitterItem(radio: SatRadio) {
|
|
||||||
// LaunchedEffect(radio) {
|
|
||||||
// BluetoothCIV.updateOnce(radio)
|
|
||||||
// }
|
|
||||||
val title = if (radio.isInverted) "INVERTED: ${radio.info}" else radio.info
|
|
||||||
val fullTitle = "$title - (${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"})"
|
|
||||||
Surface(color = MaterialTheme.colorScheme.background,
|
|
||||||
// modifier = Modifier.clickable(onClick = {
|
|
||||||
// Log.d("BluetoothCivManager", radio.toString())
|
|
||||||
// if(radio.uuid == BluetoothCIV.selected) BluetoothCIV.selected = "NONE" else
|
|
||||||
// {
|
|
||||||
// BluetoothCIV.connect(radio)
|
|
||||||
// BluetoothCIV.updateOnce(radio)
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
) {
|
|
||||||
Surface(modifier = Modifier.padding(bottom = 2.dp)) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = fullTitle,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.infiniteMarquee()
|
|
||||||
)
|
|
||||||
FrequencyRow(radio = radio, isDownlink = true)
|
|
||||||
FrequencyRow(radio = radio, isDownlink = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun FrequencyRow(radio: SatRadio, isDownlink: Boolean, modifier: Modifier = Modifier) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier.fillMaxWidth()) {
|
|
||||||
val rotateMod = Modifier.rotate(if (isDownlink) 90f else -90f)
|
|
||||||
val weightMod = Modifier.weight(1f)
|
|
||||||
val desc = if (isDownlink) stringResource(R.string.radar_downlink) else stringResource(R.string.radar_uplink)
|
|
||||||
Text(
|
|
||||||
text = if (isDownlink) "D:" else "U:",
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontSize = 18.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = modifier.size(width = 24.dp, height = 24.dp).semantics { contentDescription = desc }
|
|
||||||
)
|
|
||||||
FrequencyText(if (isDownlink) radio.downlinkLow else radio.uplinkLow, weightMod)
|
|
||||||
Text(
|
|
||||||
text = "-",
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontSize = 21.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = modifier
|
|
||||||
)
|
|
||||||
FrequencyText(if (isDownlink) radio.downlinkHigh else radio.uplinkHigh, weightMod)
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.ic_arrow),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = rotateMod.size(width = 24.dp, height = 24.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
|
|
||||||
val noLinkText = stringResource(R.string.radar_no_link)
|
|
||||||
val freqValue = frequency?.let { it / 1000000f }
|
|
||||||
Text(
|
|
||||||
text = freqValue?.let { stringResource(id = R.string.radar_link_low, it) } ?: noLinkText,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontSize = 21.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = modifier
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,39 +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.presentation.radar
|
|
||||||
|
|
||||||
import com.rtbishop.look4sat.domain.model.SatRadio
|
|
||||||
import com.rtbishop.look4sat.domain.predict.OrbitalPass
|
|
||||||
import com.rtbishop.look4sat.domain.predict.OrbitalPos
|
|
||||||
|
|
||||||
data class RadarState(
|
|
||||||
val currentPass: OrbitalPass?,
|
|
||||||
val currentTime: String,
|
|
||||||
val isCurrentTimeAos: Boolean,
|
|
||||||
val orientationValues: Pair<Float, Float>,
|
|
||||||
val orbitalPos: OrbitalPos?,
|
|
||||||
val satTrack: List<OrbitalPos>,
|
|
||||||
val shouldShowSweep: Boolean,
|
|
||||||
val shouldUseCompass: Boolean,
|
|
||||||
val transmitters: List<SatRadio>,
|
|
||||||
val sendAction: (RadarAction) -> Unit
|
|
||||||
)
|
|
||||||
|
|
||||||
sealed class RadarAction {
|
|
||||||
data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction()
|
|
||||||
}
|
|
||||||
@@ -1,252 +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.presentation.radar
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloat
|
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.Canvas
|
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
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.PathEffect
|
|
||||||
import androidx.compose.ui.graphics.PathMeasure
|
|
||||||
import androidx.compose.ui.graphics.ShaderBrush
|
|
||||||
import androidx.compose.ui.graphics.StampedPathEffectStyle
|
|
||||||
import androidx.compose.ui.graphics.SweepGradientShader
|
|
||||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
|
||||||
import androidx.compose.ui.graphics.drawscope.Fill
|
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
|
||||||
import androidx.compose.ui.graphics.drawscope.rotate
|
|
||||||
import androidx.compose.ui.graphics.drawscope.translate
|
|
||||||
import androidx.compose.ui.text.TextMeasurer
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.drawText
|
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.rtbishop.look4sat.domain.predict.OrbitalPos
|
|
||||||
import com.rtbishop.look4sat.domain.predict.PI_2
|
|
||||||
import com.rtbishop.look4sat.domain.utility.toRadians
|
|
||||||
import kotlin.math.cos
|
|
||||||
import kotlin.math.sin
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RadarViewCompose(
|
|
||||||
item: OrbitalPos,
|
|
||||||
items: List<OrbitalPos>,
|
|
||||||
azimElev: Pair<Float, Float>,
|
|
||||||
shouldShowSweep: Boolean,
|
|
||||||
shouldUseCompass: Boolean,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
// val context = LocalContext.current
|
|
||||||
// val view = LocalView.current
|
|
||||||
val radarColor = MaterialTheme.colorScheme.secondary
|
|
||||||
val trackColor = MaterialTheme.colorScheme.primary
|
|
||||||
val aimColor = MaterialTheme.colorScheme.error
|
|
||||||
val strokeWidth = 6f
|
|
||||||
val animTransition = rememberInfiniteTransition(label = "animScale")
|
|
||||||
val animSpec = infiniteRepeatable<Float>(tween(1000))
|
|
||||||
val animScale = animTransition.animateFloat(16f, 64f, animSpec, label = "animScale")
|
|
||||||
// val aimThreshold = 0.05f
|
|
||||||
val measurer = rememberTextMeasurer()
|
|
||||||
val sweepDegrees = remember { mutableFloatStateOf(0f) }
|
|
||||||
val trackCreated = remember { mutableStateOf(false) }
|
|
||||||
val trackPath = remember { mutableStateOf(Path()) }
|
|
||||||
val trackEffect = remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
|
|
||||||
// val soundPool = remember { mutableStateOf<SoundPool?>(null) }
|
|
||||||
// val beepSoundId = remember { mutableIntStateOf(0) }
|
|
||||||
// val aimTargetDifference = remember { mutableFloatStateOf(0f) }
|
|
||||||
|
|
||||||
// LaunchedEffect(item.azimuth, item.elevation, azimElev.first, azimElev.second) {
|
|
||||||
// val aimAzimuthRadians = azimElev.first.toDouble().toRadians()
|
|
||||||
// val aimElevationRadians = abs(min(azimElev.second, 0f)).toDouble().toRadians()
|
|
||||||
// // radius of 0.5 makes the aimTargetDifference range 0.0 to 1.0
|
|
||||||
// val radius = 0.5
|
|
||||||
// val aimX = sph2CartX(aimAzimuthRadians, aimElevationRadians, radius)
|
|
||||||
// val aimY = sph2CartY(aimAzimuthRadians, aimElevationRadians, radius)
|
|
||||||
// val satX = sph2CartX(item.azimuth, item.elevation, radius)
|
|
||||||
// val satY = sph2CartY(item.azimuth, item.elevation, radius)
|
|
||||||
// aimTargetDifference.floatValue = sqrt((satX - aimX).pow(2) + (satY - aimY).pow(2))
|
|
||||||
// val minPlaybackRate = 0.5f
|
|
||||||
// val maxPlaybackRate = 2.0f
|
|
||||||
// val playbackRate =
|
|
||||||
// maxPlaybackRate - (aimTargetDifference.floatValue / (maxPlaybackRate - minPlaybackRate))
|
|
||||||
// soundPool.value?.setRate(beepSoundId.intValue, playbackRate)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// LaunchedEffect(aimTargetDifference.floatValue < aimThreshold) {
|
|
||||||
// if (aimTargetDifference.floatValue < aimThreshold) {
|
|
||||||
// view.performHapticFeedback(HapticFeedbackConstantsCompat.VIRTUAL_KEY)
|
|
||||||
// soundPool.value?.pause(beepSoundId.intValue)
|
|
||||||
// } else {
|
|
||||||
// soundPool.value?.resume(beepSoundId.intValue)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// LaunchedEffect(item.elevation > 0) {
|
|
||||||
// if(item.elevation > 0) {
|
|
||||||
// soundPool.value?.resume(beepSoundId.intValue)
|
|
||||||
// } else {
|
|
||||||
// soundPool.value?.pause(beepSoundId.intValue)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// DisposableEffect(Unit) {
|
|
||||||
// val audioAttributes = AudioAttributes.Builder()
|
|
||||||
// .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
|
|
||||||
// .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
|
||||||
// .build()
|
|
||||||
// soundPool.value = SoundPool.Builder()
|
|
||||||
// .setMaxStreams(1)
|
|
||||||
// .setAudioAttributes(audioAttributes)
|
|
||||||
// .build()
|
|
||||||
// beepSoundId.intValue = soundPool.value?.load(context, R.raw.beep, 1) ?: 0
|
|
||||||
// soundPool.value?.setOnLoadCompleteListener { soundPool, _, status ->
|
|
||||||
// if (status == 0) {
|
|
||||||
// soundPool.play(beepSoundId.intValue, 0.5f, 0.5f, 0, -1, 1f)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// onDispose {
|
|
||||||
// soundPool.value?.release()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
Canvas(modifier = modifier.aspectRatio(1f)) {
|
|
||||||
val radius = (size.minDimension / 2f) * 0.95f
|
|
||||||
if (!trackCreated.value) {
|
|
||||||
trackPath.value = createTrackPath(items, radius)
|
|
||||||
trackEffect.value = createTrackEffect(trackPath.value)
|
|
||||||
trackCreated.value = true
|
|
||||||
}
|
|
||||||
rotate(if (shouldUseCompass) -azimElev.first else 0f) {
|
|
||||||
if (shouldShowSweep) {
|
|
||||||
drawSweep(center, sweepDegrees.floatValue, radius, trackColor)
|
|
||||||
}
|
|
||||||
drawRadar(radius, radarColor, strokeWidth, 3)
|
|
||||||
drawInfo(radius, trackColor, measurer, 3)
|
|
||||||
translate(center.x, center.y) {
|
|
||||||
drawTrack(trackPath.value, trackEffect.value, aimColor, trackColor)
|
|
||||||
if (item.elevation > 0) {
|
|
||||||
drawPosition(item, radius, animScale.value, trackColor)
|
|
||||||
}
|
|
||||||
if (shouldUseCompass) {
|
|
||||||
drawAim(azimElev.first, azimElev.second, radius, strokeWidth, aimColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sweepDegrees.floatValue = (sweepDegrees.floatValue + 360 / 12.0f / 60) % 360
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun DrawScope.drawRadar(radius: Float, color: Color, width: Float, circles: Int) {
|
|
||||||
for (i in 0 until circles) {
|
|
||||||
val circleRadius = radius - radius / circles.toFloat() * i.toFloat()
|
|
||||||
drawCircle(color, circleRadius, style = Stroke(width))
|
|
||||||
}
|
|
||||||
drawLine(color, center.copy(x = center.x - radius), center.copy(x = center.x + radius), width)
|
|
||||||
drawLine(color, center.copy(y = center.y - radius), center.copy(y = center.y + radius), width)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun DrawScope.drawInfo(radius: Float, color: Color, measurer: TextMeasurer, circles: Int) {
|
|
||||||
for (i in 0 until circles) {
|
|
||||||
val textY = (radius - radius / circles * i) - 32f
|
|
||||||
val textDeg = " ${(90 / circles) * (circles - i)}°"
|
|
||||||
drawText(measurer, textDeg, center.copy(y = textY), style = TextStyle(color, 15.sp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun DrawScope.drawTrack(path: Path, effect: PathEffect, color: Color, effectColor: Color) {
|
|
||||||
drawPath(path, color, style = Stroke(6f))
|
|
||||||
drawPath(path, effectColor, style = Stroke(pathEffect = effect))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun DrawScope.drawPosition(item: OrbitalPos, radius: Float, posRadius: Float, color: Color) {
|
|
||||||
val satX = sph2CartX(item.azimuth, item.elevation, radius.toDouble())
|
|
||||||
val satY = sph2CartY(item.azimuth, item.elevation, radius.toDouble())
|
|
||||||
drawCircle(color, 16f, center.copy(satX, -satY))
|
|
||||||
drawCircle(color.copy(alpha = 1 - (posRadius / 64f)), posRadius, center.copy(satX, -satY))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun DrawScope.drawAim(azim: Float, elev: Float, radius: Float, width: Float, color: Color) {
|
|
||||||
val size = 36f
|
|
||||||
val azimRadians = azim.toDouble().toRadians()
|
|
||||||
val tempElevRadians = elev.toDouble().toRadians()
|
|
||||||
val elevRadians = if (tempElevRadians > 0.0) 0.0 else tempElevRadians
|
|
||||||
val aimX = sph2CartX(azimRadians, -elevRadians, radius.toDouble())
|
|
||||||
val aimY = sph2CartY(azimRadians, -elevRadians, radius.toDouble())
|
|
||||||
try {
|
|
||||||
drawLine(color, center.copy(aimX - size, -aimY), center.copy(aimX + size, -aimY), width)
|
|
||||||
drawLine(color, center.copy(aimX, -aimY - size), center.copy(aimX, -aimY + size), width)
|
|
||||||
drawCircle(color, size / 2, center.copy(aimX, -aimY), style = Stroke(width))
|
|
||||||
} catch (exception: Exception) {
|
|
||||||
println(exception)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, color: Color) {
|
|
||||||
val colors = listOf(Color.Transparent, color.copy(alpha = 0.5f), color)
|
|
||||||
val colorStops = listOf(0.64f, 0.995f, 1f)
|
|
||||||
val brush = ShaderBrush(SweepGradientShader(center, colors, colorStops))
|
|
||||||
rotate(-90 + degrees, center) { drawCircle(brush, radius, style = Fill) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createTrackPath(positions: List<OrbitalPos>, radius: Float): Path {
|
|
||||||
val trackPath = Path()
|
|
||||||
positions.forEachIndexed { index, satPos ->
|
|
||||||
val passX = sph2CartX(satPos.azimuth, satPos.elevation, radius.toDouble())
|
|
||||||
val passY = sph2CartY(satPos.azimuth, satPos.elevation, radius.toDouble())
|
|
||||||
if (index == 0) trackPath.moveTo(passX, -passY) else trackPath.lineTo(passX, -passY)
|
|
||||||
}
|
|
||||||
return trackPath
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createTrackEffect(trackPath: Path): PathEffect {
|
|
||||||
val shape = Path()
|
|
||||||
val shapeRadius = 24f
|
|
||||||
val angle = 120.0.toRadians()
|
|
||||||
shape.moveTo((shapeRadius * cos(angle)).toFloat(), (shapeRadius * sin(angle)).toFloat())
|
|
||||||
for (i in 1 until 3) {
|
|
||||||
val x = (shapeRadius * cos(angle - angle * i)).toFloat()
|
|
||||||
val y = (shapeRadius * sin(angle - angle * i)).toFloat()
|
|
||||||
shape.lineTo(x, y)
|
|
||||||
}
|
|
||||||
shape.close()
|
|
||||||
val trackLength = PathMeasure().apply { setPath(trackPath, false) }.length
|
|
||||||
val advance = trackLength / 2f
|
|
||||||
val phase = trackLength / 4f
|
|
||||||
return PathEffect.stampedPathEffect(shape, advance, phase, StampedPathEffectStyle.Rotate)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sph2CartX(azim: Double, elev: Double, r: Double): Float {
|
|
||||||
val radius = r * (PI_2 - elev) / PI_2
|
|
||||||
return (radius * cos(PI_2 - azim)).toFloat()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sph2CartY(azim: Double, elev: Double, r: Double): Float {
|
|
||||||
val radius = r * (PI_2 - elev) / PI_2
|
|
||||||
return (radius * sin(PI_2 - azim)).toFloat()
|
|
||||||
}
|
|
||||||
@@ -1,190 +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.presentation.radar
|
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.ViewModelProvider
|
|
||||||
import androidx.lifecycle.createSavedStateHandle
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
|
||||||
import com.rtbishop.look4sat.MainApplication
|
|
||||||
import com.rtbishop.look4sat.data.framework.BluetoothReporter
|
|
||||||
import com.rtbishop.look4sat.data.framework.NetworkReporter
|
|
||||||
import com.rtbishop.look4sat.domain.model.SatRadio
|
|
||||||
import com.rtbishop.look4sat.domain.predict.OrbitalObject
|
|
||||||
import com.rtbishop.look4sat.domain.predict.OrbitalPass
|
|
||||||
import com.rtbishop.look4sat.domain.predict.OrbitalPos
|
|
||||||
import com.rtbishop.look4sat.domain.repository.ISatelliteRepo
|
|
||||||
import com.rtbishop.look4sat.domain.repository.ISensorsRepo
|
|
||||||
import com.rtbishop.look4sat.domain.repository.ISettingsRepo
|
|
||||||
import com.rtbishop.look4sat.domain.usecase.IAddToCalendar
|
|
||||||
import com.rtbishop.look4sat.domain.utility.round
|
|
||||||
import com.rtbishop.look4sat.domain.utility.toDegrees
|
|
||||||
import com.rtbishop.look4sat.domain.utility.toTimerString
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class RadarViewModel(
|
|
||||||
private val savedStateHandle: SavedStateHandle,
|
|
||||||
private val bluetoothReporter: BluetoothReporter,
|
|
||||||
private val networkReporter: NetworkReporter,
|
|
||||||
private val satelliteRepo: ISatelliteRepo,
|
|
||||||
private val settingsRepo: ISettingsRepo,
|
|
||||||
private val sensorsRepo: ISensorsRepo,
|
|
||||||
private val addToCalendar: IAddToCalendar
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(
|
|
||||||
RadarState(
|
|
||||||
currentPass = null,
|
|
||||||
currentTime = "00:00:00",
|
|
||||||
isCurrentTimeAos = true,
|
|
||||||
orientationValues = sensorsRepo.orientation.value,
|
|
||||||
orbitalPos = null,
|
|
||||||
satTrack = emptyList(),
|
|
||||||
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
|
|
||||||
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors,
|
|
||||||
transmitters = emptyList(),
|
|
||||||
sendAction = ::handleAction,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
private val stationPos = settingsRepo.stationPosition.value
|
|
||||||
private val magDeclination = sensorsRepo.getMagDeclination(stationPos)
|
|
||||||
val uiState: StateFlow<RadarState> = _uiState
|
|
||||||
|
|
||||||
init {
|
|
||||||
if (settingsRepo.otherSettings.value.stateOfSensors) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
sensorsRepo.enableSensor()
|
|
||||||
sensorsRepo.orientation.collect { data ->
|
|
||||||
val orientationValues = Pair(data.first + magDeclination, data.second)
|
|
||||||
_uiState.update { it.copy(orientationValues = orientationValues) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
|
||||||
val catNum = savedStateHandle.get<Int>("catNum") ?: 0
|
|
||||||
val aosTime = savedStateHandle.get<Long>("aosTime") ?: 0L
|
|
||||||
val passes = satelliteRepo.passes.value
|
|
||||||
val pass = passes.find { pass -> pass.catNum == catNum && pass.aosTime == aosTime }
|
|
||||||
val currentPass = pass ?: passes.firstOrNull()
|
|
||||||
currentPass?.let { satPass ->
|
|
||||||
_uiState.update { it.copy(currentPass = satPass) }
|
|
||||||
val transmitters = satelliteRepo.getRadiosWithId(satPass.catNum)
|
|
||||||
while (isActive) {
|
|
||||||
val timeNow = System.currentTimeMillis()
|
|
||||||
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
|
|
||||||
when {
|
|
||||||
satPass.isDeepSpace -> {
|
|
||||||
val time = 0L.toTimerString()
|
|
||||||
_uiState.update { it.copy(currentTime = time, isCurrentTimeAos = false) }
|
|
||||||
}
|
|
||||||
satPass.aosTime > timeNow -> {
|
|
||||||
val time = satPass.aosTime.minus(timeNow).toTimerString()
|
|
||||||
_uiState.update { it.copy(currentTime = time, isCurrentTimeAos = true) }
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val time = satPass.losTime.minus(timeNow).toTimerString()
|
|
||||||
_uiState.update { it.copy(currentTime = time, isCurrentTimeAos = false) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sendPassData(satPass, pos, satPass.orbitalObject)
|
|
||||||
sendPassDataBT(pos)
|
|
||||||
processRadios(transmitters, satPass.orbitalObject, timeNow)
|
|
||||||
delay(1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
sensorsRepo.disableSensor()
|
|
||||||
super.onCleared()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleAction(action: RadarAction) {
|
|
||||||
when (action) {
|
|
||||||
is RadarAction.AddToCalendar -> addToCalendar(action.name, action.aosTime, action.losTime)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun sendPassData(orbitalPass: OrbitalPass, orbitalPos: OrbitalPos, orbitalObject: OrbitalObject) {
|
|
||||||
var track: List<OrbitalPos> = emptyList()
|
|
||||||
if (!orbitalPass.isDeepSpace) {
|
|
||||||
track = satelliteRepo.getTrack(
|
|
||||||
orbitalObject, stationPos, orbitalPass.aosTime, orbitalPass.losTime
|
|
||||||
)
|
|
||||||
}
|
|
||||||
_uiState.update { it.copy(orbitalPos = orbitalPos, satTrack = track) }
|
|
||||||
viewModelScope.launch {
|
|
||||||
if (settingsRepo.rcSettings.value.rotatorState) {
|
|
||||||
val server = settingsRepo.rcSettings.value.rotatorAddress
|
|
||||||
val port = settingsRepo.rcSettings.value.rotatorPort.toInt()
|
|
||||||
val azimuth = orbitalPos.azimuth.toDegrees().round(2)
|
|
||||||
val elevation = orbitalPos.elevation.toDegrees().round(2)
|
|
||||||
networkReporter.reportRotation(server, port, azimuth, elevation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendPassDataBT(orbitalPos: OrbitalPos) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
if (settingsRepo.rcSettings.value.bluetoothState) {
|
|
||||||
val btDevice = settingsRepo.rcSettings.value.bluetoothAddress
|
|
||||||
if (bluetoothReporter.isConnected()) {
|
|
||||||
val format = settingsRepo.rcSettings.value.bluetoothFormat
|
|
||||||
val azimuth = orbitalPos.azimuth.toDegrees().round(0).toInt()
|
|
||||||
val elevation = orbitalPos.elevation.toDegrees().round(0).toInt()
|
|
||||||
bluetoothReporter.reportRotation(format, azimuth, elevation)
|
|
||||||
} else if (!bluetoothReporter.isConnecting()) {
|
|
||||||
// Log.i("BTReporter", "BTReporter: Attempting to connect...")
|
|
||||||
bluetoothReporter.connectBTDevice(btDevice)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) {
|
|
||||||
val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time)
|
|
||||||
_uiState.update { it.copy(transmitters = transmitters) }
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
|
||||||
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
|
|
||||||
initializer {
|
|
||||||
val container = (this[applicationKey] as MainApplication).container
|
|
||||||
RadarViewModel(
|
|
||||||
createSavedStateHandle(),
|
|
||||||
container.provideBluetoothReporter(),
|
|
||||||
container.provideNetworkReporter(),
|
|
||||||
container.satelliteRepo,
|
|
||||||
container.settingsRepo,
|
|
||||||
container.provideSensorsRepo(),
|
|
||||||
container.provideAddToCalendar()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +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.presentation.satellites
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
||||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
|
||||||
import androidx.compose.material3.Checkbox
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.rtbishop.look4sat.R
|
|
||||||
import com.rtbishop.look4sat.presentation.MainTheme
|
|
||||||
import com.rtbishop.look4sat.presentation.common.SharedDialog
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun MultiTypesDialogPreview() {
|
|
||||||
val types = listOf("Amateur", "Geostationary", "Military", "Weather")
|
|
||||||
MainTheme { MultiTypesDialog(types, emptyList(), {}) {} }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MultiTypesDialog(
|
|
||||||
allTypes: List<String>, types: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
|
|
||||||
) {
|
|
||||||
val selected = remember { mutableStateListOf<String>().apply { addAll(types) } }
|
|
||||||
val select = { type: String ->
|
|
||||||
if (selected.contains(type)) selected.remove(type) else selected.add(type)
|
|
||||||
}
|
|
||||||
val onAccept = { accept(selected.toList()) }
|
|
||||||
SharedDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
|
|
||||||
LazyVerticalGrid(
|
|
||||||
columns = GridCells.Adaptive(240.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxHeight(0.69f)
|
|
||||||
.background(MaterialTheme.colorScheme.background),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
|
||||||
) {
|
|
||||||
itemsIndexed(allTypes) { index, item ->
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.clickable { select(item) }
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "${index + 1}).",
|
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 8.dp),
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = item,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Checkbox(
|
|
||||||
checked = selected.contains(item),
|
|
||||||
onCheckedChange = null,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,277 +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.presentation.satellites
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.material3.Checkbox
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import androidx.navigation.NavGraphBuilder
|
|
||||||
import androidx.navigation.compose.composable
|
|
||||||
import com.rtbishop.look4sat.R
|
|
||||||
import com.rtbishop.look4sat.domain.model.SatItem
|
|
||||||
import com.rtbishop.look4sat.presentation.MainTheme
|
|
||||||
import com.rtbishop.look4sat.presentation.Screen
|
|
||||||
import com.rtbishop.look4sat.presentation.common.CardLoadingIndicator
|
|
||||||
import com.rtbishop.look4sat.presentation.common.EmptyListCard
|
|
||||||
import com.rtbishop.look4sat.presentation.common.IconCard
|
|
||||||
import com.rtbishop.look4sat.presentation.common.InfoDialog
|
|
||||||
import com.rtbishop.look4sat.presentation.common.PrimaryIconCard
|
|
||||||
import com.rtbishop.look4sat.presentation.common.TopBar
|
|
||||||
import com.rtbishop.look4sat.presentation.common.infiniteMarquee
|
|
||||||
import com.rtbishop.look4sat.presentation.common.isVerticalLayout
|
|
||||||
import com.rtbishop.look4sat.presentation.common.layoutPadding
|
|
||||||
|
|
||||||
fun NavGraphBuilder.satellitesDestination(navigateUp: () -> Unit) {
|
|
||||||
composable(Screen.Satellites.route) {
|
|
||||||
val viewModel = viewModel(
|
|
||||||
modelClass = SatellitesViewModel::class.java, factory = SatellitesViewModel.Factory
|
|
||||||
)
|
|
||||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
|
||||||
SatellitesScreen(uiState, navigateUp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SatellitesScreen(uiState: SatellitesState, navigateUp: () -> Unit) {
|
|
||||||
val toggleDialog = { uiState.takeAction(SatellitesAction.ToggleTypesDialog) }
|
|
||||||
if (uiState.isDialogShown) {
|
|
||||||
MultiTypesDialog(allTypes = uiState.typesList, types = uiState.currentTypes, toggleDialog) {
|
|
||||||
uiState.takeAction(SatellitesAction.SelectTypes(it))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (uiState.shouldSeeWarning) {
|
|
||||||
InfoDialog(
|
|
||||||
stringResource(R.string.sat_warning_title),
|
|
||||||
stringResource(R.string.sat_warning_message)
|
|
||||||
) {
|
|
||||||
uiState.takeAction(SatellitesAction.DismissWarning)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val unselectAll = { uiState.takeAction(SatellitesAction.UnselectAll) }
|
|
||||||
val selectAll = { uiState.takeAction(SatellitesAction.SelectAll) }
|
|
||||||
val setQuery = { newQuery: String -> uiState.takeAction(SatellitesAction.SearchFor(newQuery)) }
|
|
||||||
val saveSelection = { uiState.takeAction(SatellitesAction.SaveSelection).also { navigateUp() } }
|
|
||||||
val primCardCd = stringResource(R.string.btn_accept)
|
|
||||||
val primCardMod = Modifier.semantics { contentDescription = primCardCd }
|
|
||||||
val clearAllCd = stringResource(R.string.sat_clear_all)
|
|
||||||
val clearAllMod = Modifier.semantics { contentDescription = clearAllCd }
|
|
||||||
val selectAllCd = stringResource(R.string.sat_select_all)
|
|
||||||
val selectAllMod = Modifier.semantics { contentDescription = selectAllCd }
|
|
||||||
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
if (isVerticalLayout()) {
|
|
||||||
TopBar {
|
|
||||||
TypeCard(types = uiState.currentTypes, toggleDialog, modifier = Modifier.weight(1f))
|
|
||||||
PrimaryIconCard(onClick = saveSelection, resId = R.drawable.ic_done, modifier = primCardMod)
|
|
||||||
}
|
|
||||||
TopBar {
|
|
||||||
SearchBar(setQuery = { setQuery(it) }, modifier = Modifier.weight(1f))
|
|
||||||
IconCard(action = unselectAll, resId = R.drawable.ic_check_off, modifier = clearAllMod)
|
|
||||||
IconCard(action = selectAll, resId = R.drawable.ic_check_on, modifier = selectAllMod)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
TopBar {
|
|
||||||
PrimaryIconCard(onClick = saveSelection, resId = R.drawable.ic_done, modifier = primCardMod)
|
|
||||||
TypeCard(types = uiState.currentTypes, toggleDialog, modifier = Modifier.weight(1f))
|
|
||||||
SearchBar(setQuery = { setQuery(it) }, modifier = Modifier.weight(1f))
|
|
||||||
IconCard(action = unselectAll, resId = R.drawable.ic_check_off, modifier = clearAllMod)
|
|
||||||
IconCard(action = selectAll, resId = R.drawable.ic_check_on, modifier = selectAllMod)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxSize()) {
|
|
||||||
val emptyMessage = stringResource(R.string.sat_empty_list_message)
|
|
||||||
when {
|
|
||||||
uiState.isLoading -> CardLoadingIndicator()
|
|
||||||
uiState.itemsList.isEmpty() -> EmptyListCard(message = emptyMessage)
|
|
||||||
else -> SatellitesCard(uiState.itemsList) { id, isTicked ->
|
|
||||||
uiState.takeAction(SatellitesAction.SelectSingle(id, isTicked))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SearchBar(setQuery: (String) -> Unit, modifier: Modifier = Modifier) {
|
|
||||||
val currentQuery = rememberSaveable { mutableStateOf("") }
|
|
||||||
val setNewQuery = { newValue: String ->
|
|
||||||
currentQuery.value = newValue
|
|
||||||
setQuery(newValue)
|
|
||||||
}
|
|
||||||
ElevatedCard(modifier = modifier.height(48.dp)) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.Start,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(start = 12.dp)
|
|
||||||
) {
|
|
||||||
Icon(painter = painterResource(id = R.drawable.ic_search), contentDescription = null)
|
|
||||||
BasicTextField(
|
|
||||||
value = currentQuery.value,
|
|
||||||
onValueChange = { setNewQuery(it) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = modifier.padding(start = 12.dp),
|
|
||||||
textStyle = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
lineHeight = 20.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
),
|
|
||||||
decorationBox = { innerTextField ->
|
|
||||||
if (currentQuery.value.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.sat_search_hint),
|
|
||||||
fontSize = 16.sp,
|
|
||||||
lineHeight = 20.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
}
|
|
||||||
innerTextField()
|
|
||||||
},
|
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface)
|
|
||||||
)
|
|
||||||
IconButton(onClick = { setNewQuery("") }) {
|
|
||||||
val clearCd = stringResource(R.string.sat_search_clear)
|
|
||||||
Icon(painter = painterResource(id = R.drawable.ic_close), contentDescription = clearCd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TypeCard(types: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
|
||||||
val typesText = if (types.isEmpty()) "All" else types.joinToString(", ")
|
|
||||||
ElevatedCard(modifier = modifier) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.height(48.dp)
|
|
||||||
.clickable { onClick() }) {
|
|
||||||
Spacer(Modifier)
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.ic_tags),
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.sat_type_hint, typesText),
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 6.dp)
|
|
||||||
.infiniteMarquee()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun SatellitePreview() {
|
|
||||||
val satItem = SatItem(44444, "Ultra Super Mega long satellite name", true)
|
|
||||||
MainTheme { Satellite(item = satItem, onSelected = { _, _ -> run {} }, modifier = Modifier) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun Satellite(item: SatItem, onSelected: (Int, Boolean) -> Unit, modifier: Modifier) {
|
|
||||||
val passSatId = stringResource(id = R.string.pass_satId, item.catnum)
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.background,
|
|
||||||
modifier = modifier.clickable { onSelected(item.catnum, item.isSelected) }) {
|
|
||||||
Surface(modifier = Modifier.padding(bottom = 1.dp)) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.padding(start = 14.dp, top = 8.dp, end = 12.dp, bottom = 8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "$passSatId - ", color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = item.name,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Checkbox(
|
|
||||||
checked = item.isSelected,
|
|
||||||
onCheckedChange = null,
|
|
||||||
modifier = Modifier.padding(start = 6.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun SatellitesPreview() {
|
|
||||||
val entries = listOf(
|
|
||||||
SatItem(8888, "Meteor", false),
|
|
||||||
SatItem(44444, "ISS", true),
|
|
||||||
SatItem(88888, "Starlink", false)
|
|
||||||
)
|
|
||||||
MainTheme { SatellitesCard(entries) { _, _ -> run {} } }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SatellitesCard(items: List<SatItem>, onSelected: (Int, Boolean) -> Unit) {
|
|
||||||
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp)) {
|
|
||||||
items(items = items, key = { item -> item.catnum }) { entry ->
|
|
||||||
Satellite(entry, onSelected, Modifier.animateItem())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +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.presentation.satellites
|
|
||||||
|
|
||||||
import com.rtbishop.look4sat.domain.model.SatItem
|
|
||||||
|
|
||||||
data class SatellitesState(
|
|
||||||
val isDialogShown: Boolean,
|
|
||||||
val isLoading: Boolean,
|
|
||||||
val shouldSeeWarning: Boolean,
|
|
||||||
val itemsList: List<SatItem>,
|
|
||||||
val currentTypes: List<String>,
|
|
||||||
val typesList: List<String>,
|
|
||||||
val takeAction: (SatellitesAction) -> Unit
|
|
||||||
)
|
|
||||||
|
|
||||||
sealed class SatellitesAction {
|
|
||||||
data object DismissWarning : SatellitesAction()
|
|
||||||
data object SaveSelection : SatellitesAction()
|
|
||||||
data class SearchFor(val query: String) : SatellitesAction()
|
|
||||||
data object SelectAll : SatellitesAction()
|
|
||||||
data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction()
|
|
||||||
data class SelectTypes(val types: List<String>) : SatellitesAction()
|
|
||||||
data object ToggleTypesDialog : SatellitesAction()
|
|
||||||
data object UnselectAll : SatellitesAction()
|
|
||||||
}
|
|
||||||
-114
@@ -1,114 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.presentation.satellites
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.ViewModelProvider
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
|
||||||
import com.rtbishop.look4sat.MainApplication
|
|
||||||
import com.rtbishop.look4sat.domain.repository.ISelectionRepo
|
|
||||||
import com.rtbishop.look4sat.domain.repository.ISettingsRepo
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class SatellitesViewModel(
|
|
||||||
private val selectionRepo: ISelectionRepo,
|
|
||||||
private val settingsRepo: ISettingsRepo
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val defaultTypes = selectionRepo.getCurrentTypes()
|
|
||||||
private val _uiState = MutableStateFlow(
|
|
||||||
SatellitesState(
|
|
||||||
isDialogShown = false,
|
|
||||||
isLoading = true,
|
|
||||||
shouldSeeWarning = settingsRepo.otherSettings.value.shouldSeeWarning,
|
|
||||||
itemsList = emptyList(),
|
|
||||||
currentTypes = defaultTypes,
|
|
||||||
typesList = selectionRepo.getTypesList(),
|
|
||||||
takeAction = ::handleAction
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val uiState: StateFlow<SatellitesState> = _uiState
|
|
||||||
|
|
||||||
init {
|
|
||||||
viewModelScope.launch {
|
|
||||||
delay(1000)
|
|
||||||
selectionRepo.setQuery(String())
|
|
||||||
selectionRepo.setTypes(defaultTypes)
|
|
||||||
selectionRepo.getEntriesFlow().collectLatest { items ->
|
|
||||||
_uiState.update { it.copy(isLoading = false, itemsList = items) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
|
||||||
settingsRepo.otherSettings.collectLatest { settings ->
|
|
||||||
_uiState.update { it.copy(shouldSeeWarning = settings.shouldSeeWarning) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleAction(action: SatellitesAction) {
|
|
||||||
when (action) {
|
|
||||||
SatellitesAction.DismissWarning -> settingsRepo.setWarningDismissed()
|
|
||||||
SatellitesAction.SaveSelection -> saveSelection()
|
|
||||||
is SatellitesAction.SearchFor -> searchFor(action.query)
|
|
||||||
SatellitesAction.SelectAll -> selectAll(true)
|
|
||||||
is SatellitesAction.SelectSingle -> selectSingle(action.id, action.isTicked)
|
|
||||||
is SatellitesAction.SelectTypes -> selectTypes(action.types)
|
|
||||||
SatellitesAction.ToggleTypesDialog -> toggleTypesDialog()
|
|
||||||
SatellitesAction.UnselectAll -> selectAll(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveSelection() = viewModelScope.launch { selectionRepo.saveSelection() }
|
|
||||||
|
|
||||||
private fun searchFor(query: String) = viewModelScope.launch { selectionRepo.setQuery(query) }
|
|
||||||
|
|
||||||
private fun selectAll(selectAll: Boolean) = viewModelScope.launch {
|
|
||||||
selectionRepo.setSelection(selectAll)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun selectSingle(id: Int, isTicked: Boolean) = viewModelScope.launch {
|
|
||||||
selectionRepo.setSelection(listOf(id), isTicked.not())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun selectTypes(types: List<String>) = viewModelScope.launch {
|
|
||||||
selectionRepo.setTypes(types)
|
|
||||||
_uiState.value = _uiState.value.copy(currentTypes = types, isDialogShown = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toggleTypesDialog() {
|
|
||||||
val currentDialogState = _uiState.value.isDialogShown
|
|
||||||
_uiState.value = _uiState.value.copy(isDialogShown = currentDialogState.not())
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
|
||||||
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
|
|
||||||
initializer {
|
|
||||||
val container = (this[applicationKey] as MainApplication).container
|
|
||||||
SatellitesViewModel(container.selectionRepo, container.settingsRepo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,201 +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.presentation.settings
|
|
||||||
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import com.rtbishop.look4sat.R
|
|
||||||
import com.rtbishop.look4sat.presentation.MainTheme
|
|
||||||
import com.rtbishop.look4sat.presentation.common.SharedDialog
|
|
||||||
import com.rtbishop.look4sat.presentation.common.CardButton
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import com.rtbishop.look4sat.presentation.LocalSpacing
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun PositionDialogPreview() {
|
|
||||||
MainTheme { PositionDialog(0.0, 0.0, {}) { _, _ -> } }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PositionDialog(lat: Double, lon: Double, dismiss: () -> Unit, save: (Double, Double) -> Unit) {
|
|
||||||
val latValue = rememberSaveable { mutableStateOf(lat.toString()) }
|
|
||||||
val lonValue = rememberSaveable { mutableStateOf(lon.toString()) }
|
|
||||||
val titleText = stringResource(id = R.string.prefs_station_title)
|
|
||||||
val onAccept = { saveValues(latValue.value, lonValue.value, save).also { dismiss() } }
|
|
||||||
SharedDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = latValue.value,
|
|
||||||
onValueChange = { latValue.value = it },
|
|
||||||
label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) }
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = lonValue.value,
|
|
||||||
onValueChange = { lonValue.value = it },
|
|
||||||
label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveValues(latValue: String, lonValue: String, save: (Double, Double) -> Unit) {
|
|
||||||
val latitude = latValue.toDoubleOrNull() ?: 0.0
|
|
||||||
val longitude = lonValue.toDoubleOrNull() ?: 0.0
|
|
||||||
val newLatitude = if (latitude > 90) 90.0 else if (latitude < -90) -90.0 else latitude
|
|
||||||
val newLongitude = if (longitude > 180) 180.0 else if (longitude < -180) -180.0 else longitude
|
|
||||||
save(newLatitude, newLongitude)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun LocatorDialogPreview() {
|
|
||||||
MainTheme { LocatorDialog("IO91vl", {}) { } }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Unit) {
|
|
||||||
val locator = rememberSaveable { mutableStateOf(qthLocator) }
|
|
||||||
val onAccept = { save(locator.value).also { dismiss() } }
|
|
||||||
SharedDialog(title = stringResource(R.string.prefs_locator_title), onCancel = dismiss, onAccept = onAccept) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = locator.value,
|
|
||||||
onValueChange = { locator.value = it },
|
|
||||||
label = { Text(text = stringResource(id = R.string.prefs_locator_text)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun TransceiversDialogPreview() {
|
|
||||||
MainTheme {
|
|
||||||
DataSourcesDialog(
|
|
||||||
useCustomTle = true,
|
|
||||||
useCustomTransceivers = true,
|
|
||||||
tleUrl = "https://example.com/tle.txt",
|
|
||||||
transceiversUrl = "https://example.com/tx.json",
|
|
||||||
onImportTle = {},
|
|
||||||
onImportTransceivers = {},
|
|
||||||
onDismiss = {},
|
|
||||||
onSave = { _, _, _, _ -> }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DataSourcesDialog(
|
|
||||||
useCustomTle: Boolean,
|
|
||||||
useCustomTransceivers: Boolean,
|
|
||||||
tleUrl: String,
|
|
||||||
transceiversUrl: String,
|
|
||||||
onImportTle: () -> Unit,
|
|
||||||
onImportTransceivers: () -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onSave: (Boolean, Boolean, String, String) -> Unit
|
|
||||||
) {
|
|
||||||
val padding = LocalSpacing.current.large
|
|
||||||
val isEnabledCustomTle = rememberSaveable { mutableStateOf(useCustomTle) }
|
|
||||||
val isEnabledCustomTransceivers = rememberSaveable { mutableStateOf(useCustomTransceivers) }
|
|
||||||
val urlTle = rememberSaveable { mutableStateOf(tleUrl) }
|
|
||||||
val urlTransceivers = rememberSaveable { mutableStateOf(transceiversUrl) }
|
|
||||||
val onAccept = {
|
|
||||||
onSave(isEnabledCustomTle.value, isEnabledCustomTransceivers.value, urlTle.value, urlTransceivers.value)
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
val onCancel = { onDismiss() }
|
|
||||||
SharedDialog(
|
|
||||||
title = stringResource(id = R.string.prefs_data_sources_title),
|
|
||||||
onCancel = onCancel,
|
|
||||||
onAccept = onAccept,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(horizontal = padding)) {
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
CardButton(
|
|
||||||
onClick = {
|
|
||||||
onImportTle()
|
|
||||||
onDismiss()
|
|
||||||
},
|
|
||||||
text = "TLE (3LE)\nR4UAB (.txt)",
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
CardButton(
|
|
||||||
onClick = {
|
|
||||||
onImportTransceivers()
|
|
||||||
onDismiss()
|
|
||||||
},
|
|
||||||
text = "Transceivers\nSatNOGS (.json)",
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(id = R.string.prefs_data_sources_tle_switch))
|
|
||||||
Switch(
|
|
||||||
checked = isEnabledCustomTle.value,
|
|
||||||
onCheckedChange = { isEnabledCustomTle.value = it }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
OutlinedTextField(
|
|
||||||
value = urlTle.value,
|
|
||||||
onValueChange = { urlTle.value = it },
|
|
||||||
label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
enabled = isEnabledCustomTle.value,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(id = R.string.prefs_data_sources_transceivers_switch))
|
|
||||||
Switch(
|
|
||||||
checked = isEnabledCustomTransceivers.value,
|
|
||||||
onCheckedChange = { isEnabledCustomTransceivers.value = it }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
OutlinedTextField(
|
|
||||||
value = urlTransceivers.value,
|
|
||||||
onValueChange = { urlTransceivers.value = it },
|
|
||||||
label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
enabled = isEnabledCustomTransceivers.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,665 +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.presentation.settings
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import androidx.navigation.NavGraphBuilder
|
|
||||||
import androidx.navigation.compose.composable
|
|
||||||
import com.rtbishop.look4sat.R
|
|
||||||
import com.rtbishop.look4sat.domain.model.OtherSettings
|
|
||||||
import com.rtbishop.look4sat.domain.model.RCSettings
|
|
||||||
import com.rtbishop.look4sat.domain.predict.GeoPos
|
|
||||||
import com.rtbishop.look4sat.presentation.MainTheme
|
|
||||||
import com.rtbishop.look4sat.presentation.Screen
|
|
||||||
import com.rtbishop.look4sat.presentation.common.CardButton
|
|
||||||
import com.rtbishop.look4sat.presentation.common.IconCard
|
|
||||||
import com.rtbishop.look4sat.presentation.common.PrimaryIconCard
|
|
||||||
import com.rtbishop.look4sat.presentation.common.ScreenColumn
|
|
||||||
import com.rtbishop.look4sat.presentation.common.TopBar
|
|
||||||
import com.rtbishop.look4sat.presentation.common.infiniteMarquee
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
fun NavGraphBuilder.settingsDestination() {
|
|
||||||
composable(Screen.Settings.route) {
|
|
||||||
val viewModel = viewModel(
|
|
||||||
modelClass = SettingsViewModel::class.java,
|
|
||||||
factory = SettingsViewModel.Factory
|
|
||||||
)
|
|
||||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
|
||||||
SettingsScreen(uiState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SettingsScreen(uiState: SettingsState) {
|
|
||||||
// Permissions setup
|
|
||||||
val bluetoothContract = ActivityResultContracts.RequestPermission()
|
|
||||||
val bluetoothError = stringResource(R.string.prefs_bt_perm_error)
|
|
||||||
val bluetoothPerm = when {
|
|
||||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S -> Manifest.permission.BLUETOOTH
|
|
||||||
else -> Manifest.permission.BLUETOOTH_CONNECT
|
|
||||||
}
|
|
||||||
val bluetoothRequest = rememberLauncherForActivityResult(bluetoothContract) { isGranted ->
|
|
||||||
if (!isGranted) {
|
|
||||||
uiState.sendRCAction(RCAction.SetBluetoothState(false))
|
|
||||||
uiState.sendSystemAction(SystemAction.ShowToast(bluetoothError))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val locationContract = ActivityResultContracts.RequestMultiplePermissions()
|
|
||||||
val locationError = stringResource(R.string.prefs_loc_gps_error)
|
|
||||||
val locationPermCoarse = Manifest.permission.ACCESS_COARSE_LOCATION
|
|
||||||
val locationPermFine = Manifest.permission.ACCESS_FINE_LOCATION
|
|
||||||
val locationRequest = rememberLauncherForActivityResult(locationContract) { permissions ->
|
|
||||||
when {
|
|
||||||
permissions[locationPermFine] == true -> uiState.sendAction(SettingsAction.SetGpsPosition)
|
|
||||||
permissions[locationPermCoarse] == true -> uiState.sendAction(SettingsAction.SetGpsPosition)
|
|
||||||
else -> uiState.sendSystemAction(SystemAction.ShowToast(locationError))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val contentContract = ActivityResultContracts.GetContent()
|
|
||||||
val contentRequestForTle = rememberLauncherForActivityResult(contentContract) { uri ->
|
|
||||||
uri?.let { uiState.sendAction(SettingsAction.UpdateTLEFromFile(uri.toString())) }
|
|
||||||
}
|
|
||||||
val contentRequestForTransceivers = rememberLauncherForActivityResult(contentContract) { uri ->
|
|
||||||
uri?.let { uiState.sendAction(SettingsAction.UpdateTransceiversFromFile(uri.toString())) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Position settings
|
|
||||||
val posSettings = uiState.positionSettings
|
|
||||||
val setGpsPos = { locationRequest.launch(arrayOf(locationPermCoarse, locationPermFine)) }
|
|
||||||
val setGeoPos = { lat: Double, lon: Double ->
|
|
||||||
uiState.sendAction(SettingsAction.SetGeoPosition(lat, lon))
|
|
||||||
}
|
|
||||||
val setQthPos = { locator: String ->
|
|
||||||
uiState.sendAction(SettingsAction.SetQthPosition(locator))
|
|
||||||
}
|
|
||||||
val dismissPos = { uiState.sendAction(SettingsAction.DismissPosMessages) }
|
|
||||||
val posDialogState = rememberSaveable { mutableStateOf(false) }
|
|
||||||
val showPosDialog = { posDialogState.value = posDialogState.value.not() }
|
|
||||||
if (posDialogState.value) {
|
|
||||||
PositionDialog(posSettings.stationPos.latitude, posSettings.stationPos.longitude, showPosDialog, setGeoPos)
|
|
||||||
}
|
|
||||||
val locDialogState = rememberSaveable { mutableStateOf(false) }
|
|
||||||
val showLocDialog = { locDialogState.value = locDialogState.value.not() }
|
|
||||||
if (locDialogState.value) {
|
|
||||||
LocatorDialog(posSettings.stationPos.qthLocator, showLocDialog, setQthPos)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data sources dialog
|
|
||||||
val dataSourcesDialogState = rememberSaveable { mutableStateOf(false) }
|
|
||||||
val showDataSourcesDialog = { dataSourcesDialogState.value = true }
|
|
||||||
val dismissDataSourcesDialog = { dataSourcesDialogState.value = false }
|
|
||||||
if (dataSourcesDialogState.value) {
|
|
||||||
DataSourcesDialog(
|
|
||||||
useCustomTle = uiState.dataSourcesSettings.useCustomTLE,
|
|
||||||
useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers,
|
|
||||||
tleUrl = uiState.dataSourcesSettings.tleUrl,
|
|
||||||
transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
|
|
||||||
onImportTle = { contentRequestForTle.launch("*/*") },
|
|
||||||
onImportTransceivers = { contentRequestForTransceivers.launch("*/*") },
|
|
||||||
onDismiss = dismissDataSourcesDialog,
|
|
||||||
onSave = {
|
|
||||||
useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl ->
|
|
||||||
if (!useCustomTle || tleUrl.isNotBlank()) {
|
|
||||||
uiState.sendDataSourcesAction(DataSourcesAction.SetUseCustomTle(useCustomTle))
|
|
||||||
uiState.sendDataSourcesAction(DataSourcesAction.SetTleUrl(tleUrl))
|
|
||||||
}
|
|
||||||
if (!useCustomTransceivers || transceiversUrl.isNotBlank()) {
|
|
||||||
uiState.sendDataSourcesAction(DataSourcesAction.SetUseCustomTransceivers(useCustomTransceivers))
|
|
||||||
uiState.sendDataSourcesAction(DataSourcesAction.SetTransceiversUrl(transceiversUrl))
|
|
||||||
}
|
|
||||||
if (useCustomTle || useCustomTransceivers) {
|
|
||||||
uiState.sendAction(SettingsAction.UpdateFromWeb)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data settings
|
|
||||||
val dataSettings = uiState.dataSettings
|
|
||||||
val updateFromWeb: () -> Unit = { uiState.sendAction(SettingsAction.UpdateFromWeb) }
|
|
||||||
val clearAllData: () -> Unit = { uiState.sendAction(SettingsAction.ClearAllData) }
|
|
||||||
|
|
||||||
// RC settings
|
|
||||||
val rcSettings = uiState.rcSettings
|
|
||||||
val setRotatorState = { value: Boolean -> uiState.sendRCAction(RCAction.SetRotatorState(value)) }
|
|
||||||
val setRotatorAddress = { value: String -> uiState.sendRCAction(RCAction.SetRotatorAddress(value)) }
|
|
||||||
val setRotatorPort = { value: String -> uiState.sendRCAction(RCAction.SetRotatorPort(value)) }
|
|
||||||
val setBluetoothState = { value: Boolean ->
|
|
||||||
bluetoothRequest.launch(bluetoothPerm)
|
|
||||||
uiState.sendRCAction(RCAction.SetBluetoothState(value))
|
|
||||||
}
|
|
||||||
val setBluetoothAddress = { value: String -> uiState.sendRCAction(RCAction.SetBluetoothAddress(value)) }
|
|
||||||
val setBluetoothFormat = { value: String -> uiState.sendRCAction(RCAction.SetBluetoothFormat(value)) }
|
|
||||||
|
|
||||||
// Other settings
|
|
||||||
val otherSettings = uiState.otherSettings
|
|
||||||
val toggleUtc = { value: Boolean -> uiState.sendAction(SettingsAction.ToggleUtc(value)) }
|
|
||||||
val toggleUpdate = { value: Boolean -> uiState.sendAction(SettingsAction.ToggleUpdate(value)) }
|
|
||||||
val toggleSweep = { value: Boolean -> uiState.sendAction(SettingsAction.ToggleSweep(value)) }
|
|
||||||
val toggleSensor = { value: Boolean -> uiState.sendAction(SettingsAction.ToggleSensor(value)) }
|
|
||||||
val uriHandler = LocalUriHandler.current
|
|
||||||
val appUrl = stringResource(R.string.prefs_app_url)
|
|
||||||
val donateUrl = stringResource(R.string.prefs_donate_url)
|
|
||||||
val fdroidTitle = stringResource(R.string.prefs_fdroid_title)
|
|
||||||
val fdroidUrl = stringResource(R.string.prefs_fdroid_url)
|
|
||||||
val gitHubTitle = stringResource(R.string.prefs_github_title)
|
|
||||||
val gitHubUrl = stringResource(R.string.prefs_github_url)
|
|
||||||
val licenseUrl = stringResource(R.string.prefs_license_url)
|
|
||||||
val privacyUrl = stringResource(R.string.prefs_privacy_url)
|
|
||||||
|
|
||||||
ScreenColumn(
|
|
||||||
topBar = { isVerticalLayout ->
|
|
||||||
if (isVerticalLayout) {
|
|
||||||
TopBar {
|
|
||||||
TopCard(onClick = { uriHandler.openUri(appUrl) }, version = uiState.appVersionName, modifier = Modifier.weight(1f))
|
|
||||||
PrimaryIconCard(onClick = { uriHandler.openUri(donateUrl) }, resId = R.drawable.ic_pound)
|
|
||||||
}
|
|
||||||
TopBar {
|
|
||||||
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
BotCard(onClick = { uriHandler.openUri(fdroidUrl) }, R.drawable.ic_fdroid, fdroidTitle, modifier = Modifier.weight(1f))
|
|
||||||
BotCard(onClick = { uriHandler.openUri(gitHubUrl) }, R.drawable.ic_github, gitHubTitle, modifier = Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
IconCard(action = { uriHandler.openUri(licenseUrl) }, resId = R.drawable.ic_license)
|
|
||||||
IconCard(action = { uriHandler.openUri(privacyUrl) }, resId = R.drawable.ic_policy)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
TopBar {
|
|
||||||
PrimaryIconCard(onClick = { uriHandler.openUri(donateUrl) }, resId = R.drawable.ic_pound)
|
|
||||||
TopCard(onClick = { uriHandler.openUri(appUrl) }, version = uiState.appVersionName, modifier = Modifier.weight(1f))
|
|
||||||
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
BotCard(onClick = { uriHandler.openUri(fdroidUrl) }, R.drawable.ic_fdroid, fdroidTitle, modifier = Modifier.weight(1f))
|
|
||||||
BotCard(onClick = { uriHandler.openUri(gitHubUrl) }, R.drawable.ic_github, gitHubTitle, modifier = Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
IconCard(action = { uriHandler.openUri(licenseUrl) }, resId = R.drawable.ic_license)
|
|
||||||
IconCard(action = { uriHandler.openUri(privacyUrl) }, resId = R.drawable.ic_policy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) { _ ->
|
|
||||||
LazyVerticalGrid(
|
|
||||||
columns = GridCells.Adaptive(320.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
modifier = Modifier.clip(MaterialTheme.shapes.medium)
|
|
||||||
) {
|
|
||||||
item {
|
|
||||||
LocationCard(posSettings, setGpsPos, showPosDialog, showLocDialog, dismissPos, uiState.sendSystemAction)
|
|
||||||
}
|
|
||||||
item { DataCard(dataSettings, updateFromWeb, clearAllData, showDataSourcesDialog) }
|
|
||||||
item { NetworkOutputCard(rcSettings, setRotatorState, setRotatorAddress, setRotatorPort) }
|
|
||||||
item { BluetoothOutputCard(rcSettings, setBluetoothState, setBluetoothAddress, setBluetoothFormat) }
|
|
||||||
item { OtherCard(otherSettings, toggleUtc, toggleUpdate, toggleSweep, toggleSensor) }
|
|
||||||
item { CardCredits() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun LocationCardPreview() = MainTheme {
|
|
||||||
val stationPos = GeoPos(0.0, 0.0, 0.0, "IO91vl", 0L)
|
|
||||||
val settings = PositionSettings(true, stationPos, 0)
|
|
||||||
LocationCard(settings = settings, setGpsPos = {}, showPosDialog = {}, {}, {}) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun LocationCard(
|
|
||||||
settings: PositionSettings,
|
|
||||||
setGpsPos: () -> Unit,
|
|
||||||
showPosDialog: () -> Unit,
|
|
||||||
showLocDialog: () -> Unit,
|
|
||||||
dismissPosMessage: () -> Unit,
|
|
||||||
sendSystemAction: (SystemAction) -> Unit
|
|
||||||
) {
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.prefs_loc_title),
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
UpdateIndicator(isUpdating = settings.isUpdating, Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
Text(text = setUpdateTime(updateTime = settings.stationPos.timestamp))
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(text = "Lat: ${settings.stationPos.latitude}°")
|
|
||||||
Text(text = "Lon: ${settings.stationPos.longitude}°")
|
|
||||||
Text(text = "Qth: ${settings.stationPos.qthLocator}")
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(1.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
|
|
||||||
CardButton(
|
|
||||||
onClick = setGpsPos,
|
|
||||||
text = stringResource(id = R.string.prefs_loc_gps_title),
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
CardButton(
|
|
||||||
onClick = showPosDialog,
|
|
||||||
text = stringResource(id = R.string.prefs_loc_input_title),
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
CardButton(
|
|
||||||
onClick = showLocDialog,
|
|
||||||
text = stringResource(id = R.string.prefs_loc_qth_title),
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (settings.messageResId != 0) {
|
|
||||||
val errorString = stringResource(id = settings.messageResId)
|
|
||||||
LaunchedEffect(key1 = settings.messageResId) {
|
|
||||||
sendSystemAction(SystemAction.ShowToast(errorString))
|
|
||||||
dismissPosMessage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun DataCardPreview() = MainTheme {
|
|
||||||
val settings = DataSettings(true, 5000, 2500, 0L)
|
|
||||||
DataCard(settings = settings, {}, {}, {})
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DataCard(
|
|
||||||
settings: DataSettings,
|
|
||||||
updateFromWeb: () -> Unit,
|
|
||||||
clearAllData: () -> Unit,
|
|
||||||
showDataSourcesDialog: () -> Unit
|
|
||||||
) {
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.prefs_data_title),
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
UpdateIndicator(isUpdating = settings.isUpdating, Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
Text(text = setUpdateTime(updateTime = settings.timestamp))
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(text = stringResource(R.string.prefs_data_entries, settings.entriesTotal))
|
|
||||||
Text(text = stringResource(R.string.prefs_data_radios, settings.radiosTotal))
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(1.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
CardButton(
|
|
||||||
onClick = updateFromWeb,
|
|
||||||
text = stringResource(id = R.string.prefs_data_update),
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
CardButton(
|
|
||||||
onClick = showDataSourcesDialog,
|
|
||||||
text = stringResource(id = R.string.prefs_data_import),
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
CardButton(
|
|
||||||
onClick = clearAllData,
|
|
||||||
text = stringResource(id = R.string.prefs_data_clear),
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun NetworkOutputCardPreview() = MainTheme {
|
|
||||||
val settings = RCSettings(
|
|
||||||
rotatorState = false,
|
|
||||||
rotatorAddress = "127.0.0.1",
|
|
||||||
rotatorPort = "4533",
|
|
||||||
bluetoothState = false,
|
|
||||||
bluetoothFormat = $$"W$AZ $EL",
|
|
||||||
bluetoothName = "Name",
|
|
||||||
bluetoothAddress = "00:0C:BF:13:80:5D"
|
|
||||||
)
|
|
||||||
NetworkOutputCard(settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun NetworkOutputCard(
|
|
||||||
settings: RCSettings,
|
|
||||||
setRotatorState: (Boolean) -> Unit = {},
|
|
||||||
setRotatorAddress: (String) -> Unit = {},
|
|
||||||
setRotatorPort: (String) -> Unit = {}
|
|
||||||
) {
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.prefs_net_title),
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(R.string.prefs_net_switch))
|
|
||||||
Switch(checked = settings.rotatorState, onCheckedChange = { setRotatorState(it) })
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = settings.rotatorAddress,
|
|
||||||
singleLine = true,
|
|
||||||
label = { Text(text = stringResource(R.string.prefs_net_ip_hint)) },
|
|
||||||
onValueChange = { setRotatorAddress(it) },
|
|
||||||
modifier = Modifier.weight(1.5f),
|
|
||||||
enabled = settings.rotatorState
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = settings.rotatorPort,
|
|
||||||
onValueChange = { setRotatorPort(it) },
|
|
||||||
label = { Text(text = stringResource(R.string.prefs_net_port_hint)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
enabled = settings.rotatorState
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun BluetoothOutputCardPreview() = MainTheme {
|
|
||||||
val settings = RCSettings(
|
|
||||||
rotatorState = false,
|
|
||||||
rotatorAddress = "127.0.0.1",
|
|
||||||
rotatorPort = "4533",
|
|
||||||
bluetoothState = false,
|
|
||||||
bluetoothFormat = $$"W$AZ $EL",
|
|
||||||
bluetoothName = "Name",
|
|
||||||
bluetoothAddress = "00:0C:BF:13:80:5D"
|
|
||||||
)
|
|
||||||
BluetoothOutputCard(settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun BluetoothOutputCard(
|
|
||||||
settings: RCSettings,
|
|
||||||
setBluetoothState: (Boolean) -> Unit = {},
|
|
||||||
setBluetoothAddress: (String) -> Unit = {},
|
|
||||||
setBluetoothFormat: (String) -> Unit = {}
|
|
||||||
) {
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.prefs_bt_title),
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(R.string.prefs_bt_switch))
|
|
||||||
Switch(checked = settings.bluetoothState, onCheckedChange = { setBluetoothState(it) })
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = settings.bluetoothAddress,
|
|
||||||
singleLine = true,
|
|
||||||
label = { Text(text = stringResource(R.string.prefs_bt_device_hint)) },
|
|
||||||
onValueChange = { setBluetoothAddress(it) },
|
|
||||||
modifier = Modifier.weight(1.5f),
|
|
||||||
enabled = settings.bluetoothState
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = settings.bluetoothFormat,
|
|
||||||
onValueChange = { setBluetoothFormat(it) },
|
|
||||||
label = { Text(text = stringResource(R.string.prefs_bt_output_hint)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
enabled = settings.bluetoothState
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun OtherCardPreview() = MainTheme {
|
|
||||||
val values = OtherSettings(
|
|
||||||
stateOfAutoUpdate = true,
|
|
||||||
stateOfSensors = true,
|
|
||||||
stateOfSweep = true,
|
|
||||||
stateOfUtc = false,
|
|
||||||
stateOfLightTheme = false,
|
|
||||||
shouldSeeWarning = false,
|
|
||||||
shouldSeeWelcome = false
|
|
||||||
)
|
|
||||||
OtherCard(settings = values, {}, {}, {}, {})
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun OtherCard(
|
|
||||||
settings: OtherSettings,
|
|
||||||
toggleUtc: (Boolean) -> Unit,
|
|
||||||
toggleUpdate: (Boolean) -> Unit,
|
|
||||||
toggleSweep: (Boolean) -> Unit,
|
|
||||||
toggleSensor: (Boolean) -> Unit
|
|
||||||
) {
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth().height(220.dp)) {
|
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.prefs_other_title),
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(id = R.string.prefs_other_switch_utc))
|
|
||||||
Switch(checked = settings.stateOfUtc, onCheckedChange = { toggleUtc(it) })
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(id = R.string.prefs_other_switch_update))
|
|
||||||
Switch(checked = settings.stateOfAutoUpdate, onCheckedChange = { toggleUpdate(it) })
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(id = R.string.prefs_other_switch_sweep))
|
|
||||||
Switch(checked = settings.stateOfSweep, onCheckedChange = { toggleSweep(it) })
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(id = R.string.prefs_other_switch_sensors))
|
|
||||||
Switch(checked = settings.stateOfSensors, onCheckedChange = { toggleSensor(it) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun setUpdateTime(updateTime: Long): String {
|
|
||||||
val updateDate = if (updateTime != 0L) {
|
|
||||||
val timePattern = stringResource(id = R.string.prefs_updated_time)
|
|
||||||
SimpleDateFormat(timePattern, Locale.getDefault()).format(Date(updateTime))
|
|
||||||
} else {
|
|
||||||
stringResource(id = R.string.pass_time_placeholder)
|
|
||||||
}
|
|
||||||
return stringResource(id = R.string.prefs_updated_title, updateDate)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun UpdateIndicator(isUpdating: Boolean, modifier: Modifier = Modifier) = if (isUpdating) {
|
|
||||||
LinearProgressIndicator(modifier = modifier.padding(start = 6.dp))
|
|
||||||
} else {
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { 0f },
|
|
||||||
drawStopIndicator = {},
|
|
||||||
modifier = modifier.padding(start = 6.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun CardCreditsPreview() = MainTheme { CardCredits() }
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CardCredits(modifier: Modifier = Modifier) {
|
|
||||||
ElevatedCard(modifier = modifier.fillMaxWidth().height(220.dp)) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp).fillMaxHeight()
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.prefs_outro_title),
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.prefs_outro_thanks)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.prefs_outro_license),
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version: String) {
|
|
||||||
ElevatedCard(modifier = modifier) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.height(48.dp)
|
|
||||||
.clickable { onClick() }) {
|
|
||||||
Spacer(Modifier)
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.ic_satellites),
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.prefs_app_title, version),
|
|
||||||
fontSize = 24.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 6.dp)
|
|
||||||
.infiniteMarquee()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun BotCard(onClick: () -> Unit, resId: Int, text: String, modifier: Modifier = Modifier) {
|
|
||||||
ElevatedCard(modifier = modifier) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.height(48.dp)
|
|
||||||
.clickable { onClick() }) {
|
|
||||||
Spacer(Modifier)
|
|
||||||
Icon(painter = painterResource(id = resId), contentDescription = null)
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 6.dp)
|
|
||||||
.infiniteMarquee()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.presentation.settings
|
|
||||||
|
|
||||||
import com.rtbishop.look4sat.domain.model.OtherSettings
|
|
||||||
import com.rtbishop.look4sat.domain.model.RCSettings
|
|
||||||
import com.rtbishop.look4sat.domain.model.DataSourcesSettings
|
|
||||||
import com.rtbishop.look4sat.domain.predict.GeoPos
|
|
||||||
import com.rtbishop.look4sat.domain.predict.OrbitalPass
|
|
||||||
|
|
||||||
data class PositionSettings(
|
|
||||||
val isUpdating: Boolean, val stationPos: GeoPos, val messageResId: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
data class DataSettings(
|
|
||||||
val isUpdating: Boolean, val entriesTotal: Int, val radiosTotal: Int, val timestamp: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
data class SettingsState(
|
|
||||||
val nextPass: OrbitalPass,
|
|
||||||
val nextTime: String,
|
|
||||||
val isNextTimeAos: Boolean,
|
|
||||||
val appVersionName: String,
|
|
||||||
val positionSettings: PositionSettings,
|
|
||||||
val dataSettings: DataSettings,
|
|
||||||
val otherSettings: OtherSettings,
|
|
||||||
val rcSettings: RCSettings,
|
|
||||||
val dataSourcesSettings: DataSourcesSettings,
|
|
||||||
val sendAction: (SettingsAction) -> Unit,
|
|
||||||
val sendRCAction: (RCAction) -> Unit,
|
|
||||||
val sendSystemAction: (SystemAction) -> Unit,
|
|
||||||
val sendDataSourcesAction: (DataSourcesAction) -> Unit
|
|
||||||
)
|
|
||||||
|
|
||||||
sealed class SettingsAction {
|
|
||||||
data object SetGpsPosition : SettingsAction()
|
|
||||||
data class SetGeoPosition(val latitude: Double, val longitude: Double) : SettingsAction()
|
|
||||||
data class SetQthPosition(val locator: String) : SettingsAction()
|
|
||||||
data object DismissPosMessages : SettingsAction()
|
|
||||||
data object UpdateFromWeb : SettingsAction()
|
|
||||||
data class UpdateTLEFromFile(val uri: String) : SettingsAction()
|
|
||||||
data class UpdateTransceiversFromFile(val uri: String) : SettingsAction()
|
|
||||||
data object ClearAllData : SettingsAction()
|
|
||||||
data class ToggleUtc(val value: Boolean) : SettingsAction()
|
|
||||||
data class ToggleUpdate(val value: Boolean) : SettingsAction()
|
|
||||||
data class ToggleSweep(val value: Boolean) : SettingsAction()
|
|
||||||
data class ToggleSensor(val value: Boolean) : SettingsAction()
|
|
||||||
data class ToggleLightTheme(val value: Boolean) : SettingsAction()
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed class SystemAction {
|
|
||||||
data class ShowToast(val message: String) : SystemAction()
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed class RCAction {
|
|
||||||
data class SetRotatorState(val value: Boolean) : RCAction()
|
|
||||||
data class SetRotatorAddress(val value: String) : RCAction()
|
|
||||||
data class SetRotatorPort(val value: String) : RCAction()
|
|
||||||
data class SetBluetoothState(val value: Boolean) : RCAction()
|
|
||||||
data class SetBluetoothFormat(val value: String) : RCAction()
|
|
||||||
data class SetBluetoothName(val value: String) : RCAction()
|
|
||||||
data class SetBluetoothAddress(val value: String) : RCAction()
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed class DataSourcesAction {
|
|
||||||
data class SetUseCustomTle(val value: Boolean) : DataSourcesAction()
|
|
||||||
data class SetUseCustomTransceivers(val value: Boolean) : DataSourcesAction()
|
|
||||||
data class SetTleUrl(val value: String) : DataSourcesAction()
|
|
||||||
data class SetTransceiversUrl(val value: String) : DataSourcesAction()
|
|
||||||
}
|
|
||||||
@@ -1,298 +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.presentation.settings
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.ViewModelProvider
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
|
||||||
import com.rtbishop.look4sat.MainApplication
|
|
||||||
import com.rtbishop.look4sat.R
|
|
||||||
import com.rtbishop.look4sat.domain.model.DataSourcesSettings
|
|
||||||
import com.rtbishop.look4sat.domain.predict.OrbitalPass
|
|
||||||
import com.rtbishop.look4sat.domain.repository.IDatabaseRepo
|
|
||||||
import com.rtbishop.look4sat.domain.repository.ISatelliteRepo
|
|
||||||
import com.rtbishop.look4sat.domain.repository.ISettingsRepo
|
|
||||||
import com.rtbishop.look4sat.domain.usecase.IShowToast
|
|
||||||
import com.rtbishop.look4sat.domain.utility.toTimerString
|
|
||||||
import com.rtbishop.look4sat.presentation.common.getDefaultPass
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class SettingsViewModel(
|
|
||||||
private val databaseRepo: IDatabaseRepo,
|
|
||||||
private val satelliteRepo: ISatelliteRepo,
|
|
||||||
private val settingsRepo: ISettingsRepo,
|
|
||||||
private val showToast: IShowToast,
|
|
||||||
appVersionName: String
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val defaultPosSettings = PositionSettings(false, settingsRepo.stationPosition.value, 0)
|
|
||||||
private val defaultDataSettings = DataSettings(false, 0, 0, 0L)
|
|
||||||
private val defaultDataSourcesSettings = DataSourcesSettings(
|
|
||||||
useCustomTLE = false,
|
|
||||||
useCustomTransceivers = false,
|
|
||||||
tleUrl = settingsRepo.dataSourcesSettings.value.tleUrl,
|
|
||||||
transceiversUrl = settingsRepo.dataSourcesSettings.value.transceiversUrl
|
|
||||||
)
|
|
||||||
private val _uiState = MutableStateFlow(
|
|
||||||
SettingsState(
|
|
||||||
nextTime = "00:00:00",
|
|
||||||
isNextTimeAos = true,
|
|
||||||
nextPass = getDefaultPass(),
|
|
||||||
appVersionName = appVersionName,
|
|
||||||
positionSettings = defaultPosSettings,
|
|
||||||
dataSettings = defaultDataSettings,
|
|
||||||
otherSettings = settingsRepo.otherSettings.value,
|
|
||||||
rcSettings = settingsRepo.rcSettings.value,
|
|
||||||
dataSourcesSettings = defaultDataSourcesSettings,
|
|
||||||
sendAction = ::handleAction,
|
|
||||||
sendRCAction = ::handleAction,
|
|
||||||
sendSystemAction = ::handleAction,
|
|
||||||
sendDataSourcesAction = ::handleAction
|
|
||||||
)
|
|
||||||
)
|
|
||||||
private var processing: Job? = null
|
|
||||||
|
|
||||||
val uiState: StateFlow<SettingsState> = _uiState
|
|
||||||
|
|
||||||
init {
|
|
||||||
viewModelScope.launch {
|
|
||||||
satelliteRepo.passes.collectLatest { passes ->
|
|
||||||
processing?.cancelAndJoin()
|
|
||||||
processing = viewModelScope.launch {
|
|
||||||
while (isActive) {
|
|
||||||
val timeNow = System.currentTimeMillis()
|
|
||||||
val newPasses = satelliteRepo.processPasses(passes, timeNow)
|
|
||||||
setPassInfo(newPasses, timeNow)
|
|
||||||
delay(1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
|
||||||
settingsRepo.stationPosition.collect { geoPos ->
|
|
||||||
val newPosSettings = _uiState.value.positionSettings.copy(
|
|
||||||
isUpdating = false, stationPos = geoPos
|
|
||||||
)
|
|
||||||
_uiState.update { it.copy(positionSettings = newPosSettings) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
|
||||||
settingsRepo.databaseState.collect { state ->
|
|
||||||
val newDataSettings = _uiState.value.dataSettings.copy(
|
|
||||||
isUpdating = false,
|
|
||||||
entriesTotal = state.numberOfSatellites,
|
|
||||||
radiosTotal = state.numberOfRadios,
|
|
||||||
timestamp = state.updateTimestamp
|
|
||||||
)
|
|
||||||
_uiState.update { it.copy(dataSettings = newDataSettings) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
|
||||||
settingsRepo.rcSettings.collect { settings ->
|
|
||||||
_uiState.update { it.copy(rcSettings = settings) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
|
||||||
settingsRepo.otherSettings.collect { settings ->
|
|
||||||
_uiState.update { it.copy(otherSettings = settings) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
|
||||||
settingsRepo.dataSourcesSettings.collect { settings ->
|
|
||||||
_uiState.update { it.copy(dataSourcesSettings = settings) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleAction(action: SettingsAction) {
|
|
||||||
when (action) {
|
|
||||||
SettingsAction.SetGpsPosition -> setGpsPosition()
|
|
||||||
is SettingsAction.SetGeoPosition -> setGeoPosition(action.latitude, action.longitude)
|
|
||||||
is SettingsAction.SetQthPosition -> setQthPosition(action.locator)
|
|
||||||
SettingsAction.DismissPosMessages -> dismissPosMessage()
|
|
||||||
SettingsAction.UpdateFromWeb -> updateFromWeb()
|
|
||||||
is SettingsAction.UpdateTLEFromFile -> updateTLEFromFile(action.uri)
|
|
||||||
is SettingsAction.UpdateTransceiversFromFile -> updateTransceiversFromFile(action.uri)
|
|
||||||
SettingsAction.ClearAllData -> clearAllData()
|
|
||||||
is SettingsAction.ToggleUtc -> settingsRepo.setStateOfUtc(action.value)
|
|
||||||
is SettingsAction.ToggleUpdate -> settingsRepo.setStateOfAutoUpdate(action.value)
|
|
||||||
is SettingsAction.ToggleSweep -> settingsRepo.setStateOfSweep(action.value)
|
|
||||||
is SettingsAction.ToggleSensor -> settingsRepo.setStateOfSensors(action.value)
|
|
||||||
is SettingsAction.ToggleLightTheme -> settingsRepo.setStateOfLightTheme(action.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleAction(action: RCAction) {
|
|
||||||
when (action) {
|
|
||||||
is RCAction.SetRotatorState -> settingsRepo.setRotatorState(action.value)
|
|
||||||
is RCAction.SetRotatorAddress -> settingsRepo.setRotatorAddress(action.value)
|
|
||||||
is RCAction.SetRotatorPort -> settingsRepo.setRotatorPort(action.value)
|
|
||||||
is RCAction.SetBluetoothState -> settingsRepo.setBluetoothState(action.value)
|
|
||||||
is RCAction.SetBluetoothAddress -> settingsRepo.setBluetoothAddress(action.value)
|
|
||||||
is RCAction.SetBluetoothFormat -> settingsRepo.setBluetoothFormat(action.value)
|
|
||||||
is RCAction.SetBluetoothName -> settingsRepo.setBluetoothName(action.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleAction(action: SystemAction) {
|
|
||||||
when (action) {
|
|
||||||
is SystemAction.ShowToast -> showToast(action.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleAction(action: DataSourcesAction) {
|
|
||||||
when (action) {
|
|
||||||
is DataSourcesAction.SetUseCustomTle -> settingsRepo.setUseCustomTle(action.value)
|
|
||||||
is DataSourcesAction.SetUseCustomTransceivers -> settingsRepo.setUseCustomTransceivers(action.value)
|
|
||||||
is DataSourcesAction.SetTleUrl -> settingsRepo.setTleUrl(action.value)
|
|
||||||
is DataSourcesAction.SetTransceiversUrl -> settingsRepo.setTransceiversUrl(action.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setGpsPosition() {
|
|
||||||
if (settingsRepo.setStationPosition()) {
|
|
||||||
val messageResId = R.string.prefs_loc_success
|
|
||||||
val newPosSettings = _uiState.value.positionSettings.copy(
|
|
||||||
isUpdating = true, messageResId = messageResId
|
|
||||||
)
|
|
||||||
_uiState.update { it.copy(positionSettings = newPosSettings) }
|
|
||||||
} else {
|
|
||||||
val errorResId = R.string.prefs_loc_gps_error
|
|
||||||
val newPosSettings = _uiState.value.positionSettings.copy(
|
|
||||||
isUpdating = false, messageResId = errorResId
|
|
||||||
)
|
|
||||||
_uiState.update { it.copy(positionSettings = newPosSettings) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setGeoPosition(latitude: Double, longitude: Double) {
|
|
||||||
if (settingsRepo.setStationPosition(latitude, longitude, 0.0)) {
|
|
||||||
val messageResId = R.string.prefs_loc_success
|
|
||||||
val newPosSettings = _uiState.value.positionSettings.copy(
|
|
||||||
isUpdating = false, messageResId = messageResId
|
|
||||||
)
|
|
||||||
_uiState.update { it.copy(positionSettings = newPosSettings) }
|
|
||||||
} else {
|
|
||||||
val errorResId = R.string.prefs_loc_input_error
|
|
||||||
val newPosSettings = _uiState.value.positionSettings.copy(
|
|
||||||
isUpdating = false, messageResId = errorResId
|
|
||||||
)
|
|
||||||
_uiState.update { it.copy(positionSettings = newPosSettings) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setQthPosition(locator: String) {
|
|
||||||
if (settingsRepo.setStationPosition(locator)) {
|
|
||||||
val messageResId = R.string.prefs_loc_success
|
|
||||||
val newPosSettings = _uiState.value.positionSettings.copy(
|
|
||||||
isUpdating = false, messageResId = messageResId
|
|
||||||
)
|
|
||||||
_uiState.update { it.copy(positionSettings = newPosSettings) }
|
|
||||||
} else {
|
|
||||||
val errorResId = R.string.prefs_loc_qth_error
|
|
||||||
val newPosSettings = _uiState.value.positionSettings.copy(
|
|
||||||
isUpdating = false, messageResId = errorResId
|
|
||||||
)
|
|
||||||
_uiState.update { it.copy(positionSettings = newPosSettings) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun dismissPosMessage() {
|
|
||||||
val newPosSettings = _uiState.value.positionSettings.copy(
|
|
||||||
isUpdating = false, messageResId = 0
|
|
||||||
)
|
|
||||||
_uiState.update { it.copy(positionSettings = newPosSettings) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateFromWeb() = viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val newDataSettings = _uiState.value.dataSettings.copy(isUpdating = true)
|
|
||||||
_uiState.update { it.copy(dataSettings = newDataSettings) }
|
|
||||||
databaseRepo.updateFromRemote()
|
|
||||||
} catch (exception: Exception) {
|
|
||||||
val newDataSettings = _uiState.value.dataSettings.copy(isUpdating = false)
|
|
||||||
_uiState.update { it.copy(dataSettings = newDataSettings) }
|
|
||||||
println(exception)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateTLEFromFile(uri: String) = viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val newDataSettings = _uiState.value.dataSettings.copy(isUpdating = true)
|
|
||||||
_uiState.update { it.copy(dataSettings = newDataSettings) }
|
|
||||||
databaseRepo.updateTLEFromFile(uri)
|
|
||||||
} catch (exception: Exception) {
|
|
||||||
val newDataSettings = _uiState.value.dataSettings.copy(isUpdating = false)
|
|
||||||
_uiState.update { it.copy(dataSettings = newDataSettings) }
|
|
||||||
println(exception)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateTransceiversFromFile(uri: String) = viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val newDataSettings = _uiState.value.dataSettings.copy(isUpdating = true)
|
|
||||||
_uiState.update { it.copy(dataSettings = newDataSettings) }
|
|
||||||
databaseRepo.updateTransceiversFromFile(uri)
|
|
||||||
} catch (exception: Exception) {
|
|
||||||
val newDataSettings = _uiState.value.dataSettings.copy(isUpdating = false)
|
|
||||||
_uiState.update { it.copy(dataSettings = newDataSettings) }
|
|
||||||
println(exception)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setPassInfo(passes: List<OrbitalPass>, timeNow: Long) {
|
|
||||||
if (passes.isEmpty()) return
|
|
||||||
try {
|
|
||||||
val nextPass = passes.first { it.aosTime.minus(timeNow) > 0 }
|
|
||||||
val time = nextPass.aosTime.minus(timeNow).toTimerString()
|
|
||||||
_uiState.update { it.copy(nextPass = nextPass, nextTime = time, isNextTimeAos = true) }
|
|
||||||
} catch (_: NoSuchElementException) {
|
|
||||||
val lastPass = passes.last()
|
|
||||||
val time = lastPass.losTime.minus(timeNow).toTimerString()
|
|
||||||
_uiState.update { it.copy(nextPass = lastPass, nextTime = time, isNextTimeAos = false) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearAllData() = viewModelScope.launch { databaseRepo.clearAllData() }
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
|
||||||
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
|
|
||||||
initializer {
|
|
||||||
val container = (this[applicationKey] as MainApplication).container
|
|
||||||
SettingsViewModel(
|
|
||||||
container.databaseRepo,
|
|
||||||
container.satelliteRepo,
|
|
||||||
container.settingsRepo,
|
|
||||||
container.provideShowToast(),
|
|
||||||
container.provideAppVersionName()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M4,20h16v2L4,22zM4,2h16v2L4,4zM13,9h3l-4,-4 -4,4h3v6L8,15l4,4 4,-4h-3z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<!-- Commmon -->
|
|
||||||
<string name="btn_accept">Aceptar</string>
|
|
||||||
<string name="btn_cancel">Cancelar</string>
|
|
||||||
<string name="empty_list_message">No hay datos para mostrar</string>
|
|
||||||
|
|
||||||
<!-- Navigation -->
|
|
||||||
<string name="nav_sat">Satélites</string>
|
|
||||||
<string name="nav_pass">Passes</string>
|
|
||||||
<string name="nav_radar">Radar</string>
|
|
||||||
<string name="nav_map">Mapa</string>
|
|
||||||
<string name="nav_prefs">Ajustes</string>
|
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
|
||||||
<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_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>
|
|
||||||
<string name="sat_warning_message">
|
|
||||||
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 tipos.</string>
|
|
||||||
|
|
||||||
<!-- Passes screen -->
|
|
||||||
<string name="pass_filter_title">Filtrar pases</string>
|
|
||||||
<string name="pass_filter_elev">Elevación mínima</string>
|
|
||||||
<string name="pass_filter_hours">Horas por delante</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">
|
|
||||||
Asegúrate de que tus filtros sean correctos y hayas seleccionado satélites</string>
|
|
||||||
<string name="pass_error_message">No hay pases de satélite próximos para mostrar</string>
|
|
||||||
<string name="pass_welcome_title">Bienvenido a Look4Sat\!</string>
|
|
||||||
<string name="pass_welcome_message">
|
|
||||||
Asegúrate de configurar tu posición mediante GPS, Latitud/Longitud o QTH en Configuración.
|
|
||||||
\n\nActualiza la base de datos al menos una vez por semana para obtener predicciones precisas.</string>
|
|
||||||
|
|
||||||
<!-- Radar screen -->
|
|
||||||
<string name="radar_back">Atrás</string>
|
|
||||||
<string name="radar_notify">Notificar</string>
|
|
||||||
<string name="radar_az_text">Ácimut</string>
|
|
||||||
<string name="radar_el_text">Elevación</string>
|
|
||||||
<string name="radar_alt_text">Altitud</string>
|
|
||||||
<string name="radar_dist_text">Distancia</string>
|
|
||||||
<string name="radar_eclipsed">Eclipsado</string>
|
|
||||||
<string name="radar_downlink">Enlace descendente</string>
|
|
||||||
<string name="radar_uplink">Enlace ascendente</string>
|
|
||||||
<string name="radar_no_data">Este satélite no lleva transmisores</string>
|
|
||||||
<string name="radar_add">Añadir</string>
|
|
||||||
<string name="radar_data">Transmisores satélite:</string>
|
|
||||||
<string name="radar_inverted">Invertido: %s</string>
|
|
||||||
<string name="radar_mode">Modo: %s</string>
|
|
||||||
<string name="radar_string_no">No</string>
|
|
||||||
<string name="radar_string_yes">Si</string>
|
|
||||||
<string name="radar_visible">Visible</string>
|
|
||||||
|
|
||||||
<!-- Map screen -->
|
|
||||||
<string name="map_prev">Anterior</string>
|
|
||||||
<string name="map_next">Siguiente</string>
|
|
||||||
<string name="map_azimuth">Ácimut: %.1f°</string>
|
|
||||||
<string name="map_elevation">Elevación: %.1f°</string>
|
|
||||||
<string name="map_altitude">Altitud: %.0f km</string>
|
|
||||||
<string name="map_distance">Distancia: %.0f km</string>
|
|
||||||
<string name="map_latitude">Latitud: %.1f°</string>
|
|
||||||
<string name="map_longitude">Longitud: %.1f°</string>
|
|
||||||
<string name="map_phase">Fase: %.1f°</string>
|
|
||||||
<string name="map_eclipsed">Eclipsado</string>
|
|
||||||
<string name="map_period">Período: %.0f min</string>
|
|
||||||
<string name="map_velocity">Velocidad: %.2f km/s</string>
|
|
||||||
<string name="map_visibility">Visibilidad: %s</string>
|
|
||||||
<string name="map_visible">Visible</string>
|
|
||||||
|
|
||||||
<!-- Settings screen -->
|
|
||||||
<string name="prefs_donate_title">Soporte</string>
|
|
||||||
<string name="prefs_privacy_title">Política de privacidad</string>
|
|
||||||
|
|
||||||
<string name="prefs_updated_title">Actualizado: %s</string>
|
|
||||||
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
|
||||||
|
|
||||||
<string name="prefs_loc_title">Ubicación estación</string>
|
|
||||||
<string name="prefs_loc_gps_title">GPS</string>
|
|
||||||
<string name="prefs_loc_gps_error">Revisa los permisos de ubicación</string>
|
|
||||||
<string name="prefs_loc_input_title">Ingresar</string>
|
|
||||||
<string name="prefs_loc_input_error">Ubicación no válida</string>
|
|
||||||
<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">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>
|
|
||||||
<string name="prefs_data_radios">Transmisores: %s</string>
|
|
||||||
<string name="prefs_data_update">Actualizar</string>
|
|
||||||
<string name="prefs_data_import">Importar</string>
|
|
||||||
<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_sources_title">Custom data 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_net_title">Salida de datos de red</string>
|
|
||||||
<string name="prefs_net_switch">Habilitar salida</string>
|
|
||||||
<string name="prefs_net_ip_hint">Dirección IP</string>
|
|
||||||
<string name="prefs_net_port_hint">Puerto</string>
|
|
||||||
|
|
||||||
<string name="prefs_bt_title">Salida por Bluetooth</string>
|
|
||||||
<string name="prefs_bt_switch">Habilitar salida</string>
|
|
||||||
<string name="prefs_bt_device_hint">Id dispositivo</string>
|
|
||||||
<string name="prefs_bt_output_hint">Formato de datos</string>
|
|
||||||
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</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_outro_title">Me gustaría dar las gracias a:</string>
|
|
||||||
<string name="prefs_outro_license">La app viene sin garantías de ningún tipo.</string>
|
|
||||||
|
|
||||||
</resources>
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<!-- Commmon -->
|
|
||||||
<string name="btn_accept">Принять</string>
|
|
||||||
<string name="btn_cancel">Отменить</string>
|
|
||||||
<string name="empty_list_message">Нет данных для отображения</string>
|
|
||||||
|
|
||||||
<!-- Navigation -->
|
|
||||||
<string name="nav_sat">Спутники</string>
|
|
||||||
<string name="nav_pass">Пролеты</string>
|
|
||||||
<string name="nav_radar">Радар</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_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_empty_list_message">Убедитесь, что ваш поисковый запрос верен и база данных обновлена</string>
|
|
||||||
<string name="sat_warning_title">Внимание\!</string>
|
|
||||||
<string name="sat_warning_message">
|
|
||||||
В этом приложении перечислено более 9000 спутников.
|
|
||||||
Отслеживать их все одновременно бессмысленно.
|
|
||||||
\n\nВсегда старайтесь сузить список до тех,
|
|
||||||
которые вас интересуют, используя поиск и селектор типов.</string>
|
|
||||||
|
|
||||||
<!-- Passes screen -->
|
|
||||||
<string name="pass_filter_title">Фильтровать пролеты</string>
|
|
||||||
<string name="pass_filter_elev">Минимальная элевация</string>
|
|
||||||
<string name="pass_filter_hours">Количество часов</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">
|
|
||||||
Убедитесь, что ваши фильтры настроены правильно и вы выбрали спутники</string>
|
|
||||||
<string name="pass_error_message">Нет предстоящих пролётов спутников</string>
|
|
||||||
<string name="pass_welcome_title">Добро пожаловать в Look4Sat\!</string>
|
|
||||||
<string name="pass_welcome_message">
|
|
||||||
Обязательно укажите свое местоположение по GPS, широте/долготе или QTH в настройках.
|
|
||||||
\n\nПожалуйста, обновляйте базу данных как минимум раз в неделю, чтобы получать точные прогнозы.</string>
|
|
||||||
|
|
||||||
<!-- Radar screen -->
|
|
||||||
<string name="radar_back">Назад</string>
|
|
||||||
<string name="radar_notify">Уведомить</string>
|
|
||||||
<string name="radar_az_text">Азим.</string>
|
|
||||||
<string name="radar_el_text">Элев.</string>
|
|
||||||
<string name="radar_alt_text">Выс.</string>
|
|
||||||
<string name="radar_dist_text">Дальн.</string>
|
|
||||||
<string name="radar_eclipsed">В тени</string>
|
|
||||||
<string name="radar_downlink">Нисходящий канал</string>
|
|
||||||
<string name="radar_uplink">Восходящий канал</string>
|
|
||||||
<string name="radar_no_data">У этого спутника нет трансиверов</string>
|
|
||||||
<string name="radar_add">Добавить</string>
|
|
||||||
<string name="radar_data">Трансиверы спутника:</string>
|
|
||||||
<string name="radar_inverted">Инверсия: %s</string>
|
|
||||||
<string name="radar_mode">Модуляция: %s</string>
|
|
||||||
<string name="radar_string_no">Нет</string>
|
|
||||||
<string name="radar_string_yes">Да</string>
|
|
||||||
<string name="radar_visible">Видимый</string>
|
|
||||||
|
|
||||||
<!-- Map screen -->
|
|
||||||
<string name="map_prev">Предыдущий</string>
|
|
||||||
<string name="map_next">Следующий</string>
|
|
||||||
<string name="map_azimuth">Азимут: %.1f°</string>
|
|
||||||
<string name="map_elevation">Элевация: %.1f°</string>
|
|
||||||
<string name="map_altitude">Высота: %.0fкм</string>
|
|
||||||
<string name="map_distance">Дистанция: %.0fкм</string>
|
|
||||||
<string name="map_latitude">Широта: %.1f°</string>
|
|
||||||
<string name="map_longitude">Долгота: %.1f°</string>
|
|
||||||
<string name="map_phase">Фаза: %.1f°</string>
|
|
||||||
<string name="map_eclipsed">В тени</string>
|
|
||||||
<string name="map_period">Период: %.0fмин</string>
|
|
||||||
<string name="map_velocity">Скорость: %.2fкм/с</string>
|
|
||||||
<string name="map_visibility">Видимость: %s</string>
|
|
||||||
<string name="map_visible">Видимый</string>
|
|
||||||
|
|
||||||
<!-- Settings screen -->
|
|
||||||
<string name="prefs_donate_title">Поддержать</string>
|
|
||||||
<string name="prefs_privacy_title">Политика конфиденциальности</string>
|
|
||||||
|
|
||||||
<string name="prefs_updated_title">Обновлено: %s</string>
|
|
||||||
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
|
||||||
|
|
||||||
<string name="prefs_loc_title">Местоположение</string>
|
|
||||||
<string name="prefs_loc_gps_title">GPS</string>
|
|
||||||
<string name="prefs_loc_gps_error">Нет разрешения использовать геоданные</string>
|
|
||||||
<string name="prefs_loc_input_title">Ввести</string>
|
|
||||||
<string name="prefs_loc_input_error">Введены неверные данные</string>
|
|
||||||
<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_data_title">Данные спутников</string>
|
|
||||||
<string name="prefs_data_entries">Спутников: %s</string>
|
|
||||||
<string name="prefs_data_radios">Трансиверов: %s</string>
|
|
||||||
<string name="prefs_data_update">Обновить</string>
|
|
||||||
<string name="prefs_data_import">Импорт</string>
|
|
||||||
<string name="prefs_data_clear">Очистить</string>
|
|
||||||
<string name="prefs_data_clear_success">Очистка прошла успешно</string>
|
|
||||||
<string name="prefs_data_update_success">Обновление прошло успешно</string>
|
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Свои источники данных</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_net_title">Вывод сетевых данных</string>
|
|
||||||
<string name="prefs_net_switch">Включить вывод</string>
|
|
||||||
<string name="prefs_net_ip_hint">IP адрес</string>
|
|
||||||
<string name="prefs_net_port_hint">Порт</string>
|
|
||||||
|
|
||||||
<string name="prefs_bt_title">Вывод данных Bluetooth</string>
|
|
||||||
<string name="prefs_bt_switch">Включить вывод</string>
|
|
||||||
<string name="prefs_bt_device_hint">Id устройства</string>
|
|
||||||
<string name="prefs_bt_output_hint">Формат данных</string>
|
|
||||||
<string name="prefs_bt_perm_error">Нет разрешения использовать bluetooth</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_outro_title">Я хотел бы сказать спасибо:</string>
|
|
||||||
<string name="prefs_outro_license">Это ПО поставляется без гарантий.</string>
|
|
||||||
|
|
||||||
</resources>
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<!-- Commmon -->
|
|
||||||
<string name="btn_accept">පිළිගන්න</string>
|
|
||||||
<string name="btn_cancel">අවලංගු ක°</string>
|
|
||||||
<string name="empty_list_message">පෙන්වීමට දත්ත නැත</string>
|
|
||||||
|
|
||||||
<!-- Navigation -->
|
|
||||||
<string name="nav_sat">චන්ද්රිකා</string>
|
|
||||||
<string name="nav_pass">පසුකර යෑම්</string>
|
|
||||||
<string name="nav_radar">රේඩාර්</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_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_empty_list_message">
|
|
||||||
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
|
|
||||||
<string name="sat_warning_title">අවවාදයයි\!</string>
|
|
||||||
<string name="sat_warning_message">
|
|
||||||
මෙම යෙදුමේ ලැයිස්තුගත කර ඇති චන්ද්රිකා 9000 කට වඩා තිබේ.
|
|
||||||
ඒවා සියල්ලම එකවර නිරීක්ෂණය කිරීම තේරුමක් නැති දෙයක්.
|
|
||||||
\n\nසෙවීම සහ වර්ග තේරීම හරහා ඔබ උනන්දුවක් දක්වන
|
|
||||||
ඒවාට පමණක් ලැයිස්තුව පටු කිරීමට සැමවිටම උත්සාහ කරන්න.</string>
|
|
||||||
|
|
||||||
<!-- Passes screen -->
|
|
||||||
<string name="pass_filter_title">පසුකිරිම් පිරිපහදුව</string>
|
|
||||||
<string name="pass_filter_elev">අවම උත්තෝලනය</string>
|
|
||||||
<string name="pass_filter_hours">ඉදිරි පැය</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>
|
|
||||||
<string name="pass_error_message">ප්රදර්ශනය කිරීමට ඉදිරියට එන චන්ද්රිකා පසුකර යෑම් නැත.</string>
|
|
||||||
<string name="pass_welcome_title">Look4Sat වෙත සාදරයෙන් පිළිගනිමු\!</string>
|
|
||||||
<string name="pass_welcome_message">
|
|
||||||
සැකසුම් තුළ GPS, Lat/Lon හෝ QTH හරහා ඔබේ ස්ථානය සැකසීමට වග බලා ගන්න.
|
|
||||||
\n\nනිවැරදි අනාවැකි ලබා ගැනීම සඳහා අවම වශයෙන් සතිපතා දත්ත සමුදාය යාවත්කාලීන කරන්න.</string>
|
|
||||||
|
|
||||||
<!-- Radar screen -->
|
|
||||||
<string name="radar_back">ආපසු</string>
|
|
||||||
<string name="radar_notify">දන්වනවා</string>
|
|
||||||
<string name="radar_az_text">දිගංශය</string>
|
|
||||||
<string name="radar_el_text">උත්තෝලනය</string>
|
|
||||||
<string name="radar_alt_text">උන්නතාශය</string>
|
|
||||||
<string name="radar_dist_text">පරතරය</string>
|
|
||||||
<string name="radar_eclipsed">අදෘශ්ය</string>
|
|
||||||
<string name="radar_downlink">පහළ සබැඳිය</string>
|
|
||||||
<string name="radar_uplink">උඩුගත කිරීම</string>
|
|
||||||
<string name="radar_no_data">මෙම චන්ද්රිකාව සම්ප්රෙෂණය නොකරයි</string>
|
|
||||||
<string name="radar_add">එකතු ක°</string>
|
|
||||||
<string name="radar_data">චන්ද්රිකා සම්ප්රේෂණයන්:</string>
|
|
||||||
<string name="radar_inverted">Inverted: %s</string>
|
|
||||||
<string name="radar_mode">ප්රාකාරය: %s</string>
|
|
||||||
<string name="radar_string_no">නෑ</string>
|
|
||||||
<string name="radar_string_yes">ඔව්</string>
|
|
||||||
<string name="radar_visible">දෘශ්යමාන</string>
|
|
||||||
|
|
||||||
<!-- Map screen -->
|
|
||||||
<string name="map_prev">පසුපස</string>
|
|
||||||
<string name="map_next">ඊළග</string>
|
|
||||||
<string name="map_azimuth">දිගංශය: %.1f°</string>
|
|
||||||
<string name="map_elevation">උත්තෝලනය: %.1f°</string>
|
|
||||||
<string name="map_altitude">උන්නතාශය: %.0f km</string>,
|
|
||||||
<string name="map_distance">පරතරය: %.0f km</string>
|
|
||||||
<string name="map_latitude">අක්ෂාංශ: %.1f°</string>
|
|
||||||
<string name="map_longitude">දේශාංශ: %.1f°</string>
|
|
||||||
<string name="map_phase">අංශය: %.1f°</string>
|
|
||||||
<string name="map_eclipsed">අදෘශ්ය</string>
|
|
||||||
<string name="map_period">කාලය: %.0f min</string>
|
|
||||||
<string name="map_velocity">ප්රවේශය: %.2f km/s</string>
|
|
||||||
<string name="map_visibility">දෘශ්යතාව: %s</string>
|
|
||||||
<string name="map_visible">දෘශ්ය</string>
|
|
||||||
|
|
||||||
<!-- Settings screen -->
|
|
||||||
<string name="prefs_donate_title">දායකත්ව</string>
|
|
||||||
<string name="prefs_privacy_title">රහස්යතා ප්රතිපත්තිය</string>
|
|
||||||
|
|
||||||
<string name="prefs_updated_title">යාවත්කාලීන කරන ලදී: %s</string>
|
|
||||||
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
|
||||||
|
|
||||||
<string name="prefs_loc_title">පොළෙහි පිහිටීම</string>
|
|
||||||
<string name="prefs_loc_gps_title">GPS</string>
|
|
||||||
<string name="prefs_loc_gps_error">ඔබගේ location අවසර පරීක්ෂා ක°</string>
|
|
||||||
<string name="prefs_loc_input_title">ආදානය</string>
|
|
||||||
<string name="prefs_loc_input_error">ඇතුළත් කළ ස්ථානය වලංගු නොවේ</string>
|
|
||||||
<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_data_title">චන්ද්රිකා දත්ත</string>
|
|
||||||
<string name="prefs_data_entries">චන්ද්රිකා: %s</string>
|
|
||||||
<string name="prefs_data_radios">සම්ප්රෙේෂණය: %s</string>
|
|
||||||
<string name="prefs_data_update">නැවුම් කරන්න</string>
|
|
||||||
<string name="prefs_data_import">ආදානය</string>
|
|
||||||
<string name="prefs_data_clear">පිරිසිදු ක°</string>
|
|
||||||
<string name="prefs_data_clear_success">දත්ත ඉවත් කිරීම සාර්ථකයි</string>
|
|
||||||
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
|
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Custom data 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_net_title">ජාල ප්රතිදානය</string>
|
|
||||||
<string name="prefs_net_switch">ප්රතිදානය සක්රීය කරන්න</string>
|
|
||||||
<string name="prefs_net_ip_hint">IP ලිපිනය</string>
|
|
||||||
<string name="prefs_net_port_hint">Port</string>
|
|
||||||
|
|
||||||
<string name="prefs_bt_title">Bluetooth දත්ත ප්රතිදානය</string>
|
|
||||||
<string name="prefs_bt_switch">ප්රතිදානය සක්රීය කරන්න</string>
|
|
||||||
<string name="prefs_bt_device_hint">උපාංග id</string>
|
|
||||||
<string name="prefs_bt_output_hint">දත්ත අකාරය</string>
|
|
||||||
<string name="prefs_bt_perm_error">Bluetooth අවසර පරික්ෂා ක°</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_outro_title">මම ස්තුති කිරීමට කැමති:</string>
|
|
||||||
<string name="prefs_outro_license">මෘදුකාංගය වගකීමක් සමග නොලැබේ</string>
|
|
||||||
|
|
||||||
</resources>
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<!-- Commmon -->
|
|
||||||
<string name="btn_accept">Прийняти</string>
|
|
||||||
<string name="btn_cancel">Скасувати</string>
|
|
||||||
<string name="empty_list_message">Немає даних для відображення</string>
|
|
||||||
|
|
||||||
<!-- Navigation -->
|
|
||||||
<string name="nav_sat">Супутники</string>
|
|
||||||
<string name="nav_pass">Прольоти</string>
|
|
||||||
<string name="nav_radar">Радар</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_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_empty_list_message">
|
|
||||||
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
|
|
||||||
<string name="sat_warning_title">Увага\!</string>
|
|
||||||
<string name="sat_warning_message">
|
|
||||||
У цьому додатку перелічено понад 9000 супутників.
|
|
||||||
Немає сенсу відстежувати їх усі одночасно.
|
|
||||||
\n\nЗавжди намагайтеся звузити список лише до тих,
|
|
||||||
які вас цікавлять, за допомогою пошуку та селектора типів.</string>
|
|
||||||
|
|
||||||
<!-- Passes screen -->
|
|
||||||
<string name="pass_filter_title">Фільтр прольотів</string>
|
|
||||||
<string name="pass_filter_elev">Мінімальне піднесення</string>
|
|
||||||
<string name="pass_filter_hours">Кількість годин</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">
|
|
||||||
Переконайтеся, що ваші фільтри правильні, і ви вибрали супутники</string>
|
|
||||||
<string name="pass_error_message">Немає майбутніх прольотів супутників</string>
|
|
||||||
<string name="pass_welcome_title">Ласкаво просимо до Look4Sat\!</string>
|
|
||||||
<string name="pass_welcome_message">
|
|
||||||
Обов’язково встановіть своє місцезнаходження за допомогою GPS, широти/довготи або QTH у налаштуваннях.
|
|
||||||
\n\nБудь ласка, оновлюйте базу даних принаймні раз на тиждень, щоб отримувати точні прогнози.</string>
|
|
||||||
|
|
||||||
<!-- Radar screen -->
|
|
||||||
<string name="radar_back">Назад</string>
|
|
||||||
<string name="radar_notify">Сповістити</string>
|
|
||||||
<string name="radar_az_text">Азимут</string>
|
|
||||||
<string name="radar_el_text">Піднес.</string>
|
|
||||||
<string name="radar_alt_text">Висота</string>
|
|
||||||
<string name="radar_dist_text">Відстань</string>
|
|
||||||
<string name="radar_eclipsed">В тіні</string>
|
|
||||||
<string name="radar_downlink">Низхідний канал</string>
|
|
||||||
<string name="radar_uplink">Вихідний канал</string>
|
|
||||||
<string name="radar_no_data">Цей супутник не має трансиверів</string>
|
|
||||||
<string name="radar_add">Додати</string>
|
|
||||||
<string name="radar_data">Супутникові трансивери:</string>
|
|
||||||
<string name="radar_inverted">Інверсія: %s</string>
|
|
||||||
<string name="radar_mode">Модуляція: %s</string>
|
|
||||||
<string name="radar_string_no">Ні</string>
|
|
||||||
<string name="radar_string_yes">Так</string>
|
|
||||||
<string name="radar_visible">Видимий</string>
|
|
||||||
|
|
||||||
<!-- Map screen -->
|
|
||||||
<string name="map_prev">Попередній</string>
|
|
||||||
<string name="map_next">Наступний</string>
|
|
||||||
<string name="map_azimuth">Азимут: %.1f°</string>
|
|
||||||
<string name="map_elevation">Піднесення: %.1f°</string>
|
|
||||||
<string name="map_altitude">Висота: %.0f км</string>
|
|
||||||
<string name="map_distance">Дистанція: %.0f км</string>
|
|
||||||
<string name="map_latitude">Широта: %.1f°</string>
|
|
||||||
<string name="map_longitude">Довгота: %.1f°</string>
|
|
||||||
<string name="map_phase">Фаза: %.1f°</string>
|
|
||||||
<string name="map_eclipsed">В тіні</string>
|
|
||||||
<string name="map_period">Період: %.0f хв</string>
|
|
||||||
<string name="map_velocity">Швидкість: %.2f км/с</string>
|
|
||||||
<string name="map_visibility">Видимість: %s</string>
|
|
||||||
<string name="map_visible">Видимий</string>
|
|
||||||
|
|
||||||
<!-- Settings screen -->
|
|
||||||
<string name="prefs_donate_title">Підтримати</string>
|
|
||||||
<string name="prefs_privacy_title">Політика конфіденційності</string>
|
|
||||||
|
|
||||||
<string name="prefs_updated_title">Оновлено: %s</string>
|
|
||||||
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
|
||||||
|
|
||||||
<string name="prefs_loc_title">Місцезнаходження</string>
|
|
||||||
<string name="prefs_loc_gps_title">GPS</string>
|
|
||||||
<string name="prefs_loc_gps_error">Перевірте дозвіл на використання геолокацї</string>
|
|
||||||
<string name="prefs_loc_input_title">Ввести</string>
|
|
||||||
<string name="prefs_loc_input_error">Введено некоректну локацію</string>
|
|
||||||
<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_data_title">Супутникові дані</string>
|
|
||||||
<string name="prefs_data_entries">Супутників: %s</string>
|
|
||||||
<string name="prefs_data_radios">Трансиверів: %s</string>
|
|
||||||
<string name="prefs_data_update">Оновити</string>
|
|
||||||
<string name="prefs_data_import">Імпорт</string>
|
|
||||||
<string name="prefs_data_clear">Очистити</string>
|
|
||||||
<string name="prefs_data_clear_success">Дані успішно очищено</string>
|
|
||||||
<string name="prefs_data_update_success">Оновлення успішне</string>
|
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Custom data 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_net_title">Вивід мережевих даних</string>
|
|
||||||
<string name="prefs_net_switch">Увімкнути вихід</string>
|
|
||||||
<string name="prefs_net_ip_hint">IP адреса</string>
|
|
||||||
<string name="prefs_net_port_hint">Порт</string>
|
|
||||||
|
|
||||||
<string name="prefs_bt_title">Вивід даних Bluetooth</string>
|
|
||||||
<string name="prefs_bt_switch">Увімкнути вихід</string>
|
|
||||||
<string name="prefs_bt_device_hint">Id пристрою</string>
|
|
||||||
<string name="prefs_bt_output_hint">Формат даних</string>
|
|
||||||
<string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</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_outro_title">Я хотів би подякувати:</string>
|
|
||||||
<string name="prefs_outro_license">Ця програма поставляється без жодних гарантій</string>
|
|
||||||
|
|
||||||
</resources>
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<!-- Commmon -->
|
|
||||||
<string name="btn_accept">确定</string>
|
|
||||||
<string name="btn_cancel">取消</string>
|
|
||||||
<string name="empty_list_message">没有数据可显示</string>
|
|
||||||
|
|
||||||
<!-- Navigation -->
|
|
||||||
<string name="nav_sat">卫星</string>
|
|
||||||
<string name="nav_pass">过境</string>
|
|
||||||
<string name="nav_radar">雷达</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_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_empty_list_message">请确保您的搜索查询正确且数据库已更新</string>
|
|
||||||
<string name="sat_warning_title">警告\!</string>
|
|
||||||
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星</string>
|
|
||||||
|
|
||||||
<!-- Passes screen -->
|
|
||||||
<string name="pass_filter_title">筛选过境</string>
|
|
||||||
<string name="pass_filter_elev">仰角值</string>
|
|
||||||
<string name="pass_filter_hours">小时数</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>
|
|
||||||
<string name="pass_error_message">没有即将过境的卫星</string>
|
|
||||||
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</string>
|
|
||||||
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
|
||||||
|
|
||||||
<!-- Radar screen -->
|
|
||||||
<string name="radar_back">后退</string>
|
|
||||||
<string name="radar_notify">通知</string>
|
|
||||||
<string name="radar_az_text">方位角</string>
|
|
||||||
<string name="radar_el_text">仰角</string>
|
|
||||||
<string name="radar_alt_text">高度</string>
|
|
||||||
<string name="radar_dist_text">距离</string>
|
|
||||||
<string name="radar_eclipsed">地影中</string>
|
|
||||||
<string name="radar_downlink">下行链路</string>
|
|
||||||
<string name="radar_uplink">上行链路</string>
|
|
||||||
<string name="radar_no_data">这颗卫星没有收发器</string>
|
|
||||||
<string name="radar_add">添加</string>
|
|
||||||
<string name="radar_data">卫星收发器:</string>
|
|
||||||
<string name="radar_inverted">倒置:%s</string>
|
|
||||||
<string name="radar_mode">调制:%s</string>
|
|
||||||
<string name="radar_string_no">否</string>
|
|
||||||
<string name="radar_string_yes">是</string>
|
|
||||||
<string name="radar_visible">日照中</string>
|
|
||||||
|
|
||||||
<!-- Map screen -->
|
|
||||||
<string name="map_prev">上一个</string>
|
|
||||||
<string name="map_next">下一个</string>
|
|
||||||
<string name="map_azimuth">方位角:%.1f°</string>
|
|
||||||
<string name="map_elevation">仰角:%.1f°</string>
|
|
||||||
<string name="map_altitude">高度:%.0f km</string>
|
|
||||||
<string name="map_distance">距离:%.0f km</string>
|
|
||||||
<string name="map_latitude">纬度:%.1f°</string>
|
|
||||||
<string name="map_longitude">经度:%.1f°</string>
|
|
||||||
<string name="map_phase">相位:%.1f°</string>
|
|
||||||
<string name="map_eclipsed">地影中</string>
|
|
||||||
<string name="map_period">周期:% .0f min</string>
|
|
||||||
<string name="map_velocity">速度:%.2f km/s</string>
|
|
||||||
<string name="map_visibility">能见度:%s</string>
|
|
||||||
<string name="map_visible">日照中</string>
|
|
||||||
|
|
||||||
<!-- Settings screen -->
|
|
||||||
<string name="prefs_donate_title">支持</string>
|
|
||||||
<string name="prefs_privacy_title">隐私政策</string>
|
|
||||||
|
|
||||||
<string name="prefs_updated_title">更新:%s</string>
|
|
||||||
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
|
|
||||||
|
|
||||||
<string name="prefs_loc_title">站位</string>
|
|
||||||
<string name="prefs_loc_gps_title">GPS 定位</string>
|
|
||||||
<string name="prefs_loc_gps_error">检查您的位置权限</string>
|
|
||||||
<string name="prefs_loc_input_title">进入</string>
|
|
||||||
<string name="prefs_loc_input_error">输入的位置无效</string>
|
|
||||||
<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_data_title">卫星数据更新</string>
|
|
||||||
<string name="prefs_data_entries">卫星:%s</string>
|
|
||||||
<string name="prefs_data_radios">收发器:%s</string>
|
|
||||||
<string name="prefs_data_update">在线更新</string>
|
|
||||||
<string name="prefs_data_import">导入文件</string>
|
|
||||||
<string name="prefs_data_clear">清空</string>
|
|
||||||
<string name="prefs_data_clear_success">数据清空成功</string>
|
|
||||||
<string name="prefs_data_update_success">更新成功</string>
|
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Custom data 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_net_title">网络数据输出</string>
|
|
||||||
<string name="prefs_net_switch">启用输出</string>
|
|
||||||
<string name="prefs_net_ip_hint">IP地址</string>
|
|
||||||
<string name="prefs_net_port_hint">端口</string>
|
|
||||||
|
|
||||||
<string name="prefs_bt_title">蓝牙数据输出</string>
|
|
||||||
<string name="prefs_bt_switch">启用输出</string>
|
|
||||||
<string name="prefs_bt_device_hint">蓝牙设备 ID</string>
|
|
||||||
<string name="prefs_bt_output_hint">数据格式</string>
|
|
||||||
<string name="prefs_bt_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_outro_title">我要感谢:</string>
|
|
||||||
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
|
||||||
|
|
||||||
</resources>
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<!-- Commmon -->
|
|
||||||
<string name="app_name" translatable="false">Look4Sat</string>
|
|
||||||
<string name="btn_accept">Accept</string>
|
|
||||||
<string name="btn_cancel">Cancel</string>
|
|
||||||
<string name="empty_list_message">No data to show</string>
|
|
||||||
|
|
||||||
<!-- Navigation -->
|
|
||||||
<string name="nav_sat">Satellites</string>
|
|
||||||
<string name="nav_pass">Passes</string>
|
|
||||||
<string name="nav_radar">Radar</string>
|
|
||||||
<string name="nav_map">Map</string>
|
|
||||||
<string name="nav_prefs">Settings</string>
|
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
|
||||||
<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_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 types selector.</string>
|
|
||||||
|
|
||||||
<!-- Passes screen -->
|
|
||||||
<string name="pass_filter_title">Filter passes</string>
|
|
||||||
<string name="pass_filter_elev">Minimal elevation</string>
|
|
||||||
<string name="pass_filter_hours">Hours ahead</string>
|
|
||||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </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>
|
|
||||||
<string name="pass_altitude">Altitude: %d km</string>
|
|
||||||
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
|
|
||||||
<string name="pass_empty_list_message">
|
|
||||||
Make sure your filters are correct and you have selected satellites</string>
|
|
||||||
<string name="pass_error_message">There are no upcoming satellite passes to display</string>
|
|
||||||
<string name="pass_welcome_title">Welcome to Look4Sat\!</string>
|
|
||||||
<string name="pass_welcome_message">
|
|
||||||
Make sure to set your position via GPS, Lat/Lon or QTH in Settings.
|
|
||||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
|
||||||
|
|
||||||
<!-- Radar screen -->
|
|
||||||
<string name="radar_back">Back</string>
|
|
||||||
<string name="radar_notify">Notify</string>
|
|
||||||
<string name="radar_az_text">Azimuth</string>
|
|
||||||
<string name="radar_az_value" translatable="false">%.1f°</string>
|
|
||||||
<string name="radar_el_text">Elevation</string>
|
|
||||||
<string name="radar_el_value" translatable="false">%.1f°</string>
|
|
||||||
<string name="radar_alt_text">Altitude</string>
|
|
||||||
<string name="radar_alt_value" translatable="false">%.0f km</string>
|
|
||||||
<string name="radar_dist_text">Distance</string>
|
|
||||||
<string name="radar_dist_value" translatable="false">%.0f km</string>
|
|
||||||
<string name="radar_eclipsed">Eclipsed</string>
|
|
||||||
<string name="radar_downlink">Downlink</string>
|
|
||||||
<string name="radar_uplink">Uplink</string>
|
|
||||||
<string name="radar_link_low" translatable="false">%.4f</string>
|
|
||||||
<string name="radar_link_lowHigh" translatable="false">%.4f - %.4f</string>
|
|
||||||
<string name="radar_no_link" translatable="false">- - . - -</string>
|
|
||||||
<string name="radar_no_data">This satellite has no transceivers</string>
|
|
||||||
<string name="radar_add">Add</string>
|
|
||||||
<string name="radar_data">Satellite transceivers:</string>
|
|
||||||
<string name="radar_inverted">Inverted: %s</string>
|
|
||||||
<string name="radar_mode">Mode: %s</string>
|
|
||||||
<string name="radar_string_no">No</string>
|
|
||||||
<string name="radar_string_yes">Yes</string>
|
|
||||||
<string name="radar_visible">Visible</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_azimuth">Azimuth: %.1f°</string>
|
|
||||||
<string name="map_elevation">Elevation: %.1f°</string>
|
|
||||||
<string name="map_altitude">Altitude: %.0f km</string>
|
|
||||||
<string name="map_distance">Distance: %.0f km</string>
|
|
||||||
<string name="map_latitude">Latitude: %.1f°</string>
|
|
||||||
<string name="map_longitude">Longitude: %.1f°</string>
|
|
||||||
<string name="map_qth" translatable="false">QTH: %s</string>
|
|
||||||
<string name="map_phase">Phase: %.1f°</string>
|
|
||||||
<string name="map_eclipsed">Eclipsed</string>
|
|
||||||
<string name="map_period">Period: %.0f min</string>
|
|
||||||
<string name="map_velocity">Velocity: %.2f km/s</string>
|
|
||||||
<string name="map_visibility">Visibility: %s</string>
|
|
||||||
<string name="map_visible">Visible</string>
|
|
||||||
|
|
||||||
<!-- Settings screen -->
|
|
||||||
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
|
|
||||||
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
|
|
||||||
<string name="prefs_donate_title">Donate</string>
|
|
||||||
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
|
||||||
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
|
|
||||||
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
|
|
||||||
<string name="prefs_github_title" translatable="false">GitHub</string>
|
|
||||||
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
|
||||||
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
|
||||||
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
|
|
||||||
<string name="prefs_privacy_title">Privacy Policy</string>
|
|
||||||
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
|
|
||||||
|
|
||||||
<string name="prefs_updated_title">Updated: %s</string>
|
|
||||||
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
|
||||||
|
|
||||||
<string name="prefs_loc_title">Station position</string>
|
|
||||||
<string name="prefs_loc_gps_title">GPS</string>
|
|
||||||
<string name="prefs_loc_gps_error">Check your location permissions</string>
|
|
||||||
<string name="prefs_loc_input_title">Input</string>
|
|
||||||
<string name="prefs_loc_input_error">Invalid location entered</string>
|
|
||||||
<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 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_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>
|
|
||||||
<string name="prefs_data_radios">Transceivers: %s</string>
|
|
||||||
<string name="prefs_data_update">Update</string>
|
|
||||||
<string name="prefs_data_import">Import</string>
|
|
||||||
<string name="prefs_data_clear">Clear</string>
|
|
||||||
<string name="prefs_data_clear_success">Data was cleared successfully</string>
|
|
||||||
<string name="prefs_data_update_success">Update completed successfully</string>
|
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Custom data 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_net_title">Network data output</string>
|
|
||||||
<string name="prefs_net_switch">Enable output</string>
|
|
||||||
<string name="prefs_net_ip_hint">IP address</string>
|
|
||||||
<string name="prefs_net_port_hint">Port</string>
|
|
||||||
|
|
||||||
<string name="prefs_bt_title">Bluetooth data output</string>
|
|
||||||
<string name="prefs_bt_switch">Enable output</string>
|
|
||||||
<string name="prefs_bt_device_hint">Device id</string>
|
|
||||||
<string name="prefs_bt_output_hint">Data format</string>
|
|
||||||
<string name="prefs_bt_perm_error">Check your bluetooth permission</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_outro_title">I would like to say thanks to</string>
|
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
|
||||||
• Look4Sat users and contributors!
|
|
||||||
\n• David A. B. Johnson (predict4java)
|
|
||||||
\n• Dave Moten (predict4java)
|
|
||||||
\n• Alexandru Csete (Gpredict)
|
|
||||||
\n• Dr T.S. Kelso (Celestrak)
|
|
||||||
\n• Libre Space Foundation (SatNOGS)</string>
|
|
||||||
<string name="prefs_outro_license">The app comes with no warranty</string>
|
|
||||||
|
|
||||||
</resources>
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="true" />
|
||||||
|
</network-security-config>
|
||||||
File renamed without changes.
@@ -0,0 +1,48 @@
|
|||||||
|
import org.gradle.initialization.DependenciesAccessors
|
||||||
|
import org.gradle.kotlin.dsl.support.serviceOf
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
`kotlin-dsl`
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compileOnly(libs.android.gradlePlugin)
|
||||||
|
compileOnly(libs.kotlin.gradlePlugin)
|
||||||
|
gradle.serviceOf<DependenciesAccessors>().classes.asFiles.forEach {
|
||||||
|
compileOnly(files(it.absolutePath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks {
|
||||||
|
validatePlugins {
|
||||||
|
enableStricterValidation = true
|
||||||
|
failOnWarning = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "com.rtbishop.look4sat.build_logic.convention"
|
||||||
|
|
||||||
|
gradlePlugin {
|
||||||
|
plugins {
|
||||||
|
register("applicationPlugin") {
|
||||||
|
id = libs.plugins.convention.applicationPlugin.get().pluginId
|
||||||
|
implementationClass = "com.rtbishop.look4sat.convention.ApplicationPlugin"
|
||||||
|
}
|
||||||
|
register("coreDataPlugin") {
|
||||||
|
id = libs.plugins.convention.coreDataPlugin.get().pluginId
|
||||||
|
implementationClass = "com.rtbishop.look4sat.convention.CoreDataPlugin"
|
||||||
|
}
|
||||||
|
register("coreDomainPlugin") {
|
||||||
|
id = libs.plugins.convention.coreDomainPlugin.get().pluginId
|
||||||
|
implementationClass = "com.rtbishop.look4sat.convention.CoreDomainPlugin"
|
||||||
|
}
|
||||||
|
register("corePresentationPlugin") {
|
||||||
|
id = libs.plugins.convention.corePresentationPlugin.get().pluginId
|
||||||
|
implementationClass = "com.rtbishop.look4sat.convention.CorePresentationPlugin"
|
||||||
|
}
|
||||||
|
register("featurePlugin") {
|
||||||
|
id = libs.plugins.convention.featurePlugin.get().pluginId
|
||||||
|
implementationClass = "com.rtbishop.look4sat.convention.FeaturePlugin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* 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.convention
|
||||||
|
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
|
||||||
|
@Suppress("Unused")
|
||||||
|
internal class ApplicationPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) = with(target) {
|
||||||
|
setupAndroidApp()
|
||||||
|
setupCompose()
|
||||||
|
setupKotlin()
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core:data"))
|
||||||
|
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(libs.androidx.core.splashscreen)
|
||||||
|
implementation(libs.compose.material3.adaptive)
|
||||||
|
implementation(libs.compose.navigation3)
|
||||||
|
androidTestImplementation(libs.bundles.androidTest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* 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.convention
|
||||||
|
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
|
||||||
|
@Suppress("Unused")
|
||||||
|
internal class CoreDataPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) = with(target) {
|
||||||
|
applyPlugin(libs.plugins.google.ksp)
|
||||||
|
setupAndroidLib()
|
||||||
|
setupKotlin()
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core:domain"))
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.room)
|
||||||
|
implementation(libs.androidx.room.runtime)
|
||||||
|
ksp(libs.androidx.room.compiler)
|
||||||
|
implementation(libs.kotlin.coroutines)
|
||||||
|
implementation(libs.other.okhttp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* 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.convention
|
||||||
|
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
|
||||||
|
@Suppress("Unused")
|
||||||
|
internal class CoreDomainPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) = with(target) {
|
||||||
|
applyPlugin(libs.plugins.kotlin.jvm)
|
||||||
|
applyPlugin(libs.plugins.kotlin.serialization)
|
||||||
|
setupKotlin()
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.kotlin.coroutines)
|
||||||
|
implementation(libs.kotlin.serialization)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.convention
|
||||||
|
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
|
||||||
|
@Suppress("Unused")
|
||||||
|
internal class CorePresentationPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) = with(target) {
|
||||||
|
applyPlugin(libs.plugins.kotlin.serialization)
|
||||||
|
setupAndroidLib()
|
||||||
|
setupCompose()
|
||||||
|
setupKotlin()
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core:domain"))
|
||||||
|
implementation(libs.androidx.core.splashscreen)
|
||||||
|
implementation(libs.kotlin.serialization)
|
||||||
|
implementation(libs.compose.material3.adaptive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* 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.convention
|
||||||
|
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
|
||||||
|
@Suppress("Unused")
|
||||||
|
internal class FeaturePlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) = with(target) {
|
||||||
|
setupAndroidLib()
|
||||||
|
setupCompose()
|
||||||
|
setupKotlin()
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core:domain"))
|
||||||
|
implementation(project(":core:presentation"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
/*
|
||||||
|
* 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.convention
|
||||||
|
|
||||||
|
import com.android.build.api.dsl.ApplicationExtension
|
||||||
|
import com.android.build.api.dsl.CommonExtension
|
||||||
|
import org.gradle.accessors.dm.LibrariesForLibs
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.api.artifacts.dsl.DependencyHandler
|
||||||
|
import org.gradle.api.provider.Provider
|
||||||
|
import org.gradle.kotlin.dsl.accessors.runtime.extensionOf
|
||||||
|
import org.gradle.kotlin.dsl.configure
|
||||||
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
import org.gradle.plugin.use.PluginDependency
|
||||||
|
import org.jetbrains.kotlin.gradle.dsl.kotlinExtension
|
||||||
|
|
||||||
|
internal val Project.libs
|
||||||
|
get(): LibrariesForLibs = extensionOf(this, "libs") as LibrariesForLibs
|
||||||
|
|
||||||
|
internal fun Project.applyPlugin(notation: Provider<PluginDependency>) {
|
||||||
|
pluginManager.apply(notation.get().pluginId)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun DependencyHandler.implementation(dependencyNotation: Any) =
|
||||||
|
add("implementation", dependencyNotation)
|
||||||
|
|
||||||
|
internal fun DependencyHandler.debugImplementation(dependencyNotation: Any) =
|
||||||
|
add("debugImplementation", dependencyNotation)
|
||||||
|
|
||||||
|
internal fun DependencyHandler.testImplementation(dependencyNotation: Any) =
|
||||||
|
add("testImplementation", dependencyNotation)
|
||||||
|
|
||||||
|
internal fun DependencyHandler.androidTestImplementation(dependencyNotation: Any) =
|
||||||
|
add("androidTestImplementation", dependencyNotation)
|
||||||
|
|
||||||
|
internal fun DependencyHandler.ksp(dependencyNotation: Any) =
|
||||||
|
add("ksp", dependencyNotation)
|
||||||
|
|
||||||
|
internal fun Project.setupAndroidApp() {
|
||||||
|
applyPlugin(libs.plugins.android.application)
|
||||||
|
extensions.configure<ApplicationExtension> {
|
||||||
|
namespace = libs.versions.packageName.get()
|
||||||
|
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = libs.versions.packageName.get()
|
||||||
|
minSdk = libs.versions.minSdk.get().toInt()
|
||||||
|
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||||
|
versionName = libs.versions.appVersionName.get()
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
androidResources {
|
||||||
|
generateLocaleConfig = true
|
||||||
|
localeFilters.addAll(listOf("en", "es", "ru", "si", "tr", "uk", "zh"))
|
||||||
|
}
|
||||||
|
packaging { resources { excludes += listOf("META-INF/*") } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Project.setupAndroidLib() {
|
||||||
|
applyPlugin(libs.plugins.android.library)
|
||||||
|
extensions.configure<CommonExtension> {
|
||||||
|
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||||
|
defaultConfig.minSdk = libs.versions.minSdk.get().toInt()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
androidTestImplementation(libs.bundles.androidTest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Project.setupCompose() {
|
||||||
|
applyPlugin(libs.plugins.compose.compiler)
|
||||||
|
extensions.configure<CommonExtension> {
|
||||||
|
buildFeatures.compose = true
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
implementation(platform(libs.compose.bom))
|
||||||
|
implementation(libs.bundles.composeAll)
|
||||||
|
debugImplementation(libs.bundles.composeDebug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Project.setupKotlin() {
|
||||||
|
kotlinExtension.jvmToolchain(libs.versions.jdkVersion.get().toInt())
|
||||||
|
dependencies {
|
||||||
|
testImplementation(libs.bundles.unitTest)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Configure your build environment: http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
android.disallowKotlinSourceSets=false
|
||||||
|
org.gradle.caching=true
|
||||||
|
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.workers.max=4
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
dependencyResolutionManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
versionCatalogs {
|
||||||
|
create("libs") {
|
||||||
|
from(files("../gradle/libs.versions.toml"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootProject.name = "build-logic"
|
||||||
|
include(":convention")
|
||||||
@@ -4,8 +4,10 @@ plugins {
|
|||||||
alias(libs.plugins.compose.compiler) apply false
|
alias(libs.plugins.compose.compiler) apply false
|
||||||
alias(libs.plugins.google.ksp) apply false
|
alias(libs.plugins.google.ksp) apply false
|
||||||
alias(libs.plugins.kotlin.jvm) apply false
|
alias(libs.plugins.kotlin.jvm) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register("clean", Delete::class.java) {
|
tasks.register("clean", Delete::class.java) {
|
||||||
|
description = "Cleans the build directory"
|
||||||
delete(rootProject.layout.buildDirectory)
|
delete(rootProject.layout.buildDirectory)
|
||||||
}
|
}
|
||||||
File renamed without changes.
@@ -0,0 +1,7 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.convention.coreDataPlugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.rtbishop.look4sat.core.data"
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.BLUETOOTH"
|
||||||
|
android:maxSdkVersion="30" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* 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.database
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
|
||||||
|
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatItem
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface Look4SatDao {
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM entries")
|
||||||
|
suspend fun getEntriesTotal(): Int
|
||||||
|
|
||||||
|
@Query("SELECT catnum, name, 0 as isSelected FROM entries ORDER BY name ASC")
|
||||||
|
suspend fun getEntriesList(): List<SatItem>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)")
|
||||||
|
suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertEntries(entries: List<SatEntry>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM entries")
|
||||||
|
suspend fun deleteEntries()
|
||||||
|
|
||||||
|
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
|
||||||
|
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM radios")
|
||||||
|
suspend fun getRadiosTotal(): Int
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM radios WHERE catnum = :id AND isAlive = 1")
|
||||||
|
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertRadios(radios: List<SatRadio>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM radios")
|
||||||
|
suspend fun deleteRadios()
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* 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.database
|
||||||
|
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
|
||||||
|
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
|
||||||
|
|
||||||
|
@Database(entities = [SatEntry::class, SatRadio::class], version = 1, exportSchema = false)
|
||||||
|
abstract class Look4SatDb : RoomDatabase() {
|
||||||
|
abstract fun look4SatDao(): Look4SatDao
|
||||||
|
}
|
||||||
|
|
||||||
|
//val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||||
|
// override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
// database.execSQL("CREATE TABLE entries_backup (name TEXT NOT NULL, epoch REAL NOT NULL, meanmo REAL NOT NULL, eccn REAL NOT NULL, incl REAL NOT NULL, raan REAL NOT NULL, argper REAL NOT NULL, meanan REAL NOT NULL, catnum INTEGER NOT NULL, bstar REAL NOT NULL, xincl REAL NOT NULL, xnodeo REAL NOT NULL, omegao REAL NOT NULL, xmo REAL NOT NULL, xno REAL NOT NULL, orbitalPeriod REAL NOT NULL, isDeepSpace INTEGER NOT NULL, comment TEXT, PRIMARY KEY(catnum))")
|
||||||
|
// database.execSQL("INSERT INTO entries_backup (name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, orbitalPeriod, isDeepSpace, comment) SELECT name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, 1440 / meanmo, 1440 / meanmo >= 225.0, comment FROM entries")
|
||||||
|
// database.execSQL("DROP TABLE entries")
|
||||||
|
// database.execSQL("ALTER TABLE entries_backup RENAME TO entries")
|
||||||
|
// }
|
||||||
|
//}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.database.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
|
||||||
|
@Entity(tableName = "entries", primaryKeys = ["catnum"])
|
||||||
|
data class SatEntry(
|
||||||
|
val name: String,
|
||||||
|
val epoch: Double,
|
||||||
|
val meanmo: Double,
|
||||||
|
val eccn: Double,
|
||||||
|
val incl: Double,
|
||||||
|
val raan: Double,
|
||||||
|
val argper: Double,
|
||||||
|
val meanan: Double,
|
||||||
|
val catnum: Int,
|
||||||
|
val bstar: Double
|
||||||
|
)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/*
|
||||||
|
* 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.database.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "radios")
|
||||||
|
data class SatRadio(
|
||||||
|
@PrimaryKey val uuid: String,
|
||||||
|
val info: String,
|
||||||
|
val isAlive: Boolean,
|
||||||
|
val downlinkLow: Long?,
|
||||||
|
val downlinkHigh: Long?,
|
||||||
|
val downlinkMode: String?,
|
||||||
|
val uplinkLow: Long?,
|
||||||
|
val uplinkHigh: Long?,
|
||||||
|
val uplinkMode: String?,
|
||||||
|
val isInverted: Boolean,
|
||||||
|
val catnum: Int?
|
||||||
|
)
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
/*
|
||||||
|
* 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.framework
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.bluetooth.BluetoothSocket
|
||||||
|
import android.util.Log
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class BluetoothReporter(
|
||||||
|
private val bluetoothManager: BluetoothManager,
|
||||||
|
private val reporterScope: CoroutineScope,
|
||||||
|
private val rotatorDeviceId: String,
|
||||||
|
private val frequencyDeviceId: String
|
||||||
|
) : IReporter {
|
||||||
|
|
||||||
|
private val tag = "BTReporter"
|
||||||
|
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
|
||||||
|
private val writeMutex = Mutex()
|
||||||
|
|
||||||
|
private var rotatorSocket: BluetoothSocket? = null
|
||||||
|
private var rotatorStream: OutputStream? = null
|
||||||
|
private var rotatorConnected = false
|
||||||
|
private var rotatorConnecting = false
|
||||||
|
|
||||||
|
private var frequencySocket: BluetoothSocket? = null
|
||||||
|
private var frequencyStream: OutputStream? = null
|
||||||
|
private var frequencyConnected = false
|
||||||
|
private var frequencyConnecting = false
|
||||||
|
|
||||||
|
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
|
||||||
|
reporterScope.launch {
|
||||||
|
ensureRotatorConnected()
|
||||||
|
if (!rotatorConnected) return@launch
|
||||||
|
val el = if (elevation > 0.0) elevation else 0.0
|
||||||
|
val command = format
|
||||||
|
.replace($$"$AZ", azimuth.toString())
|
||||||
|
.replace($$"$EL", el.toString())
|
||||||
|
.unescapeControlChars()
|
||||||
|
write(rotatorStream, command) { rotatorConnected = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun reportFrequency(format: String, frequency: Long) {
|
||||||
|
reporterScope.launch {
|
||||||
|
ensureFrequencyConnected()
|
||||||
|
if (!frequencyConnected) return@launch
|
||||||
|
val command = format
|
||||||
|
.replace($$"$FREQ", frequency.toString())
|
||||||
|
.unescapeControlChars()
|
||||||
|
write(frequencyStream, command) { frequencyConnected = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureRotatorConnected() {
|
||||||
|
if (rotatorConnected || rotatorConnecting || rotatorDeviceId.isBlank()) return
|
||||||
|
reporterScope.launch {
|
||||||
|
try {
|
||||||
|
rotatorConnecting = true
|
||||||
|
val device = bluetoothManager.adapter.getRemoteDevice(rotatorDeviceId)
|
||||||
|
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||||
|
socket.connect()
|
||||||
|
rotatorSocket = socket
|
||||||
|
rotatorStream = socket.outputStream
|
||||||
|
rotatorConnected = true
|
||||||
|
Log.i(tag, "Rotator connected to $rotatorDeviceId")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Rotator connect error: ${e.message}")
|
||||||
|
rotatorConnected = false
|
||||||
|
} finally {
|
||||||
|
rotatorConnecting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureFrequencyConnected() {
|
||||||
|
if (frequencyConnected || frequencyConnecting || frequencyDeviceId.isBlank()) return
|
||||||
|
reporterScope.launch {
|
||||||
|
try {
|
||||||
|
frequencyConnecting = true
|
||||||
|
val device = bluetoothManager.adapter.getRemoteDevice(frequencyDeviceId)
|
||||||
|
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||||
|
socket.connect()
|
||||||
|
frequencySocket = socket
|
||||||
|
frequencyStream = socket.outputStream
|
||||||
|
frequencyConnected = true
|
||||||
|
Log.i(tag, "Frequency connected to $frequencyDeviceId")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Frequency connect error: ${e.message}")
|
||||||
|
frequencyConnected = false
|
||||||
|
} finally {
|
||||||
|
frequencyConnecting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun write(stream: OutputStream?, data: String, onError: () -> Unit) {
|
||||||
|
try {
|
||||||
|
writeMutex.withLock {
|
||||||
|
stream?.write(data.toByteArray())
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Write error: ${e.message}")
|
||||||
|
onError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.unescapeControlChars(): String =
|
||||||
|
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
|
||||||
|
}
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
/*
|
||||||
|
* 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.framework
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
|
||||||
|
object Ft817CatProtocol {
|
||||||
|
|
||||||
|
const val CMD_SET_FREQ: Byte = 0x01
|
||||||
|
const val CMD_READ_FREQ_MODE: Byte = 0x03
|
||||||
|
const val CMD_SET_MODE: Byte = 0x07
|
||||||
|
const val CMD_PTT_ON: Byte = 0x08
|
||||||
|
const val CMD_PTT_OFF: Byte = 0x88.toByte()
|
||||||
|
const val CMD_CTCSS_MODE: Byte = 0x0A
|
||||||
|
const val CMD_CTCSS_TONE: Byte = 0x0B
|
||||||
|
|
||||||
|
const val CTCSS_ENC_ON: Byte = 0x2A
|
||||||
|
const val CTCSS_OFF: Byte = 0x8A.toByte()
|
||||||
|
|
||||||
|
val MODE_TO_BYTE: Map<String, Byte> = mapOf(
|
||||||
|
"LSB" to 0x00,
|
||||||
|
"USB" to 0x01,
|
||||||
|
"CW" to 0x02,
|
||||||
|
"CW-R" to 0x03,
|
||||||
|
"AM" to 0x04,
|
||||||
|
"FM" to 0x08,
|
||||||
|
"DIG" to 0x0A,
|
||||||
|
"PKT" to 0x0C
|
||||||
|
)
|
||||||
|
|
||||||
|
val BYTE_TO_MODE: Map<Byte, String> = MODE_TO_BYTE.entries.associate { it.value to it.key }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode frequency in Hz to 4-byte BCD with 10 Hz resolution.
|
||||||
|
* Example: 145500000 Hz → [0x14, 0x55, 0x00, 0x00]
|
||||||
|
*/
|
||||||
|
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
|
||||||
|
val freq10Hz = frequencyHz / 10
|
||||||
|
val bcd = ByteArray(4)
|
||||||
|
val digits = String.format(Locale.US, "%08d", freq10Hz)
|
||||||
|
for (i in 0 until 4) {
|
||||||
|
val high = digits[i * 2] - '0'
|
||||||
|
val low = digits[i * 2 + 1] - '0'
|
||||||
|
bcd[i] = ((high shl 4) or low).toByte()
|
||||||
|
}
|
||||||
|
return bcd
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode 4-byte BCD frequency to Hz.
|
||||||
|
*/
|
||||||
|
fun decodeFrequencyBcd(bcd: ByteArray): Long {
|
||||||
|
var freq10Hz = 0L
|
||||||
|
for (i in 0 until 4) {
|
||||||
|
val b = bcd[i].toInt() and 0xFF
|
||||||
|
val high = b shr 4
|
||||||
|
val low = b and 0x0F
|
||||||
|
freq10Hz = freq10Hz * 100 + high * 10 + low
|
||||||
|
}
|
||||||
|
return freq10Hz * 10
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode CTCSS tone frequency (in Hz, e.g. 67.0) to 2-byte BCD.
|
||||||
|
* 67.0 Hz → 670 (in 0.1 Hz) → BCD [0x06, 0x70]
|
||||||
|
*/
|
||||||
|
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
|
||||||
|
val tone01Hz = (toneHz * 10).roundToLong()
|
||||||
|
val digits = String.format(Locale.US, "%04d", tone01Hz)
|
||||||
|
val bcd = ByteArray(2)
|
||||||
|
for (i in 0 until 2) {
|
||||||
|
val high = digits[i * 2] - '0'
|
||||||
|
val low = digits[i * 2 + 1] - '0'
|
||||||
|
bcd[i] = ((high shl 4) or low).toByte()
|
||||||
|
}
|
||||||
|
return bcd
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildSetFreqCommand(frequencyHz: Long): ByteArray {
|
||||||
|
val bcd = encodeFrequencyBcd(frequencyHz)
|
||||||
|
return byteArrayOf(bcd[0], bcd[1], bcd[2], bcd[3], CMD_SET_FREQ)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildSetModeCommand(mode: String): ByteArray? {
|
||||||
|
val modeByte = MODE_TO_BYTE[mode.uppercase(Locale.US)] ?: return null
|
||||||
|
return byteArrayOf(modeByte, 0x00, 0x00, 0x00, CMD_SET_MODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildReadFreqModeCommand(): ByteArray {
|
||||||
|
return byteArrayOf(0x00, 0x00, 0x00, 0x00, CMD_READ_FREQ_MODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildPttOnCommand(): ByteArray {
|
||||||
|
return byteArrayOf(0x00, 0x00, 0x00, 0x00, CMD_PTT_ON)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildPttOffCommand(): ByteArray {
|
||||||
|
return byteArrayOf(0x00, 0x00, 0x00, 0x00, CMD_PTT_OFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildCtcssModeCommand(enabled: Boolean): ByteArray {
|
||||||
|
val sub = if (enabled) CTCSS_ENC_ON else CTCSS_OFF
|
||||||
|
return byteArrayOf(sub, 0x00, 0x00, 0x00, CMD_CTCSS_MODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildSetCtcssToneCommand(toneHz: Double): ByteArray {
|
||||||
|
val bcd = encodeCtcssToneBcd(toneHz)
|
||||||
|
return byteArrayOf(bcd[0], bcd[1], 0x00, 0x00, CMD_CTCSS_TONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the 5-byte response from a READ FREQ+MODE command.
|
||||||
|
* Returns (frequencyHz, modeString) or null if parsing fails.
|
||||||
|
*/
|
||||||
|
fun parseReadResponse(response: ByteArray): Pair<Long, String>? {
|
||||||
|
if (response.size < 5) return null
|
||||||
|
val freqBcd = response.copyOfRange(0, 4)
|
||||||
|
val frequencyHz = decodeFrequencyBcd(freqBcd)
|
||||||
|
val modeByte = response[4]
|
||||||
|
val mode = BYTE_TO_MODE[modeByte] ?: return null
|
||||||
|
return Pair(frequencyHz, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
/*
|
||||||
|
* 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.framework
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.bluetooth.BluetoothSocket
|
||||||
|
import android.util.Log
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
class Ft817Controller(
|
||||||
|
private val bluetoothManager: BluetoothManager,
|
||||||
|
private val deviceAddress: String
|
||||||
|
) : IRadioController {
|
||||||
|
|
||||||
|
private val tag = "FT817"
|
||||||
|
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
|
||||||
|
private val ioMutex = Mutex()
|
||||||
|
private val commandDelayMs = 200L
|
||||||
|
private val maxAckReadFailures = 3
|
||||||
|
|
||||||
|
private var socket: BluetoothSocket? = null
|
||||||
|
private var outputStream: OutputStream? = null
|
||||||
|
private var inputStream: InputStream? = null
|
||||||
|
private var ackReadFailureCount = 0
|
||||||
|
|
||||||
|
override var isConnected: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
if (isConnected) return@withContext true
|
||||||
|
if (deviceAddress.isBlank()) return@withContext false
|
||||||
|
try {
|
||||||
|
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
|
||||||
|
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||||
|
btSocket.connect()
|
||||||
|
socket = btSocket
|
||||||
|
outputStream = btSocket.outputStream
|
||||||
|
inputStream = btSocket.inputStream
|
||||||
|
ackReadFailureCount = 0
|
||||||
|
isConnected = true
|
||||||
|
Log.i(tag, "Connected to $deviceAddress")
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Connect error: ${e.message}")
|
||||||
|
isConnected = false
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun disconnect() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
inputStream?.close()
|
||||||
|
outputStream?.close()
|
||||||
|
socket?.close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Disconnect error: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
inputStream = null
|
||||||
|
outputStream = null
|
||||||
|
socket = null
|
||||||
|
ackReadFailureCount = 0
|
||||||
|
isConnected = false
|
||||||
|
Log.i(tag, "Disconnected from $deviceAddress")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock {
|
||||||
|
sendCommandWithAck(Ft817CatProtocol.buildSetFreqCommand(frequencyHz))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setMode(mode: String): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val cmd = Ft817CatProtocol.buildSetModeCommand(mode) ?: return@withContext false
|
||||||
|
ioMutex.withLock { sendCommandWithAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setCtcssMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock {
|
||||||
|
sendCommandWithAck(Ft817CatProtocol.buildCtcssModeCommand(enabled))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setCtcssTone(toneHz: Double): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock {
|
||||||
|
sendCommandWithAck(Ft817CatProtocol.buildSetCtcssToneCommand(toneHz))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readFrequencyAndMode(): Pair<Long, String>? = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock {
|
||||||
|
val sent = sendCommand(Ft817CatProtocol.buildReadFreqModeCommand())
|
||||||
|
if (!sent) return@withContext null
|
||||||
|
delay(commandDelayMs.milliseconds)
|
||||||
|
val response = readResponse() ?: return@withContext null
|
||||||
|
Ft817CatProtocol.parseReadResponse(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun pttOn(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock { sendCommandWithAck(Ft817CatProtocol.buildPttOnCommand()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun pttOff(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock { sendCommandWithAck(Ft817CatProtocol.buildPttOffCommand()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendCommand(bytes: ByteArray): Boolean {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
outputStream?.write(bytes) ?: return@withContext false
|
||||||
|
outputStream?.flush()
|
||||||
|
delay(commandDelayMs.milliseconds)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Send error: ${e.message}")
|
||||||
|
isConnected = false
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send command and read the 1-byte ACK response (0x00 = OK). */
|
||||||
|
private suspend fun sendCommandWithAck(bytes: ByteArray): Boolean {
|
||||||
|
if (!sendCommand(bytes)) return false
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val ack = inputStream?.read() ?: run {
|
||||||
|
ackReadFailureCount = 0
|
||||||
|
isConnected = false
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
if (ack < 0) {
|
||||||
|
ackReadFailureCount = 0
|
||||||
|
Log.i(tag, "ACK stream closed by remote device")
|
||||||
|
isConnected = false
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
ackReadFailureCount = 0
|
||||||
|
ack == 0x00
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ackReadFailureCount += 1
|
||||||
|
Log.w(tag, "ACK read error (${ackReadFailureCount}/$maxAckReadFailures): ${e.message}")
|
||||||
|
if (ackReadFailureCount >= maxAckReadFailures) {
|
||||||
|
Log.e(tag, "Too many ACK read errors, marking radio disconnected")
|
||||||
|
isConnected = false
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true // Command was sent, treat transient ACK read failures as best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun readResponse(): ByteArray? {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val responseSize = 5
|
||||||
|
val buffer = ByteArray(responseSize)
|
||||||
|
var read = 0
|
||||||
|
while (read < responseSize) {
|
||||||
|
val count = inputStream?.read(buffer, read, responseSize - read) ?: run {
|
||||||
|
isConnected = false
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
if (count < 0) {
|
||||||
|
Log.i(tag, "Response stream closed by remote device")
|
||||||
|
isConnected = false
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
read += count
|
||||||
|
}
|
||||||
|
buffer
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Read error: ${e.message}")
|
||||||
|
isConnected = false
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
/*
|
||||||
|
* 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.framework
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.bluetooth.BluetoothSocket
|
||||||
|
import android.util.Log
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icom IC-705 CI-V controller over Bluetooth SPP.
|
||||||
|
*
|
||||||
|
* The IC-705 emits broadcast frames continuously (band scope, UTC, signal
|
||||||
|
* level, …). A reply to any command we send may therefore be buried in
|
||||||
|
* that noise. All response reads drain up to [ACK_TIMEOUT_MS] and scan the
|
||||||
|
* entire accumulated buffer for the frame we expect rather than assuming
|
||||||
|
* the very next byte is the response.
|
||||||
|
*/
|
||||||
|
class Ic705Controller(
|
||||||
|
private val bluetoothManager: BluetoothManager,
|
||||||
|
private val deviceAddress: String
|
||||||
|
) : IRadioController {
|
||||||
|
|
||||||
|
private val tag = "IC705"
|
||||||
|
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
|
||||||
|
private val ioMutex = Mutex()
|
||||||
|
|
||||||
|
/** Time budget (ms) to wait for a response amid broadcast noise. */
|
||||||
|
private val ACK_TIMEOUT_MS = 500L
|
||||||
|
/** Polling interval while draining the input buffer. */
|
||||||
|
private val POLL_INTERVAL_MS = 20L
|
||||||
|
/** Small pause after writing a command before reading the response. */
|
||||||
|
private val WRITE_SETTLE_MS = 50L
|
||||||
|
|
||||||
|
private var socket: BluetoothSocket? = null
|
||||||
|
private var outputStream: OutputStream? = null
|
||||||
|
private var inputStream: InputStream? = null
|
||||||
|
|
||||||
|
override var isConnected: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
// ── Connection ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
if (isConnected) return@withContext true
|
||||||
|
if (deviceAddress.isBlank()) return@withContext false
|
||||||
|
try {
|
||||||
|
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
|
||||||
|
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||||
|
btSocket.connect()
|
||||||
|
socket = btSocket
|
||||||
|
outputStream = btSocket.outputStream
|
||||||
|
inputStream = btSocket.inputStream
|
||||||
|
isConnected = true
|
||||||
|
// Enter VFO mode — frequency/mode commands return FA if the radio
|
||||||
|
// is in memory-channel mode. Safe to send regardless of current state.
|
||||||
|
Log.i(tag, "Connected to $deviceAddress — entering VFO mode")
|
||||||
|
val vfoCmd = IcomCivProtocol.buildEnterVfoModeCommand()
|
||||||
|
Log.d(tag, "CMD enterVfoMode → ${IcomCivProtocol.toHex(vfoCmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(vfoCmd) }
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Connect error: ${e.message}")
|
||||||
|
isConnected = false
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun disconnect() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
inputStream?.close()
|
||||||
|
outputStream?.close()
|
||||||
|
socket?.close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Disconnect error: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
inputStream = null
|
||||||
|
outputStream = null
|
||||||
|
socket = null
|
||||||
|
isConnected = false
|
||||||
|
Log.i(tag, "Disconnected from $deviceAddress")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IRadioController – standard operations ──────────────────────────────
|
||||||
|
|
||||||
|
override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(tag, "setFrequency: ${frequencyHz}Hz")
|
||||||
|
ioMutex.withLock {
|
||||||
|
val cmd = IcomCivProtocol.buildSetFreqCommand(frequencyHz)
|
||||||
|
Log.d(tag, "CMD setFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
sendAndWaitAck(cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setMode(mode: String): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val cmd = IcomCivProtocol.buildSetModeCommand(mode) ?: run {
|
||||||
|
Log.w(tag, "setMode: unknown mode '$mode'")
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
Log.d(tag, "setMode: $mode")
|
||||||
|
Log.d(tag, "CMD setMode → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setCtcssMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(tag, "setCtcssMode: $enabled")
|
||||||
|
val cmd = IcomCivProtocol.buildCtcssModeCommand(enabled)
|
||||||
|
Log.d(tag, "CMD ctcssMode → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setCtcssTone(toneHz: Double): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(tag, "setCtcssTone: ${toneHz}Hz")
|
||||||
|
val cmd = IcomCivProtocol.buildSetCtcssToneCommand(toneHz)
|
||||||
|
Log.d(tag, "CMD ctcssTone → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readFrequencyAndMode(): Pair<Long, String>? = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock {
|
||||||
|
val cmd = IcomCivProtocol.buildReadFreqCommand()
|
||||||
|
Log.d(tag, "CMD readFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_READ_FREQ) ?: return@withContext null
|
||||||
|
// Read-freq reply payload: [cmd byte already stripped by parseResponse] [5 freq bytes] [mode] [filter]
|
||||||
|
IcomCivProtocol.parseFreqModePayload(payload).also {
|
||||||
|
if (it != null) Log.d(tag, "readFreqMode: ${it.first}Hz, ${it.second}")
|
||||||
|
else Log.w(tag, "readFreqMode: parse failed, payload=${IcomCivProtocol.toHex(payload)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun pttOn(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.w(tag, "pttOn: not used for IC-705")
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun pttOff(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.w(tag, "pttOff: not used for IC-705")
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IRadioController – IC-705 extended operations ───────────────────────
|
||||||
|
|
||||||
|
/** Select the band for [frequencyHz] via CMD 0x1A sub 0x00 (band stacking register). */
|
||||||
|
override suspend fun setBand(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val cmd = IcomCivProtocol.buildBandSelectCommand(frequencyHz) ?: run {
|
||||||
|
Log.w(tag, "setBand: no band code for ${frequencyHz}Hz — skipping")
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
Log.d(tag, "CMD setBand (${frequencyHz}Hz) → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select VFO-A (main/RX) or VFO-B (sub/TX). */
|
||||||
|
override suspend fun setVfo(vfoA: Boolean): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val cmd = if (vfoA) IcomCivProtocol.buildSelectVfoACommand()
|
||||||
|
else IcomCivProtocol.buildSelectVfoBCommand()
|
||||||
|
Log.d(tag, "CMD selectVFO${if (vfoA) "A" else "B"} → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable SPLIT mode (TX on sub-VFO while listening on main VFO).
|
||||||
|
*/
|
||||||
|
override suspend fun setSplitMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val cmd = IcomCivProtocol.buildSplitModeCommand(enabled)
|
||||||
|
Log.d(tag, "CMD split ${if (enabled) "ON" else "OFF"} → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the frequency of the **currently active** VFO (CMD 0x25 sub 0x00).
|
||||||
|
* In split mode the radio automatically switches active VFO on PTT, so
|
||||||
|
* always writing to the active VFO is the correct strategy.
|
||||||
|
*/
|
||||||
|
override suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(tag, "setWorkingFrequency (0x25/00): ${frequencyHz}Hz")
|
||||||
|
val cmd = IcomCivProtocol.buildSetWorkingFreqCommand(frequencyHz)
|
||||||
|
Log.d(tag, "CMD setWorkingFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set TX VFO frequency via CMD 0x25 sub 0x01 (unselected VFO).
|
||||||
|
* Sent every tracking cycle in split mode alongside [setWorkingFrequency].
|
||||||
|
*/
|
||||||
|
override suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(tag, "setTxVfoFrequency (0x25/01): ${frequencyHz}Hz")
|
||||||
|
val cmd = IcomCivProtocol.buildSetUnselectedVfoFreqCommand(frequencyHz)
|
||||||
|
Log.d(tag, "CMD setTxVfoFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the frequency of the currently active VFO (CMD 0x25 sub 0x00).
|
||||||
|
* Used for tuning detection in split mode.
|
||||||
|
*/
|
||||||
|
override suspend fun readWorkingFrequency(): Long? = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock {
|
||||||
|
val cmd = IcomCivProtocol.buildReadWorkingFreqCommand()
|
||||||
|
Log.d(tag, "CMD readWorkingFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_SELECTED_VFO_FREQ) ?: return@withContext null
|
||||||
|
// Response payload: [sub] [5 freq bytes] — CMD byte already stripped by parseResponse
|
||||||
|
Log.d(tag, "readWorkingFreq: got ${payload.size} bytes: ${IcomCivProtocol.toHex(payload)}")
|
||||||
|
if (payload.size < 6) {
|
||||||
|
Log.w(tag, "readWorkingFreq: payload too short (${payload.size} bytes)")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
val freqBcd = payload.sliceArray(1..5)
|
||||||
|
val freq = IcomCivProtocol.decodeFrequencyBcd(freqBcd)
|
||||||
|
Log.d(tag, "readWorkingFreq: ${freq}Hz")
|
||||||
|
freq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the frequency of the inactive/TX VFO (CMD 0x25 sub 0x01).
|
||||||
|
* Used for tuning detection in split mode.
|
||||||
|
*/
|
||||||
|
override suspend fun readTxVfoFrequency(): Long? = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock {
|
||||||
|
val cmd = IcomCivProtocol.buildReadTxVfoFreqCommand()
|
||||||
|
Log.d(tag, "CMD readTxVfoFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_SELECTED_VFO_FREQ) ?: return@withContext null
|
||||||
|
// Response payload: [sub] [5 freq bytes] — CMD byte already stripped by parseResponse
|
||||||
|
Log.d(tag, "readTxVfoFreq: got ${payload.size} bytes: ${IcomCivProtocol.toHex(payload)}")
|
||||||
|
if (payload.size < 6) {
|
||||||
|
Log.w(tag, "readTxVfoFreq: payload too short (${payload.size} bytes)")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
val freqBcd = payload.sliceArray(1..5)
|
||||||
|
val freq = IcomCivProtocol.decodeFrequencyBcd(freqBcd)
|
||||||
|
Log.d(tag, "readTxVfoFreq: ${freq}Hz")
|
||||||
|
freq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal I/O helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write [cmd] to the radio and drain the input stream for up to
|
||||||
|
* [ACK_TIMEOUT_MS], looking for an OK/NG acknowledgement frame.
|
||||||
|
*/
|
||||||
|
private suspend fun sendAndWaitAck(cmd: ByteArray): Boolean {
|
||||||
|
if (!write(cmd)) return false
|
||||||
|
delay(WRITE_SETTLE_MS)
|
||||||
|
val buf = drainWithTimeout(ACK_TIMEOUT_MS)
|
||||||
|
val ok = IcomCivProtocol.containsAck(buf)
|
||||||
|
if (!ok) Log.w(tag, "ACK not found in ${buf.size} bytes: ${IcomCivProtocol.toHex(buf)}")
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write [cmd] to the radio and drain the input stream for up to
|
||||||
|
* [ACK_TIMEOUT_MS], scanning for a response frame carrying [expectCmd].
|
||||||
|
* Returns the payload bytes of that frame, or null on timeout/error.
|
||||||
|
*/
|
||||||
|
private suspend fun sendAndReadResponse(cmd: ByteArray, expectCmd: Byte): ByteArray? {
|
||||||
|
if (!write(cmd)) return null
|
||||||
|
delay(WRITE_SETTLE_MS)
|
||||||
|
val buf = drainWithTimeout(ACK_TIMEOUT_MS)
|
||||||
|
val response = IcomCivProtocol.parseResponse(buf, expectCmd)
|
||||||
|
if (response == null) {
|
||||||
|
Log.w(tag, "No response for cmd 0x${String.format("%02X", expectCmd.toInt() and 0xFF)} " +
|
||||||
|
"in ${buf.size} bytes: ${IcomCivProtocol.toHex(buf)}")
|
||||||
|
}
|
||||||
|
return response?.payload
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drain whatever bytes the radio has buffered within a [timeoutMs] window.
|
||||||
|
* Exits early as soon as a complete CI-V frame addressed to us is present
|
||||||
|
* in the buffer (i.e., FE FE E0 A4 … FD), so we don't waste the remaining
|
||||||
|
* timeout on responses that already arrived.
|
||||||
|
*/
|
||||||
|
private suspend fun drainWithTimeout(timeoutMs: Long): ByteArray {
|
||||||
|
val result = mutableListOf<Byte>()
|
||||||
|
val deadline = System.currentTimeMillis() + timeoutMs
|
||||||
|
val stream = inputStream ?: return ByteArray(0)
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
try {
|
||||||
|
val available = stream.available()
|
||||||
|
if (available > 0) {
|
||||||
|
val chunk = ByteArray(available)
|
||||||
|
val read = stream.read(chunk)
|
||||||
|
if (read > 0) {
|
||||||
|
result.addAll(chunk.take(read))
|
||||||
|
// Exit early once we have a complete frame for us
|
||||||
|
if (hasCompleteFrameForUs(result)) break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delay(POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Drain error: ${e.message}")
|
||||||
|
isConnected = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if [buf] contains a complete CI-V frame addressed to the
|
||||||
|
* controller (FE FE [ADDR_CTRL] [ADDR_IC705] … FD).
|
||||||
|
* CI-V data bytes cannot be 0xFD, so the first 0xFD after the header is
|
||||||
|
* always the frame terminator.
|
||||||
|
*/
|
||||||
|
private fun hasCompleteFrameForUs(buf: List<Byte>): Boolean {
|
||||||
|
var i = 0
|
||||||
|
while (i < buf.size - 4) {
|
||||||
|
if (buf[i] == IcomCivProtocol.PREAMBLE &&
|
||||||
|
buf[i + 1] == IcomCivProtocol.PREAMBLE &&
|
||||||
|
buf[i + 2] == IcomCivProtocol.ADDR_CTRL &&
|
||||||
|
buf[i + 3] == IcomCivProtocol.ADDR_IC705
|
||||||
|
) {
|
||||||
|
for (k in i + 4 until buf.size) {
|
||||||
|
if (buf[k] == IcomCivProtocol.END_OF_MSG) return true
|
||||||
|
}
|
||||||
|
return false // header found but no FD yet
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun write(bytes: ByteArray): Boolean {
|
||||||
|
return try {
|
||||||
|
outputStream?.write(bytes)
|
||||||
|
outputStream?.flush()
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Write error: ${e.message}")
|
||||||
|
isConnected = false
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
/*
|
||||||
|
* 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.framework
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icom CI-V protocol encoder/decoder for the IC-705.
|
||||||
|
*
|
||||||
|
* Frame structure:
|
||||||
|
* FE FE <DEST> <SRC> <CMD> [<SUB>] [<DATA...>] FD
|
||||||
|
*
|
||||||
|
* IC-705 default CI-V address : 0xA4
|
||||||
|
* Controller (us) address : 0xE0
|
||||||
|
*/
|
||||||
|
object IcomCivProtocol {
|
||||||
|
|
||||||
|
// ── Framing constants ──────────────────────────────────────────────────
|
||||||
|
const val PREAMBLE: Byte = 0xFE.toByte()
|
||||||
|
const val END_OF_MSG: Byte = 0xFD.toByte()
|
||||||
|
const val ACK_OK: Byte = 0xFB.toByte()
|
||||||
|
const val ACK_NG: Byte = 0xFA.toByte()
|
||||||
|
|
||||||
|
// ── Address constants ──────────────────────────────────────────────────
|
||||||
|
/** Default CI-V address of the IC-705. */
|
||||||
|
const val ADDR_IC705: Byte = 0xA4.toByte()
|
||||||
|
/** Default CI-V address of the controller (us). */
|
||||||
|
const val ADDR_CTRL: Byte = 0xE0.toByte()
|
||||||
|
|
||||||
|
// ── Command bytes ──────────────────────────────────────────────────────
|
||||||
|
/** Read operating frequency (main VFO). */
|
||||||
|
const val CMD_READ_FREQ: Byte = 0x03
|
||||||
|
/** Set operating frequency (main VFO). */
|
||||||
|
const val CMD_SET_FREQ: Byte = 0x05
|
||||||
|
/** Set operating mode. */
|
||||||
|
const val CMD_SET_MODE: Byte = 0x06
|
||||||
|
/** Select VFO / memory. */
|
||||||
|
const val CMD_SELECT_VFO: Byte = 0x07
|
||||||
|
/**
|
||||||
|
* Select operating mode (VFO vs memory-channel).
|
||||||
|
* Sub 0x00 = VFO mode. Must be sent after connect if the radio is in
|
||||||
|
* memory-channel mode — frequency/mode commands return FA until it is.
|
||||||
|
*/
|
||||||
|
const val CMD_SELECT_OP_MODE: Byte = 0x08
|
||||||
|
/** Set repeater duplex / SPLIT. */
|
||||||
|
const val CMD_DUPLEX_SPLIT: Byte = 0x0F
|
||||||
|
/** Band stacking register / band select (sub 0x00 = select, data = BCD band number). */
|
||||||
|
const val CMD_BAND_SELECT: Byte = 0x1A
|
||||||
|
/** Read/write CTCSS tone frequency. */
|
||||||
|
const val CMD_CTCSS_TONE: Byte = 0x1B
|
||||||
|
/** Read/write misc settings (used for enabling CTCSS encode). */
|
||||||
|
const val CMD_MISC_SETTING: Byte = 0x16
|
||||||
|
/** Read/write selected-VFO frequency (cmd 0x25). */
|
||||||
|
const val CMD_SELECTED_VFO_FREQ: Byte = 0x25
|
||||||
|
|
||||||
|
// ── Sub-command bytes ──────────────────────────────────────────────────
|
||||||
|
/** Sub for CMD_SELECT_VFO: select VFO-A (main). */
|
||||||
|
const val SUB_VFO_A: Byte = 0x00
|
||||||
|
/** Sub for CMD_SELECT_VFO: select VFO-B (sub). */
|
||||||
|
const val SUB_VFO_B: Byte = 0x01
|
||||||
|
/** Sub for CMD_DUPLEX_SPLIT: simplex / split OFF. */
|
||||||
|
const val SUB_SPLIT_OFF: Byte = 0x00
|
||||||
|
/** Sub for CMD_DUPLEX_SPLIT: SPLIT ON. */
|
||||||
|
const val SUB_SPLIT_ON: Byte = 0x01
|
||||||
|
/** Sub for CMD_SELECTED_VFO_FREQ: selected (active) VFO frequency. */
|
||||||
|
const val SUB_SELECTED_VFO: Byte = 0x00
|
||||||
|
/** Sub for CMD_SELECTED_VFO_FREQ: unselected (inactive / TX in split) VFO frequency. */
|
||||||
|
const val SUB_UNSELECTED_VFO: Byte = 0x01
|
||||||
|
/** Sub for CMD_MISC_SETTING: CTCSS/DTCS tone squelch. */
|
||||||
|
const val SUB_CTCSS_SETTING: Byte = 0x42.toByte()
|
||||||
|
|
||||||
|
// ── Mode bytes ────────────────────────────────────────────────────────
|
||||||
|
/** Maps mode strings (upper-case) → IC-705 mode bytes. */
|
||||||
|
val MODE_TO_BYTE: Map<String, Byte> = mapOf(
|
||||||
|
"LSB" to 0x00,
|
||||||
|
"USB" to 0x01,
|
||||||
|
"AM" to 0x02,
|
||||||
|
"CW" to 0x03,
|
||||||
|
"RTTY" to 0x04,
|
||||||
|
"FM" to 0x05,
|
||||||
|
"WFM" to 0x06,
|
||||||
|
"CW-R" to 0x07,
|
||||||
|
"RTTY-R" to 0x08,
|
||||||
|
"DV" to 0x12,
|
||||||
|
"AFSK" to 0x05 // AFSK uses FM modulation
|
||||||
|
)
|
||||||
|
|
||||||
|
val BYTE_TO_MODE: Map<Byte, String> = MODE_TO_BYTE.entries.associate { it.value to it.key }
|
||||||
|
|
||||||
|
// ── Frequency BCD encoding ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a frequency in Hz to the IC-705's 5-byte BCD format.
|
||||||
|
*
|
||||||
|
* The IC-705 uses 5 bytes, LSB pair first, with 1 Hz resolution.
|
||||||
|
* Example: 145,500,000 Hz → "0145500000" → pairs LSB→MSB:
|
||||||
|
* [00, 00, 50, 45, 01]
|
||||||
|
*/
|
||||||
|
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
|
||||||
|
val digits = String.format(Locale.US, "%010d", frequencyHz)
|
||||||
|
val bcd = ByteArray(5)
|
||||||
|
for (i in 0 until 5) {
|
||||||
|
// digits are MSB first; we want pair index 0 = LSB pair
|
||||||
|
val pairIndex = 4 - i
|
||||||
|
val high = digits[pairIndex * 2] - '0'
|
||||||
|
val low = digits[pairIndex * 2 + 1] - '0'
|
||||||
|
bcd[i] = ((high shl 4) or low).toByte()
|
||||||
|
}
|
||||||
|
return bcd
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode 5-byte BCD frequency (LSB pair first) to Hz.
|
||||||
|
*/
|
||||||
|
fun decodeFrequencyBcd(bcd: ByteArray): Long {
|
||||||
|
// Build digit string MSB→LSB by reversing the byte order
|
||||||
|
var freqHz = 0L
|
||||||
|
for (i in 4 downTo 0) {
|
||||||
|
val b = bcd[i].toInt() and 0xFF
|
||||||
|
val high = b shr 4
|
||||||
|
val low = b and 0x0F
|
||||||
|
freqHz = freqHz * 100 + high * 10 + low
|
||||||
|
}
|
||||||
|
return freqHz
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a CTCSS tone (Hz, e.g. 67.0) to 2-byte BCD (0.1 Hz resolution).
|
||||||
|
* 67.0 → 670 (tenths of Hz) → BCD bytes [0x06, 0x70].
|
||||||
|
*/
|
||||||
|
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
|
||||||
|
val tone01 = (toneHz * 10).toLong()
|
||||||
|
val digits = String.format(Locale.US, "%04d", tone01)
|
||||||
|
return byteArrayOf(
|
||||||
|
((digits[0] - '0') shl 4 or (digits[1] - '0')).toByte(),
|
||||||
|
((digits[2] - '0') shl 4 or (digits[3] - '0')).toByte()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Message builders ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Wrap payload bytes in a CI-V frame: FE FE DEST SRC ... FD. */
|
||||||
|
private fun frame(vararg payload: Byte): ByteArray {
|
||||||
|
return byteArrayOf(PREAMBLE, PREAMBLE, ADDR_IC705, ADDR_CTRL) +
|
||||||
|
payload +
|
||||||
|
byteArrayOf(END_OF_MSG)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set operating frequency via CMD 0x05 (main VFO). */
|
||||||
|
fun buildSetFreqCommand(frequencyHz: Long): ByteArray {
|
||||||
|
return frame(CMD_SET_FREQ, *encodeFrequencyBcd(frequencyHz))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set selected-VFO frequency via CMD 0x25 sub 0x00.
|
||||||
|
* This updates whichever VFO is currently active (RX or TX after split).
|
||||||
|
*/
|
||||||
|
fun buildSetWorkingFreqCommand(frequencyHz: Long): ByteArray {
|
||||||
|
return frame(CMD_SELECTED_VFO_FREQ, SUB_SELECTED_VFO, *encodeFrequencyBcd(frequencyHz))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set unselected-VFO frequency via CMD 0x25 sub 0x01.
|
||||||
|
* In split mode while PTT is pressed the IC-705 makes VFO-B active, so
|
||||||
|
* this command targets VFO-A (the RX VFO) — and vice-versa when in RX.
|
||||||
|
* Use this to update the TX VFO when PTT is on.
|
||||||
|
*/
|
||||||
|
fun buildSetUnselectedVfoFreqCommand(frequencyHz: Long): ByteArray {
|
||||||
|
return frame(CMD_SELECTED_VFO_FREQ, SUB_UNSELECTED_VFO, *encodeFrequencyBcd(frequencyHz))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read operating frequency (CMD 0x03). */
|
||||||
|
fun buildReadFreqCommand(): ByteArray = frame(CMD_READ_FREQ)
|
||||||
|
|
||||||
|
/** Read selected (active) VFO frequency (CMD 0x25 sub 0x00). */
|
||||||
|
fun buildReadWorkingFreqCommand(): ByteArray = frame(CMD_SELECTED_VFO_FREQ, SUB_SELECTED_VFO)
|
||||||
|
|
||||||
|
/** Read unselected (inactive/TX in split) VFO frequency (CMD 0x25 sub 0x01). */
|
||||||
|
fun buildReadTxVfoFreqCommand(): ByteArray = frame(CMD_SELECTED_VFO_FREQ, SUB_UNSELECTED_VFO)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select band via CMD 0x1A sub 0x00.
|
||||||
|
* Band codes are BCD-numbered: 1=160m, 2=80m, …, 9=10m, 0x10=6m, 0x11=2m, 0x12=70cm, 0x13=23cm.
|
||||||
|
* Returns null if [frequencyHz] doesn't fall in a known amateur band.
|
||||||
|
*/
|
||||||
|
fun buildBandSelectCommand(frequencyHz: Long): ByteArray? {
|
||||||
|
val code = bandCodeForFrequency(frequencyHz) ?: return null
|
||||||
|
return frame(CMD_BAND_SELECT, 0x00, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a frequency in Hz to the IC-705 band stacking register code.
|
||||||
|
* Codes are BCD (band number in decimal expressed as hex nibbles).
|
||||||
|
*/
|
||||||
|
fun bandCodeForFrequency(frequencyHz: Long): Byte? = when {
|
||||||
|
frequencyHz in 1_800_000L ..1_999_999L -> 0x01 // 160 m
|
||||||
|
frequencyHz in 3_500_000L ..3_999_999L -> 0x02 // 80 m
|
||||||
|
frequencyHz in 7_000_000L ..7_299_999L -> 0x03 // 40 m
|
||||||
|
frequencyHz in 10_100_000L ..10_149_999L -> 0x04 // 30 m
|
||||||
|
frequencyHz in 14_000_000L ..14_349_999L -> 0x05 // 20 m
|
||||||
|
frequencyHz in 18_068_000L ..18_167_999L -> 0x06 // 17 m
|
||||||
|
frequencyHz in 21_000_000L ..21_449_999L -> 0x07 // 15 m
|
||||||
|
frequencyHz in 24_890_000L ..24_989_999L -> 0x08 // 12 m
|
||||||
|
frequencyHz in 28_000_000L ..29_699_999L -> 0x09 // 10 m
|
||||||
|
frequencyHz in 50_000_000L ..53_999_999L -> 0x10 // 6 m (BCD 10)
|
||||||
|
frequencyHz in 144_000_000L ..147_999_999L -> 0x11 // 2 m (BCD 11)
|
||||||
|
frequencyHz in 420_000_000L ..449_999_999L -> 0x12 // 70 cm (BCD 12)
|
||||||
|
frequencyHz in 1_240_000_000L ..1_299_999_999L -> 0x13 // 23 cm (BCD 13)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set operating mode (CMD 0x06). Filter byte is omitted — radio uses its default filter for the mode. */
|
||||||
|
fun buildSetModeCommand(mode: String): ByteArray? {
|
||||||
|
val modeByte = MODE_TO_BYTE[mode.uppercase(Locale.US)] ?: return null
|
||||||
|
return frame(CMD_SET_MODE, modeByte)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select VFO-A (CMD 0x07 sub 0x00). */
|
||||||
|
fun buildSelectVfoACommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_A)
|
||||||
|
|
||||||
|
/** Select VFO-B (CMD 0x07 sub 0x01). */
|
||||||
|
fun buildSelectVfoBCommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_B)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter VFO operating mode (CMD 0x08 sub 0x00).
|
||||||
|
* Sent after connect — if the radio is in memory-channel mode frequency
|
||||||
|
* and mode commands return FA until this is issued.
|
||||||
|
*/
|
||||||
|
fun buildEnterVfoModeCommand(): ByteArray = frame(CMD_SELECT_OP_MODE, 0x00)
|
||||||
|
|
||||||
|
/** Enable or disable SPLIT mode (CMD 0x0F). */
|
||||||
|
fun buildSplitModeCommand(enable: Boolean): ByteArray {
|
||||||
|
val sub = if (enable) SUB_SPLIT_ON else SUB_SPLIT_OFF
|
||||||
|
return frame(CMD_DUPLEX_SPLIT, sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable CTCSS encode (CMD 0x16 sub 0x42).
|
||||||
|
* 0x01 = CTCSS encoder ON, 0x00 = OFF.
|
||||||
|
*/
|
||||||
|
fun buildCtcssModeCommand(enabled: Boolean): ByteArray {
|
||||||
|
val value: Byte = if (enabled) 0x01 else 0x00
|
||||||
|
return frame(CMD_MISC_SETTING, SUB_CTCSS_SETTING, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set CTCSS tone frequency (CMD 0x1B sub 0x00).
|
||||||
|
*/
|
||||||
|
fun buildSetCtcssToneCommand(toneHz: Double): ByteArray {
|
||||||
|
val bcd = encodeCtcssToneBcd(toneHz)
|
||||||
|
return frame(CMD_CTCSS_TONE, 0x00, *bcd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Response parsing ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find and parse a complete CI-V response frame from a buffer.
|
||||||
|
*
|
||||||
|
* Returns the bytes between "FE FE E0 A4 <CMD>" and FD, or null if no
|
||||||
|
* complete frame was found. The search is tolerant of interleaved
|
||||||
|
* broadcast traffic.
|
||||||
|
*
|
||||||
|
* @param buf bytes accumulated from the radio
|
||||||
|
* @param expectCmd the command byte we are looking for in the reply, or
|
||||||
|
* null to accept any command response from the radio
|
||||||
|
*/
|
||||||
|
fun parseResponse(buf: ByteArray, expectCmd: Byte?): ParsedResponse? {
|
||||||
|
var i = 0
|
||||||
|
while (i < buf.size - 5) {
|
||||||
|
// Look for FE FE preamble
|
||||||
|
if (buf[i] != PREAMBLE || buf[i + 1] != PREAMBLE) { i++; continue }
|
||||||
|
val dest = buf[i + 2]
|
||||||
|
val src = buf[i + 3]
|
||||||
|
val cmd = buf[i + 4]
|
||||||
|
// We only care about frames addressed to us from the radio
|
||||||
|
if (dest != ADDR_CTRL || src != ADDR_IC705) { i++; continue }
|
||||||
|
// Find the terminating FD
|
||||||
|
val fdIdx = buf.indexOf(END_OF_MSG, startIndex = i + 5)
|
||||||
|
if (fdIdx < 0) break // incomplete frame, wait for more data
|
||||||
|
val payload = buf.copyOfRange(i + 5, fdIdx)
|
||||||
|
if (expectCmd == null || cmd == expectCmd) {
|
||||||
|
return ParsedResponse(cmd, payload, fdIdx + 1)
|
||||||
|
}
|
||||||
|
i = fdIdx + 1
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.indexOf(b: Byte, startIndex: Int): Int {
|
||||||
|
for (k in startIndex until size) if (this[k] == b) return k
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a buffer contains an OK acknowledgement (FB FD) from
|
||||||
|
* the radio. Tolerates broadcast noise before the ACK.
|
||||||
|
*/
|
||||||
|
fun containsAck(buf: ByteArray): Boolean {
|
||||||
|
var i = 0
|
||||||
|
while (i < buf.size - 5) {
|
||||||
|
if (buf[i] != PREAMBLE || buf[i + 1] != PREAMBLE) { i++; continue }
|
||||||
|
val dest = buf[i + 2]
|
||||||
|
val src = buf[i + 3]
|
||||||
|
val cmd = buf[i + 4]
|
||||||
|
if (dest != ADDR_CTRL || src != ADDR_IC705) { i++; continue }
|
||||||
|
// Skip to FD
|
||||||
|
val fdIdx = buf.indexOf(END_OF_MSG, startIndex = i + 5)
|
||||||
|
if (fdIdx < 0) break
|
||||||
|
if (cmd == ACK_OK) return true
|
||||||
|
if (cmd == ACK_NG) return false
|
||||||
|
i = fdIdx + 1
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse frequency + mode from a CMD_READ_FREQ reply payload.
|
||||||
|
* Payload layout after stripping command byte: [5 freq bytes] [mode byte] [filter byte]
|
||||||
|
*/
|
||||||
|
fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? {
|
||||||
|
if (payload.size < 6) return null
|
||||||
|
val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5))
|
||||||
|
val mode = BYTE_TO_MODE[payload[5]] ?: return null
|
||||||
|
return freqHz to mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hex dump of bytes, useful for debug logging. */
|
||||||
|
fun toHex(bytes: ByteArray): String =
|
||||||
|
bytes.joinToString(" ") { String.format(Locale.US, "%02X", it.toInt() and 0xFF) }
|
||||||
|
|
||||||
|
data class ParsedResponse(
|
||||||
|
val cmd: Byte,
|
||||||
|
val payload: ByteArray,
|
||||||
|
/** Index in the source buffer immediately after the FD terminator. */
|
||||||
|
val nextOffset: Int
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/*
|
||||||
|
* 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.framework
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.channels.SocketChannel
|
||||||
|
|
||||||
|
class NetworkReporter(
|
||||||
|
private val reporterScope: CoroutineScope,
|
||||||
|
private val rotatorServer: String,
|
||||||
|
private val rotatorPort: Int,
|
||||||
|
private val frequencyServer: String,
|
||||||
|
private val frequencyPort: Int
|
||||||
|
) : IReporter {
|
||||||
|
|
||||||
|
private val writeMutex = Mutex()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
|
||||||
|
reporterScope.launch {
|
||||||
|
ensureRotatorConnected()
|
||||||
|
if (!rotatorConnected) return@launch
|
||||||
|
val el = if (elevation > 0.0) elevation else 0.0
|
||||||
|
val command = format
|
||||||
|
.replace($$"$AZ", azimuth.toString())
|
||||||
|
.replace($$"$EL", el.toString())
|
||||||
|
.unescapeControlChars()
|
||||||
|
write(rotatorSocket, command) { rotatorConnected = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureRotatorConnected() {
|
||||||
|
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
|
||||||
|
reporterScope.launch {
|
||||||
|
try {
|
||||||
|
rotatorConnecting = true
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureFrequencyConnected() {
|
||||||
|
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
|
||||||
|
reporterScope.launch {
|
||||||
|
try {
|
||||||
|
frequencyConnecting = true
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun write(socket: SocketChannel?, command: String, onError: () -> Unit) {
|
||||||
|
try {
|
||||||
|
writeMutex.withLock {
|
||||||
|
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
|
||||||
|
socket?.write(buffer)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("NetworkReporter write error: ${e.message}")
|
||||||
|
onError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.unescapeControlChars(): String =
|
||||||
|
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
|
||||||
|
}
|
||||||
+556
@@ -0,0 +1,556 @@
|
|||||||
|
/*
|
||||||
|
* 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.framework
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.util.Log
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class RadioTrackingService(
|
||||||
|
private val appScope: CoroutineScope,
|
||||||
|
private val bluetoothManager: BluetoothManager,
|
||||||
|
private val satelliteRepo: ISatelliteRepo,
|
||||||
|
private val settingsRepo: ISettingsRepo
|
||||||
|
) : IRadioTrackingService {
|
||||||
|
|
||||||
|
private val tag = "RadioTracking"
|
||||||
|
/** Delay between each step of the split-mode init sequence (ms). */
|
||||||
|
private val INIT_STEP_DELAY_MS = 200L
|
||||||
|
private val _state = MutableStateFlow(RadioTrackingState())
|
||||||
|
override val state: StateFlow<RadioTrackingState> = _state
|
||||||
|
|
||||||
|
private var txController: IRadioController? = null
|
||||||
|
private var rxController: IRadioController? = null
|
||||||
|
private var trackingJob: Job? = null
|
||||||
|
|
||||||
|
// ── Connection ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
override suspend fun connectRadios() {
|
||||||
|
txController?.disconnect()
|
||||||
|
rxController?.disconnect()
|
||||||
|
|
||||||
|
val rcSettings = settingsRepo.radioControlSettings.value
|
||||||
|
val txAddr = rcSettings.txRadioAddress
|
||||||
|
val rxAddr = rcSettings.rxRadioAddress
|
||||||
|
val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705
|
||||||
|
val isSplit = isIcom && rcSettings.splitMode
|
||||||
|
|
||||||
|
Log.i(tag, "connectRadios model=${rcSettings.radioModel} split=$isSplit TX=$txAddr RX=$rxAddr")
|
||||||
|
|
||||||
|
if (isSplit) {
|
||||||
|
// Single-radio split mode: only TX slot is used
|
||||||
|
if (txAddr.isBlank()) {
|
||||||
|
_state.update { it.copy(errorMessage = "No radio address configured in Settings") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val tx = makeController(isIcom, txAddr)
|
||||||
|
txController = tx
|
||||||
|
rxController = null
|
||||||
|
_state.update { it.copy(errorMessage = null) }
|
||||||
|
val txOk = tx.connect()
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
txConnected = txOk,
|
||||||
|
rxConnected = false,
|
||||||
|
errorMessage = if (!txOk) "Could not connect to radio ($txAddr)" else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Log.i(tag, "IC-705 split mode connected: txOk=$txOk")
|
||||||
|
} else {
|
||||||
|
if (txAddr.isBlank() && rxAddr.isBlank()) {
|
||||||
|
_state.update { it.copy(errorMessage = "No radio addresses configured in Settings") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val tx = makeController(isIcom, txAddr)
|
||||||
|
val rx = makeController(isIcom, rxAddr)
|
||||||
|
txController = tx
|
||||||
|
rxController = rx
|
||||||
|
_state.update { it.copy(errorMessage = null) }
|
||||||
|
val txOk = if (txAddr.isNotBlank()) tx.connect() else false
|
||||||
|
val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
txConnected = txOk,
|
||||||
|
rxConnected = rxOk,
|
||||||
|
errorMessage = when {
|
||||||
|
!txOk && !rxOk -> "Could not connect to TX and RX radios"
|
||||||
|
!txOk -> "Could not connect to TX radio ($txAddr)"
|
||||||
|
!rxOk -> "Could not connect to RX radio ($rxAddr)"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Log.i(tag, "Dual-radio connected: txOk=$txOk rxOk=$rxOk")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeController(isIcom: Boolean, address: String): IRadioController =
|
||||||
|
if (isIcom) Ic705Controller(bluetoothManager, address)
|
||||||
|
else Ft817Controller(bluetoothManager, address)
|
||||||
|
|
||||||
|
override suspend fun disconnectRadios() {
|
||||||
|
stopTracking()
|
||||||
|
txController?.disconnect()
|
||||||
|
rxController?.disconnect()
|
||||||
|
txController = null
|
||||||
|
rxController = null
|
||||||
|
_state.update { it.copy(txConnected = false, rxConnected = false, isActive = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tracking ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isActive = true,
|
||||||
|
currentPass = pass,
|
||||||
|
selectedTransponder = transponder,
|
||||||
|
txBaseFrequencyHz = txBaseFreqHz
|
||||||
|
)
|
||||||
|
}
|
||||||
|
trackingJob?.cancel()
|
||||||
|
|
||||||
|
val rcSettings = settingsRepo.radioControlSettings.value
|
||||||
|
val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705
|
||||||
|
val isSplit = isIcom && rcSettings.splitMode
|
||||||
|
|
||||||
|
if (isSplit) {
|
||||||
|
trackingJob = appScope.launch { runSplitTracking(transponder, txBaseFreqHz) }
|
||||||
|
} else {
|
||||||
|
trackingJob = appScope.launch { runDualRadioTracking(transponder, txBaseFreqHz) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dual-radio tracking (Yaesu or two IC-705s) ──────────────────────────
|
||||||
|
|
||||||
|
private suspend fun runDualRadioTracking(transponder: SatRadio, initialTxBaseFreqHz: Long?) {
|
||||||
|
val tx = txController
|
||||||
|
val rx = rxController
|
||||||
|
|
||||||
|
// Initial setup: set band/mode/CTCSS on both radios
|
||||||
|
val txMode = transponder.uplinkMode
|
||||||
|
val rxMode = transponder.downlinkMode
|
||||||
|
?: transponder.uplinkMode?.let {
|
||||||
|
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(tag, "DualRadio start: txMode=$txMode rxMode=$rxMode")
|
||||||
|
|
||||||
|
if (tx != null && tx.isConnected && txMode != null) {
|
||||||
|
Log.d(tag, "Setting TX mode: $txMode")
|
||||||
|
tx.setMode(txMode)
|
||||||
|
}
|
||||||
|
if (rx != null && rx.isConnected && rxMode != null) {
|
||||||
|
Log.d(tag, "Setting RX mode: $rxMode")
|
||||||
|
rx.setMode(rxMode)
|
||||||
|
}
|
||||||
|
if (txMode?.uppercase() == "FM") {
|
||||||
|
_state.value.ctcssTone?.let { tone ->
|
||||||
|
Log.d(tag, "Setting CTCSS: ${tone}Hz")
|
||||||
|
tx?.setCtcssTone(tone)
|
||||||
|
tx?.setCtcssMode(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
||||||
|
|
||||||
|
var lastSetTxFreq = 0.0
|
||||||
|
var lastSetRxFreq = 0.0
|
||||||
|
var tuningRadio = ""
|
||||||
|
var lastReadFreq = 0L
|
||||||
|
var stableCount = 0
|
||||||
|
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
val currentState = _state.value
|
||||||
|
if (!currentState.isActive) break
|
||||||
|
|
||||||
|
val satPass = currentState.currentPass ?: break
|
||||||
|
val xpdr = currentState.selectedTransponder ?: break
|
||||||
|
var txBaseFreq = currentState.txBaseFrequencyHz
|
||||||
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
|
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis())
|
||||||
|
val txNow = txController
|
||||||
|
val rxNow = rxController
|
||||||
|
val v = pos.distanceRate * 1000.0
|
||||||
|
|
||||||
|
if (tuningRadio.isNotEmpty()) {
|
||||||
|
val radio = if (tuningRadio == "tx") txNow else rxNow
|
||||||
|
if (radio != null && radio.isConnected) {
|
||||||
|
val read = radio.readFrequencyAndMode()
|
||||||
|
if (read != null) {
|
||||||
|
val (freq, _) = read
|
||||||
|
if (kotlin.math.abs(freq - lastReadFreq) <= 20) stableCount++
|
||||||
|
else { stableCount = 0; lastReadFreq = freq }
|
||||||
|
if (stableCount >= 2) {
|
||||||
|
if (tuningRadio == "tx" && txBaseFreq != null) {
|
||||||
|
val newBase = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong()
|
||||||
|
if (newBase > 0) {
|
||||||
|
txBaseFreq = newBase
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = newBase) }
|
||||||
|
Log.i(tag, "TX tuning done → base=$newBase")
|
||||||
|
}
|
||||||
|
} else if (tuningRadio == "rx") {
|
||||||
|
val rxNominal = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong()
|
||||||
|
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
|
||||||
|
if (newTxBase != null && newTxBase > 0) {
|
||||||
|
txBaseFreq = newTxBase
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
|
||||||
|
Log.i(tag, "RX tuning done → txBase=$newTxBase")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tuningRadio = ""
|
||||||
|
stableCount = 0
|
||||||
|
lastSetTxFreq = 0.0
|
||||||
|
lastSetRxFreq = 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Detect manual dial changes
|
||||||
|
if (txBaseFreq != null && txNow != null && txNow.isConnected && lastSetTxFreq > 0.0) {
|
||||||
|
val read = txNow.readFrequencyAndMode()
|
||||||
|
if (read != null && kotlin.math.abs(read.first - lastSetTxFreq) >= 20.0) {
|
||||||
|
tuningRadio = "tx"
|
||||||
|
lastReadFreq = read.first
|
||||||
|
stableCount = 0
|
||||||
|
Log.i(tag, "TX tuning detected (read=${read.first}, lastSet=$lastSetTxFreq)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tuningRadio.isEmpty() && rxNow != null && rxNow.isConnected && lastSetRxFreq > 0.0) {
|
||||||
|
val read = rxNow.readFrequencyAndMode()
|
||||||
|
if (read != null && kotlin.math.abs(read.first - lastSetRxFreq) >= 20.0) {
|
||||||
|
tuningRadio = "rx"
|
||||||
|
lastReadFreq = read.first
|
||||||
|
stableCount = 0
|
||||||
|
Log.i(tag, "RX tuning detected (read=${read.first}, lastSet=$lastSetRxFreq)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
|
||||||
|
val rxBaseFreq = if (txBaseFreq != null) {
|
||||||
|
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
|
||||||
|
} else xpdr.downlinkLow
|
||||||
|
val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) }
|
||||||
|
|
||||||
|
if (tuningRadio.isEmpty()) {
|
||||||
|
if (txNow != null && txNow.isConnected && txRadioFreq != null) {
|
||||||
|
txNow.setFrequency(txRadioFreq)
|
||||||
|
lastSetTxFreq = txRadioFreq.toDouble()
|
||||||
|
}
|
||||||
|
if (rxNow != null && rxNow.isConnected && rxRadioFreq != null) {
|
||||||
|
rxNow.setFrequency(rxRadioFreq)
|
||||||
|
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
txConnected = txNow?.isConnected ?: false,
|
||||||
|
rxConnected = rxNow?.isConnected ?: false,
|
||||||
|
txFrequencyHz = txRadioFreq,
|
||||||
|
rxFrequencyHz = rxRadioFreq,
|
||||||
|
azimuth = Math.toDegrees(pos.azimuth),
|
||||||
|
elevation = Math.toDegrees(pos.elevation),
|
||||||
|
distance = pos.distance
|
||||||
|
)
|
||||||
|
}
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IC-705 split-radio tracking ─────────────────────────────────────────
|
||||||
|
|
||||||
|
private suspend fun runSplitTracking(transponder: SatRadio, initialTxBaseFreqHz: Long?) {
|
||||||
|
val radio = txController ?: return
|
||||||
|
if (!radio.isConnected) return
|
||||||
|
|
||||||
|
val txMode = transponder.uplinkMode
|
||||||
|
val rxMode = transponder.downlinkMode
|
||||||
|
?: transponder.uplinkMode?.let {
|
||||||
|
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute nominal base frequencies
|
||||||
|
val txCenter = when {
|
||||||
|
transponder.uplinkLow != null && transponder.uplinkHigh != null ->
|
||||||
|
(transponder.uplinkLow!! + transponder.uplinkHigh!!) / 2
|
||||||
|
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val rxNominal = if (txCenter != null) {
|
||||||
|
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
||||||
|
} else transponder.downlinkLow
|
||||||
|
|
||||||
|
val txBase = initialTxBaseFreqHz ?: txCenter
|
||||||
|
Log.i(tag, "IC-705 split setup: txBase=${txBase}Hz rxNominal=${rxNominal}Hz txMode=$txMode rxMode=$rxMode")
|
||||||
|
|
||||||
|
// ── Initial setup sequence ──────────────────────────────────────────
|
||||||
|
// Sequence per IC-705: explicitly select VFO, then band → freq → mode.
|
||||||
|
// ACK from each command gates the next — no fixed delays needed.
|
||||||
|
|
||||||
|
// VFO-A = RX (downlink)
|
||||||
|
Log.d(tag, "Split init: selecting VFO-A for RX (downlink)")
|
||||||
|
radio.setVfo(vfoA = true)
|
||||||
|
if (rxNominal != null) {
|
||||||
|
Log.d(tag, "Split init: VFO-A band for ${rxNominal}Hz")
|
||||||
|
radio.setBand(rxNominal)
|
||||||
|
Log.d(tag, "Split init: VFO-A freq=${rxNominal}Hz")
|
||||||
|
radio.setFrequency(rxNominal)
|
||||||
|
}
|
||||||
|
if (rxMode != null) {
|
||||||
|
Log.d(tag, "Split init: VFO-A mode=$rxMode")
|
||||||
|
radio.setMode(rxMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VFO-B = TX (uplink)
|
||||||
|
Log.d(tag, "Split init: selecting VFO-B for TX (uplink)")
|
||||||
|
radio.setVfo(vfoA = false)
|
||||||
|
if (txBase != null) {
|
||||||
|
Log.d(tag, "Split init: VFO-B band for ${txBase}Hz")
|
||||||
|
radio.setBand(txBase)
|
||||||
|
Log.d(tag, "Split init: VFO-B freq=${txBase}Hz")
|
||||||
|
radio.setFrequency(txBase)
|
||||||
|
}
|
||||||
|
if (txMode != null) {
|
||||||
|
Log.d(tag, "Split init: VFO-B mode=$txMode")
|
||||||
|
radio.setMode(txMode)
|
||||||
|
}
|
||||||
|
if (txMode?.uppercase() == "FM") {
|
||||||
|
val tone = _state.value.ctcssTone
|
||||||
|
if (tone != null) {
|
||||||
|
Log.d(tag, "Split init: CTCSS=${tone}Hz")
|
||||||
|
radio.setCtcssTone(tone)
|
||||||
|
radio.setCtcssMode(true)
|
||||||
|
} else {
|
||||||
|
radio.setCtcssMode(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable SPLIT on VFO-A (return display to RX VFO first)
|
||||||
|
Log.d(tag, "Split init: returning to VFO-A, then enabling SPLIT mode")
|
||||||
|
radio.setVfo(vfoA = true)
|
||||||
|
radio.setSplitMode(enabled = true)
|
||||||
|
|
||||||
|
_state.update { it.copy(txMode = txMode, rxMode = rxMode, txBaseFrequencyHz = txBase) }
|
||||||
|
Log.i(tag, "IC-705 split init done — entering tracking loop")
|
||||||
|
|
||||||
|
// ── Tracking loop with tuning detection ─────────────────────────────
|
||||||
|
var lastSetTxFreq = 0.0
|
||||||
|
var lastSetRxFreq = 0.0
|
||||||
|
var tuningRadio = "" // "tx" or "rx" when manual tuning detected
|
||||||
|
var lastReadFreq = 0L
|
||||||
|
var stableCount = 0
|
||||||
|
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
val currentState = _state.value
|
||||||
|
if (!currentState.isActive) break
|
||||||
|
|
||||||
|
val satPass = currentState.currentPass ?: break
|
||||||
|
val xpdr = currentState.selectedTransponder ?: break
|
||||||
|
var txBaseFreq = currentState.txBaseFrequencyHz
|
||||||
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
|
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis())
|
||||||
|
val v = pos.distanceRate * 1000.0
|
||||||
|
|
||||||
|
if (tuningRadio.isNotEmpty()) {
|
||||||
|
// User is tuning — wait for frequency to stabilize
|
||||||
|
val readFreq = if (tuningRadio == "tx") radio.readTxVfoFrequency() else radio.readWorkingFrequency()
|
||||||
|
if (readFreq != null) {
|
||||||
|
if (kotlin.math.abs(readFreq - lastReadFreq) <= 20) stableCount++
|
||||||
|
else { stableCount = 0; lastReadFreq = readFreq }
|
||||||
|
|
||||||
|
if (stableCount >= 2) {
|
||||||
|
// Frequency stable — reverse-calculate base frequency
|
||||||
|
if (tuningRadio == "tx" && txBaseFreq != null) {
|
||||||
|
val newBase = (readFreq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong()
|
||||||
|
if (newBase > 0) {
|
||||||
|
txBaseFreq = newBase
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = newBase) }
|
||||||
|
Log.i(tag, "Split TX tuning done → base=$newBase")
|
||||||
|
}
|
||||||
|
} else if (tuningRadio == "rx") {
|
||||||
|
val rxNominal = (readFreq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong()
|
||||||
|
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
|
||||||
|
if (newTxBase != null && newTxBase > 0) {
|
||||||
|
txBaseFreq = newTxBase
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
|
||||||
|
Log.i(tag, "Split RX tuning done → txBase=$newTxBase")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tuningRadio = ""
|
||||||
|
stableCount = 0
|
||||||
|
lastSetTxFreq = 0.0
|
||||||
|
lastSetRxFreq = 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Detect manual dial changes
|
||||||
|
if (txBaseFreq != null && lastSetTxFreq > 0.0) {
|
||||||
|
val readTx = radio.readTxVfoFrequency()
|
||||||
|
if (readTx != null && kotlin.math.abs(readTx - lastSetTxFreq) >= 20.0) {
|
||||||
|
tuningRadio = "tx"
|
||||||
|
lastReadFreq = readTx
|
||||||
|
stableCount = 0
|
||||||
|
Log.i(tag, "Split TX tuning detected (read=${readTx}, lastSet=$lastSetTxFreq)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tuningRadio.isEmpty() && lastSetRxFreq > 0.0) {
|
||||||
|
val readRx = radio.readWorkingFrequency()
|
||||||
|
if (readRx != null && kotlin.math.abs(readRx - lastSetRxFreq) >= 20.0) {
|
||||||
|
tuningRadio = "rx"
|
||||||
|
lastReadFreq = readRx
|
||||||
|
stableCount = 0
|
||||||
|
Log.i(tag, "Split RX tuning detected (read=${readRx}, lastSet=$lastSetRxFreq)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine Doppler-corrected frequencies
|
||||||
|
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
|
||||||
|
val rxBaseCalc = if (txBaseFreq != null) {
|
||||||
|
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
|
||||||
|
} else xpdr.downlinkLow
|
||||||
|
val rxRadioFreq = rxBaseCalc?.let { pos.getDownlinkFreq(it) }
|
||||||
|
|
||||||
|
if (radio.isConnected && tuningRadio.isEmpty()) {
|
||||||
|
// Update both VFOs every cycle — no PTT polling needed.
|
||||||
|
// 0x25/00 = active (RX) VFO, 0x25/01 = inactive (TX) VFO.
|
||||||
|
if (rxRadioFreq != null) {
|
||||||
|
Log.d(tag, "Split loop RX (0x25/00): ${rxRadioFreq}Hz")
|
||||||
|
radio.setWorkingFrequency(rxRadioFreq)
|
||||||
|
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||||
|
}
|
||||||
|
if (txRadioFreq != null) {
|
||||||
|
Log.d(tag, "Split loop TX (0x25/01): ${txRadioFreq}Hz")
|
||||||
|
radio.setTxVfoFrequency(txRadioFreq)
|
||||||
|
lastSetTxFreq = txRadioFreq.toDouble()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
txConnected = radio.isConnected,
|
||||||
|
rxConnected = false, // single radio
|
||||||
|
txFrequencyHz = txRadioFreq,
|
||||||
|
rxFrequencyHz = rxRadioFreq,
|
||||||
|
azimuth = Math.toDegrees(pos.azimuth),
|
||||||
|
elevation = Math.toDegrees(pos.elevation),
|
||||||
|
distance = pos.distance
|
||||||
|
)
|
||||||
|
}
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Other IRadioTrackingService methods ─────────────────────────────────
|
||||||
|
|
||||||
|
override fun stopTracking() {
|
||||||
|
trackingJob?.cancel()
|
||||||
|
trackingJob = null
|
||||||
|
_state.update { it.copy(isActive = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setTransponder(transponder: SatRadio) {
|
||||||
|
appScope.launch {
|
||||||
|
val tx = txController
|
||||||
|
val rx = rxController
|
||||||
|
transponder.uplinkMode?.let { tx?.setMode(it) }
|
||||||
|
val rxMode = transponder.downlinkMode
|
||||||
|
?: transponder.uplinkMode?.let {
|
||||||
|
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||||
|
}
|
||||||
|
rxMode?.let { rx?.setMode(it) }
|
||||||
|
if (transponder.uplinkMode?.uppercase() == "FM") {
|
||||||
|
_state.value.ctcssTone?.let { tone ->
|
||||||
|
tx?.setCtcssTone(tone)
|
||||||
|
tx?.setCtcssMode(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val txCenter = when {
|
||||||
|
transponder.uplinkLow != null && transponder.uplinkHigh != null ->
|
||||||
|
(transponder.uplinkLow!! + transponder.uplinkHigh!!) / 2
|
||||||
|
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val rxNominal = if (txCenter != null) {
|
||||||
|
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
||||||
|
} else transponder.downlinkLow
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
selectedTransponder = transponder,
|
||||||
|
txBaseFrequencyHz = txCenter,
|
||||||
|
txFrequencyHz = txCenter,
|
||||||
|
rxFrequencyHz = rxNominal,
|
||||||
|
txMode = transponder.uplinkMode,
|
||||||
|
rxMode = transponder.downlinkMode
|
||||||
|
?: transponder.uplinkMode?.let { m ->
|
||||||
|
TransponderMapper.mapUplinkModeToDownlinkMode(m, transponder.isInverted)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setTxBaseFrequency(frequencyHz: Long) {
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = frequencyHz) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun adjustTxBaseFrequency(deltaHz: Long) {
|
||||||
|
val current = _state.value.txBaseFrequencyHz ?: return
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = current + deltaHz) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setCtcssTone(toneHz: Double?) {
|
||||||
|
_state.update { it.copy(ctcssTone = toneHz) }
|
||||||
|
appScope.launch {
|
||||||
|
val tx = txController
|
||||||
|
if (toneHz != null) {
|
||||||
|
tx?.setCtcssTone(toneHz)
|
||||||
|
tx?.setCtcssMode(true)
|
||||||
|
} else {
|
||||||
|
tx?.setCtcssMode(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setMode(txMode: String, rxMode: String) {
|
||||||
|
appScope.launch {
|
||||||
|
txController?.setMode(txMode)
|
||||||
|
rxController?.setMode(rxMode)
|
||||||
|
}
|
||||||
|
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.injection
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.hardware.SensorManager
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
|
import android.location.LocationManager
|
||||||
|
import androidx.room.Room
|
||||||
|
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||||
|
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||||
|
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.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.source.LocalSource
|
||||||
|
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
||||||
|
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
||||||
|
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
||||||
|
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
||||||
|
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||||
|
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.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.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
|
||||||
|
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 com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
|
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 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 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)
|
||||||
|
|
||||||
|
override fun provideAudioCapture(): IAudioCapture = AudioCapture()
|
||||||
|
|
||||||
|
override fun provideSaveImage(): ISaveImage = SaveImage(context)
|
||||||
|
|
||||||
|
override fun provideBluetoothReporter(): IReporter {
|
||||||
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
val rc = settingsRepo.rcSettings.value
|
||||||
|
return BluetoothReporter(
|
||||||
|
manager,
|
||||||
|
CoroutineScope(Dispatchers.IO),
|
||||||
|
rc.bluetoothRotatorAddress,
|
||||||
|
rc.bluetoothFrequencyAddress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun provideNetworkReporter(): IReporter {
|
||||||
|
val rc = settingsRepo.rcSettings.value
|
||||||
|
return NetworkReporter(
|
||||||
|
CoroutineScope(Dispatchers.IO),
|
||||||
|
rc.rotatorAddress,
|
||||||
|
rc.rotatorPort.toIntOrNull() ?: 0,
|
||||||
|
rc.frequencyAddress,
|
||||||
|
rc.frequencyPort.toIntOrNull() ?: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun provideTxRadioController(): IRadioController {
|
||||||
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
val settings = settingsRepo.radioControlSettings.value
|
||||||
|
val address = settings.txRadioAddress
|
||||||
|
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
|
||||||
|
Ic705Controller(manager, address)
|
||||||
|
} else {
|
||||||
|
Ft817Controller(manager, address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun provideRxRadioController(): IRadioController {
|
||||||
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
val settings = settingsRepo.radioControlSettings.value
|
||||||
|
val address = settings.rxRadioAddress
|
||||||
|
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
|
||||||
|
Ic705Controller(manager, address)
|
||||||
|
} else {
|
||||||
|
Ft817Controller(manager, address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun provideSensorsRepo(): ISensorsRepo {
|
||||||
|
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||||
|
val displayManager = context.getSystemService(DisplayManager::class.java)
|
||||||
|
return SensorsRepo(manager, displayManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||||
|
val dbDispatcher = Dispatchers.Default
|
||||||
|
val dataParser = DataParser(dbDispatcher)
|
||||||
|
val remoteSource = provideRemoteSource()
|
||||||
|
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun provideLocalSource(): ILocalSource {
|
||||||
|
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
|
||||||
|
val database = builder.fallbackToDestructiveMigration(false).build()
|
||||||
|
return LocalSource(database.look4SatDao())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun provideRemoteSource(): IRemoteSource {
|
||||||
|
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun provideSatelliteRepo(): ISatelliteRepo {
|
||||||
|
return SatelliteRepo(Dispatchers.Default, localSource, settingsRepo)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun provideSelectionRepo(): ISelectionRepo {
|
||||||
|
return SelectionRepo(Dispatchers.Default, localSource, settingsRepo)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun provideSettingsRepo(): ISettingsRepo {
|
||||||
|
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||||
|
val appPrefsFileName = "${context.packageName}_preferences"
|
||||||
|
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
||||||
|
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
||||||
|
return SettingsRepo(manager, appPreferences, appVersionName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/*
|
||||||
|
* 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.DatabaseState
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||||
|
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.Sources
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
|
||||||
|
class DatabaseRepo(
|
||||||
|
private val dispatcher: CoroutineDispatcher,
|
||||||
|
private val dataParser: DataParser,
|
||||||
|
private val localSource: ILocalSource,
|
||||||
|
private val remoteSource: IRemoteSource,
|
||||||
|
private val settingsRepo: ISettingsRepo
|
||||||
|
) : IDatabaseRepo {
|
||||||
|
|
||||||
|
private val customSourceType = "Other"
|
||||||
|
|
||||||
|
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
||||||
|
var importedCount = 0
|
||||||
|
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())
|
||||||
|
importedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateTransceiversFromFile(uri: String): Int = withContext(dispatcher) {
|
||||||
|
var importedCount = 0
|
||||||
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
|
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
||||||
|
localSource.insertRadios(transceivers)
|
||||||
|
importedCount = transceivers.size
|
||||||
|
}
|
||||||
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
|
importedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
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 and associate with types
|
||||||
|
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
||||||
|
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
||||||
|
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
||||||
|
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
||||||
|
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
||||||
|
}
|
||||||
|
// insert parsed data into the database
|
||||||
|
localSource.insertEntries(importedEntries)
|
||||||
|
localSource.insertRadios(importedRadios)
|
||||||
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearAllData() = withContext(dispatcher) {
|
||||||
|
localSource.deleteEntries()
|
||||||
|
localSource.deleteRadios()
|
||||||
|
setUpdateSuccessful(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
|
||||||
|
val bufferedStream = stream.buffered()
|
||||||
|
return when {
|
||||||
|
hasCsvHint(url) || looksLikeCsv(bufferedStream) -> dataParser.parseCSVStream(bufferedStream)
|
||||||
|
else -> dataParser.parseTLEStream(bufferedStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasCsvHint(url: String): Boolean {
|
||||||
|
return url.contains("FORMAT=csv", ignoreCase = true) ||
|
||||||
|
url.endsWith(".csv", ignoreCase = true) ||
|
||||||
|
url.endsWith(".csv.zip", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun looksLikeCsv(stream: InputStream): Boolean {
|
||||||
|
if (!stream.markSupported()) return false
|
||||||
|
stream.mark(4096)
|
||||||
|
val preview = ByteArray(4096)
|
||||||
|
val length = stream.read(preview)
|
||||||
|
stream.reset()
|
||||||
|
if (length <= 0) return false
|
||||||
|
val line = preview.decodeToString(0, length).lineSequence().firstOrNull()?.trim().orEmpty()
|
||||||
|
return line.contains("OBJECT_NAME", ignoreCase = true) ||
|
||||||
|
line.contains("NORAD_CAT_ID", ignoreCase = true) ||
|
||||||
|
line.count { it == ',' } >= 4
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
||||||
|
settingsRepo.updateDatabaseState(
|
||||||
|
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unwrapIfZipped(url: String, stream: InputStream): InputStream =
|
||||||
|
if (url.endsWith(".zip", ignoreCase = true)) ZipInputStream(stream).apply { nextEntry } else stream
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
/*
|
||||||
|
* 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.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.round
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
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
|
||||||
|
|
||||||
|
class SatelliteRepo(
|
||||||
|
private val dispatcher: CoroutineDispatcher,
|
||||||
|
private val localStorage: ILocalSource,
|
||||||
|
private val settingsRepo: ISettingsRepo
|
||||||
|
) : ISatelliteRepo {
|
||||||
|
|
||||||
|
private val _passes = MutableStateFlow<List<OrbitalPass>>(emptyList())
|
||||||
|
override val passes: StateFlow<List<OrbitalPass>> = _passes
|
||||||
|
|
||||||
|
private val _isCalculating = MutableStateFlow(false)
|
||||||
|
override val isCalculating: StateFlow<Boolean> = _isCalculating
|
||||||
|
|
||||||
|
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
|
||||||
|
override val satellites: StateFlow<List<OrbitalObject>> = _satellites
|
||||||
|
|
||||||
|
private val _selectedPass = MutableStateFlow(0 to 0L)
|
||||||
|
override val selectedPass: StateFlow<Pair<Int, Long>> = _selectedPass
|
||||||
|
|
||||||
|
override fun selectPass(catNum: Int, aosTime: Long) {
|
||||||
|
_selectedPass.value = catNum to aosTime
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||||
|
|
||||||
|
override suspend fun initRepository() = withContext(dispatcher) {
|
||||||
|
combine(
|
||||||
|
settingsRepo.selectedIds,
|
||||||
|
settingsRepo.stationPosition
|
||||||
|
) { selectedIds, _ -> selectedIds }
|
||||||
|
.collect { selectedIds ->
|
||||||
|
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||||
|
val settings = settingsRepo.passesSettings.value
|
||||||
|
calculatePasses(
|
||||||
|
time = System.currentTimeMillis(),
|
||||||
|
hoursAhead = settings.hoursAhead,
|
||||||
|
minElevation = settings.minElevation,
|
||||||
|
aosStartMinute = settings.aosStartMinute,
|
||||||
|
aosEndMinute = settings.aosEndMinute,
|
||||||
|
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||||
|
modes = settings.selectedModes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
|
||||||
|
return withContext(dispatcher) { sat.getPosition(pos, time) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos> {
|
||||||
|
return withContext(dispatcher) {
|
||||||
|
val estimatedSize = ((end - start) / 15000).toInt() + 1
|
||||||
|
val positions = ArrayList<OrbitalPos>(estimatedSize)
|
||||||
|
var currentTime = start
|
||||||
|
while (currentTime < end) {
|
||||||
|
positions.add(sat.getPosition(pos, currentTime))
|
||||||
|
currentTime += 15000
|
||||||
|
}
|
||||||
|
positions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getRadios(
|
||||||
|
sat: OrbitalObject,
|
||||||
|
pos: GeoPos,
|
||||||
|
radios: List<SatRadio>,
|
||||||
|
time: Long
|
||||||
|
): List<SatRadio> {
|
||||||
|
return withContext(dispatcher) {
|
||||||
|
val satPos = sat.getPosition(pos, time)
|
||||||
|
radios.map { transmitter ->
|
||||||
|
transmitter.copy(
|
||||||
|
downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) },
|
||||||
|
downlinkHigh = transmitter.downlinkHigh?.let { satPos.getDownlinkFreq(it) },
|
||||||
|
uplinkLow = transmitter.uplinkLow?.let { satPos.getUplinkFreq(it) },
|
||||||
|
uplinkHigh = transmitter.uplinkHigh?.let { satPos.getUplinkFreq(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun calculatePasses(
|
||||||
|
time: Long,
|
||||||
|
hoursAhead: Int,
|
||||||
|
minElevation: Double,
|
||||||
|
aosStartMinute: Int,
|
||||||
|
aosEndMinute: Int,
|
||||||
|
invertAosTimeWindow: Boolean,
|
||||||
|
modes: List<String>
|
||||||
|
) {
|
||||||
|
_isCalculating.value = true
|
||||||
|
// Normalize to the start of the current minute so that coarse 60-second stepping
|
||||||
|
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
|
||||||
|
val normalizedTime = time / 60_000L * 60_000L
|
||||||
|
val currentSatellites = _satellites.value
|
||||||
|
withContext(dispatcher) {
|
||||||
|
val idsWithModes = localStorage.getIdsWithModes(modes)
|
||||||
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
|
val filteredSatellites = if (idsWithModes.isEmpty()) {
|
||||||
|
currentSatellites
|
||||||
|
} else {
|
||||||
|
currentSatellites.filter { it.data.catnum in idsWithModes }
|
||||||
|
}
|
||||||
|
// Compute passes for each satellite in parallel
|
||||||
|
val passLists = coroutineScope {
|
||||||
|
filteredSatellites.map { satellite ->
|
||||||
|
async { satellite.getPasses(stationPos, normalizedTime, hoursAhead) }
|
||||||
|
}.awaitAll()
|
||||||
|
}
|
||||||
|
// Flatten and filter in a single pass
|
||||||
|
val timeFuture = normalizedTime + (hoursAhead * 60L * 60L * 1000L)
|
||||||
|
val newPasses = ArrayList<OrbitalPass>()
|
||||||
|
for (list in passLists) {
|
||||||
|
for (pass in list) {
|
||||||
|
if (
|
||||||
|
pass.losTime > time
|
||||||
|
&& pass.aosTime < timeFuture
|
||||||
|
&& pass.maxElevation > minElevation
|
||||||
|
&& (pass.isDeepSpace || isAosInRange(pass.aosTime, aosStartMinute, aosEndMinute, invertAosTimeWindow))
|
||||||
|
) {
|
||||||
|
newPasses.add(pass)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newPasses.sortBy { it.aosTime }
|
||||||
|
delay(1000) // Simulate loading time for better UX
|
||||||
|
_passes.update { newPasses }
|
||||||
|
}
|
||||||
|
_isCalculating.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isAosInRange(
|
||||||
|
aosTime: Long,
|
||||||
|
aosStartMinute: Int,
|
||||||
|
aosEndMinute: Int,
|
||||||
|
invertAosTimeWindow: Boolean
|
||||||
|
): Boolean {
|
||||||
|
val offsetMillis = TimeZone.getDefault().getOffset(aosTime).toLong()
|
||||||
|
val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L)
|
||||||
|
val aosMinute = (localMillis / 60_000L).toInt()
|
||||||
|
val inRange = if (aosStartMinute <= aosEndMinute) {
|
||||||
|
aosMinute in aosStartMinute..aosEndMinute
|
||||||
|
} else {
|
||||||
|
aosMinute >= aosStartMinute || aosMinute <= aosEndMinute
|
||||||
|
}
|
||||||
|
return if (invertAosTimeWindow) !inRange else inRange
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun OrbitalObject.getPasses(pos: GeoPos, time: Long, hours: Int): List<OrbitalPass> {
|
||||||
|
val passes = mutableListOf<OrbitalPass>()
|
||||||
|
val endDate = time + hours * 60L * 60L * 1000L
|
||||||
|
val quarterOrbitMin = (this.data.orbitalPeriod / 4.0).toInt()
|
||||||
|
val decayed = this.data.hasDecayed(time)
|
||||||
|
var startDate = time
|
||||||
|
var shouldRewind = true
|
||||||
|
var lastAosDate: Long
|
||||||
|
var count = 0
|
||||||
|
if (this.willBeSeen(pos)) {
|
||||||
|
if (this.data.isDeepSpace) {
|
||||||
|
passes.add(getGeoPass(this, pos, time, decayed))
|
||||||
|
} else {
|
||||||
|
do {
|
||||||
|
if (count > 0) shouldRewind = false
|
||||||
|
val pass = getLeoPass(this, pos, startDate, shouldRewind, decayed)
|
||||||
|
lastAosDate = pass.aosTime
|
||||||
|
passes.add(pass)
|
||||||
|
startDate = pass.losTime + (quarterOrbitMin * 3) * 60L * 1000L
|
||||||
|
count++
|
||||||
|
} while (lastAosDate < endDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return passes
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, decayed: Boolean): OrbitalPass {
|
||||||
|
val satPos = sat.getPosition(pos, time)
|
||||||
|
val aos = time - 24 * 60L * 60L * 1000L
|
||||||
|
val los = time + 24 * 60L * 60L * 1000L // val tca = (aos + los) / 2
|
||||||
|
val az = satPos.azimuth.toDegrees().round(1)
|
||||||
|
val elev = satPos.elevation.toDegrees().round(1)
|
||||||
|
val alt = satPos.altitude
|
||||||
|
return OrbitalPass(aos, az, los, az, alt.toInt(), elev, sat, hasDecayed = decayed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getLeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, rewind: Boolean, decayed: Boolean): OrbitalPass {
|
||||||
|
val quarterOrbitMin = (sat.data.orbitalPeriod / 4.0).toInt()
|
||||||
|
var calendarTimeMillis = time
|
||||||
|
var elevation: Double
|
||||||
|
var maxElevation = 0.0
|
||||||
|
// rewind 1/4 of an orbit
|
||||||
|
if (rewind) calendarTimeMillis -= quarterOrbitMin * 60L * 1000L
|
||||||
|
|
||||||
|
// Use lightweight elevation check for coarse searching
|
||||||
|
if (sat.getElevation(pos, calendarTimeMillis) > 0.0) {
|
||||||
|
// move forward in 30 second intervals until the sat goes below the horizon
|
||||||
|
do {
|
||||||
|
calendarTimeMillis += 30 * 1000L
|
||||||
|
} while (sat.getElevation(pos, calendarTimeMillis) > 0.0)
|
||||||
|
// move forward 3/4 of an orbit
|
||||||
|
calendarTimeMillis += quarterOrbitMin * 3 * 60L * 1000L
|
||||||
|
}
|
||||||
|
|
||||||
|
// find the next time sat comes above the horizon (coarse: 60s steps)
|
||||||
|
do {
|
||||||
|
calendarTimeMillis += 60L * 1000L
|
||||||
|
elevation = sat.getElevation(pos, calendarTimeMillis)
|
||||||
|
if (elevation > maxElevation) maxElevation = elevation
|
||||||
|
} while (elevation < 0.0)
|
||||||
|
|
||||||
|
// refine AOS to ~500ms precision
|
||||||
|
calendarTimeMillis -= 60L * 1000L
|
||||||
|
do {
|
||||||
|
calendarTimeMillis += 500L
|
||||||
|
elevation = sat.getElevation(pos, calendarTimeMillis)
|
||||||
|
if (elevation > maxElevation) maxElevation = elevation
|
||||||
|
} while (elevation < 0.0)
|
||||||
|
|
||||||
|
// Get full position for AOS data (azimuth, altitude)
|
||||||
|
val aosPos = sat.getFullPosition(pos, calendarTimeMillis)
|
||||||
|
val aos = 1000 * ((aosPos.time + 500) / 1000)
|
||||||
|
val aosAz = aosPos.azimuth.toDegrees().round(1)
|
||||||
|
|
||||||
|
// find when sat goes below (coarse: 30s steps)
|
||||||
|
do {
|
||||||
|
calendarTimeMillis += 30L * 1000L
|
||||||
|
elevation = sat.getElevation(pos, calendarTimeMillis)
|
||||||
|
if (elevation > maxElevation) maxElevation = elevation
|
||||||
|
} while (elevation > 0.0)
|
||||||
|
|
||||||
|
// refine LOS to ~500ms precision
|
||||||
|
calendarTimeMillis -= 30L * 1000L
|
||||||
|
do {
|
||||||
|
calendarTimeMillis += 500L
|
||||||
|
elevation = sat.getElevation(pos, calendarTimeMillis)
|
||||||
|
if (elevation > maxElevation) maxElevation = elevation
|
||||||
|
} while (elevation > 0.0)
|
||||||
|
|
||||||
|
// Get full position for LOS data (azimuth, altitude)
|
||||||
|
val losPos = sat.getFullPosition(pos, calendarTimeMillis)
|
||||||
|
val los = 1000 * ((losPos.time + 500) / 1000)
|
||||||
|
val losAz = losPos.azimuth.toDegrees().round(1)
|
||||||
|
|
||||||
|
// Get altitude at approximate TCA (max elevation)
|
||||||
|
val tcaTime = (aos + los) / 2
|
||||||
|
val tcaPos = sat.getFullPosition(pos, tcaTime)
|
||||||
|
val alt = tcaPos.altitude
|
||||||
|
|
||||||
|
val elev = maxElevation.toDegrees().round(1)
|
||||||
|
return OrbitalPass(aos, aosAz, los, losAz, alt.toInt(), elev, sat, hasDecayed = decayed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatItem
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class SelectionRepo(
|
||||||
|
private val dispatcher: CoroutineDispatcher,
|
||||||
|
private val localSource: ILocalSource,
|
||||||
|
private val settingsRepo: ISettingsRepo
|
||||||
|
) : ISelectionRepo {
|
||||||
|
|
||||||
|
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
||||||
|
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
|
||||||
|
private val currentQuery = MutableStateFlow("")
|
||||||
|
|
||||||
|
// Resolve type IDs once when types change, then filter items reactively.
|
||||||
|
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
||||||
|
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
||||||
|
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
||||||
|
null // null = no filtering
|
||||||
|
} else {
|
||||||
|
val ids = settingsRepo.getSatelliteTypesIds(types)
|
||||||
|
if (ids.isEmpty()) null else ids.toHashSet()
|
||||||
|
}
|
||||||
|
currentItems.map { items ->
|
||||||
|
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
||||||
|
itemsWithTypes.map { items -> filterByQuery(items, query) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCurrentTypes() = currentTypes.value
|
||||||
|
|
||||||
|
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
|
||||||
|
removeAt(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
||||||
|
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
|
||||||
|
currentItems.value = localSource.getEntriesList().map { item ->
|
||||||
|
item.copy(isSelected = item.catnum in selectedIds)
|
||||||
|
}
|
||||||
|
return@withContext itemsWithQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setTypes(types: List<String>) {
|
||||||
|
currentTypes.value = types
|
||||||
|
settingsRepo.setSelectedTypes(types)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setQuery(query: String) {
|
||||||
|
currentQuery.value = query
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setSelection(selectAll: Boolean) = withContext(dispatcher) {
|
||||||
|
val visibleIds = itemsWithQuery.first().mapTo(HashSet()) { it.catnum }
|
||||||
|
setSelection(visibleIds, selectAll)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setSelection(ids: List<Int>, isTicked: Boolean) = withContext(dispatcher) {
|
||||||
|
val idSet = ids.toHashSet()
|
||||||
|
currentItems.value = currentItems.value.map { item ->
|
||||||
|
if (item.catnum in idSet) item.copy(isSelected = isTicked) else item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveSelection() = withContext(dispatcher) {
|
||||||
|
val currentSelection = currentItems.value.filter { it.isSelected }.map { it.catnum }
|
||||||
|
settingsRepo.setSelectedIds(currentSelection)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk selection using a pre-built Set for O(1) lookups.
|
||||||
|
*/
|
||||||
|
private suspend fun setSelection(idSet: Set<Int>, isTicked: Boolean) = withContext(dispatcher) {
|
||||||
|
currentItems.value = currentItems.value.map { item ->
|
||||||
|
if (item.catnum in idSet) item.copy(isSelected = isTicked) else item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
|
||||||
|
* and lowercases the query once up front instead of per-item.
|
||||||
|
*/
|
||||||
|
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
|
||||||
|
if (query.isBlank()) return items
|
||||||
|
val catnum = query.toIntOrNull()
|
||||||
|
if (catnum != null) return items.filter { it.catnum == catnum }
|
||||||
|
val lowerQuery = query.lowercase()
|
||||||
|
return items.filter { it.name.lowercase().contains(lowerQuery) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/*
|
||||||
|
* 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 android.hardware.GeomagneticField
|
||||||
|
import android.hardware.Sensor
|
||||||
|
import android.hardware.SensorEvent
|
||||||
|
import android.hardware.SensorEventListener
|
||||||
|
import android.hardware.SensorManager
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
|
import android.view.Display
|
||||||
|
import android.view.Surface
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlin.math.round
|
||||||
|
|
||||||
|
private const val SMOOTHING_FACTOR = 0.15f
|
||||||
|
private const val SENSOR_RATE_US = 16_000
|
||||||
|
|
||||||
|
class SensorsRepo(
|
||||||
|
private val sensorManager: SensorManager,
|
||||||
|
private val displayManager: DisplayManager?
|
||||||
|
) : ISensorsRepo, SensorEventListener {
|
||||||
|
|
||||||
|
private val _sensorData = MutableStateFlow(Pair(0f, 0f))
|
||||||
|
private val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
||||||
|
private val rotationMatrix = FloatArray(9)
|
||||||
|
private val tempMatrix = FloatArray(9)
|
||||||
|
private val orientationValues = FloatArray(3)
|
||||||
|
private var smoothAzimuth = 0f
|
||||||
|
private var smoothPitch = 0f
|
||||||
|
private var hasInitialReading = false
|
||||||
|
|
||||||
|
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
|
||||||
|
|
||||||
|
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
||||||
|
return GeomagneticField(
|
||||||
|
geoPos.latitude.toFloat(),
|
||||||
|
geoPos.longitude.toFloat(),
|
||||||
|
geoPos.altitude.toFloat(),
|
||||||
|
time
|
||||||
|
).declination
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun enableSensor() {
|
||||||
|
hasInitialReading = false
|
||||||
|
sensor?.let { sensorManager.registerListener(this, it, SENSOR_RATE_US) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun disableSensor() = sensorManager.unregisterListener(this)
|
||||||
|
|
||||||
|
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
|
||||||
|
|
||||||
|
override fun onSensorChanged(event: SensorEvent) {
|
||||||
|
if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) handleSensorEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDisplayRotation(): Int {
|
||||||
|
return displayManager?.getDisplay(Display.DEFAULT_DISPLAY)?.rotation ?: Surface.ROTATION_0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun remapForRotation(rotation: Int) {
|
||||||
|
val remapped = when (rotation) {
|
||||||
|
Surface.ROTATION_90 -> SensorManager.remapCoordinateSystem(
|
||||||
|
rotationMatrix, SensorManager.AXIS_Y, SensorManager.AXIS_MINUS_X, tempMatrix
|
||||||
|
)
|
||||||
|
|
||||||
|
Surface.ROTATION_180 -> SensorManager.remapCoordinateSystem(
|
||||||
|
rotationMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_MINUS_Y, tempMatrix
|
||||||
|
)
|
||||||
|
|
||||||
|
Surface.ROTATION_270 -> SensorManager.remapCoordinateSystem(
|
||||||
|
rotationMatrix, SensorManager.AXIS_MINUS_Y, SensorManager.AXIS_X, tempMatrix
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSensorEvent(event: SensorEvent) {
|
||||||
|
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
|
||||||
|
remapForRotation(getDisplayRotation())
|
||||||
|
SensorManager.getOrientation(rotationMatrix, orientationValues)
|
||||||
|
val azimuth = normalizeAzimuth((orientationValues[0] * RAD2DEG).toFloat())
|
||||||
|
val pitch = (orientationValues[1] * RAD2DEG).toFloat()
|
||||||
|
if (!hasInitialReading) {
|
||||||
|
smoothAzimuth = azimuth
|
||||||
|
smoothPitch = pitch
|
||||||
|
hasInitialReading = true
|
||||||
|
} else {
|
||||||
|
smoothAzimuth = lowPassAngle(smoothAzimuth, azimuth)
|
||||||
|
smoothPitch = lowPass(smoothPitch, pitch)
|
||||||
|
}
|
||||||
|
_sensorData.value = Pair(round(smoothAzimuth * 10) / 10, round(smoothPitch * 10) / 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lowPass(previous: Float, current: Float): Float {
|
||||||
|
return previous + SMOOTHING_FACTOR * (current - previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Low-pass filter that accounts for the 0°/360° wraparound.
|
||||||
|
* Always takes the shortest angular path between the two values.
|
||||||
|
*/
|
||||||
|
private fun lowPassAngle(previous: Float, current: Float): Float {
|
||||||
|
var delta = current - previous
|
||||||
|
while (delta > 180f) delta -= 360f
|
||||||
|
while (delta <= -180f) delta += 360f
|
||||||
|
return normalizeAzimuth(previous + SMOOTHING_FACTOR * delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeAzimuth(value: Float): Float = (value + 360f) % 360f
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
/*
|
||||||
|
* 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 android.content.SharedPreferences
|
||||||
|
import android.location.Location
|
||||||
|
import android.location.LocationManager
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.core.location.LocationListenerCompat
|
||||||
|
import androidx.core.location.LocationManagerCompat
|
||||||
|
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.predict.GeoPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.round
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
|
class SettingsRepo(
|
||||||
|
private val locationManager: LocationManager,
|
||||||
|
private val preferences: SharedPreferences,
|
||||||
|
override val appVersionName: String
|
||||||
|
) : ISettingsRepo, LocationListenerCompat {
|
||||||
|
|
||||||
|
private val keyBluetoothRotatorAddress = "bluetoothAddress"
|
||||||
|
private val keyBluetoothRotatorName = "bluetoothName"
|
||||||
|
private val keyBluetoothRotatorFormat = "bluetoothFormat"
|
||||||
|
private val keyBluetoothRotatorState = "bluetoothState"
|
||||||
|
private val keyBluetoothFrequencyState = "bluetoothFrequencyState"
|
||||||
|
private val keyBluetoothFrequencyAddress = "bluetoothFrequencyAddress"
|
||||||
|
private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat"
|
||||||
|
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
|
||||||
|
private val keyFilterHoursAhead = "filterHoursAhead"
|
||||||
|
private val keyFilterMinElevation = "filterMinElevation"
|
||||||
|
private val keyFilterAosStartMinute = "filterAosStartMinute"
|
||||||
|
private val keyFilterAosEndMinute = "filterAosEndMinute"
|
||||||
|
private val keyFilterAosInvert = "filterAosInvert"
|
||||||
|
private val keyNumberOfRadios = "numberOfRadios"
|
||||||
|
private val keyNumberOfSatellites = "numberOfSatellites"
|
||||||
|
private val keyRotatorAddress = "rotatorAddress"
|
||||||
|
private val keyRotatorPort = "rotatorPort"
|
||||||
|
private val keyRotatorState = "rotatorState"
|
||||||
|
private val keyRotatorFormat = "rotatorFormat"
|
||||||
|
private val keyFrequencyState = "frequencyState"
|
||||||
|
private val keyFrequencyAddress = "frequencyAddress"
|
||||||
|
private val keyFrequencyPort = "frequencyPort"
|
||||||
|
private val keyFrequencyFormat = "frequencyFormat"
|
||||||
|
private val keySelectedIds = "selectedIds"
|
||||||
|
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 keyStationAltitude = "stationAltitude"
|
||||||
|
private val keyStationLatitude = "stationLatitude"
|
||||||
|
private val keyStationLongitude = "stationLongitude"
|
||||||
|
private val keyStationQth = "stationQth"
|
||||||
|
private val keyStationTimestamp = "stationTimestamp"
|
||||||
|
private val keyUpdateTimestamp = "updateTimestamp"
|
||||||
|
private val keyShouldSeeWarning = "shouldSeeWarning"
|
||||||
|
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
||||||
|
private val keySstvMode = "sstvMode"
|
||||||
|
private val keyLowElevation = "lowElevation"
|
||||||
|
private val keyHighElevation = "highElevation"
|
||||||
|
private val keyUseCustomTle = "useCustomTle"
|
||||||
|
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||||
|
private val keyTleUrl = "tleUrl"
|
||||||
|
private val keyTransceiversUrl = "transceiversUrl"
|
||||||
|
private val separatorComma = ","
|
||||||
|
|
||||||
|
//region # Satellites selection settings
|
||||||
|
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
||||||
|
private val _typesSelection = MutableStateFlow(getSelectedTypes())
|
||||||
|
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
|
||||||
|
override val selectedTypes: StateFlow<List<String>> = _typesSelection
|
||||||
|
|
||||||
|
override fun setSelectedIds(ids: List<Int>) {
|
||||||
|
val selectionString = ids.joinToString(separatorComma)
|
||||||
|
preferences.edit { putString(keySelectedIds, selectionString) }
|
||||||
|
_satelliteSelection.value = ids
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setSelectedTypes(types: List<String>) {
|
||||||
|
val typesString = types.joinToString(separatorComma)
|
||||||
|
preferences.edit { putString(keySelectedTypes, typesString) }
|
||||||
|
_typesSelection.value = types
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSelectedIds(): List<Int> {
|
||||||
|
val selectionString = preferences.getString(keySelectedIds, null)
|
||||||
|
if (selectionString.isNullOrEmpty()) return emptyList()
|
||||||
|
return selectionString.split(separatorComma).map { it.toInt() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSelectedTypes(): List<String> {
|
||||||
|
val typesString = preferences.getString(keySelectedTypes, "Amateur")
|
||||||
|
if (typesString.isNullOrEmpty()) return emptyList()
|
||||||
|
return typesString.split(separatorComma)
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # Passes filter settings
|
||||||
|
private val _passesSettings = MutableStateFlow(getPassesSettings())
|
||||||
|
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
|
||||||
|
|
||||||
|
override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
|
||||||
|
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
|
||||||
|
putInt(keyFilterHoursAhead, settings.hoursAhead)
|
||||||
|
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
|
||||||
|
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
||||||
|
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
||||||
|
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
||||||
|
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
|
||||||
|
_passesSettings.value = settings
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPassesSettings(): PassesSettings {
|
||||||
|
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
|
||||||
|
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
|
||||||
|
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
|
||||||
|
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
||||||
|
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
||||||
|
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
|
||||||
|
val selectedModesString = preferences.getString(keySelectedModes, null)
|
||||||
|
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
|
||||||
|
return PassesSettings(
|
||||||
|
showDeepSpace,
|
||||||
|
hoursAhead,
|
||||||
|
minElevation,
|
||||||
|
aosStartMinute,
|
||||||
|
aosEndMinute,
|
||||||
|
invertAosTimeWindow,
|
||||||
|
selectedModes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # Station position settings
|
||||||
|
private val _stationPosition = MutableStateFlow(getStationPosition())
|
||||||
|
private val providerDef = LocationManager.PASSIVE_PROVIDER
|
||||||
|
private val providerGps = LocationManager.GPS_PROVIDER
|
||||||
|
private val providerNet = LocationManager.NETWORK_PROVIDER
|
||||||
|
override val stationPosition: StateFlow<GeoPos> = _stationPosition
|
||||||
|
|
||||||
|
override fun onLocationChanged(location: Location) {
|
||||||
|
setStationPosition(location.latitude, location.longitude, location.altitude)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean {
|
||||||
|
val newLongitude = if (longitude > 180.0) longitude - 180 else longitude
|
||||||
|
val locator = positionToQth(latitude, newLongitude) ?: return false
|
||||||
|
setStationPosition(latitude, newLongitude, altitude, locator)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setStationPosition(): Boolean {
|
||||||
|
if (!LocationManagerCompat.isLocationEnabled(locationManager)) return false
|
||||||
|
try {
|
||||||
|
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
|
||||||
|
val hasNet = LocationManagerCompat.hasProvider(locationManager, providerNet)
|
||||||
|
val provider = if (hasGps) providerGps else if (hasNet) providerNet else providerDef
|
||||||
|
val location = locationManager.getLastKnownLocation(providerDef)
|
||||||
|
if (location == null || System.currentTimeMillis() - location.time > 600_000L) {
|
||||||
|
println("Requesting location for $provider provider")
|
||||||
|
locationManager.requestLocationUpdates(provider, 0L, 0f, this)
|
||||||
|
} else {
|
||||||
|
setStationPosition(location.latitude, location.longitude, location.altitude)
|
||||||
|
}
|
||||||
|
} catch (exception: SecurityException) {
|
||||||
|
println("No permissions were given - $exception")
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setStationPosition(locator: String): Boolean {
|
||||||
|
val position = qthToPosition(locator) ?: return false
|
||||||
|
setStationPosition(position.latitude, position.longitude, 0.0, locator)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getStationPosition(): GeoPos {
|
||||||
|
val latitude = (preferences.getString(keyStationLatitude, null) ?: "0.0").toDouble()
|
||||||
|
val longitude = (preferences.getString(keyStationLongitude, null) ?: "0.0").toDouble()
|
||||||
|
val altitude = (preferences.getString(keyStationAltitude, null) ?: "0.0").toDouble()
|
||||||
|
val qthLocator = preferences.getString(keyStationQth, null) ?: "JJ00aa"
|
||||||
|
val timestamp = preferences.getLong(keyStationTimestamp, 0L)
|
||||||
|
return GeoPos(latitude, longitude, altitude, qthLocator, timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setStationPosition(latitude: Double, longitude: Double, altitude: Double, locator: String) {
|
||||||
|
val newLat = latitude.round(4)
|
||||||
|
val newLon = longitude.round(4)
|
||||||
|
val newAlt = altitude.round(1)
|
||||||
|
val timestamp = System.currentTimeMillis()
|
||||||
|
println("Received new Position($newLat, $newLon, $newAlt) & Locator $locator")
|
||||||
|
setStationPosition(GeoPos(newLat, newLon, newAlt, locator, timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setStationPosition(stationPos: GeoPos) = preferences.edit {
|
||||||
|
putString(keyStationLatitude, stationPos.latitude.toString())
|
||||||
|
putString(keyStationLongitude, stationPos.longitude.toString())
|
||||||
|
putString(keyStationAltitude, stationPos.altitude.toString())
|
||||||
|
putString(keyStationQth, stationPos.qthLocator)
|
||||||
|
putLong(keyStationTimestamp, stationPos.timestamp)
|
||||||
|
_stationPosition.value = stationPos
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # Database update settings
|
||||||
|
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)
|
||||||
|
putInt(keyNumberOfRadios, state.numberOfRadios)
|
||||||
|
putLong(keyUpdateTimestamp, state.updateTimestamp)
|
||||||
|
_databaseState.value = state
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDatabaseState(): DatabaseState {
|
||||||
|
val numberOfRadios = preferences.getInt(keyNumberOfRadios, 0)
|
||||||
|
val numberOfSatellites = preferences.getInt(keyNumberOfSatellites, 0)
|
||||||
|
val updateTimestamp = preferences.getLong(keyUpdateTimestamp, 0L)
|
||||||
|
return DatabaseState(numberOfRadios, numberOfSatellites, updateTimestamp)
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # RC settings
|
||||||
|
init {
|
||||||
|
migrateRCFormats()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Remove after a few releases (added in v4.2.0)
|
||||||
|
private val keyRCFormatsMigrated = "rcFormatsMigrated"
|
||||||
|
|
||||||
|
private fun migrateRCFormats() {
|
||||||
|
if (preferences.getBoolean(keyRCFormatsMigrated, false)) return
|
||||||
|
val formatKeys = listOf(
|
||||||
|
keyRotatorFormat, keyFrequencyFormat, keyBluetoothRotatorFormat, keyBluetoothFrequencyFormat
|
||||||
|
)
|
||||||
|
preferences.edit {
|
||||||
|
for (key in formatKeys) {
|
||||||
|
val value = preferences.getString(key, null) ?: continue
|
||||||
|
if (value.contains("_") && !value.startsWith("\\")) {
|
||||||
|
putString(key, "\\$value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
putBoolean(keyRCFormatsMigrated, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _rcSettings = MutableStateFlow(getRCSettings())
|
||||||
|
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
||||||
|
|
||||||
|
override fun updateRCSettings(settings: RCSettings) {
|
||||||
|
preferences.edit {
|
||||||
|
putBoolean(keyRotatorState, settings.rotatorState)
|
||||||
|
putString(keyRotatorAddress, settings.rotatorAddress)
|
||||||
|
putString(keyRotatorPort, settings.rotatorPort)
|
||||||
|
putString(keyRotatorFormat, settings.rotatorFormat)
|
||||||
|
putBoolean(keyFrequencyState, settings.frequencyState)
|
||||||
|
putString(keyFrequencyAddress, settings.frequencyAddress)
|
||||||
|
putString(keyFrequencyPort, settings.frequencyPort)
|
||||||
|
putString(keyFrequencyFormat, settings.frequencyFormat)
|
||||||
|
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
||||||
|
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
||||||
|
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
||||||
|
putString(keyBluetoothRotatorAddress, settings.bluetoothRotatorAddress)
|
||||||
|
putBoolean(keyBluetoothFrequencyState, settings.bluetoothFrequencyState)
|
||||||
|
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
||||||
|
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
||||||
|
}
|
||||||
|
_rcSettings.value = settings
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getRCSettings(): RCSettings = RCSettings(
|
||||||
|
rotatorState = preferences.getBoolean(keyRotatorState, false),
|
||||||
|
rotatorAddress = preferences.getString(keyRotatorAddress, null) ?: "127.0.0.1",
|
||||||
|
rotatorPort = preferences.getString(keyRotatorPort, null) ?: "4533",
|
||||||
|
rotatorFormat = preferences.getString(keyRotatorFormat, null) ?: $$"P $AZ $EL",
|
||||||
|
frequencyState = preferences.getBoolean(keyFrequencyState, false),
|
||||||
|
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
|
||||||
|
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
|
||||||
|
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
|
||||||
|
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
|
||||||
|
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
||||||
|
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
||||||
|
bluetoothRotatorAddress = preferences.getString(keyBluetoothRotatorAddress, null) ?: "00:0C:BF:13:80:5D",
|
||||||
|
bluetoothFrequencyState = preferences.getBoolean(keyBluetoothFrequencyState, false),
|
||||||
|
bluetoothFrequencyAddress = preferences.getString(keyBluetoothFrequencyAddress, null) ?: "00:0C:BF:13:80:5D",
|
||||||
|
bluetoothFrequencyFormat = preferences.getString(keyBluetoothFrequencyFormat, null) ?: $$"F $FREQ"
|
||||||
|
)
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # Other settings
|
||||||
|
private val _otherSettings = MutableStateFlow(getOtherSettings())
|
||||||
|
override val otherSettings: StateFlow<OtherSettings> = _otherSettings
|
||||||
|
|
||||||
|
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) {
|
||||||
|
_otherSettings.update { current ->
|
||||||
|
val new = transform(current)
|
||||||
|
preferences.edit {
|
||||||
|
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
|
||||||
|
putBoolean(keyStateOfSensors, new.stateOfSensors)
|
||||||
|
putBoolean(keyStateOfSweep, new.stateOfSweep)
|
||||||
|
putBoolean(keyStateOfUtc, new.stateOfUtc)
|
||||||
|
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
|
||||||
|
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
|
||||||
|
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
||||||
|
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
||||||
|
putString(keySstvMode, new.sstvMode)
|
||||||
|
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
||||||
|
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||||
|
}
|
||||||
|
new
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOtherSettings(): OtherSettings = OtherSettings(
|
||||||
|
stateOfAutoUpdate = preferences.getBoolean(keyStateOfAutoUpdate, true),
|
||||||
|
stateOfSensors = preferences.getBoolean(keyStateOfSensors, true),
|
||||||
|
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
|
||||||
|
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
|
||||||
|
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
|
||||||
|
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, 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()))
|
||||||
|
)
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # Data sources settings
|
||||||
|
private val _dataSourcesSettings = MutableStateFlow(getDataSourcesSettings())
|
||||||
|
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
|
||||||
|
|
||||||
|
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
|
||||||
|
preferences.edit {
|
||||||
|
putBoolean(keyUseCustomTle, settings.useCustomTLE)
|
||||||
|
putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers)
|
||||||
|
putString(keyTleUrl, settings.tleUrl)
|
||||||
|
putString(keyTransceiversUrl, settings.transceiversUrl)
|
||||||
|
}
|
||||||
|
_dataSourcesSettings.value = settings
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
||||||
|
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
|
||||||
|
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
|
||||||
|
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
|
||||||
|
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
|
||||||
|
)
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # Radio control settings
|
||||||
|
private val keyRadioControlEnabled = "radioControlEnabled"
|
||||||
|
private val keyRadioModel = "radioModel"
|
||||||
|
private val keyTxRadioAddress = "txRadioAddress"
|
||||||
|
private val keyRxRadioAddress = "rxRadioAddress"
|
||||||
|
private val keyTxRadioName = "txRadioName"
|
||||||
|
private val keyRxRadioName = "rxRadioName"
|
||||||
|
private val keyRadioBaudRate = "radioBaudRate"
|
||||||
|
private val keyRadioSplitMode = "radioSplitMode"
|
||||||
|
|
||||||
|
private val _radioControlSettings = MutableStateFlow(getRadioControlSettings())
|
||||||
|
override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings
|
||||||
|
|
||||||
|
override fun updateRadioControlSettings(settings: RadioControlSettings) {
|
||||||
|
preferences.edit {
|
||||||
|
putBoolean(keyRadioControlEnabled, settings.enabled)
|
||||||
|
putString(keyRadioModel, settings.radioModel)
|
||||||
|
putString(keyTxRadioAddress, settings.txRadioAddress)
|
||||||
|
putString(keyRxRadioAddress, settings.rxRadioAddress)
|
||||||
|
putString(keyTxRadioName, settings.txRadioName)
|
||||||
|
putString(keyRxRadioName, settings.rxRadioName)
|
||||||
|
putInt(keyRadioBaudRate, settings.baudRate)
|
||||||
|
putBoolean(keyRadioSplitMode, settings.splitMode)
|
||||||
|
}
|
||||||
|
_radioControlSettings.value = settings
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings(
|
||||||
|
enabled = preferences.getBoolean(keyRadioControlEnabled, false),
|
||||||
|
radioModel = preferences.getString(keyRadioModel, null) ?: RadioControlSettings.MODEL_YAESU_FT817,
|
||||||
|
txRadioAddress = preferences.getString(keyTxRadioAddress, null) ?: "",
|
||||||
|
rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "",
|
||||||
|
txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio",
|
||||||
|
rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio",
|
||||||
|
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
|
||||||
|
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
||||||
|
)
|
||||||
|
//endregion
|
||||||
|
}
|
||||||
@@ -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.source
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.data.database.Look4SatDao
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
|
import com.rtbishop.look4sat.core.data.database.entity.SatEntry as FrameworkEntry
|
||||||
|
import com.rtbishop.look4sat.core.data.database.entity.SatRadio as FrameworkRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio as DomainRadio
|
||||||
|
|
||||||
|
class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
||||||
|
|
||||||
|
//region # Entries region
|
||||||
|
|
||||||
|
override suspend fun getEntriesTotal() = look4SatDao.getEntriesTotal()
|
||||||
|
|
||||||
|
override suspend fun getEntriesList() = look4SatDao.getEntriesList()
|
||||||
|
|
||||||
|
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> {
|
||||||
|
val selectedOrbitalObjects = mutableListOf<OrbitalObject>()
|
||||||
|
ids.chunked(999).forEach { idsPart ->
|
||||||
|
val entries = look4SatDao.getEntriesWithIds(idsPart)
|
||||||
|
selectedOrbitalObjects.addAll(entries.toDomain().map { entry -> entry.getObject() })
|
||||||
|
}
|
||||||
|
return selectedOrbitalObjects
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity())
|
||||||
|
|
||||||
|
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
|
||||||
|
|
||||||
|
override suspend fun getIdsWithModes(modes: List<String>) = look4SatDao.getIdsWithModes(modes)
|
||||||
|
|
||||||
|
private fun FrameworkEntry.toDomain() = OrbitalData(
|
||||||
|
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
||||||
|
this.raan, this.argper, this.meanan, this.catnum, this.bstar
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun OrbitalData.toEntity() = FrameworkEntry(
|
||||||
|
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
||||||
|
this.raan, this.argper, this.meanan, this.catnum, this.bstar
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() }
|
||||||
|
|
||||||
|
private fun List<FrameworkEntry>.toDomain() = this.map { item -> item.toDomain() }
|
||||||
|
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # Radios region
|
||||||
|
|
||||||
|
override suspend fun getRadiosTotal() = look4SatDao.getRadiosTotal()
|
||||||
|
|
||||||
|
override suspend fun getRadiosWithId(id: Int): List<SatRadio> {
|
||||||
|
return look4SatDao.getRadiosWithId(id).toDomainRadios()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun insertRadios(radios: List<SatRadio>) {
|
||||||
|
look4SatDao.insertRadios(radios.toFrameworkRadios())
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteRadios() = look4SatDao.deleteRadios()
|
||||||
|
|
||||||
|
private fun DomainRadio.toFramework() = FrameworkRadio(
|
||||||
|
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
||||||
|
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun FrameworkRadio.toDomain() = DomainRadio(
|
||||||
|
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
||||||
|
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() }
|
||||||
|
|
||||||
|
private fun List<FrameworkRadio>.toDomainRadios() = this.map { radio -> radio.toDomain() }
|
||||||
|
|
||||||
|
//endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.source
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
class RemoteSource(
|
||||||
|
private val dispatcher: CoroutineDispatcher,
|
||||||
|
private val contentResolver: ContentResolver,
|
||||||
|
private val httpClient: OkHttpClient
|
||||||
|
) : IRemoteSource {
|
||||||
|
|
||||||
|
override suspend fun getFileStream(uri: String): InputStream? = withContext(dispatcher) {
|
||||||
|
try {
|
||||||
|
val fileUri = uri.toUri()
|
||||||
|
contentResolver.openInputStream(fileUri)?.buffered()
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
println("RemoteSource file stream exception: $exception")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
||||||
|
try {
|
||||||
|
val networkRequest = Request.Builder().url(url).build()
|
||||||
|
httpClient.newCall(networkRequest).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@withContext null
|
||||||
|
ByteArrayInputStream(response.body.bytes())
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
println("RemoteSource network stream exception: $exception")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* 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.usecase
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||||
|
|
||||||
|
class AddToCalendar(private val context: Context) : IAddToCalendar {
|
||||||
|
override fun invoke(name: String, aosTime: Long, losTime: Long) {
|
||||||
|
val intent = Intent(Intent.ACTION_INSERT).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
setData(CalendarContract.Events.CONTENT_URI)
|
||||||
|
putExtra(CalendarContract.Events.TITLE, name)
|
||||||
|
putExtra(CalendarContract.Events.DESCRIPTION, "Look4Sat Pass")
|
||||||
|
putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, aosTime)
|
||||||
|
putExtra(CalendarContract.EXTRA_EVENT_END_TIME, losTime)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
context.startActivity(intent)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* 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.usecase
|
||||||
|
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.AudioRecord
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import androidx.annotation.RequiresPermission
|
||||||
|
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
|
||||||
|
class AudioCapture : IAudioCapture {
|
||||||
|
|
||||||
|
override val sampleRate: Int = 44100
|
||||||
|
|
||||||
|
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
|
||||||
|
private val audioFormat = AudioFormat.ENCODING_PCM_FLOAT
|
||||||
|
private val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
|
||||||
|
.coerceAtLeast(sampleRate) // at least 1 second buffer
|
||||||
|
|
||||||
|
@RequiresPermission(android.Manifest.permission.RECORD_AUDIO)
|
||||||
|
override fun audioFlow(): Flow<FloatArray> = flow {
|
||||||
|
val recorder = AudioRecord(
|
||||||
|
MediaRecorder.AudioSource.MIC,
|
||||||
|
sampleRate,
|
||||||
|
channelConfig,
|
||||||
|
audioFormat,
|
||||||
|
bufferSize * 4 // bytes for float
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
recorder.startRecording()
|
||||||
|
val chunkSize = sampleRate / 10 // ~100ms chunks
|
||||||
|
val buffer = FloatArray(chunkSize)
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
val read = recorder.read(buffer, 0, chunkSize, AudioRecord.READ_BLOCKING)
|
||||||
|
if (read > 0) emit(if (read == chunkSize) buffer.copyOf() else buffer.copyOfRange(0, read))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
recorder.stop()
|
||||||
|
recorder.release()
|
||||||
|
}
|
||||||
|
}.flowOn(Dispatchers.IO)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/*
|
||||||
|
* 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.usecase
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
//Saves images to the device gallery using MediaStore (API 29+) or direct file write (API < 29)
|
||||||
|
class SaveImage(private val context: Context) : ISaveImage {
|
||||||
|
|
||||||
|
override suspend fun invoke(pixels: IntArray, width: Int, height: Int, modeName: String): Boolean {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val bitmap = Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
|
||||||
|
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||||
|
val filename = "SSTV_${modeName}_$timestamp.png"
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
saveWithMediaStore(bitmap, filename)
|
||||||
|
} else {
|
||||||
|
saveToExternalStorage(bitmap, filename)
|
||||||
|
}
|
||||||
|
bitmap.recycle()
|
||||||
|
true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveWithMediaStore(bitmap: Bitmap, filename: String) {
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(MediaStore.Images.Media.DISPLAY_NAME, filename)
|
||||||
|
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
|
||||||
|
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/Look4Sat")
|
||||||
|
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||||
|
}
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
|
||||||
|
?: throw IllegalStateException("Failed to create MediaStore entry")
|
||||||
|
resolver.openOutputStream(uri)?.use { stream ->
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||||
|
}
|
||||||
|
values.clear()
|
||||||
|
values.put(MediaStore.Images.Media.IS_PENDING, 0)
|
||||||
|
resolver.update(uri, values, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveToExternalStorage(bitmap: Bitmap, filename: String) {
|
||||||
|
val picturesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
|
||||||
|
val dir = File(picturesDir, "Look4Sat")
|
||||||
|
if (!dir.exists()) dir.mkdirs()
|
||||||
|
val file = File(dir, filename)
|
||||||
|
FileOutputStream(file).use { stream ->
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.data.usecase
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.widget.Toast
|
||||||
|
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||||
|
|
||||||
|
class ShowToast(private val context: Context) : IShowToast {
|
||||||
|
override fun invoke(message: String) {
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class Ft817CatProtocolTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeFrequencyBcd_145500000() {
|
||||||
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(145500000L)
|
||||||
|
assertArrayEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00), bcd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeFrequencyBcd_435100000() {
|
||||||
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(435100000L)
|
||||||
|
assertArrayEquals(byteArrayOf(0x43, 0x51, 0x00, 0x00), bcd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeFrequencyBcd_7074000() {
|
||||||
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(7074000L)
|
||||||
|
assertArrayEquals(byteArrayOf(0x00, 0x70, 0x74, 0x00), bcd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decodeFrequencyBcd_roundTrips() {
|
||||||
|
val testFreqs = listOf(145500000L, 435100000L, 7074000L, 14200000L, 28500000L)
|
||||||
|
for (freq in testFreqs) {
|
||||||
|
val rounded = (freq / 10) * 10
|
||||||
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(freq)
|
||||||
|
assertEquals(rounded, Ft817CatProtocol.decodeFrequencyBcd(bcd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildSetFreqCommand_correctFormat() {
|
||||||
|
val cmd = Ft817CatProtocol.buildSetFreqCommand(145500000L)
|
||||||
|
assertEquals(5, cmd.size)
|
||||||
|
assertEquals(0x01.toByte(), cmd[4])
|
||||||
|
assertArrayEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01), cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildSetModeCommand_usb() {
|
||||||
|
val cmd = Ft817CatProtocol.buildSetModeCommand("USB")
|
||||||
|
assertNotNull(cmd)
|
||||||
|
assertArrayEquals(byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x07), cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildSetModeCommand_fm() {
|
||||||
|
val cmd = Ft817CatProtocol.buildSetModeCommand("FM")
|
||||||
|
assertNotNull(cmd)
|
||||||
|
assertArrayEquals(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x07), cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildSetModeCommand_unknownReturnsNull() {
|
||||||
|
assertNull(Ft817CatProtocol.buildSetModeCommand("INVALID"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeCtcssTone_67_0() {
|
||||||
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(67.0)
|
||||||
|
assertArrayEquals(byteArrayOf(0x06, 0x70), bcd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeCtcssTone_74_4() {
|
||||||
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(74.4)
|
||||||
|
assertArrayEquals(byteArrayOf(0x07, 0x44), bcd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeCtcssTone_141_3() {
|
||||||
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(141.3)
|
||||||
|
assertArrayEquals(byteArrayOf(0x14, 0x13), bcd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildSetCtcssToneCommand_correctFormat() {
|
||||||
|
val cmd = Ft817CatProtocol.buildSetCtcssToneCommand(67.0)
|
||||||
|
assertEquals(5, cmd.size)
|
||||||
|
assertEquals(0x0B.toByte(), cmd[4])
|
||||||
|
assertArrayEquals(byteArrayOf(0x06, 0x70, 0x00, 0x00, 0x0B), cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildCtcssModeCommand_enable() {
|
||||||
|
val cmd = Ft817CatProtocol.buildCtcssModeCommand(true)
|
||||||
|
assertArrayEquals(byteArrayOf(0x2A, 0x00, 0x00, 0x00, 0x0A), cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildCtcssModeCommand_disable() {
|
||||||
|
val cmd = Ft817CatProtocol.buildCtcssModeCommand(false)
|
||||||
|
assertEquals(0x8A.toByte(), cmd[0])
|
||||||
|
assertEquals(0x0A.toByte(), cmd[4])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseReadResponse_validResponse() {
|
||||||
|
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01)
|
||||||
|
val result = Ft817CatProtocol.parseReadResponse(response)
|
||||||
|
assertNotNull(result)
|
||||||
|
result ?: return
|
||||||
|
assertEquals(145500000L, result.first)
|
||||||
|
assertEquals("USB", result.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseReadResponse_fmMode() {
|
||||||
|
val response = byteArrayOf(0x14, 0x60, 0x00, 0x00, 0x08)
|
||||||
|
val result = Ft817CatProtocol.parseReadResponse(response)
|
||||||
|
assertNotNull(result)
|
||||||
|
result ?: return
|
||||||
|
assertEquals(146000000L, result.first)
|
||||||
|
assertEquals("FM", result.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseReadResponse_tooShort() {
|
||||||
|
assertNull(Ft817CatProtocol.parseReadResponse(byteArrayOf(0x14, 0x55, 0x00)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseReadResponse_unknownMode() {
|
||||||
|
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x0F)
|
||||||
|
assertNull(Ft817CatProtocol.parseReadResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildPttCommands() {
|
||||||
|
val on = Ft817CatProtocol.buildPttOnCommand()
|
||||||
|
assertArrayEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x08), on)
|
||||||
|
|
||||||
|
val off = Ft817CatProtocol.buildPttOffCommand()
|
||||||
|
assertEquals(0x88.toByte(), off[4])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildReadCommand() {
|
||||||
|
val cmd = Ft817CatProtocol.buildReadFreqModeCommand()
|
||||||
|
assertArrayEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x03), cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
+234
@@ -0,0 +1,234 @@
|
|||||||
|
/*
|
||||||
|
* 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.OrbitalData
|
||||||
|
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.utility.DataParser
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class DatabaseRepoTest {
|
||||||
|
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
private val dataParser = DataParser(dispatcher)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
|
||||||
|
val uri = "content://look4sat/import/satellites"
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
fileStreams[uri] = { validCsvStream() }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo()
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
|
||||||
|
repository.updateTLEFromFile(uri)
|
||||||
|
|
||||||
|
assertEquals(1, localSource.insertedEntries.size)
|
||||||
|
assertEquals(25544, localSource.insertedEntries.first().catnum)
|
||||||
|
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
||||||
|
assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `manual satellite import keeps tle support`() = runTest(dispatcher) {
|
||||||
|
val uri = "content://look4sat/import/legacy"
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
fileStreams[uri] = { validTleStream() }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo()
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
|
||||||
|
repository.updateTLEFromFile(uri)
|
||||||
|
|
||||||
|
assertEquals(1, localSource.insertedEntries.size)
|
||||||
|
assertEquals(25544, localSource.insertedEntries.first().catnum)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `custom data source imports omm csv from web`() = runTest(dispatcher) {
|
||||||
|
val customCsvUrl = "https://example.com/custom-omm.csv"
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
networkStreams[customCsvUrl] = { validCsvStream() }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo(
|
||||||
|
dataSources = DataSourcesSettings(
|
||||||
|
useCustomTLE = true,
|
||||||
|
useCustomTransceivers = false,
|
||||||
|
tleUrl = customCsvUrl,
|
||||||
|
transceiversUrl = ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
|
||||||
|
repository.updateFromRemote()
|
||||||
|
|
||||||
|
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
||||||
|
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validCsvStream(): InputStream = """
|
||||||
|
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||||
|
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
|
||||||
|
private fun validTleStream(): InputStream = """
|
||||||
|
ISS (ZARYA)
|
||||||
|
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||||
|
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeRemoteSource : IRemoteSource {
|
||||||
|
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||||
|
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||||
|
|
||||||
|
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
||||||
|
|
||||||
|
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeLocalSource : ILocalSource {
|
||||||
|
val insertedEntries = mutableListOf<OrbitalData>()
|
||||||
|
private val insertedRadios = mutableListOf<SatRadio>()
|
||||||
|
|
||||||
|
override suspend fun getEntriesTotal(): Int = insertedEntries.size
|
||||||
|
|
||||||
|
override suspend fun getEntriesList(): List<SatItem> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun insertEntries(entries: List<OrbitalData>) {
|
||||||
|
insertedEntries += entries
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteEntries() {
|
||||||
|
insertedEntries.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun getRadiosTotal(): Int = insertedRadios.size
|
||||||
|
|
||||||
|
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun insertRadios(radios: List<SatRadio>) {
|
||||||
|
insertedRadios += radios
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteRadios() {
|
||||||
|
insertedRadios.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo {
|
||||||
|
|
||||||
|
override val appVersionName: String = "test"
|
||||||
|
|
||||||
|
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
|
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
|
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||||
|
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
|
||||||
|
)
|
||||||
|
|
||||||
|
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: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
|
||||||
|
|
||||||
|
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||||
|
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||||
|
)
|
||||||
|
|
||||||
|
val satelliteTypeIdsByType = mutableMapOf<String, List<Int>>()
|
||||||
|
|
||||||
|
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||||
|
|
||||||
|
override fun setSelectedTypes(types: List<String>) = Unit
|
||||||
|
|
||||||
|
override fun setPassesSettings(settings: PassesSettings) = Unit
|
||||||
|
|
||||||
|
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
|
||||||
|
|
||||||
|
override fun setStationPosition(): Boolean = true
|
||||||
|
|
||||||
|
override fun setStationPosition(locator: String): Boolean = true
|
||||||
|
|
||||||
|
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
|
||||||
|
|
||||||
|
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
||||||
|
satelliteTypeIdsByType[type] = ids
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
dataSourcesSettings.value = settings
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||||
|
return DataSourcesSettings(
|
||||||
|
useCustomTLE = false,
|
||||||
|
useCustomTransceivers = false,
|
||||||
|
tleUrl = "",
|
||||||
|
transceiversUrl = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.convention.coreDomainPlugin)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
data class SatItem(val catnum: Int, val name: String, val isSelected: Boolean = false)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/*
|
||||||
|
* 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 kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SatRadio(
|
||||||
|
@SerialName("uuid") val uuid: String,
|
||||||
|
@SerialName("description") val info: String,
|
||||||
|
@SerialName("alive") val isAlive: Boolean,
|
||||||
|
@SerialName("downlink_low") val downlinkLow: Long?,
|
||||||
|
@SerialName("downlink_high") val downlinkHigh: Long?,
|
||||||
|
@SerialName("mode") val downlinkMode: String?,
|
||||||
|
@SerialName("uplink_low") val uplinkLow: Long?,
|
||||||
|
@SerialName("uplink_high") val uplinkHigh: Long?,
|
||||||
|
@SerialName("uplink_mode") val uplinkMode: String?,
|
||||||
|
@SerialName("invert") val isInverted: Boolean,
|
||||||
|
@SerialName("norad_cat_id") val catnum: Int?
|
||||||
|
)
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.model
|
||||||
|
|
||||||
|
data class DatabaseState(
|
||||||
|
val numberOfRadios: Int,
|
||||||
|
val numberOfSatellites: Int,
|
||||||
|
val updateTimestamp: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PassesSettings(
|
||||||
|
val showDeepSpace: Boolean = true,
|
||||||
|
val hoursAhead: Int,
|
||||||
|
val minElevation: Double,
|
||||||
|
val aosStartMinute: Int = 0,
|
||||||
|
val aosEndMinute: Int = 23 * 60 + 59,
|
||||||
|
val invertAosTimeWindow: Boolean = false,
|
||||||
|
val selectedModes: List<String>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RCSettings(
|
||||||
|
val rotatorState: Boolean,
|
||||||
|
val rotatorAddress: String,
|
||||||
|
val rotatorPort: String,
|
||||||
|
val rotatorFormat: String,
|
||||||
|
val frequencyState: Boolean,
|
||||||
|
val frequencyAddress: String,
|
||||||
|
val frequencyPort: String,
|
||||||
|
val frequencyFormat: String,
|
||||||
|
val bluetoothRotatorState: Boolean,
|
||||||
|
val bluetoothRotatorFormat: String,
|
||||||
|
val bluetoothRotatorName: String,
|
||||||
|
val bluetoothRotatorAddress: String,
|
||||||
|
val bluetoothFrequencyState: Boolean,
|
||||||
|
val bluetoothFrequencyFormat: String,
|
||||||
|
val bluetoothFrequencyAddress: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class OtherSettings(
|
||||||
|
val stateOfAutoUpdate: Boolean,
|
||||||
|
val stateOfSensors: Boolean,
|
||||||
|
val stateOfSweep: Boolean,
|
||||||
|
val stateOfUtc: Boolean,
|
||||||
|
val stateOfLightTheme: Boolean,
|
||||||
|
val stateOfNightMode: Boolean = false,
|
||||||
|
val shouldSeeWarning: Boolean,
|
||||||
|
val shouldSeeWhatsNew: Boolean,
|
||||||
|
val sstvMode: String = "Auto",
|
||||||
|
val lowElevation: Double = 15.0,
|
||||||
|
val highElevation: Double = 45.0
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DataSourcesSettings(
|
||||||
|
val useCustomTLE: Boolean,
|
||||||
|
val useCustomTransceivers: Boolean,
|
||||||
|
val tleUrl: String,
|
||||||
|
val transceiversUrl: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RadioControlSettings(
|
||||||
|
val enabled: Boolean,
|
||||||
|
val radioModel: String,
|
||||||
|
val txRadioAddress: String,
|
||||||
|
val rxRadioAddress: String,
|
||||||
|
val txRadioName: String,
|
||||||
|
val rxRadioName: String,
|
||||||
|
val baudRate: Int,
|
||||||
|
/** IC-705 only: use single-radio split-VFO mode instead of two radios. */
|
||||||
|
val splitMode: Boolean = false
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val MODEL_YAESU_FT817 = "Yaesu FT-817/818"
|
||||||
|
const val MODEL_YAESU_FT857 = "Yaesu FT-857/897"
|
||||||
|
const val MODEL_ICOM_IC705 = "Icom IC-705"
|
||||||
|
|
||||||
|
val SUPPORTED_RADIOS = listOf(MODEL_YAESU_FT817, MODEL_YAESU_FT857, MODEL_ICOM_IC705)
|
||||||
|
|
||||||
|
/** Baud rates available for Yaesu radios. */
|
||||||
|
val BAUD_RATES_YAESU = listOf(4800, 9600, 38400)
|
||||||
|
/** Baud rates available for Icom IC-705 (higher speeds supported via CI-V USB/BT). */
|
||||||
|
val BAUD_RATES_ICOM = listOf(4800, 9600, 19200, 38400, 57600, 115200)
|
||||||
|
}
|
||||||
|
}
|
||||||
+667
@@ -0,0 +1,667 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.predict
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.toRadians
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.acos
|
||||||
|
import kotlin.math.asin
|
||||||
|
import kotlin.math.atan
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.log10
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
import kotlin.math.tan
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone celestial computations extracted from PREDICT v2.2.5.
|
||||||
|
* Provides Sun position, Moon position, satellite visibility classification,
|
||||||
|
* orbital metadata, RA/Dec conversion, and rise/set finding for Sun and Moon.
|
||||||
|
*
|
||||||
|
* All angles are in degrees unless noted. Time is Unix epoch milliseconds.
|
||||||
|
*
|
||||||
|
* Shared math utilities (thetaGJD, modulus, mod2PI, deltaET, millisToDaynum,
|
||||||
|
* solarPositionECI, eciToGeodetic) live in OrbitalMath.kt in the same package.
|
||||||
|
*/
|
||||||
|
object CelestialComputer {
|
||||||
|
|
||||||
|
// ── Result types ──
|
||||||
|
|
||||||
|
/** Sun position as seen from a ground observer. */
|
||||||
|
data class SunPosition(
|
||||||
|
val azimuth: Double, // degrees, 0=N, 90=E
|
||||||
|
val elevation: Double, // degrees, >0 above horizon
|
||||||
|
val distance: Double, // normalized: 1.0 + ((range - AU) / AU)
|
||||||
|
val rangeRate: Double, // km/s
|
||||||
|
val latitude: Double, // sub-solar point latitude, degrees
|
||||||
|
val longitude: Double, // sub-solar point longitude, degrees
|
||||||
|
val rightAscension: Double, // degrees
|
||||||
|
val declination: Double // degrees
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Moon position as seen from a ground observer. */
|
||||||
|
data class MoonPosition(
|
||||||
|
val azimuth: Double, // degrees, 0=N, 90=E
|
||||||
|
val elevation: Double, // degrees
|
||||||
|
val rightAscension: Double, // degrees
|
||||||
|
val declination: Double, // degrees
|
||||||
|
val gha: Double, // Greenwich Hour Angle, degrees
|
||||||
|
val angularDiameter: Double, // apparent diameter relative to Earth's diameter
|
||||||
|
val radialVelocity: Double // m/s, Doppler radial velocity for EME
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3-state satellite visibility classification.
|
||||||
|
* - [VISIBLE]: satellite is sunlit, observer is in darkness (sun below -12°) — optically visible
|
||||||
|
* - [DAYLIGHT]: satellite is sunlit, observer is in daylight
|
||||||
|
* - [ECLIPSED]: satellite is in Earth's shadow
|
||||||
|
*/
|
||||||
|
enum class SatVisibility { VISIBLE, DAYLIGHT, ECLIPSED }
|
||||||
|
|
||||||
|
/** Orbital metadata not typically included in pass data. */
|
||||||
|
data class OrbitalMetadata(
|
||||||
|
val footprintDiameter: Double, // km, ground coverage circle diameter
|
||||||
|
val orbitNumber: Long, // current orbit/revolution number
|
||||||
|
val betaAngle: Double, // degrees, angle between orbital plane and Sun
|
||||||
|
val orbitalPhase: Double // 0-256 phase within current orbit
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Sun position ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the Sun's full position as seen from [observer] at [timeMillis].
|
||||||
|
* Includes az/el, RA/Dec, sub-solar lat/lon, range, and range rate.
|
||||||
|
* Based on FindSun() from PREDICT v2.2.5.
|
||||||
|
*/
|
||||||
|
fun getSunPosition(observer: GeoPos, timeMillis: Long): SunPosition {
|
||||||
|
val daynum = millisToDaynum(timeMillis)
|
||||||
|
val julUtc = daynum + 2444238.5
|
||||||
|
val sunVec = solarPositionECI(julUtc)
|
||||||
|
val zeroVel = doubleArrayOf(0.0, 0.0, 0.0)
|
||||||
|
val obsGeo = observerGeodetic(observer)
|
||||||
|
|
||||||
|
// Az, El, Range, RangeRate
|
||||||
|
val obsSet = computeObsAngles(julUtc, sunVec, zeroVel, obsGeo)
|
||||||
|
|
||||||
|
// Lat/Lon of sub-solar point
|
||||||
|
val latLon = eciToGeodetic(julUtc, sunVec)
|
||||||
|
|
||||||
|
// RA/Dec
|
||||||
|
val raDec = calculateRADec(julUtc, sunVec, zeroVel, obsGeo)
|
||||||
|
|
||||||
|
return SunPosition(
|
||||||
|
azimuth = obsSet[0].toDegrees(),
|
||||||
|
elevation = obsSet[1].toDegrees(),
|
||||||
|
distance = 1.0 + ((obsSet[2] - ASTRONOMICAL_UNIT) / ASTRONOMICAL_UNIT),
|
||||||
|
rangeRate = 1000.0 * obsSet[3],
|
||||||
|
latitude = latLon[0].toDegrees(),
|
||||||
|
longitude = latLon[1].toDegrees().let { if (it > 180.0) it - 360.0 else it },
|
||||||
|
rightAscension = raDec[0].toDegrees(),
|
||||||
|
declination = raDec[1].toDegrees()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Moon position ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the Moon's position as seen from [observer] at [timeMillis].
|
||||||
|
* Full Meeus lunar ephemeris from PREDICT v2.2.5 with expanded terms
|
||||||
|
* and radial velocity approximation for EME Doppler.
|
||||||
|
*/
|
||||||
|
fun getMoonPosition(observer: GeoPos, timeMillis: Long): MoonPosition {
|
||||||
|
val daynum = millisToDaynum(timeMillis)
|
||||||
|
val jd = daynum + 2444238.5
|
||||||
|
var t = (jd - 2415020.0) / 36525.0
|
||||||
|
val t2 = t * t
|
||||||
|
val t3 = t2 * t
|
||||||
|
|
||||||
|
var l1 = 270.434164 + 481267.8831 * t - 0.001133 * t2 + 0.0000019 * t3
|
||||||
|
var mSun = 358.475833 + 35999.0498 * t - 0.00015 * t2 - 0.0000033 * t3
|
||||||
|
var m1 = 296.104608 + 477198.8491 * t + 0.009192 * t2 + 0.0000144 * t3
|
||||||
|
var d = 350.737486 + 445267.1142 * t - 0.001436 * t2 + 0.0000019 * t3
|
||||||
|
var ff = 11.250889 + 483202.0251 * t - 0.003211 * t2 - 0.0000003 * t3
|
||||||
|
val om = (259.183275 - 1934.142 * t + 0.002078 * t2 + 0.0000022 * t3) * DEG2RAD
|
||||||
|
|
||||||
|
val correction512 = sin((51.2 + 20.2 * t) * DEG2RAD)
|
||||||
|
val ss = 0.003964 * sin((346.56 + 132.87 * t - 0.0091731 * t2) * DEG2RAD)
|
||||||
|
l1 += 0.000233 * correction512 + ss + 0.001964 * sin(om)
|
||||||
|
mSun -= 0.001778 * correction512
|
||||||
|
m1 += 0.000817 * correction512 + ss + 0.002541 * sin(om)
|
||||||
|
d += 0.002011 * correction512 + ss + 0.001964 * sin(om)
|
||||||
|
ff += ss - 0.024691 * sin(om) - 0.004328 * sin(om + (275.05 - 2.3 * t) * DEG2RAD)
|
||||||
|
|
||||||
|
val ex = 1.0 - 0.002495 * t - 0.00000752 * t2
|
||||||
|
l1 = primeAngle(l1); mSun = primeAngle(mSun); m1 = primeAngle(m1)
|
||||||
|
d = primeAngle(d); ff = primeAngle(ff)
|
||||||
|
|
||||||
|
val mR = mSun * DEG2RAD
|
||||||
|
val m1R = m1 * DEG2RAD
|
||||||
|
val dR = d * DEG2RAD
|
||||||
|
val ffR = ff * DEG2RAD
|
||||||
|
|
||||||
|
// Ecliptic longitude — expanded v225 terms
|
||||||
|
var l = l1 + 6.28875 * sin(m1R) + 1.274018 * sin(2 * dR - m1R) + 0.658309 * sin(2 * dR)
|
||||||
|
l += 0.213616 * sin(2 * m1R) - ex * 0.185596 * sin(mR) - 0.114336 * sin(2 * ffR)
|
||||||
|
l += 0.058793 * sin(2 * dR - 2 * m1R) + ex * 0.057212 * sin(2 * dR - mR - m1R) + 0.05332 * sin(2 * dR + m1R)
|
||||||
|
l += ex * 0.045874 * sin(2 * dR - mR) + ex * 0.041024 * sin(m1R - mR) - 0.034718 * sin(dR)
|
||||||
|
l -= ex * 0.030465 * sin(mR + m1R) + 0.015326 * sin(2 * dR - 2 * ffR) - 0.012528 * sin(2 * ffR + m1R)
|
||||||
|
l -= 0.01098 * sin(2 * ffR - m1R) + 0.010674 * sin(4 * dR - m1R) + 0.010034 * sin(3 * m1R)
|
||||||
|
l += 0.008548 * sin(4 * dR - 2 * m1R) - ex * 0.00791 * sin(mR - m1R + 2 * dR)
|
||||||
|
l -= ex * 0.006783 * sin(2 * dR + mR)
|
||||||
|
l += 0.005162 * sin(m1R - dR) + ex * 0.005 * sin(mR + dR) + ex * 0.004049 * sin(m1R - mR + 2 * dR)
|
||||||
|
l += 0.003996 * sin(2 * m1R + 2 * dR) + 0.003862 * sin(4 * dR) + 0.003665 * sin(2 * dR - 3 * m1R)
|
||||||
|
l += ex * 0.002695 * sin(2 * m1R - mR) + 0.002602 * sin(m1R - 2 * ffR - 2 * dR)
|
||||||
|
l += ex * 0.002396 * sin(2 * dR - mR - 2 * m1R)
|
||||||
|
l -= 0.002349 * sin(m1R + dR) + ex * ex * 0.002249 * sin(2 * dR - 2 * mR)
|
||||||
|
l -= ex * 0.002125 * sin(2 * m1R + mR)
|
||||||
|
l -= ex * ex * 0.002079 * sin(2 * mR) + ex * ex * 0.002059 * sin(2 * dR - m1R - 2 * mR)
|
||||||
|
l -= 0.001773 * sin(m1R + 2 * dR - 2 * ffR)
|
||||||
|
l += ex * 0.00122 * sin(4 * dR - mR - m1R) - 0.00111 * sin(2 * m1R + 2 * ffR) + 0.000892 * sin(m1R - 3 * dR)
|
||||||
|
l -= ex * 0.000811 * sin(mR + m1R + 2 * dR) + ex * 0.000761 * sin(4 * dR - mR - 2 * m1R)
|
||||||
|
l += ex * ex * 0.000717 * sin(m1R - 2 * mR)
|
||||||
|
l += ex * ex * 0.000704 * sin(m1R - 2 * mR - 2 * dR) + ex * 0.000693 * sin(mR - 2 * m1R + 2 * dR)
|
||||||
|
l += ex * 0.000598 * sin(2 * dR - mR - 2 * ffR) + 0.00055 * sin(m1R + 4 * dR)
|
||||||
|
l += 0.000538 * sin(4 * m1R) + ex * 0.000521 * sin(4 * dR - mR) + 0.000486 * sin(2 * m1R - dR)
|
||||||
|
l -= 0.001595 * sin(2 * ffR + 2 * dR)
|
||||||
|
|
||||||
|
// Ecliptic latitude — expanded v225 terms
|
||||||
|
var b =
|
||||||
|
5.128189 * sin(ffR) + 0.280606 * sin(m1R + ffR) + 0.277693 * sin(m1R - ffR) + 0.173238 * sin(2 * dR - ffR)
|
||||||
|
b += 0.055413 * sin(2 * dR + ffR - m1R) + 0.046272 * sin(2 * dR - ffR - m1R) + 0.032573 * sin(2 * dR + ffR)
|
||||||
|
b += 0.017198 * sin(2 * m1R + ffR) + 9.266999e-03 * sin(2 * dR + m1R - ffR) + 0.008823 * sin(2 * m1R - ffR)
|
||||||
|
b += ex * 0.008247 * sin(2 * dR - mR - ffR) + 0.004323 * sin(2 * dR - ffR - 2 * m1R)
|
||||||
|
b += 0.0042 * sin(2 * dR + ffR + m1R)
|
||||||
|
b += ex * 0.003372 * sin(ffR - mR - 2 * dR) + ex * 0.002472 * sin(2 * dR + ffR - mR - m1R)
|
||||||
|
b += ex * 0.002222 * sin(2 * dR + ffR - mR)
|
||||||
|
b += 0.002072 * sin(2 * dR - ffR - mR - m1R) + ex * 0.001877 * sin(ffR - mR + m1R)
|
||||||
|
b += 0.001828 * sin(4 * dR - ffR - m1R)
|
||||||
|
b -= ex * 0.001803 * sin(ffR + mR) - 0.00175 * sin(3 * ffR)
|
||||||
|
b += ex * 0.00157 * sin(m1R - mR - ffR) - 0.001487 * sin(ffR + dR)
|
||||||
|
b -= ex * 0.001481 * sin(ffR + mR + m1R) + ex * 0.001417 * sin(ffR - mR - m1R)
|
||||||
|
b += ex * 0.00135 * sin(ffR - mR) + 0.00133 * sin(ffR - dR)
|
||||||
|
b += 0.001106 * sin(ffR + 3 * m1R) + 0.00102 * sin(4 * dR - ffR) + 0.000833 * sin(ffR + 4 * dR - m1R)
|
||||||
|
b += 0.000781 * sin(m1R - 3 * ffR) + 0.00067 * sin(ffR + 4 * dR - 2 * m1R)
|
||||||
|
b += 0.000606 * sin(2 * dR - 3 * ffR)
|
||||||
|
b += 0.000597 * sin(2 * dR + 2 * m1R - ffR) + ex * 0.000492 * sin(2 * dR + m1R - mR - ffR)
|
||||||
|
b += 0.00045 * sin(2 * m1R - ffR - 2 * dR)
|
||||||
|
b += 0.000439 * sin(3 * m1R - ffR) + 0.000423 * sin(ffR + 2 * dR + 2 * m1R)
|
||||||
|
b += 0.000422 * sin(2 * dR - ffR - 3 * m1R)
|
||||||
|
b -= ex * 0.000367 * sin(mR + ffR + 2 * dR - m1R) - ex * 0.000353 * sin(mR + ffR + 2 * dR)
|
||||||
|
b += 0.000331 * sin(ffR + 4 * dR)
|
||||||
|
b += ex * 0.000317 * sin(2 * dR + ffR - mR + m1R) + ex * ex * 0.000306 * sin(2 * dR - 2 * mR - ffR)
|
||||||
|
b -= 0.000283 * sin(m1R + 3 * ffR)
|
||||||
|
|
||||||
|
val w1 = 0.0004664 * cos(om)
|
||||||
|
val w2 = 0.0000754 * cos(om + (275.05 - 2.3 * t) * DEG2RAD)
|
||||||
|
val bt = b * (1.0 - w1 - w2)
|
||||||
|
|
||||||
|
// Parallax — expanded v225 terms
|
||||||
|
var p =
|
||||||
|
0.950724 + 0.051818 * cos(m1R) + 0.009531 * cos(2 * dR - m1R) + 0.007843 * cos(2 * dR) + 0.002824 * cos(2 * m1R)
|
||||||
|
p += 0.000857 * cos(2 * dR + m1R) + ex * 0.000533 * cos(2 * dR - mR) + ex * 0.000401 * cos(2 * dR - mR - m1R)
|
||||||
|
p += 0.000173 * cos(3 * m1R) + 0.000167 * cos(4 * dR - m1R) - ex * 0.000111 * cos(mR)
|
||||||
|
p += 0.000103 * cos(4 * dR - 2 * m1R) - 0.000084 * cos(2 * m1R - 2 * dR) - ex * 0.000083 * cos(2 * dR + mR)
|
||||||
|
p += 0.000079 * cos(2 * dR + 2 * m1R)
|
||||||
|
p += 0.000072 * cos(4 * dR) + ex * 0.000064 * cos(2 * dR - mR + m1R) - ex * 0.000063 * cos(2 * dR + mR - m1R)
|
||||||
|
p += ex * 0.000041 * cos(mR + dR) + ex * 0.000035 * cos(2 * m1R - mR) - 0.000033 * cos(3 * m1R - 2 * dR)
|
||||||
|
p -= 0.00003 * cos(m1R + dR) - 0.000029 * cos(2 * ffR - 2 * dR) - ex * 0.000029 * cos(2 * m1R + mR)
|
||||||
|
p += ex * ex * 0.000026 * cos(2 * dR - 2 * mR) - 0.000023 * cos(2 * ffR - 2 * dR + m1R)
|
||||||
|
p += ex * 0.000019 * cos(4 * dR - mR - m1R)
|
||||||
|
|
||||||
|
val bRad = bt * DEG2RAD
|
||||||
|
val lm = l * DEG2RAD
|
||||||
|
val moonDx = 3.0 / (PI * p)
|
||||||
|
|
||||||
|
// Ecliptic → equatorial
|
||||||
|
val z = (jd - 2415020.5) / 365.2422
|
||||||
|
val ob = (23.452294 - (0.46845 * z + 5.9e-07 * z * z) / 3600.0).toRadians()
|
||||||
|
val dec = asin(sin(bRad) * cos(ob) + cos(bRad) * sin(ob) * sin(lm))
|
||||||
|
var ra = acos(cos(bRad) * cos(lm) / cos(dec)); if (lm > PI) ra = TWO_PI - ra
|
||||||
|
|
||||||
|
val n = observer.latitude * DEG2RAD
|
||||||
|
t = (jd - 2451545.0) / 36525.0
|
||||||
|
var teg = 280.46061837 + 360.98564736629 * (jd - 2451545.0) + (0.000387933 * t - t * t / 38710000.0) * t
|
||||||
|
while (teg > 360.0) teg -= 360.0
|
||||||
|
// LST = GMST + east longitude (positive east convention)
|
||||||
|
val th = mod2PI((teg + observer.longitude) * DEG2RAD)
|
||||||
|
val h = th - ra
|
||||||
|
val azVal = atan2(sin(h), cos(h) * sin(n) - tan(dec) * cos(n)) + PI
|
||||||
|
val el = asin(sin(n) * sin(dec) + cos(n) * cos(dec) * cos(h))
|
||||||
|
|
||||||
|
// Moon radial velocity approximation (from "Amateur Radio Software", GM4ANB, RSGB 1985)
|
||||||
|
val mm = fixAngle(1.319238 + daynum * 0.228027135)
|
||||||
|
val radT2 = 0.10976
|
||||||
|
val radT1 = mm + radT2 * sin(mm)
|
||||||
|
var dv = 0.01255 * moonDx * moonDx * sin(radT1) * (1.0 + radT2 * cos(mm))
|
||||||
|
dv *= 4449.0
|
||||||
|
val earthR = 6378.0
|
||||||
|
val moonDist = 384401.0
|
||||||
|
val radT3 = earthR * moonDist * (cos(dec) * cos(n) * sin(h)) /
|
||||||
|
sqrt(moonDist * moonDist - moonDist * earthR * sin(el))
|
||||||
|
val moonDv = dv + radT3 * 0.0753125
|
||||||
|
|
||||||
|
val moonRa = ra / DEG2RAD
|
||||||
|
var moonGha = teg - moonRa
|
||||||
|
if (moonGha < 0.0) moonGha += 360.0
|
||||||
|
|
||||||
|
return MoonPosition(
|
||||||
|
azimuth = azVal / DEG2RAD,
|
||||||
|
elevation = el / DEG2RAD,
|
||||||
|
rightAscension = moonRa,
|
||||||
|
declination = dec / DEG2RAD,
|
||||||
|
gha = moonGha,
|
||||||
|
angularDiameter = moonDx,
|
||||||
|
radialVelocity = moonDv
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Satellite visibility ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify satellite visibility given its eclipse state and the Sun's elevation
|
||||||
|
* at the observer's location.
|
||||||
|
*
|
||||||
|
* @param isEclipsed whether the satellite is in Earth's shadow
|
||||||
|
* @param sunElevationDeg Sun elevation at observer in degrees
|
||||||
|
* @param satElevationDeg satellite elevation at observer in degrees (must be >= 0)
|
||||||
|
*/
|
||||||
|
fun classifyVisibility(
|
||||||
|
isEclipsed: Boolean,
|
||||||
|
sunElevationDeg: Double,
|
||||||
|
satElevationDeg: Double
|
||||||
|
): SatVisibility {
|
||||||
|
if (isEclipsed) return SatVisibility.ECLIPSED
|
||||||
|
return if (sunElevationDeg <= -12.0 && satElevationDeg >= 0.0) SatVisibility.VISIBLE
|
||||||
|
else SatVisibility.DAYLIGHT
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Orbital metadata ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute orbital metadata for a satellite at its current position.
|
||||||
|
*
|
||||||
|
* @param altitudeKm satellite altitude in km
|
||||||
|
* @param meanMotion revolutions per day from TLE
|
||||||
|
* @param bstar drag term from TLE
|
||||||
|
* @param meanAnomaly mean anomaly at epoch (radians)
|
||||||
|
* @param revNumAtEpoch revolution number at TLE epoch
|
||||||
|
* @param ageDays days since TLE epoch (julUTC - julEpoch)
|
||||||
|
* @param phase orbital phase in radians (from SGP4/SDP4 output)
|
||||||
|
* @param satPosECI satellite ECI position [x, y, z]
|
||||||
|
* @param satVelECI satellite ECI velocity [vx, vy, vz]
|
||||||
|
* @param sunPosECI sun ECI position [x, y, z]
|
||||||
|
*/
|
||||||
|
fun computeOrbitalMetadata(
|
||||||
|
altitudeKm: Double,
|
||||||
|
meanMotion: Double,
|
||||||
|
bstar: Double,
|
||||||
|
meanAnomaly: Double,
|
||||||
|
revNumAtEpoch: Int,
|
||||||
|
ageDays: Double,
|
||||||
|
phase: Double,
|
||||||
|
satPosECI: DoubleArray,
|
||||||
|
satVelECI: DoubleArray,
|
||||||
|
sunPosECI: DoubleArray
|
||||||
|
): OrbitalMetadata {
|
||||||
|
// Footprint diameter (km)
|
||||||
|
val footprint = 12756.33 * acos(EARTH_RADIUS / (EARTH_RADIUS + altitudeKm))
|
||||||
|
|
||||||
|
// Orbit number
|
||||||
|
val xmnpda = 1.44E3
|
||||||
|
val orbitNum = floor(
|
||||||
|
(meanMotion * xmnpda / TWO_PI + ageDays * bstar) * ageDays + meanAnomaly / TWO_PI
|
||||||
|
).toLong() + revNumAtEpoch
|
||||||
|
|
||||||
|
// Beta angle: angle between orbital plane and Sun direction
|
||||||
|
// Orbital plane normal = cross(pos, vel)
|
||||||
|
val nx = satPosECI[1] * satVelECI[2] - satPosECI[2] * satVelECI[1]
|
||||||
|
val ny = satPosECI[2] * satVelECI[0] - satPosECI[0] * satVelECI[2]
|
||||||
|
val nz = satPosECI[0] * satVelECI[1] - satPosECI[1] * satVelECI[0]
|
||||||
|
val nMag = sqrt(nx * nx + ny * ny + nz * nz)
|
||||||
|
val sMag = sqrt(sunPosECI[0] * sunPosECI[0] + sunPosECI[1] * sunPosECI[1] + sunPosECI[2] * sunPosECI[2])
|
||||||
|
val dotNS = nx * sunPosECI[0] + ny * sunPosECI[1] + nz * sunPosECI[2]
|
||||||
|
val betaAngle = if (nMag > 0 && sMag > 0) {
|
||||||
|
(PI / 2.0 - acos(dotNS / (nMag * sMag))).toDegrees()
|
||||||
|
} else 0.0
|
||||||
|
|
||||||
|
// Phase (0-256 scale, matching PREDICT convention)
|
||||||
|
val orbitalPhase = 256.0 * (phase / TWO_PI)
|
||||||
|
|
||||||
|
return OrbitalMetadata(footprint, orbitNum, betaAngle, orbitalPhase)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Satellite status checks ──
|
||||||
|
|
||||||
|
/** Check if a satellite is geostationary (mean motion ≈ 1.0027 rev/day). */
|
||||||
|
fun isGeostationary(meanMotion: Double): Boolean = abs(meanMotion - 1.0027) < 0.0002
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a satellite has likely decayed based on drag and time since epoch.
|
||||||
|
*
|
||||||
|
* @param meanMotion revolutions per day
|
||||||
|
* @param drag first derivative of mean motion / 2 (from TLE line 1)
|
||||||
|
* @param epochDaynum TLE epoch as daynum (days since 31Dec79)
|
||||||
|
* @param currentDaynum current time as daynum
|
||||||
|
*/
|
||||||
|
fun hasDecayed(meanMotion: Double, drag: Double, epochDaynum: Double, currentDaynum: Double): Boolean {
|
||||||
|
return epochDaynum + ((16.666666 - meanMotion) / (10.0 * abs(drag))) < currentDaynum
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rise/Set finding ──
|
||||||
|
|
||||||
|
/** Rise and set times for a celestial body. */
|
||||||
|
data class RiseSetTimes(
|
||||||
|
val riseTimeMillis: Long, // 0 if not found
|
||||||
|
val setTimeMillis: Long // 0 if not found
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the next sunrise and sunset times from [startMillis] for [observer].
|
||||||
|
* Uses elevation threshold of -0.8333° to match the standard civil definition:
|
||||||
|
* upper limb on geometric horizon with standard atmospheric refraction (~0.57°)
|
||||||
|
* and solar semidiameter (~0.27°) corrections applied, matching USNO/timeanddate.com.
|
||||||
|
*/
|
||||||
|
fun findSunRiseSet(observer: GeoPos, startMillis: Long): RiseSetTimes {
|
||||||
|
// Standard civil threshold: center elevation when upper limb meets geometric horizon
|
||||||
|
// -0.8333° = standard refraction (~0.5667°) + solar semidiameter (~0.2667°)
|
||||||
|
val threshold = 0.8333
|
||||||
|
var daynum = millisToDaynum(startMillis)
|
||||||
|
var sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
|
||||||
|
// Phase 1: if sun is above threshold, fast-forward to well past sunset into night
|
||||||
|
if (sunPos.elevation > -threshold) {
|
||||||
|
var guard = 0
|
||||||
|
while (sunPos.elevation > -threshold && guard++ < 500) {
|
||||||
|
daynum += 0.008 // fixed ~11.5 min steps past the setting sun
|
||||||
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
// Now advance until sun is clearly below minimum (deep night)
|
||||||
|
guard = 0
|
||||||
|
while (sunPos.elevation > -12.0 && guard++ < 500) {
|
||||||
|
daynum += 0.02
|
||||||
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: advance until sun starts rising toward threshold (elevation increasing)
|
||||||
|
var guard = 0
|
||||||
|
while (sunPos.elevation < -threshold && guard++ < 500) {
|
||||||
|
daynum += 0.008
|
||||||
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: converge symmetrically on elevation = -threshold (sunrise)
|
||||||
|
var sunrise = 0.0
|
||||||
|
guard = 0
|
||||||
|
while (sunrise == 0.0 && guard++ < 200) {
|
||||||
|
val delta = sunPos.elevation + threshold
|
||||||
|
if (abs(delta) < 0.01) {
|
||||||
|
sunrise = daynum
|
||||||
|
} else {
|
||||||
|
daynum -= 0.004 * delta
|
||||||
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sunrise == 0.0) sunrise = daynum
|
||||||
|
|
||||||
|
// Phase 4: fast-forward through the day until sun drops back below threshold
|
||||||
|
daynum = sunrise
|
||||||
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
guard = 0
|
||||||
|
while (sunPos.elevation > -threshold && guard++ < 500) {
|
||||||
|
daynum += 0.008
|
||||||
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 5: converge symmetrically on elevation = -threshold (sunset)
|
||||||
|
var sunset = 0.0
|
||||||
|
guard = 0
|
||||||
|
while (sunset == 0.0 && guard++ < 200) {
|
||||||
|
val delta = sunPos.elevation + threshold
|
||||||
|
if (abs(delta) < 0.01) {
|
||||||
|
sunset = daynum
|
||||||
|
} else {
|
||||||
|
daynum += 0.004 * delta
|
||||||
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sunset == 0.0) sunset = daynum
|
||||||
|
|
||||||
|
return RiseSetTimes(daynumToMillis(sunrise), daynumToMillis(sunset))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the next moonrise and moonset times from [startMillis] for [observer].
|
||||||
|
* Uses the adaptive iteration from PREDICT v2.2.5's PredictMoon().
|
||||||
|
*/
|
||||||
|
fun findMoonRiseSet(observer: GeoPos, startMillis: Long): RiseSetTimes {
|
||||||
|
var daynum = millisToDaynum(startMillis)
|
||||||
|
var moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||||
|
|
||||||
|
// If moon is already up, move forward until it sets
|
||||||
|
var guard = 0
|
||||||
|
if (moonPos.elevation > 0) {
|
||||||
|
while (moonPos.elevation > 0 && guard++ < 1000) {
|
||||||
|
daynum += 0.004 * sin(DEG2RAD * (moonPos.elevation + 0.5))
|
||||||
|
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
daynum += 0.4
|
||||||
|
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
// Find moonrise
|
||||||
|
var moonrise = 0.0
|
||||||
|
guard = 0
|
||||||
|
while (moonrise == 0.0 && guard++ < 1000) {
|
||||||
|
if (abs(moonPos.elevation) < 0.03) {
|
||||||
|
moonrise = daynum
|
||||||
|
} else {
|
||||||
|
daynum -= 0.004 * moonPos.elevation
|
||||||
|
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (moonrise == 0.0) moonrise = daynum
|
||||||
|
|
||||||
|
// Find moonset from moonrise
|
||||||
|
daynum = moonrise
|
||||||
|
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||||
|
guard = 0
|
||||||
|
while (moonPos.elevation > -1 && guard++ < 1000) {
|
||||||
|
daynum += 0.04 * cos(DEG2RAD * (moonPos.elevation + 0.5))
|
||||||
|
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
var moonset = 0.0
|
||||||
|
guard = 0
|
||||||
|
while (moonset == 0.0 && guard++ < 1000) {
|
||||||
|
if (abs(moonPos.elevation) < 0.03) {
|
||||||
|
moonset = daynum
|
||||||
|
} else {
|
||||||
|
daynum += 0.004 * moonPos.elevation
|
||||||
|
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (moonset == 0.0) moonset = daynum
|
||||||
|
|
||||||
|
return RiseSetTimes(daynumToMillis(moonrise), daynumToMillis(moonset))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Visual magnitude estimation ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate the apparent visual magnitude of a satellite.
|
||||||
|
*
|
||||||
|
* Uses the standard formula from McCants/Heavens-Above:
|
||||||
|
* apparentMag = stdMag + 5 * log10(range / 1000) - 15 * log10(cos(phaseAngle / 2))
|
||||||
|
*
|
||||||
|
* @param rangeKm slant range from observer to satellite in km
|
||||||
|
* @param phaseAngleDeg Sun-satellite-observer angle in degrees
|
||||||
|
* @param stdMag intrinsic/standard magnitude (default 4.0)
|
||||||
|
* @return estimated apparent visual magnitude
|
||||||
|
*/
|
||||||
|
fun estimateVisualMagnitude(rangeKm: Double, phaseAngleDeg: Double, stdMag: Double = 4.0): Double {
|
||||||
|
if (rangeKm <= 0) return stdMag
|
||||||
|
val halfPhaseRad = phaseAngleDeg.toRadians() / 2.0
|
||||||
|
val cosHalfPhase = cos(halfPhaseRad)
|
||||||
|
val phaseTerm = if (cosHalfPhase > 1e-6) -15.0 * log10(cosHalfPhase) else 99.0
|
||||||
|
return stdMag + 5.0 * log10(rangeKm / 1000.0) + phaseTerm
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the phase angle (Sun-satellite-observer) in degrees.
|
||||||
|
*
|
||||||
|
* @param satPosECI satellite ECI position [x, y, z] in km
|
||||||
|
* @param sunPosECI sun ECI position [x, y, z] in km
|
||||||
|
* @param obsPosECI observer ECI position [x, y, z] in km
|
||||||
|
* @return phase angle in degrees (0 = fully illuminated face toward observer)
|
||||||
|
*/
|
||||||
|
fun computePhaseAngle(satPosECI: DoubleArray, sunPosECI: DoubleArray, obsPosECI: DoubleArray): Double {
|
||||||
|
val toSunX = sunPosECI[0] - satPosECI[0]
|
||||||
|
val toSunY = sunPosECI[1] - satPosECI[1]
|
||||||
|
val toSunZ = sunPosECI[2] - satPosECI[2]
|
||||||
|
val toObsX = obsPosECI[0] - satPosECI[0]
|
||||||
|
val toObsY = obsPosECI[1] - satPosECI[1]
|
||||||
|
val toObsZ = obsPosECI[2] - satPosECI[2]
|
||||||
|
val dot = toSunX * toObsX + toSunY * toObsY + toSunZ * toObsZ
|
||||||
|
val magSun = sqrt(toSunX * toSunX + toSunY * toSunY + toSunZ * toSunZ)
|
||||||
|
val magObs = sqrt(toObsX * toObsX + toObsY * toObsY + toObsZ * toObsZ)
|
||||||
|
if (magSun == 0.0 || magObs == 0.0) return 90.0
|
||||||
|
val cosAngle = (dot / (magSun * magObs)).coerceIn(-1.0, 1.0)
|
||||||
|
return acos(cosAngle).toDegrees()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Doppler ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute Doppler shift for a given base frequency and range rate.
|
||||||
|
*
|
||||||
|
* @param frequencyHz base frequency in Hz
|
||||||
|
* @param rangeRateKmS range rate in km/s (negative = approaching)
|
||||||
|
* @return shifted frequency in Hz
|
||||||
|
*/
|
||||||
|
fun dopplerShift(frequencyHz: Double, rangeRateKmS: Double): Double {
|
||||||
|
return frequencyHz * (299792.458 - rangeRateKmS) / 299792.458
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal helpers ──
|
||||||
|
|
||||||
|
private fun observerGeodetic(pos: GeoPos): DoubleArray {
|
||||||
|
// [lat_rad, lon_rad, alt_km] — longitude positive east, matching OrbitalObject convention.
|
||||||
|
// LST = thetaGJD(julUtc) + obsGeo[1] = GMST + lon_rad (correct).
|
||||||
|
return doubleArrayOf(pos.latitude * DEG2RAD, pos.longitude * DEG2RAD, pos.altitude / 1000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert az/el observation to Right Ascension / Declination.
|
||||||
|
* Returns [ra_rad, dec_rad].
|
||||||
|
* Based on Calculate_RADec() from PREDICT v2.2.5 (Escobal method).
|
||||||
|
*/
|
||||||
|
private fun calculateRADec(
|
||||||
|
julUtc: Double,
|
||||||
|
targetPos: DoubleArray,
|
||||||
|
targetVel: DoubleArray,
|
||||||
|
obsGeo: DoubleArray
|
||||||
|
): DoubleArray {
|
||||||
|
val obsSet = computeObsAngles(julUtc, targetPos, targetVel, obsGeo)
|
||||||
|
val az = obsSet[0]
|
||||||
|
val el = obsSet[1]
|
||||||
|
val phi = obsGeo[0]
|
||||||
|
val theta = mod2PI(thetaGJD(julUtc) + obsGeo[1])
|
||||||
|
val sinTheta = sin(theta)
|
||||||
|
val cosTheta = cos(theta)
|
||||||
|
val sinPhi = sin(phi)
|
||||||
|
val cosPhi = cos(phi)
|
||||||
|
val lxh = -cos(az) * cos(el)
|
||||||
|
val lyh = sin(az) * cos(el)
|
||||||
|
val lzh = sin(el)
|
||||||
|
val sx = sinPhi * cosTheta
|
||||||
|
val ex2 = -sinTheta
|
||||||
|
val zx = cosTheta * cosPhi
|
||||||
|
val sy = sinPhi * sinTheta
|
||||||
|
val zy = sinTheta * cosPhi
|
||||||
|
val sz = -cosPhi
|
||||||
|
val lx = sx * lxh + ex2 * lyh + zx * lzh
|
||||||
|
val ly = sy * lxh + cosTheta * lyh + zy * lzh
|
||||||
|
val lz = sz * lxh + 0.0 * lyh + sinPhi * lzh
|
||||||
|
val dec = asin(lz)
|
||||||
|
val cosDelta = sqrt(1.0 - lz * lz)
|
||||||
|
val sinAlpha = ly / cosDelta
|
||||||
|
val cosAlpha = lx / cosDelta
|
||||||
|
val ra = mod2PI(atan2(sinAlpha, cosAlpha))
|
||||||
|
return doubleArrayOf(ra, dec)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute observer look-angles (az, el, range, rangeRate) to a target at ECI position.
|
||||||
|
* Returns [azimuth_rad, elevation_rad, range_km, rangeRate_km/s].
|
||||||
|
* Azimuth is north-referenced (0=N, π/2=E), matching OrbitalObject's convention.
|
||||||
|
*/
|
||||||
|
private fun computeObsAngles(
|
||||||
|
julUtc: Double,
|
||||||
|
targetPos: DoubleArray,
|
||||||
|
targetVel: DoubleArray,
|
||||||
|
obsGeo: DoubleArray // [lat_rad, lon_rad, alt_km]
|
||||||
|
): DoubleArray {
|
||||||
|
val theta = mod2PI(thetaGJD(julUtc) + obsGeo[1])
|
||||||
|
val c = 1.0 / sqrt(1 + FLAT_FACT * (FLAT_FACT - 2) * sin(obsGeo[0]).pow(2))
|
||||||
|
val sq = (1 - FLAT_FACT).pow(2) * c
|
||||||
|
val achcp = (EARTH_RADIUS * c + obsGeo[2]) * cos(obsGeo[0])
|
||||||
|
val ox = achcp * cos(theta)
|
||||||
|
val oy = achcp * sin(theta)
|
||||||
|
val oz = (EARTH_RADIUS * sq + obsGeo[2]) * sin(obsGeo[0])
|
||||||
|
val ovx = -MFACTOR * oy
|
||||||
|
val ovy = MFACTOR * ox
|
||||||
|
|
||||||
|
val rx = targetPos[0] - ox
|
||||||
|
val ry = targetPos[1] - oy
|
||||||
|
val rz = targetPos[2] - oz
|
||||||
|
val rMag = sqrt(rx * rx + ry * ry + rz * rz)
|
||||||
|
val rvx = targetVel[0] - ovx
|
||||||
|
val rvy = targetVel[1] - ovy
|
||||||
|
val rvz = targetVel[2]
|
||||||
|
|
||||||
|
val sinLat = sin(obsGeo[0])
|
||||||
|
val cosLat = cos(obsGeo[0])
|
||||||
|
val sinTheta = sin(theta)
|
||||||
|
val cosTheta = cos(theta)
|
||||||
|
val topS = sinLat * cosTheta * rx + sinLat * sinTheta * ry - cosLat * rz
|
||||||
|
val topE = -sinTheta * rx + cosTheta * ry
|
||||||
|
val topZ = cosLat * cosTheta * rx + cosLat * sinTheta * ry + sinLat * rz
|
||||||
|
|
||||||
|
// Match north-based convention (0=N, 90=E) used by OrbitalObject.calculateObs
|
||||||
|
// Must use atan(-topE / topS) not atan2(-topE, topS) — they differ in quadrant handling
|
||||||
|
var azim = atan(-topE / topS)
|
||||||
|
if (topS > 0.0) azim += PI
|
||||||
|
if (azim < 0.0) azim += TWO_PI
|
||||||
|
val el = asin(topZ / rMag)
|
||||||
|
val rangeRate = (rx * rvx + ry * rvy + rz * rvz) / rMag
|
||||||
|
|
||||||
|
return doubleArrayOf(azim, el, rMag, rangeRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MFACTOR = 7.292115E-5
|
||||||
|
|
||||||
|
private fun primeAngle(x: Double) = x - 360.0 * floor(x / 360.0)
|
||||||
|
|
||||||
|
private fun fixAngle(x: Double): Double {
|
||||||
|
var a = x; while (a > TWO_PI) a -= TWO_PI; return a
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.predict
|
||||||
|
|
||||||
|
const val ASTRONOMICAL_UNIT = 1.49597870691E8
|
||||||
|
const val DEG2RAD = 0.017453292519943295
|
||||||
|
const val RAD2DEG = 57.29577951308232
|
||||||
|
const val EARTH_RADIUS = 6378.137
|
||||||
|
const val EARTH_ROT_PER_SID_DAY = 1.00273790934
|
||||||
|
const val EPSILON = 1.0E-12
|
||||||
|
const val FLAT_FACT = 3.35281066474748E-3
|
||||||
|
const val J3_HARMONIC = -2.53881E-6
|
||||||
|
const val MIN_PER_DAY = 1.44E3
|
||||||
|
const val SEC_PER_DAY = 8.6400E4
|
||||||
|
const val SOLAR_RADIUS = 6.96000E5
|
||||||
|
const val SPEED_OF_LIGHT = 2.99792458E8
|
||||||
|
const val PI = 3.141592653589793
|
||||||
|
const val PI_2 = PI / 2.0
|
||||||
|
const val TWO_PI = PI * 2.0
|
||||||
|
const val TWO_THIRDS = 2.0 / 3.0
|
||||||
|
const val CK2 = 5.413079E-4
|
||||||
|
const val CK4 = 6.209887E-7
|
||||||
|
const val XKE = 7.43669161E-2
|
||||||
+851
@@ -0,0 +1,851 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.predict
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
class DeepSpaceObject(data: OrbitalData) : OrbitalObject(data) {
|
||||||
|
|
||||||
|
private val c1: Double
|
||||||
|
private val c4: Double
|
||||||
|
private val x1mth2: Double
|
||||||
|
private val x3thm1: Double
|
||||||
|
private val xlcof: Double
|
||||||
|
private val xnodcf: Double
|
||||||
|
private val t2cof: Double
|
||||||
|
private val aycof: Double
|
||||||
|
private val x7thm1: Double
|
||||||
|
private val deep: DeepSpaceCalculator
|
||||||
|
private val dsv = DeepSpaceValueObject()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Recover original mean motion (xnodp) and semimajor axis (aodp) from input elements
|
||||||
|
val a1 = (XKE / data.xno).pow(TWO_THIRDS)
|
||||||
|
dsv.cosio = cos(data.xincl)
|
||||||
|
dsv.theta2 = dsv.cosio * dsv.cosio
|
||||||
|
x3thm1 = 3.0 * dsv.theta2 - 1
|
||||||
|
dsv.eosq = data.eccn * data.eccn
|
||||||
|
dsv.betao2 = 1.0 - dsv.eosq
|
||||||
|
dsv.betao = sqrt(dsv.betao2)
|
||||||
|
val del1 = 1.5 * CK2 * x3thm1 / (a1 * a1 * dsv.betao * dsv.betao2)
|
||||||
|
val ao = a1 * (1.0 - del1 * (0.5 * TWO_THIRDS + del1 * (1.0 + 134.0 / 81.0 * del1)))
|
||||||
|
val delo = 1.5 * CK2 * x3thm1 / (ao * ao * dsv.betao * dsv.betao2)
|
||||||
|
dsv.xnodp = data.xno / (1.0 + delo)
|
||||||
|
dsv.aodp = ao / (1.0 - delo)
|
||||||
|
// For perigee below 156 km, the values of S and QOMS2T are altered
|
||||||
|
setPerigee((dsv.aodp * (1.0 - data.eccn) - 1.0) * EARTH_RADIUS)
|
||||||
|
val pinvsq = invert(dsv.aodp * dsv.aodp * dsv.betao2 * dsv.betao2)
|
||||||
|
dsv.sing = sin(data.omegao)
|
||||||
|
dsv.cosg = cos(data.omegao)
|
||||||
|
val tsi = invert(dsv.aodp - s4)
|
||||||
|
val eta = dsv.aodp * data.eccn * tsi
|
||||||
|
val etasq = eta * eta
|
||||||
|
val eeta = data.eccn * eta
|
||||||
|
val psisq = abs(1.0 - etasq)
|
||||||
|
val coef = qoms24 * tsi.pow(4.0)
|
||||||
|
val coef1 = coef / psisq.pow(3.5)
|
||||||
|
val c2 = coef1 * dsv.xnodp * (dsv.aodp * (1.0 + 1.5 * etasq + eeta * (4.0 + etasq))
|
||||||
|
+ 0.75 * CK2 * tsi / psisq * x3thm1 * (8.0 + 3.0 * etasq * (8.0 + etasq)))
|
||||||
|
c1 = data.bstar * c2
|
||||||
|
dsv.sinio = sin(data.xincl)
|
||||||
|
val a3ovk2 = -J3_HARMONIC / CK2
|
||||||
|
x1mth2 = 1.0 - dsv.theta2
|
||||||
|
c4 =
|
||||||
|
2 * dsv.xnodp * coef1 * dsv.aodp * dsv.betao2 * (eta * (2.0 + 0.5 * etasq) + data.eccn
|
||||||
|
* (0.5 + 2 * etasq) - 2 * CK2 * tsi / (dsv.aodp * psisq)
|
||||||
|
* (-3 * x3thm1 * (1.0 - 2 * eeta + etasq * (1.5 - 0.5 * eeta)) + (0.75 * x1mth2
|
||||||
|
* (2.0 * etasq - eeta * (1.0 + etasq)) * cos(2.0 * data.omegao))))
|
||||||
|
val theta4 = dsv.theta2 * dsv.theta2
|
||||||
|
val temp1 = 3.0 * CK2 * pinvsq * dsv.xnodp
|
||||||
|
val temp2 = temp1 * CK2 * pinvsq
|
||||||
|
val temp3 = 1.25 * CK4 * pinvsq * pinvsq * dsv.xnodp
|
||||||
|
dsv.xmdot =
|
||||||
|
dsv.xnodp + 0.5 * temp1 * dsv.betao * x3thm1 + 0.0625 * temp2 * dsv.betao * (13 - 78 * dsv.theta2 + 137 * theta4)
|
||||||
|
val x1m5th = 1.0 - 5 * dsv.theta2
|
||||||
|
dsv.omgdot =
|
||||||
|
-0.5 * temp1 * x1m5th + 0.0625 * temp2 * (7.0 - 114 * dsv.theta2 + 395 * theta4) + temp3 * (3.0 - 36 * dsv.theta2 + 49 * theta4)
|
||||||
|
val xhdot1 = -temp1 * dsv.cosio
|
||||||
|
dsv.xnodot =
|
||||||
|
xhdot1 + (0.5 * temp2 * (4.0 - 19 * dsv.theta2) + 2 * temp3 * (3.0 - 7 * dsv.theta2)) * dsv.cosio
|
||||||
|
xnodcf = 3.5 * dsv.betao2 * xhdot1 * c1
|
||||||
|
t2cof = 1.5 * c1
|
||||||
|
xlcof = 0.125 * a3ovk2 * dsv.sinio * (3.0 + 5 * dsv.cosio) / (1.0 + dsv.cosio)
|
||||||
|
aycof = 0.25 * a3ovk2 * dsv.sinio
|
||||||
|
x7thm1 = 7.0 * dsv.theta2 - 1
|
||||||
|
deep = DeepSpaceCalculator(dsv)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun calculateSDP4(tSince: Double) {
|
||||||
|
val temp = DoubleArray(12)
|
||||||
|
val xmdf = data.xmo + dsv.xmdot * tSince
|
||||||
|
val tsq = tSince * tSince
|
||||||
|
val templ = t2cof * tsq
|
||||||
|
dsv.xll = xmdf + dsv.xnodp * templ
|
||||||
|
dsv.omgadf = data.omegao + dsv.omgdot * tSince
|
||||||
|
val xnoddf = data.xnodeo + dsv.xnodot * tSince
|
||||||
|
dsv.xnode = xnoddf + xnodcf * tsq
|
||||||
|
val tempa = 1.0 - c1 * tSince
|
||||||
|
val tempe = data.bstar * c4 * tSince
|
||||||
|
dsv.xn = dsv.xnodp
|
||||||
|
dsv.t = tSince
|
||||||
|
deep.dpsec(data)
|
||||||
|
val a = (XKE / dsv.xn).pow(TWO_THIRDS) * tempa * tempa
|
||||||
|
dsv.em -= tempe
|
||||||
|
deep.dpper()
|
||||||
|
val xl = dsv.xll + dsv.omgadf + dsv.xnode
|
||||||
|
val beta = sqrt(1.0 - dsv.em * dsv.em)
|
||||||
|
dsv.xn = XKE / a.pow(1.5)
|
||||||
|
// Long period periodics
|
||||||
|
val axn = dsv.em * cos(dsv.omgadf)
|
||||||
|
temp[0] = invert(a * beta * beta)
|
||||||
|
val xll = temp[0] * xlcof * axn
|
||||||
|
val aynl = temp[0] * aycof
|
||||||
|
val xlt = xl + xll
|
||||||
|
val ayn = dsv.em * sin(dsv.omgadf) + aynl
|
||||||
|
// Solve Kepler's equation
|
||||||
|
val capu = mod2PI(xlt - dsv.xnode)
|
||||||
|
temp[2] = capu
|
||||||
|
converge(temp, axn, ayn, capu)
|
||||||
|
calculatePosAndVel(temp, a, axn, ayn)
|
||||||
|
calculatePhase(xlt, dsv.xnode, dsv.omgadf)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculatePosAndVel(temp: DoubleArray, a: Double, axn: Double, ayn: Double) {
|
||||||
|
val ecose = temp[5] + temp[6]
|
||||||
|
val esine = temp[3] - temp[4]
|
||||||
|
val elsq = axn * axn + ayn * ayn
|
||||||
|
temp[0] = 1.0 - elsq
|
||||||
|
val pl = a * temp[0]
|
||||||
|
temp[9] = a * (1.0 - ecose)
|
||||||
|
temp[1] = invert(temp[9])
|
||||||
|
temp[10] = XKE * sqrt(a) * esine * temp[1]
|
||||||
|
temp[11] = XKE * sqrt(pl) * temp[1]
|
||||||
|
temp[2] = a * temp[1]
|
||||||
|
val betal = sqrt(temp[0])
|
||||||
|
temp[3] = invert(1.0 + betal)
|
||||||
|
val cosu = temp[2] * (temp[8] - axn + ayn * esine * temp[3])
|
||||||
|
val sinu = temp[2] * (temp[7] - ayn - axn * esine * temp[3])
|
||||||
|
val u = atan2(sinu, cosu)
|
||||||
|
val sin2u = 2.0 * sinu * cosu
|
||||||
|
val cos2u = 2.0 * cosu * cosu - 1
|
||||||
|
temp[0] = invert(pl)
|
||||||
|
temp[1] = CK2 * temp[0]
|
||||||
|
temp[2] = temp[1] * temp[0]
|
||||||
|
// Update for short periodics
|
||||||
|
val rk = temp[9] * (1.0 - 1.5 * temp[2] * betal * x3thm1) + 0.5 * temp[1] * x1mth2 * cos2u
|
||||||
|
val uk = u - 0.25 * temp[2] * x7thm1 * sin2u
|
||||||
|
val xnodek = dsv.xnode + 1.5 * temp[2] * dsv.cosio * sin2u
|
||||||
|
val xinck = dsv.xinc + 1.5 * temp[2] * dsv.cosio * dsv.sinio * cos2u
|
||||||
|
val rdotk = temp[10] - dsv.xn * temp[1] * x1mth2 * sin2u
|
||||||
|
val rfdotk = temp[11] + dsv.xn * temp[1] * (x1mth2 * cos2u + 1.5 * x3thm1)
|
||||||
|
super.calculatePosAndVel(rk, uk, xnodek, xinck, rdotk, rfdotk)
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeepSpaceValueObject {
|
||||||
|
|
||||||
|
var eosq = 0.0
|
||||||
|
var sinio = 0.0
|
||||||
|
var cosio = 0.0
|
||||||
|
var betao = 0.0
|
||||||
|
var aodp = 0.0
|
||||||
|
var theta2 = 0.0
|
||||||
|
var sing = 0.0
|
||||||
|
var cosg = 0.0
|
||||||
|
var betao2 = 0.0
|
||||||
|
var xmdot = 0.0
|
||||||
|
var omgdot = 0.0
|
||||||
|
var xnodot = 0.0
|
||||||
|
var xnodp = 0.0
|
||||||
|
|
||||||
|
// Used by dpsec and dpper parts of Deep()
|
||||||
|
var xll = 0.0
|
||||||
|
var omgadf = 0.0
|
||||||
|
var xnode = 0.0
|
||||||
|
var em = 0.0
|
||||||
|
var xinc = 0.0
|
||||||
|
var xn = 0.0
|
||||||
|
var t = 0.0
|
||||||
|
|
||||||
|
// Used by thetg and Deep()
|
||||||
|
var ds50 = 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class DeepSpaceCalculator(private val dsv: DeepSpaceValueObject) {
|
||||||
|
|
||||||
|
private val zSinis = 3.9785416E-1
|
||||||
|
private val zSings = -9.8088458E-1
|
||||||
|
private val zNs = 1.19459E-5
|
||||||
|
private val c1ss = 2.9864797E-6
|
||||||
|
private val zEs = 1.675E-2
|
||||||
|
private val zNl = 1.5835218E-4
|
||||||
|
private val c1l = 4.7968065E-7
|
||||||
|
private val zEl = 5.490E-2
|
||||||
|
private val root22 = 1.7891679E-6
|
||||||
|
private val root32 = 3.7393792E-7
|
||||||
|
private val root44 = 7.3636953E-9
|
||||||
|
private val root52 = 1.1428639E-7
|
||||||
|
private val root54 = 2.1765803E-9
|
||||||
|
private val tHdt = 4.3752691E-3
|
||||||
|
private val q22 = 1.7891679E-6
|
||||||
|
private val q31 = 2.1460748E-6
|
||||||
|
private val q33 = 2.2123015E-7
|
||||||
|
private val g22 = 5.7686396
|
||||||
|
private val g32 = 9.5240898E-1
|
||||||
|
private val g44 = 1.8014998
|
||||||
|
private val g52 = 1.0508330
|
||||||
|
private val g54 = 4.4108898
|
||||||
|
private val thgr: Double
|
||||||
|
private val xnq: Double
|
||||||
|
private val xqncl: Double
|
||||||
|
private val omegaq: Double
|
||||||
|
private var zmol = 0.0
|
||||||
|
private var zmos = 0.0
|
||||||
|
|
||||||
|
// Many fields below cannot be final because they are iteratively refined
|
||||||
|
private var savtsn = 0.0
|
||||||
|
private var ee2 = 0.0
|
||||||
|
private var e3 = 0.0
|
||||||
|
private var xi2 = 0.0
|
||||||
|
private var xl2 = 0.0
|
||||||
|
private var xl3 = 0.0
|
||||||
|
private var xl4 = 0.0
|
||||||
|
private var xgh2 = 0.0
|
||||||
|
private var xgh3 = 0.0
|
||||||
|
private var xgh4 = 0.0
|
||||||
|
private var xh2 = 0.0
|
||||||
|
private var xh3 = 0.0
|
||||||
|
private var sse = 0.0
|
||||||
|
private var ssi = 0.0
|
||||||
|
private var ssg = 0.0
|
||||||
|
private var xi3 = 0.0
|
||||||
|
private var se2 = 0.0
|
||||||
|
private var si2 = 0.0
|
||||||
|
private var sl2 = 0.0
|
||||||
|
private var sgh2 = 0.0
|
||||||
|
private var sh2 = 0.0
|
||||||
|
private var se3 = 0.0
|
||||||
|
private var si3 = 0.0
|
||||||
|
private var sl3 = 0.0
|
||||||
|
private var sgh3 = 0.0
|
||||||
|
private var sh3 = 0.0
|
||||||
|
private var sl4 = 0.0
|
||||||
|
private var sgh4 = 0.0
|
||||||
|
private var ssl = 0.0
|
||||||
|
private var ssh = 0.0
|
||||||
|
private var d3210 = 0.0
|
||||||
|
private var d3222 = 0.0
|
||||||
|
private var d4410 = 0.0
|
||||||
|
private var d4422 = 0.0
|
||||||
|
private var d5220 = 0.0
|
||||||
|
private var d5232 = 0.0
|
||||||
|
private var d5421 = 0.0
|
||||||
|
private var d5433 = 0.0
|
||||||
|
private var del1 = 0.0
|
||||||
|
private var del2 = 0.0
|
||||||
|
private var del3 = 0.0
|
||||||
|
private var fasx2 = 0.0
|
||||||
|
private var fasx4 = 0.0
|
||||||
|
private var fasx6 = 0.0
|
||||||
|
private var xlamo = 0.0
|
||||||
|
private val xfact: Double
|
||||||
|
private var xni: Double
|
||||||
|
private var atime: Double
|
||||||
|
private val stepp: Double
|
||||||
|
private val stepn: Double
|
||||||
|
private val step2: Double
|
||||||
|
private var preep = 0.0
|
||||||
|
private var pl = 0.0
|
||||||
|
private var sghs = 0.0
|
||||||
|
private var xli: Double
|
||||||
|
private var d2201 = 0.0
|
||||||
|
private var d2211 = 0.0
|
||||||
|
private var sghl = 0.0
|
||||||
|
private var sh1 = 0.0
|
||||||
|
private var pinc = 0.0
|
||||||
|
private var pe = 0.0
|
||||||
|
private var shs = 0.0
|
||||||
|
private var zsingl = 0.0
|
||||||
|
private var zcosgl = 0.0
|
||||||
|
private var zsinhl = 0.0
|
||||||
|
private var zcoshl = 0.0
|
||||||
|
private var zsinil = 0.0
|
||||||
|
private var zcosil = 0.0
|
||||||
|
private var a1 = 0.0
|
||||||
|
private var a2 = 0.0
|
||||||
|
private var a3 = 0.0
|
||||||
|
private var a4 = 0.0
|
||||||
|
private var a5 = 0.0
|
||||||
|
private var a6 = 0.0
|
||||||
|
private var a7 = 0.0
|
||||||
|
private var a8 = 0.0
|
||||||
|
private var a9 = 0.0
|
||||||
|
private var a10 = 0.0
|
||||||
|
private var ainv2 = 0.0
|
||||||
|
private var alfdp = 0.0
|
||||||
|
private val aqnv: Double
|
||||||
|
private var sgh = 0.0
|
||||||
|
private var sini2 = 0.0
|
||||||
|
private var sinis = 0.0
|
||||||
|
private var sinok = 0.0
|
||||||
|
private var sh = 0.0
|
||||||
|
private var si = 0.0
|
||||||
|
private var sil = 0.0
|
||||||
|
private val day: Double
|
||||||
|
private var betdp = 0.0
|
||||||
|
private var dalf = 0.0
|
||||||
|
private var bfact = 0.0
|
||||||
|
private var c = 0.0
|
||||||
|
private var cc = 0.0
|
||||||
|
private var cosis = 0.0
|
||||||
|
private var cosok = 0.0
|
||||||
|
private val cosq: Double
|
||||||
|
private var ctem = 0.0
|
||||||
|
private var f322 = 0.0
|
||||||
|
private var zx = 0.0
|
||||||
|
private var zy = 0.0
|
||||||
|
private var dbet = 0.0
|
||||||
|
private var dls = 0.0
|
||||||
|
private var eoc = 0.0
|
||||||
|
private val eq: Double
|
||||||
|
private var f2 = 0.0
|
||||||
|
private var f220 = 0.0
|
||||||
|
private var f221 = 0.0
|
||||||
|
private var f3 = 0.0
|
||||||
|
private var f311 = 0.0
|
||||||
|
private var f321 = 0.0
|
||||||
|
private var xnoh = 0.0
|
||||||
|
private var f330 = 0.0
|
||||||
|
private var f441 = 0.0
|
||||||
|
private var f442 = 0.0
|
||||||
|
private var f522 = 0.0
|
||||||
|
private var f523 = 0.0
|
||||||
|
private var f542 = 0.0
|
||||||
|
private var f543 = 0.0
|
||||||
|
private var g200 = 0.0
|
||||||
|
private var g201 = 0.0
|
||||||
|
private var g211 = 0.0
|
||||||
|
private var pgh = 0.0
|
||||||
|
private var ph = 0.0
|
||||||
|
private var s1 = 0.0
|
||||||
|
private var s2 = 0.0
|
||||||
|
private var s3 = 0.0
|
||||||
|
private var s4 = 0.0
|
||||||
|
private var s5 = 0.0
|
||||||
|
private var s6 = 0.0
|
||||||
|
private var s7 = 0.0
|
||||||
|
private var se = 0.0
|
||||||
|
private var sel = 0.0
|
||||||
|
private var ses = 0.0
|
||||||
|
private var xls = 0.0
|
||||||
|
private var g300 = 0.0
|
||||||
|
private var g310 = 0.0
|
||||||
|
private var g322 = 0.0
|
||||||
|
private var g410 = 0.0
|
||||||
|
private var g422 = 0.0
|
||||||
|
private var g520 = 0.0
|
||||||
|
private var g521 = 0.0
|
||||||
|
private var g532 = 0.0
|
||||||
|
private var g533 = 0.0
|
||||||
|
private var gam = 0.0
|
||||||
|
private val sinq: Double
|
||||||
|
private var sinzf = 0.0
|
||||||
|
private var sis = 0.0
|
||||||
|
private var sl = 0.0
|
||||||
|
private var sll = 0.0
|
||||||
|
private var sls = 0.0
|
||||||
|
private var stem = 0.0
|
||||||
|
private var temp = 0.0
|
||||||
|
private var temp1 = 0.0
|
||||||
|
private var x1 = 0.0
|
||||||
|
private var x2 = 0.0
|
||||||
|
private var x2li = 0.0
|
||||||
|
private var x2omi = 0.0
|
||||||
|
private var x3 = 0.0
|
||||||
|
private var x4 = 0.0
|
||||||
|
private var x5 = 0.0
|
||||||
|
private var x6 = 0.0
|
||||||
|
private var x7 = 0.0
|
||||||
|
private var x8 = 0.0
|
||||||
|
private var xl = 0.0
|
||||||
|
private var xldot = 0.0
|
||||||
|
private val xmao: Double
|
||||||
|
private var xnddt = 0.0
|
||||||
|
private var xndot = 0.0
|
||||||
|
private var xno2 = 0.0
|
||||||
|
private var xnodce = 0.0
|
||||||
|
private var xnoi = 0.0
|
||||||
|
private var xomi = 0.0
|
||||||
|
private val xpidot: Double
|
||||||
|
private var z1 = 0.0
|
||||||
|
private var z11 = 0.0
|
||||||
|
private var z12 = 0.0
|
||||||
|
private var z13 = 0.0
|
||||||
|
private var z2 = 0.0
|
||||||
|
private var z21 = 0.0
|
||||||
|
private var z22 = 0.0
|
||||||
|
private var z23 = 0.0
|
||||||
|
private var z3 = 0.0
|
||||||
|
private var z31 = 0.0
|
||||||
|
private var z32 = 0.0
|
||||||
|
private var z33 = 0.0
|
||||||
|
private var ze = 0.0
|
||||||
|
private var zf = 0.0
|
||||||
|
private var zm = 0.0
|
||||||
|
private var zn = 0.0
|
||||||
|
private var zsing = 0.0
|
||||||
|
private var zsinh = 0.0
|
||||||
|
private var zsini = 0.0
|
||||||
|
private var zcosg = 0.0
|
||||||
|
private var zcosh = 0.0
|
||||||
|
private var zcosi = 0.0
|
||||||
|
private var delt = 0.0
|
||||||
|
private var ft = 0.0
|
||||||
|
private var resonance: Boolean
|
||||||
|
private var synchronous: Boolean
|
||||||
|
private var doLoop = false
|
||||||
|
private var epochRestart = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
thgr = thetaG(data.epoch)
|
||||||
|
eq = data.eccn
|
||||||
|
xnq = dsv.xnodp
|
||||||
|
aqnv = invert(dsv.aodp)
|
||||||
|
xqncl = data.xincl
|
||||||
|
xmao = data.xmo
|
||||||
|
xpidot = dsv.omgdot + dsv.xnodot
|
||||||
|
sinq = sin(data.xnodeo)
|
||||||
|
cosq = cos(data.xnodeo)
|
||||||
|
omegaq = data.omegao
|
||||||
|
// Initialize lunar solar terms, days since 1900 Jan 0.5
|
||||||
|
day = dsv.ds50 + 18261.5
|
||||||
|
if (abs(day - preep) > 1.0E-6) {
|
||||||
|
preep = day
|
||||||
|
xnodce = 4.5236020 - 9.2422029E-4 * day
|
||||||
|
stem = sin(xnodce)
|
||||||
|
ctem = cos(xnodce)
|
||||||
|
zcosil = 0.91375164 - 0.03568096 * ctem
|
||||||
|
zsinil = sqrt(1.0 - zcosil * zcosil)
|
||||||
|
zsinhl = 0.089683511 * stem / zsinil
|
||||||
|
zcoshl = sqrt(1.0 - zsinhl * zsinhl)
|
||||||
|
c = 4.7199672 + 0.22997150 * day
|
||||||
|
gam = 5.8351514 + 0.0019443680 * day
|
||||||
|
zmol = mod2PI(c - gam)
|
||||||
|
zx = 0.39785416 * stem / zsinil
|
||||||
|
zy = zcoshl * ctem + 0.91744867 * zsinhl * stem
|
||||||
|
zx = atan2(zx, zy)
|
||||||
|
zx = gam + zx - xnodce
|
||||||
|
zcosgl = cos(zx)
|
||||||
|
zsingl = sin(zx)
|
||||||
|
zmos = mod2PI(6.2565837 + 0.017201977 * day)
|
||||||
|
} else {
|
||||||
|
zmol = 0.0
|
||||||
|
zmos = 0.0
|
||||||
|
}
|
||||||
|
doSolarTerms()
|
||||||
|
|
||||||
|
// Geopotential resonance initialization for 12 hour orbits
|
||||||
|
resonance = false
|
||||||
|
synchronous = false
|
||||||
|
if (!(xnq < 0.0052359877 && xnq > 0.0034906585)) {
|
||||||
|
if (xnq !in 0.00826..0.00924)
|
||||||
|
if (eq < 0.5)
|
||||||
|
// calculateResonance
|
||||||
|
resonance = true
|
||||||
|
eoc = eq * dsv.eosq
|
||||||
|
g201 = -0.306 - (eq - 0.64) * 0.440
|
||||||
|
if (eq <= 0.65) {
|
||||||
|
g211 = 3.616 - 13.247 * eq + 16.290 * dsv.eosq
|
||||||
|
g310 = -19.302 + 117.390 * eq - 228.419 * dsv.eosq + 156.591 * eoc
|
||||||
|
g322 = -18.9068 + 109.7927 * eq - 214.6334 * dsv.eosq + 146.5816 * eoc
|
||||||
|
g410 = -41.122 + 242.694 * eq - 471.094 * dsv.eosq + 313.953 * eoc
|
||||||
|
g422 = -146.407 + 841.880 * eq - 1629.014 * dsv.eosq + 1083.435 * eoc
|
||||||
|
g520 = -532.114 + 3017.977 * eq - 5740 * dsv.eosq + 3708.276 * eoc
|
||||||
|
} else {
|
||||||
|
g211 = -72.099 + 331.819 * eq - 508.738 * dsv.eosq + 266.724 * eoc
|
||||||
|
g310 = -346.844 + 1582.851 * eq - 2415.925 * dsv.eosq + 1246.113 * eoc
|
||||||
|
g322 = -342.585 + 1554.908 * eq - 2366.899 * dsv.eosq + 1215.972 * eoc
|
||||||
|
g410 = -1052.797 + 4758.686 * eq - 7193.992 * dsv.eosq + 3651.957 * eoc
|
||||||
|
g422 = -3581.69 + 16178.11 * eq - 24462.77 * dsv.eosq + 12422.52 * eoc
|
||||||
|
g520 =
|
||||||
|
if (eq <= 0.715) 1464.74 - 4664.75 * eq + 3763.64 * dsv.eosq
|
||||||
|
else -5149.66 + 29936.92 * eq - 54087.36 * dsv.eosq + 31324.56 * eoc
|
||||||
|
}
|
||||||
|
if (eq < 0.7) {
|
||||||
|
g533 = -919.2277 + 4988.61 * eq - 9064.77 * dsv.eosq + 5542.21 * eoc
|
||||||
|
g521 = -822.71072 + 4568.6173 * eq - 8491.4146 * dsv.eosq + 5337.524 * eoc
|
||||||
|
g532 = -853.666 + 4690.25 * eq - 8624.77 * dsv.eosq + 5341.4 * eoc
|
||||||
|
} else {
|
||||||
|
g533 = -37995.78 + 161616.52 * eq - 229838.2 * dsv.eosq + 109377.94 * eoc
|
||||||
|
g521 = -51752.104 + 218913.95 * eq - 309468.16 * dsv.eosq + 146349.42 * eoc
|
||||||
|
g532 = -40023.88 + 170470.89 * eq - 242699.48 * dsv.eosq + 115605.82 * eoc
|
||||||
|
}
|
||||||
|
sini2 = dsv.sinio * dsv.sinio
|
||||||
|
f220 = 0.75 * (1.0 + 2 * dsv.cosio + dsv.theta2)
|
||||||
|
f221 = 1.5 * sini2
|
||||||
|
f321 = 1.875 * dsv.sinio * (1.0 - 2 * dsv.cosio - 3.0 * dsv.theta2)
|
||||||
|
f322 = -1.875 * dsv.sinio * (1.0 + 2 * dsv.cosio - 3.0 * dsv.theta2)
|
||||||
|
f441 = 35 * sini2 * f220
|
||||||
|
f442 = 39.3750 * sini2 * sini2
|
||||||
|
f522 =
|
||||||
|
9.84375 * dsv.sinio * (sini2 * (1.0 - 2 * dsv.cosio - 5 * dsv.theta2) + 0.33333333 * (-2 + 4 * dsv.cosio + 6 * dsv.theta2))
|
||||||
|
f523 =
|
||||||
|
dsv.sinio * (4.92187512 * sini2 * (-2 - 4 * dsv.cosio + 10 * dsv.theta2) + 6.56250012 * (1.0 + 2 * dsv.cosio - 3.0 * dsv.theta2))
|
||||||
|
f542 =
|
||||||
|
29.53125 * dsv.sinio * (2.0 - 8 * dsv.cosio + dsv.theta2 * (-12 + 8 * dsv.cosio + 10 * dsv.theta2))
|
||||||
|
f543 =
|
||||||
|
29.53125 * dsv.sinio * (-2 - 8 * dsv.cosio + dsv.theta2 * (12 + 8 * dsv.cosio - 10 * dsv.theta2))
|
||||||
|
xno2 = xnq * xnq
|
||||||
|
ainv2 = aqnv * aqnv
|
||||||
|
temp1 = 3.0 * xno2 * ainv2
|
||||||
|
temp = temp1 * root22
|
||||||
|
d2201 = temp * f220 * g201
|
||||||
|
d2211 = temp * f221 * g211
|
||||||
|
temp1 *= aqnv
|
||||||
|
temp = temp1 * root32
|
||||||
|
d3210 = temp * f321 * g310
|
||||||
|
d3222 = temp * f322 * g322
|
||||||
|
temp1 *= aqnv
|
||||||
|
temp = 2.0 * temp1 * root44
|
||||||
|
d4410 = temp * f441 * g410
|
||||||
|
d4422 = temp * f442 * g422
|
||||||
|
temp1 *= aqnv
|
||||||
|
temp = temp1 * root52
|
||||||
|
d5220 = temp * f522 * g520
|
||||||
|
d5232 = temp * f523 * g532
|
||||||
|
temp = 2.0 * temp1 * root54
|
||||||
|
d5421 = temp * f542 * g521
|
||||||
|
d5433 = temp * f543 * g533
|
||||||
|
xlamo = xmao + data.xnodeo + data.xnodeo - thgr - thgr
|
||||||
|
bfact = dsv.xmdot + dsv.xnodot + dsv.xnodot - tHdt - tHdt
|
||||||
|
bfact += ssl + ssh + ssh
|
||||||
|
} else {
|
||||||
|
// Init synchronous resonance terms
|
||||||
|
resonance = true
|
||||||
|
synchronous = true
|
||||||
|
g200 = 1.0 + dsv.eosq * (-2.5 + 0.8125 * dsv.eosq)
|
||||||
|
g310 = 1.0 + 2 * dsv.eosq
|
||||||
|
g300 = 1.0 + dsv.eosq * (-6 + 6.60937 * dsv.eosq)
|
||||||
|
f220 = 0.75 * (1.0 + dsv.cosio) * (1.0 + dsv.cosio)
|
||||||
|
f311 =
|
||||||
|
0.9375 * dsv.sinio * dsv.sinio * (1.0 + 3.0 * dsv.cosio) - 0.75 * (1.0 + dsv.cosio)
|
||||||
|
f330 = 1.0 + dsv.cosio
|
||||||
|
f330 *= 1.875 * f330 * f330
|
||||||
|
del1 = 3.0 * xnq * xnq * aqnv * aqnv
|
||||||
|
del2 = 2.0 * del1 * f220 * g200 * q22
|
||||||
|
del3 = 3.0 * del1 * f330 * g300 * q33 * aqnv
|
||||||
|
del1 *= f311 * g310 * q31 * aqnv
|
||||||
|
fasx2 = 0.13130908
|
||||||
|
fasx4 = 2.8843198
|
||||||
|
fasx6 = 0.37448087
|
||||||
|
xlamo = xmao + data.xnodeo + data.omegao - thgr
|
||||||
|
bfact = dsv.xmdot + xpidot - tHdt
|
||||||
|
bfact += ssl + ssg + ssh
|
||||||
|
}
|
||||||
|
xfact = bfact - xnq
|
||||||
|
// Init integrator
|
||||||
|
xli = xlamo
|
||||||
|
xni = xnq
|
||||||
|
atime = 0.0
|
||||||
|
stepp = 720.0
|
||||||
|
stepn = -720.0
|
||||||
|
step2 = 259200.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entrance for lunar-solar periodics
|
||||||
|
fun dpper() {
|
||||||
|
sinis = sin(dsv.xinc)
|
||||||
|
cosis = cos(dsv.xinc)
|
||||||
|
if (abs(savtsn - dsv.t) >= 30) {
|
||||||
|
savtsn = dsv.t
|
||||||
|
zm = zmos + zNs * dsv.t
|
||||||
|
zf = zm + 2 * zEs * sin(zm)
|
||||||
|
sinzf = sin(zf)
|
||||||
|
f2 = 0.5 * sinzf * sinzf - 0.25
|
||||||
|
f3 = -0.5 * sinzf * cos(zf)
|
||||||
|
ses = se2 * f2 + se3 * f3
|
||||||
|
sis = si2 * f2 + si3 * f3
|
||||||
|
sls = sl2 * f2 + sl3 * f3 + sl4 * sinzf
|
||||||
|
sghs = sgh2 * f2 + sgh3 * f3 + sgh4 * sinzf
|
||||||
|
shs = sh2 * f2 + sh3 * f3
|
||||||
|
zm = zmol + zNl * dsv.t
|
||||||
|
zf = zm + 2 * zEl * sin(zm)
|
||||||
|
sinzf = sin(zf)
|
||||||
|
f2 = 0.5 * sinzf * sinzf - 0.25
|
||||||
|
f3 = -0.5 * sinzf * cos(zf)
|
||||||
|
sel = ee2 * f2 + e3 * f3
|
||||||
|
sil = xi2 * f2 + xi3 * f3
|
||||||
|
sll = xl2 * f2 + xl3 * f3 + xl4 * sinzf
|
||||||
|
sghl = xgh2 * f2 + xgh3 * f3 + xgh4 * sinzf
|
||||||
|
sh1 = xh2 * f2 + xh3 * f3
|
||||||
|
pe = ses + sel
|
||||||
|
pinc = sis + sil
|
||||||
|
pl = sls + sll
|
||||||
|
}
|
||||||
|
pgh = sghs + sghl
|
||||||
|
ph = shs + sh1
|
||||||
|
dsv.xinc += pinc
|
||||||
|
dsv.em += pe
|
||||||
|
if (xqncl >= 0.2) {
|
||||||
|
/* Apply periodics directly */
|
||||||
|
ph /= dsv.sinio
|
||||||
|
pgh -= dsv.cosio * ph
|
||||||
|
dsv.omgadf += pgh
|
||||||
|
dsv.xnode += ph
|
||||||
|
dsv.xll += pl
|
||||||
|
} else {
|
||||||
|
applyPeriodics()
|
||||||
|
// This is a patch to Lyddane modification suggested by Rob Matson
|
||||||
|
if (abs(xnoh - dsv.xnode) > PI) {
|
||||||
|
if (dsv.xnode < xnoh) dsv.xnode += TWO_PI else dsv.xnode -= TWO_PI
|
||||||
|
}
|
||||||
|
dsv.xll += pl
|
||||||
|
dsv.omgadf = xls - dsv.xll - cos(dsv.xinc) * dsv.xnode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entrance for deep space secular effects
|
||||||
|
fun dpsec(params: OrbitalData) {
|
||||||
|
dsv.xll += ssl * dsv.t
|
||||||
|
dsv.omgadf += ssg * dsv.t
|
||||||
|
dsv.xnode += ssh * dsv.t
|
||||||
|
dsv.em = params.eccn + sse * dsv.t
|
||||||
|
dsv.xinc = params.xincl + ssi * dsv.t
|
||||||
|
if (dsv.xinc < 0) {
|
||||||
|
dsv.xinc = -dsv.xinc
|
||||||
|
dsv.xnode += PI
|
||||||
|
dsv.omgadf -= PI
|
||||||
|
}
|
||||||
|
if (!resonance) return
|
||||||
|
do processEpochRestartLoop() while (doLoop && epochRestart)
|
||||||
|
dsv.xn = xni + xndot * ft + xnddt * ft * ft * 0.5
|
||||||
|
xl = xli + xldot * ft + xndot * ft * ft * 0.5
|
||||||
|
temp = -dsv.xnode + thgr + dsv.t * tHdt
|
||||||
|
if (synchronous) dsv.xll = xl - dsv.omgadf + temp else dsv.xll = xl + temp + temp
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun doSolarTerms() {
|
||||||
|
savtsn = 1E20
|
||||||
|
zcosg = 1.945905E-1
|
||||||
|
zsing = zSings
|
||||||
|
zcosi = 9.1744867E-1
|
||||||
|
zsini = zSinis
|
||||||
|
zcosh = cosq
|
||||||
|
zsinh = sinq
|
||||||
|
cc = c1ss
|
||||||
|
zn = zNs
|
||||||
|
ze = zEs
|
||||||
|
xnoi = invert(xnq)
|
||||||
|
calculateSolarTerms()
|
||||||
|
calculateLunarTerms()
|
||||||
|
calculateSolarTerms() // Solar terms done again after Lunar terms are done
|
||||||
|
sse += se
|
||||||
|
ssi += si
|
||||||
|
ssl += sl
|
||||||
|
ssg = ssg + sgh - dsv.cosio / dsv.sinio * sh
|
||||||
|
ssh += sh / dsv.sinio
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateLunarTerms() {
|
||||||
|
sse = se
|
||||||
|
ssi = si
|
||||||
|
ssl = sl
|
||||||
|
ssh = sh / dsv.sinio
|
||||||
|
ssg = sgh - dsv.cosio * ssh
|
||||||
|
se2 = ee2
|
||||||
|
si2 = xi2
|
||||||
|
sl2 = xl2
|
||||||
|
sgh2 = xgh2
|
||||||
|
sh2 = xh2
|
||||||
|
se3 = e3
|
||||||
|
si3 = xi3
|
||||||
|
sl3 = xl3
|
||||||
|
sgh3 = xgh3
|
||||||
|
sh3 = xh3
|
||||||
|
sl4 = xl4
|
||||||
|
sgh4 = xgh4
|
||||||
|
zcosg = zcosgl
|
||||||
|
zsing = zsingl
|
||||||
|
zcosi = zcosil
|
||||||
|
zsini = zsinil
|
||||||
|
zcosh = zcoshl * cosq + zsinhl * sinq
|
||||||
|
zsinh = sinq * zcoshl - cosq * zsinhl
|
||||||
|
zn = zNl
|
||||||
|
cc = c1l
|
||||||
|
ze = zEl
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateSolarTerms() {
|
||||||
|
a1 = zcosg * zcosh + zsing * zcosi * zsinh
|
||||||
|
a3 = -zsing * zcosh + zcosg * zcosi * zsinh
|
||||||
|
a7 = -zcosg * zsinh + zsing * zcosi * zcosh
|
||||||
|
a8 = zsing * zsini
|
||||||
|
a9 = zsing * zsinh + zcosg * zcosi * zcosh
|
||||||
|
a10 = zcosg * zsini
|
||||||
|
a2 = dsv.cosio * a7 + dsv.sinio * a8
|
||||||
|
a4 = dsv.cosio * a9 + dsv.sinio * a10
|
||||||
|
a5 = -dsv.sinio * a7 + dsv.cosio * a8
|
||||||
|
a6 = -dsv.sinio * a9 + dsv.cosio * a10
|
||||||
|
x1 = a1 * dsv.cosg + a2 * dsv.sing
|
||||||
|
x2 = a3 * dsv.cosg + a4 * dsv.sing
|
||||||
|
x3 = -a1 * dsv.sing + a2 * dsv.cosg
|
||||||
|
x4 = -a3 * dsv.sing + a4 * dsv.cosg
|
||||||
|
x5 = a5 * dsv.sing
|
||||||
|
x6 = a6 * dsv.sing
|
||||||
|
x7 = a5 * dsv.cosg
|
||||||
|
x8 = a6 * dsv.cosg
|
||||||
|
z31 = 12 * x1 * x1 - 3.0 * x3 * x3
|
||||||
|
z32 = 24 * x1 * x2 - 6 * x3 * x4
|
||||||
|
z33 = 12 * x2 * x2 - 3.0 * x4 * x4
|
||||||
|
z1 = 3.0 * (a1 * a1 + a2 * a2) + z31 * dsv.eosq
|
||||||
|
z2 = 6.0 * (a1 * a3 + a2 * a4) + z32 * dsv.eosq
|
||||||
|
z3 = 3.0 * (a3 * a3 + a4 * a4) + z33 * dsv.eosq
|
||||||
|
z11 = -6 * a1 * a5 + dsv.eosq * (-24 * x1 * x7 - 6 * x3 * x5)
|
||||||
|
z12 =
|
||||||
|
-6 * (a1 * a6 + a3 * a5) + dsv.eosq * (-24 * (x2 * x7 + x1 * x8) - 6 * (x3 * x6 + x4 * x5))
|
||||||
|
z13 = -6 * a3 * a6 + dsv.eosq * (-24 * x2 * x8 - 6 * x4 * x6)
|
||||||
|
z21 = 6.0 * a2 * a5 + dsv.eosq * (24 * x1 * x5 - 6 * x3 * x7)
|
||||||
|
z22 =
|
||||||
|
6.0 * (a4 * a5 + a2 * a6) + dsv.eosq * (24 * (x2 * x5 + x1 * x6) - 6 * (x4 * x7 + x3 * x8))
|
||||||
|
z23 = 6.0 * a4 * a6 + dsv.eosq * (24 * x2 * x6 - 6 * x4 * x8)
|
||||||
|
z1 += z1 + dsv.betao2 * z31
|
||||||
|
z2 += z2 + dsv.betao2 * z32
|
||||||
|
z3 += z3 + dsv.betao2 * z33
|
||||||
|
s3 = cc * xnoi
|
||||||
|
s2 = -0.5 * s3 / dsv.betao
|
||||||
|
s4 = s3 * dsv.betao
|
||||||
|
s1 = -15 * eq * s4
|
||||||
|
s5 = x1 * x3 + x2 * x4
|
||||||
|
s6 = x2 * x3 + x1 * x4
|
||||||
|
s7 = x2 * x4 - x1 * x3
|
||||||
|
se = s1 * zn * s5
|
||||||
|
si = s2 * zn * (z11 + z13)
|
||||||
|
sl = -zn * s3 * (z1 + z3 - 14 - 6 * dsv.eosq)
|
||||||
|
sgh = s4 * zn * (z31 + z33 - 6)
|
||||||
|
sh = -zn * s2 * (z21 + z23)
|
||||||
|
if (xqncl < 5.2359877E-2) sh = 0.0
|
||||||
|
ee2 = 2.0 * s1 * s6
|
||||||
|
e3 = 2.0 * s1 * s7
|
||||||
|
xi2 = 2.0 * s2 * z12
|
||||||
|
xi3 = 2.0 * s2 * (z13 - z11)
|
||||||
|
xl2 = -2 * s3 * z2
|
||||||
|
xl3 = -2 * s3 * (z3 - z1)
|
||||||
|
xl4 = -2 * s3 * (-21 - 9 * dsv.eosq) * ze
|
||||||
|
xgh2 = 2.0 * s4 * z32
|
||||||
|
xgh3 = 2.0 * s4 * (z33 - z31)
|
||||||
|
xgh4 = -18 * s4 * ze
|
||||||
|
xh2 = -2 * s2 * z22
|
||||||
|
xh3 = -2 * s2 * (z23 - z21)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processEpochRestartLoop() {
|
||||||
|
if (atime == 0.0 || dsv.t >= 0 && atime < 0 || dsv.t < 0 && atime >= 0) {
|
||||||
|
calculateDelta()
|
||||||
|
atime = 0.0
|
||||||
|
xni = xnq
|
||||||
|
xli = xlamo
|
||||||
|
} else if (abs(dsv.t) >= abs(atime)) calculateDelta()
|
||||||
|
processNotEpochRestartLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateDelta() {
|
||||||
|
delt = if (dsv.t < 0) stepn else stepp
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processNotEpochRestartLoop() {
|
||||||
|
do {
|
||||||
|
if (abs(dsv.t - atime) >= stepp) {
|
||||||
|
doLoop = true
|
||||||
|
epochRestart = false
|
||||||
|
} else {
|
||||||
|
ft = dsv.t - atime
|
||||||
|
doLoop = false
|
||||||
|
}
|
||||||
|
if (abs(dsv.t) < abs(atime)) {
|
||||||
|
delt = if (dsv.t >= 0) stepn else stepp
|
||||||
|
doLoop = doLoop or epochRestart
|
||||||
|
}
|
||||||
|
if (synchronous) {
|
||||||
|
xndot = del1 * sin(xli - fasx2) + del2 * sin(2.0 * (xli - fasx4)) +
|
||||||
|
del3 * sin(3.0 * (xli - fasx6))
|
||||||
|
xnddt = del1 * cos(xli - fasx2) + 2 * del2 * cos(2.0 * (xli - fasx4)) +
|
||||||
|
3.0 * del3 * cos(3.0 * (xli - fasx6))
|
||||||
|
} else {
|
||||||
|
xomi = omegaq + dsv.omgdot * atime
|
||||||
|
x2omi = xomi + xomi
|
||||||
|
x2li = xli + xli
|
||||||
|
xndot =
|
||||||
|
d2201 * sin(x2omi + xli - g22) + d2211 * sin(xli - g22) + (d3210
|
||||||
|
* sin(xomi + xli - g32)) + d3222 * sin(-xomi + xli - g32) + (d4410
|
||||||
|
* sin(x2omi + x2li - g44)) + d4422 * sin(x2li - g44) + (d5220
|
||||||
|
* sin(xomi + xli - g52)) + d5232 * sin(-xomi + xli - g52) + (d5421
|
||||||
|
* sin(xomi + x2li - g54)) + d5433 * sin(-xomi + x2li - g54)
|
||||||
|
xnddt =
|
||||||
|
d2201 * cos(x2omi + xli - g22) + d2211 * cos(xli - g22) + (d3210
|
||||||
|
* cos(xomi + xli - g32)) + d3222 * cos(-xomi + xli - g32) + (d5220
|
||||||
|
* cos(xomi + xli - g52)) + d5232 * cos(-xomi + xli - g52) + (2
|
||||||
|
* (d4410 * cos(x2omi + x2li - g44) + d4422 * cos(x2li - g44) + (d5421
|
||||||
|
* cos(xomi + x2li - g54)) + d5433 * cos(-xomi + x2li - g54)))
|
||||||
|
}
|
||||||
|
xldot = xni + xfact
|
||||||
|
xnddt *= xldot
|
||||||
|
if (doLoop) {
|
||||||
|
xli += xldot * delt + xndot * step2
|
||||||
|
xni += xndot * delt + xnddt * step2
|
||||||
|
atime += delt
|
||||||
|
}
|
||||||
|
} while (doLoop && !epochRestart)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply periodics with Lyddane modification
|
||||||
|
private fun applyPeriodics() {
|
||||||
|
sinok = sin(dsv.xnode)
|
||||||
|
cosok = cos(dsv.xnode)
|
||||||
|
alfdp = sinis * sinok
|
||||||
|
betdp = sinis * cosok
|
||||||
|
dalf = ph * cosok + pinc * cosis * sinok
|
||||||
|
dbet = -ph * sinok + pinc * cosis * cosok
|
||||||
|
alfdp += dalf
|
||||||
|
betdp += dbet
|
||||||
|
dsv.xnode = mod2PI(dsv.xnode)
|
||||||
|
xls = dsv.xll + dsv.omgadf + cosis * dsv.xnode
|
||||||
|
dls = pl + pgh - pinc * dsv.xnode * sinis
|
||||||
|
xls += dls
|
||||||
|
xnoh = dsv.xnode
|
||||||
|
dsv.xnode = atan2(alfdp, betdp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculates the Greenwich Mean Sidereal Time for an epoch, valid 1957 through 2056
|
||||||
|
private fun thetaG(epoch: Double): Double {
|
||||||
|
var year = floor(epoch * 1E-3)
|
||||||
|
var dayOfYear = (epoch * 1E-3 - year) * 1000.0
|
||||||
|
year = if (year < 57) year + 2000 else year + 1900
|
||||||
|
val dayFloor = floor(dayOfYear)
|
||||||
|
val dayFraction = dayOfYear - dayFloor
|
||||||
|
dayOfYear = dayFloor
|
||||||
|
val jd = julianDateOfYear(year) + dayOfYear
|
||||||
|
dsv.ds50 = jd - 2433281.5 + dayFraction
|
||||||
|
return mod2PI(6.3003880987 * dsv.ds50 + 1.72944494)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.predict
|
||||||
|
|
||||||
|
data class GeoPos(
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val altitude: Double = 0.0,
|
||||||
|
val qthLocator: String = "null",
|
||||||
|
val timestamp: Long = 0L
|
||||||
|
)
|
||||||
Loaded 100 of 5813 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user