mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
165
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7924a6f69e | ||
|
|
9a2a715d1f | ||
|
|
ee32c7a1eb | ||
|
|
1d54ac4718 | ||
|
|
433ce85561 | ||
|
|
cebe3b7ec4 | ||
|
|
d8f60cf270 | ||
|
|
4a3332fdb5 | ||
|
|
9e7fdbfe71 | ||
|
|
463df58eb9 | ||
|
|
58b8801c0c | ||
|
|
2581ba92c0 | ||
|
|
e5131ebb72 | ||
|
|
f9accdd517 | ||
|
|
ee3c1e7058 | ||
|
|
a08a28df39 | ||
|
|
759a6de2c6 | ||
|
|
ba5a984c16 | ||
|
|
083b8a2847 | ||
|
|
e8ebfb1ef3 | ||
|
|
12eed1d9a9 | ||
|
|
b3859fbece | ||
|
|
f40a67e1b7 | ||
|
|
16dce5df2a | ||
|
|
80a58bed61 | ||
|
|
a8afc63bba | ||
|
|
dbf9a2a65f | ||
|
|
ddecaa6017 | ||
|
|
418a05e343 | ||
|
|
c80e351212 | ||
|
|
a333192df5 | ||
|
|
a7b41c95c7 | ||
|
|
2298d8eea4 | ||
|
|
1813e1bfa4 | ||
|
|
bd09ad22ff | ||
|
|
7e90071c4d | ||
|
|
d1cc944be2 | ||
|
|
fb28283679 | ||
|
|
81ae8772d2 | ||
|
|
571145838d | ||
|
|
017b178f2e | ||
|
|
670444858c | ||
|
|
7bdff5df81 | ||
|
|
df083840ac | ||
|
|
a0279bac17 | ||
|
|
3d630041e1 | ||
|
|
ea92d3c150 | ||
|
|
86ee3dcbac | ||
|
|
43788ba7a7 | ||
|
|
1a3f1bb34c | ||
|
|
998c98267b | ||
|
|
28fa373db0 | ||
|
|
4ca33c37ad | ||
|
|
bd42e643ea | ||
|
|
4baf3821e5 | ||
|
|
d7e1ce11c9 | ||
|
|
5de370983d | ||
|
|
f272e268ab | ||
|
|
e59507a4d3 | ||
|
|
0bae306312 | ||
|
|
65f61a5147 | ||
|
|
1064dc739e | ||
|
|
8aedd38b51 | ||
|
|
ad8f08a577 | ||
|
|
2afce54472 | ||
|
|
8e50fcf8fb | ||
|
|
e11b03d56b | ||
|
|
fa55b82a5a | ||
|
|
d57832b0d4 | ||
|
|
1d0e8921a9 | ||
|
|
23d95b8241 | ||
|
|
8af80866bc | ||
|
|
b9e5ff70f5 | ||
|
|
c176d6ad88 | ||
|
|
5a6b162e4f | ||
|
|
33712c29fc | ||
|
|
eac1e6e273 | ||
|
|
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 | ||
|
|
ed71c8148b | ||
|
|
13a79af27e | ||
|
|
f6564c3c71 | ||
|
|
8e42f81ea3 | ||
|
|
ada5b48ba5 | ||
|
|
a6a59877a8 |
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,7 +16,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5
|
||||
@@ -25,50 +25,55 @@ jobs:
|
||||
java-version: '21'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Assemble Artifacts
|
||||
run: |
|
||||
./gradlew assembleRelease
|
||||
./gradlew bundleRelease
|
||||
run: ./gradlew assembleRelease bundleRelease
|
||||
|
||||
- name: Sign APK
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
id: sign_apk
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/apk/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"
|
||||
run: |
|
||||
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
|
||||
BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
|
||||
${BUILD_TOOLS}apksigner sign \
|
||||
--ks keystore.jks \
|
||||
--ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
|
||||
--ks-key-alias ${{ secrets.KEY_ALIAS }} \
|
||||
--key-pass pass:${{ secrets.KEY_PASSWORD }} \
|
||||
--out app/build/outputs/apk/release/look4sat.apk \
|
||||
"$APK"
|
||||
rm keystore.jks
|
||||
|
||||
- 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: |
|
||||
mv ${{steps.sign_apk.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.apk
|
||||
mv ${{steps.sign_bundle.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.aab
|
||||
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
||||
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
|
||||
-keystore keystore.jks \
|
||||
-storepass ${{ secrets.KEY_STORE_PASSWORD }} \
|
||||
-keypass ${{ secrets.KEY_PASSWORD }} \
|
||||
"$AAB" ${{ secrets.KEY_ALIAS }}
|
||||
rm keystore.jks
|
||||
|
||||
- name: Deploy Bundle
|
||||
uses: r0adkll/upload-google-play@v1
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
serviceAccountJsonPlainText: ${{secrets.SERVICE_ACCOUNT_JSON}}
|
||||
packageName: com.rtbishop.look4sat
|
||||
releaseFiles: app/build/outputs/apk/release/look4sat.aab
|
||||
track: beta
|
||||
whatsNewDirectory: fastlane/metadata/android/en-US/whatsnew
|
||||
ruby-version: '3.4'
|
||||
|
||||
- name: Deploy Bundle to Google Play
|
||||
run: |
|
||||
gem install multi_json
|
||||
gem install fastlane --no-document
|
||||
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
||||
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
|
||||
fastlane supply \
|
||||
--aab "$AAB" \
|
||||
--json_key service_account.json \
|
||||
--package_name com.rtbishop.look4sat \
|
||||
--track production \
|
||||
--skip_upload_images true \
|
||||
--skip_upload_screenshots true \
|
||||
rm service_account.json
|
||||
|
||||
- name: Create Release
|
||||
run: |
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Built application files
|
||||
*.ap_
|
||||
|
||||
# Hermes AI plans and metadata
|
||||
.hermes/
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
@@ -15,6 +18,7 @@ out/
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
.cxx/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
@@ -64,3 +68,4 @@ fastlane/readme.md
|
||||
/app/release/output-metadata.json
|
||||
/app/release/
|
||||
/.kotlin/sessions/
|
||||
dist/
|
||||
@@ -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,38 @@
|
||||
# Look4Sat: Satellite tracker
|
||||
# Look4Sat-BA7OPF
|
||||
|
||||
[](https://github.com/rt-bishop/Look4Sat/actions/workflows/release.yml)
|
||||
[](https://github.com/atsunatsu/Look4Sat/releases)
|
||||
|
||||
[<img src="https://play.google.com/intl/en_gb/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="80">](https://play.google.com/store/apps/details?id=com.rtbishop.look4sat)
|
||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/com.rtbishop.look4sat/)
|
||||
**BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的业余无线电卫星追踪器,增加了线性卫星频率计算器等功能。
|
||||
|
||||
### Radio satellite tracker and pass predictor for Android, inspired by Gpredict
|
||||
## 本仓库特色功能
|
||||
|
||||
<p float="left">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="192"/>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="192"/>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="192"/>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="192">
|
||||
</p>
|
||||
- **双站过境匹配(Mutual Pass)** — 输入友台经纬度/网格,筛选双方同时可见的卫星过境,在地图上同时显示双方仰角曲线和地面轨迹,支持一键跳转雷达页查看详情
|
||||
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
|
||||
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本
|
||||
- **Passband 模式** — 支持通过位置滑块(Passband)自动计算 TX/RX 频率,避免切换时跳变
|
||||
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放
|
||||
- **中文界面优化** — 翻译修正、UI 布局调整,系统语言自动切换
|
||||
|
||||
### Track satellite passes with ease!
|
||||
## 上游仓库
|
||||
|
||||
Thanks to [Celestrak](https://celestrak.com/) and [SatNOGS](https://satnogs.org/) you have access to over 9000 active satellites.\
|
||||
You can search the entire database by NORAD Catalog Number or the satellite's name.
|
||||
本仓库是 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的分支,上游仓库的原始功能包括:
|
||||
|
||||
Orbital positions and passes are calculated relative to your location.\
|
||||
To get reliable data make sure to set the station position via the app Settings.
|
||||
- 基于 Celestrak / SatNOGS 数据的 9000+ 活跃卫星追踪
|
||||
- SGP4/SDP4 轨道预测,10 天过境预报
|
||||
- 极坐标雷达图、地面轨迹图
|
||||
- SSTV 图像解码
|
||||
- 无广告、无跟踪、完全离线
|
||||
|
||||
The application is built using Kotlin, Coroutines, Jetpack Compose and Navigation.\
|
||||
It is now and always will be completely ad-free and open-source.
|
||||
## 许可证
|
||||
|
||||
## Main features:
|
||||
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.
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.dera.page/#atsunatsu/Look4Sat&type=timeline&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
+13
-39
@@ -1,50 +1,24 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
alias(libs.plugins.convention.applicationPlugin)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.rtbishop.look4sat"
|
||||
compileSdk = 36
|
||||
namespace = libs.versions.packageName.get()
|
||||
defaultConfig {
|
||||
applicationId = "com.rtbishop.look4sat"
|
||||
minSdk = 24
|
||||
versionCode = 400
|
||||
versionName = "4.0.0"
|
||||
applicationId = "cn.ba7opf.look4sat"
|
||||
ndk { abiFilters.add("armeabi-v7a") }
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file(System.getProperty("user.home") + "/my-release-key.jks")
|
||||
storePassword = "look4sat123"
|
||||
keyAlias = "look4sat"
|
||||
keyPassword = "look4sat123"
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
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">
|
||||
|
||||
<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
|
||||
@@ -11,21 +12,33 @@
|
||||
<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" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.Look4Sat.SplashScreen">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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>
|
||||
|
||||
<meta-data
|
||||
|
||||
@@ -19,13 +19,21 @@ package com.rtbishop.look4sat
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.ColorMatrix
|
||||
import android.graphics.ColorMatrixColorFilter
|
||||
import android.graphics.Paint
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.rtbishop.look4sat.presentation.MainScreen
|
||||
import com.rtbishop.look4sat.presentation.MainTheme
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
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() {
|
||||
|
||||
@@ -39,8 +47,37 @@ class MainActivity : ComponentActivity() {
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
observeNightFilterState()
|
||||
setContent {
|
||||
MainTheme(isDarkTheme = true) { MainScreen() }
|
||||
MainTheme(isDarkTheme = true) { NavRoot() }
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,26 +17,55 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import com.rtbishop.look4sat.data.injection.MainContainer
|
||||
import android.app.Application.ActivityLifecycleCallbacks
|
||||
import android.os.Bundle
|
||||
import com.rtbishop.look4sat.core.data.injection.MainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
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() {
|
||||
super.onCreate()
|
||||
container = MainContainer(this)
|
||||
clearAmSatCacheOnAppBackground()
|
||||
// trigger automatic update every 48 hours
|
||||
container.appScope.launch { checkAutoUpdate() }
|
||||
// load satellite data on every app start
|
||||
container.appScope.launch { container.satelliteRepo.initRepository() }
|
||||
}
|
||||
|
||||
private fun clearAmSatCacheOnAppBackground() {
|
||||
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
|
||||
private var startedActivityCount = 0
|
||||
|
||||
override fun onActivityStarted(activity: Activity) {
|
||||
startedActivityCount += 1
|
||||
}
|
||||
|
||||
override fun onActivityStopped(activity: Activity) {
|
||||
startedActivityCount = (startedActivityCount - 1).coerceAtLeast(0)
|
||||
if (startedActivityCount == 0) container.amSatRepo.clearStatusCache()
|
||||
}
|
||||
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
|
||||
override fun onActivityResumed(activity: Activity) = Unit
|
||||
override fun onActivityPaused(activity: Activity) = Unit
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
|
||||
override fun onActivityDestroyed(activity: Activity) = Unit
|
||||
})
|
||||
}
|
||||
|
||||
private suspend fun checkAutoUpdate(timeNow: Long = System.currentTimeMillis()) {
|
||||
if (container.settingsRepo.otherSettings.value.stateOfAutoUpdate) {
|
||||
val timeDelta = timeNow - container.settingsRepo.databaseState.value.updateTimestamp
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
* 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.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.Surface
|
||||
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
|
||||
import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
||||
|
||||
@Composable
|
||||
fun NavRoot(deeplink: String? = null) {
|
||||
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||
val deeplinkResolver = DeeplinkResolver()
|
||||
LaunchedEffect(deeplink) {
|
||||
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
||||
}
|
||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
||||
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
|
||||
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
||||
slideOutHorizontally(tween(300)) { -it / 3 }
|
||||
// Reverse: outgoing slides out to the right, incoming drifts in from the left
|
||||
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
|
||||
slideOutHorizontally(tween(300)) { it }
|
||||
NavDisplay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
backStack = rootBackStack,
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { pushTransition },
|
||||
popTransitionSpec = { popTransition },
|
||||
predictivePopTransitionSpec = { popTransition },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) }
|
||||
entry<RadarDestination> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
val backStack = rememberNavBackStack(Screen.Passes)
|
||||
val currentKey = backStack.lastOrNull()
|
||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||
val navigateToMap: () -> Unit = {
|
||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||
backStack.add(Screen.Map)
|
||||
}
|
||||
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.AMSAT, Screen.Mutual, Screen.Settings)
|
||||
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||
// Activity-scoped so the mutual query results survive navigation to Radar and back
|
||||
val mutualViewModel: MutualViewModel = viewModel(
|
||||
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||
factory = MutualViewModel.factory(container)
|
||||
)
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalElevationThresholds provides ElevationThresholds(
|
||||
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.Map -> screen is Screen.Passes
|
||||
is Screen.AMSAT -> screen is Screen.AMSAT
|
||||
is Screen.Mutual -> screen is Screen.Mutual
|
||||
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 && !(currentKey is Screen.Map && screen is Screen.Passes)) 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(
|
||||
navigateToRadar = { catNum, aosTime ->
|
||||
container.setMutualPassData(MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
navigateToRadar()
|
||||
},
|
||||
navigateToMap = navigateToMap
|
||||
)
|
||||
}
|
||||
entry<Screen.Map> {
|
||||
MapDestination()
|
||||
}
|
||||
entry<Screen.Mutual> {
|
||||
MutualScreen(
|
||||
viewModel = mutualViewModel,
|
||||
navigateToRadar = { catNum, aosTime, pass ->
|
||||
container.setMutualPassData(pass ?: MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
navigateToRadar()
|
||||
}
|
||||
)
|
||||
}
|
||||
entry<Screen.AMSAT> { SatStatusDestination() }
|
||||
entry<Screen.Settings> {
|
||||
SettingsDestination()
|
||||
}
|
||||
}
|
||||
)
|
||||
// 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)
|
||||
navigateToRadar()
|
||||
}
|
||||
}
|
||||
.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,90 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.presentation
|
||||
|
||||
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) {
|
||||
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,400 +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.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
|
||||
) { Text(text = text, fontSize = 17.sp) }
|
||||
}
|
||||
|
||||
@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)
|
||||
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 = 18.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,430 +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.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) {
|
||||
RadarViewCompose(
|
||||
item = uiState.orbitalPos,
|
||||
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(
|
||||
uiState.orbitalPos?.azimuth ?: 0.0,
|
||||
stringResource(R.string.radar_az_text),
|
||||
true
|
||||
)
|
||||
RadarTextTop(
|
||||
uiState.orbitalPos?.elevation ?: 0.0,
|
||||
stringResource(R.string.radar_el_text),
|
||||
false
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
RadarTextBottom(
|
||||
uiState.orbitalPos?.altitude ?: 0.0,
|
||||
stringResource(R.string.radar_alt_text),
|
||||
true
|
||||
)
|
||||
RadarTextBottom(
|
||||
uiState.orbitalPos?.distance ?: 0.0,
|
||||
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)
|
||||
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) {
|
||||
RadarViewCompose(
|
||||
item = uiState.orbitalPos,
|
||||
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(
|
||||
uiState.orbitalPos?.azimuth ?: 0.0,
|
||||
stringResource(R.string.radar_az_text),
|
||||
true
|
||||
)
|
||||
RadarTextTop(
|
||||
uiState.orbitalPos?.elevation ?: 0.0,
|
||||
stringResource(R.string.radar_el_text),
|
||||
false
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
RadarTextBottom(
|
||||
uiState.orbitalPos?.altitude ?: 0.0,
|
||||
stringResource(R.string.radar_alt_text),
|
||||
true
|
||||
)
|
||||
RadarTextBottom(
|
||||
uiState.orbitalPos?.distance ?: 0.0,
|
||||
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)
|
||||
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,263 +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 android.media.SoundPool
|
||||
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.mutableIntStateOf
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
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 != null && 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,73 +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
|
||||
|
||||
@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) {
|
||||
Text(text = stringResource(id = R.string.prefs_station_lat_text))
|
||||
OutlinedTextField(value = latValue.value, onValueChange = { latValue.value = it })
|
||||
Text(text = stringResource(id = R.string.prefs_station_lon_text))
|
||||
OutlinedTextField(value = lonValue.value, onValueChange = { lonValue.value = it })
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
Text(text = stringResource(id = R.string.prefs_locator_text))
|
||||
OutlinedTextField(value = locator.value, onValueChange = { locator.value = it })
|
||||
}
|
||||
}
|
||||
@@ -1,635 +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 contentRequest = rememberLauncherForActivityResult(contentContract) { uri ->
|
||||
uri?.let { uiState.sendAction(SettingsAction.UpdateFromFile(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 settings
|
||||
val dataSettings = uiState.dataSettings
|
||||
val updateFromWeb: () -> Unit = { uiState.sendAction(SettingsAction.UpdateFromWeb) }
|
||||
val updateFromFile = { contentRequest.launch("*/*") }
|
||||
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, updateFromFile, clearAllData) }
|
||||
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,
|
||||
updateFromFile: () -> Unit,
|
||||
clearAllData: () -> 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.SpaceEvenly) {
|
||||
CardButton(
|
||||
onClick = { updateFromWeb() },
|
||||
text = stringResource(id = R.string.prefs_data_update),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
CardButton(
|
||||
onClick = { updateFromFile() },
|
||||
text = stringResource(id = R.string.prefs_data_import),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
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,74 +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.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 sendAction: (SettingsAction) -> Unit,
|
||||
val sendRCAction: (RCAction) -> Unit,
|
||||
val sendSystemAction: (SystemAction) -> 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 UpdateFromFile(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()
|
||||
}
|
||||
@@ -1,262 +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.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 _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,
|
||||
sendAction = ::handleAction,
|
||||
sendRCAction = ::handleAction,
|
||||
sendSystemAction = ::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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.UpdateFromFile -> updateFromFile(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 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 updateFromFile(uri: String) = viewModelScope.launch {
|
||||
try {
|
||||
val newDataSettings = _uiState.value.dataSettings.copy(isUpdating = true)
|
||||
_uiState.update { it.copy(dataSettings = newDataSettings) }
|
||||
databaseRepo.updateFromFile(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,131 +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_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,130 +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_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,131 +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_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,131 +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_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,123 +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_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,161 +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_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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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(project(":feature:status"))
|
||||
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.google.ksp) apply false
|
||||
alias(libs.plugins.kotlin.jvm) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
}
|
||||
|
||||
tasks.register("clean", Delete::class.java) {
|
||||
description = "Cleans the build directory"
|
||||
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,160 @@
|
||||
/*
|
||||
* 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.model.Constants
|
||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
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,
|
||||
private val frequencyOffsetHz: Long = 0L
|
||||
) : IReporter {
|
||||
|
||||
private val writeMutex = Mutex()
|
||||
private val connectionMutex = Mutex()
|
||||
private val frequencyCommands = Channel<String>(Channel.CONFLATED)
|
||||
|
||||
private var rotatorSocket: SocketChannel? = null
|
||||
private var rotatorConnected = false
|
||||
|
||||
private var frequencySocket: SocketChannel? = null
|
||||
private var frequencyConnected = false
|
||||
|
||||
init {
|
||||
// Keep only the latest frequency command to avoid stale backlog and effective lag.
|
||||
reporterScope.launch {
|
||||
for (command in frequencyCommands) {
|
||||
ensureFrequencyConnected()
|
||||
if (!frequencyConnected) continue
|
||||
write(frequencySocket, command) { resetFrequencyConnection() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
|
||||
reporterScope.launch {
|
||||
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) { resetRotatorConnection() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun reportFrequency(format: String, frequency: Long) {
|
||||
val clampedOffset = frequencyOffsetHz.coerceIn(
|
||||
Constants.FREQ_OFFSET_MIN_HZ,
|
||||
Constants.FREQ_OFFSET_MAX_HZ
|
||||
)
|
||||
val correctedFreq = frequency.coerceAtLeast(0L).safeAdd(clampedOffset).coerceAtLeast(0L)
|
||||
val command = format
|
||||
.replace($$"$FREQ", correctedFreq.toString())
|
||||
.unescapeControlChars()
|
||||
frequencyCommands.trySend(command)
|
||||
}
|
||||
|
||||
private suspend fun ensureRotatorConnected() {
|
||||
connectionMutex.withLock {
|
||||
if (rotatorConnected || rotatorServer.isBlank()) return
|
||||
try {
|
||||
resetRotatorConnection()
|
||||
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
||||
rotatorConnected = true
|
||||
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter rotator connect error: ${e.message}")
|
||||
resetRotatorConnection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ensureFrequencyConnected() {
|
||||
connectionMutex.withLock {
|
||||
if (frequencyConnected || frequencyServer.isBlank()) return
|
||||
try {
|
||||
resetFrequencyConnection()
|
||||
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
||||
frequencyConnected = true
|
||||
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter frequency connect error: ${e.message}")
|
||||
resetFrequencyConnection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun write(socket: SocketChannel?, command: String, onError: () -> Unit) {
|
||||
try {
|
||||
writeMutex.withLock {
|
||||
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
|
||||
while (buffer.hasRemaining()) {
|
||||
socket?.write(buffer)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter write error: ${e.message}")
|
||||
onError()
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetRotatorConnection() {
|
||||
rotatorConnected = false
|
||||
closeQuietly(rotatorSocket)
|
||||
rotatorSocket = null
|
||||
}
|
||||
|
||||
private fun resetFrequencyConnection() {
|
||||
frequencyConnected = false
|
||||
closeQuietly(frequencySocket)
|
||||
frequencySocket = null
|
||||
}
|
||||
|
||||
private fun closeQuietly(socket: SocketChannel?) {
|
||||
try {
|
||||
socket?.close()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun Long.safeAdd(delta: Long): Long {
|
||||
return when {
|
||||
delta > 0 && this > Long.MAX_VALUE - delta -> Long.MAX_VALUE
|
||||
delta < 0 && this < Long.MIN_VALUE - delta -> Long.MIN_VALUE
|
||||
else -> this + delta
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.unescapeControlChars(): String =
|
||||
replace("""\r""", 13.toChar().toString())
|
||||
.replace("""\n""", 10.toChar().toString())
|
||||
.replace("""\t""", 9.toChar().toString())
|
||||
}
|
||||
+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,185 @@
|
||||
/*
|
||||
* 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.AmSatRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
|
||||
import com.rtbishop.look4sat.core.data.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 remoteSource = provideRemoteSource()
|
||||
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
|
||||
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
|
||||
override val settingsRepo = provideSettingsRepo()
|
||||
override val selectionRepo = provideSelectionRepo()
|
||||
override val satelliteRepo = provideSatelliteRepo()
|
||||
override val databaseRepo = provideDatabaseRepo()
|
||||
override val amSatRepo by lazy { AmSatRepository(remoteSource) }
|
||||
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,
|
||||
rc.frequencyOffsetHz
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
|
||||
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/** One report from the AMSAT API (data layer model). */
|
||||
private data class ApiReport(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val callsign: String,
|
||||
val report: String,
|
||||
val gridSquare: String,
|
||||
val reportedTimeUtcSec: Long
|
||||
)
|
||||
|
||||
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
|
||||
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
|
||||
|
||||
private val statusCacheMutex = Mutex()
|
||||
|
||||
@Volatile
|
||||
private var statusCache: SatStatusPage? = null
|
||||
|
||||
@Volatile
|
||||
private var cacheGeneration = 0
|
||||
|
||||
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
override fun getCachedStatus(): SatStatusPage? = statusCache
|
||||
|
||||
override suspend fun fetchStatus(forceRefresh: Boolean): SatStatusPage? = withContext(Dispatchers.IO) {
|
||||
statusCacheMutex.lock()
|
||||
try {
|
||||
if (!forceRefresh) statusCache?.let { return@withContext it }
|
||||
val generation = cacheGeneration
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
|
||||
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
|
||||
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
|
||||
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
|
||||
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return@withContext null
|
||||
|
||||
val names = parseCatalog(catalogJson)
|
||||
val reports = parseReports(reportsJson)
|
||||
|
||||
if (names.isEmpty() && reports.isEmpty()) return@withContext null
|
||||
|
||||
val statuses = buildStatuses(names, reports, nowSec)
|
||||
val reportMap = reports.associate { it.id to toSatReport(it) }
|
||||
SatStatusPage(System.currentTimeMillis(), statuses, reportMap).also { page ->
|
||||
if (generation == cacheGeneration) statusCache = page
|
||||
}
|
||||
} finally {
|
||||
statusCacheMutex.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
override fun clearStatusCache() {
|
||||
statusCache = null
|
||||
cacheGeneration += 1
|
||||
}
|
||||
|
||||
override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
|
||||
val payload = JSONObject().apply {
|
||||
put("name", submission.name)
|
||||
put("report", submission.report)
|
||||
put("callsign", submission.callsign)
|
||||
put("reported_at", isoUtcFormat.format(Date(submission.reportedAtUtcMillis)))
|
||||
if (submission.gridSquare.isNotBlank()) put("grid_square", submission.gridSquare)
|
||||
}
|
||||
val response = remoteSource.submitAmSatReport(payload.toString())
|
||||
?: return@withContext AmSatReportSubmitResult(success = false, message = "Network request failed")
|
||||
val (code, body) = response
|
||||
val errorMessage = parseSubmitError(body)
|
||||
return@withContext if (code in 200..299 && errorMessage.isBlank()) {
|
||||
AmSatReportSubmitResult(success = true, reportId = parseSubmitReportId(body))
|
||||
} else {
|
||||
AmSatReportSubmitResult(
|
||||
success = false,
|
||||
message = errorMessage.ifBlank { "HTTP $code" }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSubmitError(json: String): String {
|
||||
return try {
|
||||
JSONObject(json).optJSONObject("error")?.optString("message").orEmpty()
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSubmitReportId(json: String): String? {
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
obj.optJSONObject("data")?.optString("id")?.takeIf { it.isNotBlank() }
|
||||
?: obj.optString("id").takeIf { it.isNotBlank() }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse catalog JSON to list of satellite names */
|
||||
private fun parseCatalog(json: String): List<String> {
|
||||
return try {
|
||||
val arr = JSONObject(json).getJSONArray("data")
|
||||
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse reports JSON to list of ApiReport domain objects */
|
||||
private fun parseReports(json: String): List<ApiReport> {
|
||||
return try {
|
||||
val arr = JSONObject(json).getJSONArray("data")
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
val iso = o.optString("reported_time", "")
|
||||
if (iso.isEmpty()) null else ApiReport(
|
||||
id = o.optString("id", ""),
|
||||
name = o.optString("name", ""),
|
||||
callsign = o.optString("callsign", ""),
|
||||
report = o.optString("report", ""),
|
||||
gridSquare = o.optString("grid_square", ""),
|
||||
reportedTimeUtcSec = parseIsoUtcSec(iso)
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */
|
||||
private fun parseIsoUtcSec(iso: String): Long {
|
||||
return try {
|
||||
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
/** Build one SatStatus (3 days x 12 slots) per catalog satellite, slotting reports by age. */
|
||||
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||
val byName = reports.groupBy { it.name }
|
||||
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
val labels = (0 until 3).map { d ->
|
||||
utc.timeInMillis = (nowSec - d * 86400L) * 1000
|
||||
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
|
||||
}
|
||||
return names.map { name ->
|
||||
val slots = (0 until 36).map { slotIdx ->
|
||||
val slotStart = nowSec - (slotIdx + 1) * 7200L
|
||||
val slotEnd = nowSec - slotIdx * 7200L
|
||||
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
|
||||
if (inSlot.isEmpty()) {
|
||||
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
|
||||
} else {
|
||||
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
|
||||
SatSlot(
|
||||
statusColor = statusColorOf(newest.report),
|
||||
count = inSlot.size,
|
||||
reportIds = inSlot.map { it.id }
|
||||
)
|
||||
}
|
||||
}
|
||||
val days = (0 until 3).map { d ->
|
||||
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
|
||||
}
|
||||
SatStatus(name = name, days = days)
|
||||
}
|
||||
}
|
||||
|
||||
private fun toSatReport(r: ApiReport): SatReport {
|
||||
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = r.reportedTimeUtcSec * 1000
|
||||
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
|
||||
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
|
||||
val y = cal.get(Calendar.YEAR)
|
||||
val mo = (cal.get(Calendar.MONTH) + 1).toString().padStart(2, '0')
|
||||
val d = cal.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0')
|
||||
return SatReport(
|
||||
id = r.id,
|
||||
statusText = r.report,
|
||||
call = r.callsign,
|
||||
grid = r.gridSquare,
|
||||
dateUtc = "$y-$mo-$d",
|
||||
timeUtc = "$hh:$mm UTC"
|
||||
)
|
||||
}
|
||||
|
||||
/** Map status text to color value (for UI rendering). */
|
||||
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
|
||||
"heard", "crew active" -> ACTIVE_BLUE
|
||||
"telemetry only" -> TLM_ORANGE
|
||||
"not heard" -> NOT_HEARD_PINK
|
||||
else -> CONFLICT_DEEP_ORANGE
|
||||
}
|
||||
|
||||
companion object {
|
||||
// AMSAT official status colors (from amsat.org/status)
|
||||
private const val ACTIVE_BLUE = 0xFF648FFF
|
||||
private const val TLM_ORANGE = 0xFFFFB000
|
||||
private const val NOT_HEARD_PINK = 0xFFDC267F
|
||||
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
|
||||
private const val NO_REPORT_GRAY = 0xFFC0C0C0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* 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 settings = settingsRepo.dataSourcesSettings.value
|
||||
val tleUrls = settings.satelliteUrls.filter { it.isNotBlank() }.map(::normalizeUrl).distinct()
|
||||
val radioUrls = settings.transceiversUrls.filter { it.isNotBlank() }.map(::normalizeUrl).distinct()
|
||||
val builtinTypesByUrl = Sources.satelliteDataUrls
|
||||
.filterValues { it.isNotBlank() }
|
||||
.mapValues { normalizeUrl(it.value) }
|
||||
.entries
|
||||
.associate { (type, url) -> url to type }
|
||||
val importedTypeIds = mutableMapOf<String, MutableList<Int>>()
|
||||
// launch all network requests concurrently
|
||||
val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
// parse fetched data concurrently and associate known built-in URLs with existing type filters.
|
||||
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
||||
val entries = stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty()
|
||||
val type = builtinTypesByUrl[url] ?: customSourceType
|
||||
importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum })
|
||||
entries
|
||||
}
|
||||
importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
|
||||
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 fun normalizeUrl(url: String): String =
|
||||
if (url.startsWith("http", ignoreCase = true)) url else "https://$url"
|
||||
|
||||
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
|
||||
val bufferedStream = stream.buffered()
|
||||
return when {
|
||||
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,530 @@
|
||||
/*
|
||||
* 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
|
||||
import org.json.JSONObject
|
||||
import java.util.Locale
|
||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
|
||||
class SettingsRepo(
|
||||
private val locationManager: LocationManager,
|
||||
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 keyFrequencyOffsetHz = "frequencyOffsetHz"
|
||||
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 keySatelliteUrls = "satelliteUrls"
|
||||
private val keyTransceiversUrls = "transceiversUrls"
|
||||
private val separatorComma = ","
|
||||
private val separatorUrl = "\n"
|
||||
|
||||
//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) {
|
||||
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
|
||||
Constants.FREQ_OFFSET_MIN_HZ,
|
||||
Constants.FREQ_OFFSET_MAX_HZ
|
||||
)
|
||||
preferences.edit {
|
||||
putBoolean(keyRotatorState, settings.rotatorState)
|
||||
putString(keyRotatorAddress, settings.rotatorAddress)
|
||||
putString(keyRotatorPort, settings.rotatorPort)
|
||||
putString(keyRotatorFormat, settings.rotatorFormat)
|
||||
putBoolean(keyFrequencyState, settings.frequencyState)
|
||||
putString(keyFrequencyAddress, settings.frequencyAddress)
|
||||
putString(keyFrequencyPort, settings.frequencyPort)
|
||||
putString(keyFrequencyFormat, settings.frequencyFormat)
|
||||
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
|
||||
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
||||
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
||||
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
||||
putString(keyBluetoothRotatorAddress, settings.bluetoothRotatorAddress)
|
||||
putBoolean(keyBluetoothFrequencyState, settings.bluetoothFrequencyState)
|
||||
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
||||
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
||||
}
|
||||
_rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
|
||||
}
|
||||
|
||||
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",
|
||||
frequencyOffsetHz = preferences.getLong(keyFrequencyOffsetHz, 0L)
|
||||
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ),
|
||||
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
|
||||
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
||||
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
||||
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 {
|
||||
putString(keySatelliteUrls, settings.satelliteUrls.joinToString(separatorUrl))
|
||||
putString(keyTransceiversUrls, settings.transceiversUrls.joinToString(separatorUrl))
|
||||
}
|
||||
_dataSourcesSettings.value = settings
|
||||
}
|
||||
|
||||
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
||||
satelliteUrls = getDataSourceUrls(
|
||||
key = keySatelliteUrls,
|
||||
defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
|
||||
legacyEnabledKey = keyUseCustomTle,
|
||||
legacyUrlKey = keyTleUrl
|
||||
),
|
||||
transceiversUrls = getDataSourceUrls(
|
||||
key = keyTransceiversUrls,
|
||||
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
|
||||
legacyEnabledKey = keyUseCustomTransceivers,
|
||||
legacyUrlKey = keyTransceiversUrl
|
||||
)
|
||||
)
|
||||
|
||||
private fun getDataSourceUrls(
|
||||
key: String,
|
||||
defaultUrls: List<String>,
|
||||
legacyEnabledKey: String,
|
||||
legacyUrlKey: String
|
||||
): List<String> {
|
||||
val stored = preferences.getString(key, null)
|
||||
?.split(separatorUrl)
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotBlank() }
|
||||
if (stored != null) return stored
|
||||
val legacyCustomUrl = preferences.getString(legacyUrlKey, null)?.trim().orEmpty()
|
||||
return if (preferences.getBoolean(legacyEnabledKey, false) && legacyCustomUrl.isNotBlank()) {
|
||||
(defaultUrls + legacyCustomUrl).distinct()
|
||||
} else {
|
||||
defaultUrls
|
||||
}
|
||||
}
|
||||
//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
|
||||
|
||||
//region # Per-satellite calculator offset settings
|
||||
private val keySatelliteOffsets = "satelliteOffsets"
|
||||
private val keyLegacySatelliteOffsetPrefix = "offset_khz_"
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String {
|
||||
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||
val stored = try {
|
||||
JSONObject(json).optString(catnum.toString(), "")
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
if (stored.isNotEmpty()) return stored
|
||||
|
||||
// Lazy migration from the fork's old UI-layer implementation, which stored
|
||||
// one SharedPreferences entry per satellite directly from TransceiversPage.
|
||||
val legacyKey = "$keyLegacySatelliteOffsetPrefix$catnum"
|
||||
val legacy = preferences.getString(legacyKey, "").orEmpty()
|
||||
if (legacy.isNotEmpty()) {
|
||||
setSatelliteOffset(catnum, legacy)
|
||||
}
|
||||
return legacy
|
||||
}
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) {
|
||||
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||
val updated = try {
|
||||
val obj = JSONObject(json)
|
||||
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
|
||||
obj.toString()
|
||||
} catch (_: Exception) {
|
||||
if (offset.isEmpty()) "{}" else """{"$catnum": "$offset"}"""
|
||||
}
|
||||
preferences.edit {
|
||||
putString(keySatelliteOffsets, updated)
|
||||
remove("$keyLegacySatelliteOffsetPrefix$catnum")
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # AMSAT status report settings
|
||||
private val keyAmSatCallsign = "amSatCallsign"
|
||||
|
||||
override fun getAmSatCallsign(): String {
|
||||
return preferences.getString(keyAmSatCallsign, "").orEmpty()
|
||||
}
|
||||
|
||||
override fun setAmSatCallsign(callsign: String) {
|
||||
preferences.edit { putString(keyAmSatCallsign, callsign.trim().uppercase(Locale.US)) }
|
||||
}
|
||||
//endregion
|
||||
}
|
||||
@@ -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,112 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.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.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
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()
|
||||
val response = httpClient.newCall(networkRequest).execute()
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
return@withContext null
|
||||
}
|
||||
// Return the body stream directly as the caller is responsible for closing it.
|
||||
// Closing the stream returns the connection to OkHttp's pool.
|
||||
response.body.byteStream().buffered()
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource network stream exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAmSatCatalog(): String? = withContext(dispatcher) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("https://www.amsat.org/status/api/v1/catalog.php")
|
||||
.header("User-Agent", "Look4Sat")
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body.string()
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource getAmSatCatalog exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = withContext(dispatcher) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit")
|
||||
.header("User-Agent", "Look4Sat")
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body.string()
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource getAmSatReports exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = withContext(dispatcher) {
|
||||
try {
|
||||
val body = payloadJson.toRequestBody("application/json; charset=utf-8".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("https://www.amsat.org/status/api/v1/reports.php")
|
||||
.header("User-Agent", "Look4Sat")
|
||||
.post(body)
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
response.code to response.body.string()
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource submitAmSatReport exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
import java.io.InputStream
|
||||
|
||||
class AmSatRepositoryTest {
|
||||
|
||||
@Test
|
||||
fun fetchStatusReturnsSeededCacheUntilCacheIsCleared() = runTest {
|
||||
val remoteSource = FakeAmSatRemoteSource()
|
||||
val repository = AmSatRepository(remoteSource)
|
||||
val cachedPage = SatStatusPage(
|
||||
fetchedAtUtcMs = 123L,
|
||||
statuses = listOf(SatStatus(name = "AO-7", days = emptyList())),
|
||||
reports = emptyMap()
|
||||
)
|
||||
repository.seedStatusCache(cachedPage)
|
||||
|
||||
val firstPage = repository.fetchStatus()
|
||||
val secondPage = repository.fetchStatus()
|
||||
|
||||
assertSame(cachedPage, firstPage)
|
||||
assertSame(cachedPage, secondPage)
|
||||
assertSame(cachedPage, repository.getCachedStatus())
|
||||
assertEquals(0, remoteSource.catalogRequests)
|
||||
assertEquals(0, remoteSource.reportRequests)
|
||||
|
||||
repository.clearStatusCache()
|
||||
assertNull(repository.getCachedStatus())
|
||||
repository.fetchStatus()
|
||||
|
||||
assertEquals(1, remoteSource.catalogRequests)
|
||||
assertEquals(1, remoteSource.reportRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forceRefreshBypassesCachedPage() = runTest {
|
||||
val remoteSource = FakeAmSatRemoteSource()
|
||||
val repository = AmSatRepository(remoteSource)
|
||||
repository.seedStatusCache(
|
||||
SatStatusPage(
|
||||
fetchedAtUtcMs = 123L,
|
||||
statuses = listOf(SatStatus(name = "AO-7", days = emptyList())),
|
||||
reports = emptyMap()
|
||||
)
|
||||
)
|
||||
|
||||
repository.fetchStatus(forceRefresh = true)
|
||||
|
||||
assertEquals(1, remoteSource.catalogRequests)
|
||||
assertEquals(1, remoteSource.reportRequests)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
|
||||
val cacheField = AmSatRepository::class.java.getDeclaredField("statusCache")
|
||||
cacheField.isAccessible = true
|
||||
cacheField.set(this, page)
|
||||
}
|
||||
|
||||
private class FakeAmSatRemoteSource : IRemoteSource {
|
||||
var catalogRequests = 0
|
||||
var reportRequests = 0
|
||||
|
||||
override suspend fun getFileStream(uri: String): InputStream? = null
|
||||
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = null
|
||||
|
||||
override suspend fun getAmSatCatalog(): String? {
|
||||
catalogRequests += 1
|
||||
return """{"data":[{"name":"AO-7"}]}"""
|
||||
}
|
||||
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? {
|
||||
reportRequests += 1
|
||||
return """{"data":[]}"""
|
||||
}
|
||||
|
||||
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
* 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(
|
||||
satelliteUrls = listOf(customCsvUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
)
|
||||
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()
|
||||
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||
|
||||
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
||||
}
|
||||
|
||||
private class FakeLocalSource : ILocalSource {
|
||||
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, "", "", "", 0L, 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
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
|
||||
override fun setAmSatCallsign(callsign: String) = Unit
|
||||
}
|
||||
|
||||
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||
return DataSourcesSettings(
|
||||
satelliteUrls = emptyList(),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
/** One public AMSAT satellite status report submission. */
|
||||
data class AmSatReportSubmission(
|
||||
val name: String,
|
||||
val report: String,
|
||||
val callsign: String,
|
||||
val gridSquare: String,
|
||||
val reportedAtUtcMillis: Long
|
||||
)
|
||||
|
||||
/** Result of submitting an AMSAT satellite status report. */
|
||||
data class AmSatReportSubmitResult(
|
||||
val success: Boolean,
|
||||
val message: String = "",
|
||||
val reportId: String? = null
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
object Constants {
|
||||
const val FREQ_OFFSET_MIN_HZ = -50_000L
|
||||
const val FREQ_OFFSET_MAX_HZ = 50_000L
|
||||
}
|
||||
@@ -0,0 +1,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,37 @@
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
/** One satellite status report (AMSAT site tooltip data) */
|
||||
data class SatReport(
|
||||
val id: String, // 报告 ID(a885153)
|
||||
val statusText: String, // Heard / Telemetry Only / Not Heard ...
|
||||
val call: String, // 呼号
|
||||
val grid: String, // 网格坐标(可为空)
|
||||
val dateUtc: String, // 2026-08-04
|
||||
val timeUtc: String // 2:46-:59 UTC
|
||||
)
|
||||
|
||||
/** State of one 2-hour slot */
|
||||
data class SatSlot(
|
||||
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
|
||||
val count: Int, // 报告数量(0 = 无)
|
||||
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
|
||||
)
|
||||
|
||||
/** One satellite day (12 two-hour slots) */
|
||||
data class SatDay(
|
||||
val dateLabel: String, // "Aug 4"
|
||||
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
||||
)
|
||||
|
||||
/** One satellite, 3 days of state */
|
||||
data class SatStatus(
|
||||
val name: String, // "AO-123_[FM]"
|
||||
val days: List<SatDay> // 3 days (newest first)
|
||||
)
|
||||
|
||||
/** Overall page parse result */
|
||||
data class SatStatusPage(
|
||||
val fetchedAtUtcMs: Long,
|
||||
val statuses: List<SatStatus>,
|
||||
val reports: Map<String, SatReport> // id → 报告
|
||||
)
|
||||
Loaded 100 of 5826 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user