mirror of
https://github.com/rt-bishop/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
782b438a07 | ||
|
|
fac319b215 | ||
|
|
b7ac9c913a | ||
|
|
de3d5a6d84 | ||
|
|
5e39c945b2 | ||
|
|
1d07f76ada | ||
|
|
d471f02370 | ||
|
|
9699d142fa | ||
|
|
6f3e3351c9 | ||
|
|
d4a2fae0cd | ||
|
|
d0bbb97f29 | ||
|
|
c2fc5688b9 | ||
|
|
796056a582 | ||
|
|
9cac33dae7 | ||
|
|
082266e385 | ||
|
|
a42a5f1f0d | ||
|
|
7cdc2952dc | ||
|
|
bd51044690 | ||
|
|
1982c2d3dc | ||
|
|
2f4e3f5802 | ||
|
|
24eebdef74 | ||
|
|
3f5b48f270 | ||
|
|
060fa2dfcd | ||
|
|
8b5960282f | ||
|
|
10eb84690e | ||
|
|
bf25292bf8 | ||
|
|
8fbfcb3712 | ||
|
|
602b1553a2 | ||
|
|
bd0881fb4b | ||
|
|
a9dd3e6e55 | ||
|
|
905995ab44 | ||
|
|
f9806d7f7f | ||
|
|
2d3adfc6a6 | ||
|
|
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 |
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,10 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
- package-ecosystem: "bundler"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "never"
|
||||||
@@ -16,59 +16,64 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v6
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '17'
|
java-version: '21'
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v5
|
uses: gradle/actions/setup-gradle@v6
|
||||||
|
|
||||||
- name: Assemble Artifacts
|
- name: Assemble Artifacts
|
||||||
run: |
|
run: ./gradlew assembleRelease bundleRelease
|
||||||
./gradlew assembleRelease
|
|
||||||
./gradlew bundleRelease
|
|
||||||
|
|
||||||
- name: Sign APK
|
- name: Sign APK
|
||||||
uses: r0adkll/sign-android-release@v1
|
run: |
|
||||||
id: sign_apk
|
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||||
with:
|
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
|
||||||
releaseDirectory: app/build/outputs/apk/release
|
BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
|
||||||
signingKeyBase64: ${{ secrets.KEY_STORE }}
|
${BUILD_TOOLS}apksigner sign \
|
||||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
--ks keystore.jks \
|
||||||
alias: ${{ secrets.KEY_ALIAS }}
|
--ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
|
||||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
--ks-key-alias ${{ secrets.KEY_ALIAS }} \
|
||||||
env:
|
--key-pass pass:${{ secrets.KEY_PASSWORD }} \
|
||||||
BUILD_TOOLS_VERSION: "36.0.0"
|
--out app/build/outputs/apk/release/look4sat.apk \
|
||||||
|
"$APK"
|
||||||
|
rm keystore.jks
|
||||||
|
|
||||||
- name: Sign Bundle
|
- name: Sign Bundle
|
||||||
uses: r0adkll/sign-android-release@v1
|
|
||||||
id: sign_bundle
|
|
||||||
with:
|
|
||||||
releaseDirectory: app/build/outputs/bundle/release
|
|
||||||
signingKeyBase64: ${{ secrets.KEY_STORE }}
|
|
||||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
|
||||||
alias: ${{ secrets.KEY_ALIAS }}
|
|
||||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
|
||||||
env:
|
|
||||||
BUILD_TOOLS_VERSION: "36.0.0"
|
|
||||||
|
|
||||||
- name: Rename Artifacts
|
|
||||||
run: |
|
run: |
|
||||||
mv ${{steps.sign_apk.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.apk
|
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||||
mv ${{steps.sign_bundle.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.aab
|
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
||||||
|
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
|
||||||
|
-keystore keystore.jks \
|
||||||
|
-storepass ${{ secrets.KEY_STORE_PASSWORD }} \
|
||||||
|
-keypass ${{ secrets.KEY_PASSWORD }} \
|
||||||
|
"$AAB" ${{ secrets.KEY_ALIAS }}
|
||||||
|
rm keystore.jks
|
||||||
|
|
||||||
- name: Deploy Bundle
|
- name: Setup Ruby
|
||||||
uses: r0adkll/upload-google-play@v1
|
uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
serviceAccountJsonPlainText: ${{secrets.SERVICE_ACCOUNT_JSON}}
|
ruby-version: '3.4'
|
||||||
packageName: com.rtbishop.look4sat
|
|
||||||
releaseFiles: app/build/outputs/apk/release/look4sat.aab
|
- name: Deploy Bundle to Google Play
|
||||||
track: production
|
run: |
|
||||||
whatsNewDirectory: fastlane/metadata/android/en-US/whatsnew
|
gem install multi_json
|
||||||
|
gem install fastlane --no-document
|
||||||
|
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
||||||
|
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
|
||||||
|
fastlane supply \
|
||||||
|
--aab "$AAB" \
|
||||||
|
--json_key service_account.json \
|
||||||
|
--package_name com.rtbishop.look4sat \
|
||||||
|
--track production \
|
||||||
|
--skip_upload_images true \
|
||||||
|
--skip_upload_screenshots true \
|
||||||
|
rm service_account.json
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -64,3 +64,4 @@ fastlane/readme.md
|
|||||||
/app/release/output-metadata.json
|
/app/release/output-metadata.json
|
||||||
/app/release/
|
/app/release/
|
||||||
/.kotlin/sessions/
|
/.kotlin/sessions/
|
||||||
|
.hermes/
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# 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 Celestrak/SatNOGS orbital data, calculates positions via SGP4/SDP4, and predicts passes relative to
|
||||||
|
the user's location. Features include polar radar visualization, SSTV image decoding, and ground track mapping. No ads,
|
||||||
|
no tracking, no network required after initial data download.
|
||||||
|
|
||||||
|
## Architecture & Design
|
||||||
|
|
||||||
|
**MVI (Model-View-Intent)** with unidirectional data flow:
|
||||||
|
- `State` data class (named `<Feature>State`) exposed via `StateFlow` from ViewModel
|
||||||
|
- `Action` sealed interface (named `<Feature>Action`) dispatched to ViewModel's `onAction()`
|
||||||
|
- 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 isolation:**
|
||||||
|
- `feature:*` modules depend only on `core:domain` and `core:presentation`.
|
||||||
|
- No feature-to-feature dependencies; cross-feature communication goes through core layers.
|
||||||
|
|
||||||
|
## Build & Platform
|
||||||
|
|
||||||
|
```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**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/`
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
||||||
|
- **Navigation3**: Type-safe navigation with `@Serializable` nav keys
|
||||||
|
- **Room** (KSP code generation) for local satellite/orbital storage
|
||||||
|
- **OkHttp** 5.x for data downloads
|
||||||
|
- **OSMDroid** for map rendering
|
||||||
|
- **Kotlin Serialization** for navigation args and parsing
|
||||||
|
- **Coroutines** + `StateFlow` for async/reactive patterns
|
||||||
|
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh)
|
||||||
|
|
||||||
|
## Data Formats & Migration
|
||||||
|
|
||||||
|
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
|
||||||
|
|
||||||
|
- **TLE format**: Legacy 3-line element format limited by 5-digit NORAD IDs
|
||||||
|
- **OMM/CSV format**: Successor format with ISO 8601 timestamps and larger NORAD ID support
|
||||||
|
- New 5-digit NORAD IDs are exhausted; TLE is officially deprecated and OMM/CSV is the clear default
|
||||||
|
- `DataParser.kt` supports both via `parseTLEStream()` and `parseCSVStream()`
|
||||||
|
- Downloads auto-detect format; both produce identical `OrbitalData` objects
|
||||||
|
- Existing code already supports transparent source transition without feature changes
|
||||||
|
- Refresh orbital data weekly for accurate pass prediction (orbital decay)
|
||||||
|
|
||||||
|
## Engineering Heuristics (Lazy = Efficient)
|
||||||
|
|
||||||
|
- Treat "lazy" as efficient, not careless: the best code is the code never written.
|
||||||
|
- First understand the task and trace the real flow end-to-end, then climb this ladder:
|
||||||
|
1. Does this need to be built now? (YAGNI)
|
||||||
|
2. Does it already exist in this codebase? Reuse helpers/patterns before rewriting.
|
||||||
|
3. Does Kotlin/Java stdlib already solve it?
|
||||||
|
4. Does the Android/platform API already solve it?
|
||||||
|
5. Does an already-installed dependency solve it?
|
||||||
|
6. Can this be simpler (including one-liner simple)?
|
||||||
|
7. Only then: write the minimum code that works.
|
||||||
|
- Prefer deletion to addition, boring over clever, and the fewest touched files.
|
||||||
|
- Avoid new abstractions, dependencies, and boilerplate unless explicitly requested.
|
||||||
|
- Manual DI only: ViewModels use companion `factory()` methods with `IMainContainer`.
|
||||||
|
- Release builds use ProGuard: avoid reflection-heavy libraries unless explicitly approved.
|
||||||
|
- When two options are similar in size, choose the edge-case-correct one.
|
||||||
|
- If you keep a deliberate simplification (for example O(n^2) scan or global lock), leave a short comment with the ceiling and upgrade path.
|
||||||
|
- For complex asks, challenge scope when appropriate: "Do you need X, or does Y already cover it?"
|
||||||
|
|
||||||
|
## Bug-Fix Policy
|
||||||
|
|
||||||
|
- Fix root cause, not just the reported symptom.
|
||||||
|
- If touching a shared function, inspect callers and prefer one shared fix over per-caller patches.
|
||||||
|
- The smallest correct diff wins only after behavior is understood.
|
||||||
|
|
||||||
|
## 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/` — dense vector math (SGP4/SDP4). Tread carefully.
|
||||||
|
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
||||||
|
- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -33,3 +33,13 @@ It is now and always will be completely ad-free and open-source.
|
|||||||
* Showing the satellite positional data, footprint and ground track on the map
|
* 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
|
* Custom TLE satellite data import is available via Three Line Element .txt files
|
||||||
* Offline first: calculations are made offline. Weekly TLE data update is recommended.
|
* Offline first: calculations are made offline. Weekly TLE data update is recommended.
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
<a href="https://star-history.dera.page/#rt-bishop/Look4Sat&type=timeline&legend=top-left">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||||
|
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
@@ -11,11 +12,14 @@
|
|||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MainApplication"
|
android:name=".MainApplication"
|
||||||
|
android:allowBackup="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round">
|
android:roundIcon="@mipmap/ic_launcher_round">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
@@ -26,6 +30,14 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- <intent-filter android:autoVerify="true">-->
|
||||||
|
<!-- <action android:name="android.intent.action.VIEW" />-->
|
||||||
|
<!-- <category android:name="android.intent.category.DEFAULT" />-->
|
||||||
|
<!-- <category android:name="android.intent.category.BROWSABLE" />-->
|
||||||
|
<!-- <data android:scheme="https" />-->
|
||||||
|
<!-- <data android:host="github.com" />-->
|
||||||
|
<!-- <data android:pathPattern="/rt-bishop/Look4Sat/passes.*" />-->
|
||||||
|
<!-- </intent-filter>-->
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|||||||
@@ -19,12 +19,23 @@ package com.rtbishop.look4sat
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.ColorMatrix
|
||||||
|
import android.graphics.ColorMatrixColorFilter
|
||||||
|
import android.graphics.Paint
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
@@ -38,8 +49,39 @@ class MainActivity : ComponentActivity() {
|
|||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
observeNightFilterState()
|
||||||
|
val settingsRepo = (applicationContext as IContainerProvider).getMainContainer().settingsRepo
|
||||||
setContent {
|
setContent {
|
||||||
MainTheme(isDarkTheme = true) { MainScreen() }
|
val otherSettings by settingsRepo.otherSettings.collectAsState()
|
||||||
|
MainTheme(isDarkTheme = !otherSettings.stateOfLightTheme) { 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,8 @@ import androidx.compose.animation.core.rememberInfiniteTransition
|
|||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -32,6 +34,7 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@@ -39,11 +42,14 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -56,45 +62,114 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import androidx.navigation3.runtime.entryProvider
|
import androidx.navigation3.runtime.entryProvider
|
||||||
import androidx.navigation3.runtime.rememberNavBackStack
|
import androidx.navigation3.runtime.rememberNavBackStack
|
||||||
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
|
||||||
|
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.Screen
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
||||||
import com.rtbishop.look4sat.feature.map.MapDestination
|
import com.rtbishop.look4sat.feature.map.MapDestination
|
||||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||||
import com.rtbishop.look4sat.feature.radiocontrol.RadioControlDestination
|
|
||||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||||
|
import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen() {
|
fun NavRoot(deeplink: String? = null) {
|
||||||
val backStack = rememberNavBackStack(Screen.Passes)
|
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||||
val currentKey = backStack.lastOrNull()
|
val deeplinkResolver = DeeplinkResolver()
|
||||||
val navigateBack: () -> Unit = { backStack.removeAt(backStack.size - 1) }
|
LaunchedEffect(deeplink) {
|
||||||
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
||||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar(), Screen.Map, Screen.Settings)
|
}
|
||||||
|
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 }
|
||||||
|
// Elevation color thresholds must be provided at the root level so that BOTH
|
||||||
|
// the tab content (MainScreen) and the root-level RadarDestination see the
|
||||||
|
// user's custom thresholds. RadarDestination is a sibling entry of MainScreen
|
||||||
|
// in this NavDisplay, so a provider inside MainScreen never reaches it.
|
||||||
|
val context = LocalContext.current
|
||||||
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
|
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalElevationThresholds provides ElevationThresholds(
|
||||||
|
low = otherSettings.lowElevation,
|
||||||
|
high = otherSettings.highElevation
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
private fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val backStack = rememberNavBackStack(Screen.Passes)
|
||||||
|
val currentKey = backStack.lastOrNull()
|
||||||
|
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||||
|
val fadeTransition = fadeIn(animationSpec = tween(300)) togetherWith
|
||||||
|
fadeOut(animationSpec = tween(300))
|
||||||
|
val navItems =
|
||||||
|
listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings)
|
||||||
|
|
||||||
NavigationSuiteScaffold(
|
NavigationSuiteScaffold(
|
||||||
navigationSuiteItems = {
|
navigationSuiteItems = {
|
||||||
navItems.forEach { screen ->
|
navItems.forEach { screen ->
|
||||||
val isSelected = when (currentKey) {
|
val isSelected = when (currentKey) {
|
||||||
is Screen.Satellites -> screen is Screen.Satellites
|
is Screen.Satellites -> screen is Screen.Satellites
|
||||||
is Screen.Passes -> screen is Screen.Passes
|
is Screen.Passes -> screen is Screen.Passes
|
||||||
is Screen.Radar -> screen is Screen.Radar
|
is Screen.Status -> screen is Screen.Status
|
||||||
is Screen.Map -> screen is Screen.Map
|
is Screen.Map -> screen is Screen.Map
|
||||||
is Screen.Settings -> screen is Screen.Settings
|
is Screen.Settings -> screen is Screen.Settings
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
item(
|
item(
|
||||||
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(screen.iconResId),
|
||||||
|
contentDescription = stringResource(screen.titleResId)
|
||||||
|
)
|
||||||
|
},
|
||||||
label = { Text(stringResource(screen.titleResId)) },
|
label = { Text(stringResource(screen.titleResId)) },
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -122,61 +197,58 @@ fun MainScreen() {
|
|||||||
transitionSpec = { fadeTransition },
|
transitionSpec = { fadeTransition },
|
||||||
popTransitionSpec = { fadeTransition },
|
popTransitionSpec = { fadeTransition },
|
||||||
predictivePopTransitionSpec = { fadeTransition },
|
predictivePopTransitionSpec = { fadeTransition },
|
||||||
|
entryDecorators = listOf(
|
||||||
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
|
),
|
||||||
entryProvider = entryProvider {
|
entryProvider = entryProvider {
|
||||||
entry<Screen.Satellites> {
|
entry<Screen.Satellites> {
|
||||||
SatellitesDestination(navigateUp = navigateBack)
|
SatellitesDestination(navigateUp = navigateBack)
|
||||||
}
|
}
|
||||||
entry<Screen.Passes> {
|
entry<Screen.Passes> {
|
||||||
PassesDestination { catNum, aosTime ->
|
PassesDestination { catNum, aosTime ->
|
||||||
backStack.add(Screen.Radar(catNum, aosTime))
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
|
navigateToRadar()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entry<Screen.Radar> { route ->
|
entry<Screen.Status> { SatStatusDestination() }
|
||||||
RadarDestination(
|
entry<Screen.Map> { MapDestination() }
|
||||||
catNum = route.catNum,
|
entry<Screen.Settings> { SettingsDestination() }
|
||||||
aosTime = route.aosTime,
|
|
||||||
navigateUp = navigateBack,
|
|
||||||
navigateToRadioControl = { catNum, aosTime ->
|
|
||||||
backStack.add(Screen.RadioControl(catNum, aosTime))
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
|
||||||
entry<Screen.RadioControl> { route ->
|
|
||||||
RadioControlDestination(
|
|
||||||
catNum = route.catNum,
|
|
||||||
aosTime = route.aosTime,
|
|
||||||
navigateUp = navigateBack
|
|
||||||
)
|
|
||||||
}
|
|
||||||
entry<Screen.Map> {
|
|
||||||
MapDestination()
|
|
||||||
}
|
|
||||||
entry<Screen.Settings> {
|
|
||||||
SettingsDestination()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
// Radio tracking status banner
|
|
||||||
if (trackingState.isActive) {
|
if (trackingState.isActive) {
|
||||||
|
TrackingBanner(
|
||||||
|
state = trackingState,
|
||||||
|
onClick = {
|
||||||
|
val pass = trackingState.currentPass
|
||||||
|
if (pass != null) {
|
||||||
|
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||||
|
navigateToRadar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TrackingBanner(state: RadioTrackingState, onClick: () -> Unit) {
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
||||||
val alpha by infiniteTransition.animateFloat(
|
val alpha by infiniteTransition.animateFloat(
|
||||||
initialValue = 1f, targetValue = 0.4f,
|
initialValue = 1f,
|
||||||
|
targetValue = 0.4f,
|
||||||
animationSpec = infiniteRepeatable(
|
animationSpec = infiniteRepeatable(
|
||||||
animation = tween(1000, easing = LinearEasing),
|
animation = tween(1000, easing = LinearEasing), repeatMode = RepeatMode.Reverse
|
||||||
repeatMode = RepeatMode.Reverse
|
),
|
||||||
), label = "pulseAlpha"
|
label = "pulseAlpha"
|
||||||
)
|
)
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
.clickable {
|
.clickable(onClick = onClick)
|
||||||
val pass = trackingState.currentPass
|
|
||||||
if (pass != null) {
|
|
||||||
backStack.add(Screen.RadioControl(pass.catNum, pass.aosTime))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
@@ -187,21 +259,19 @@ fun MainScreen() {
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
|
text = stringResource(R.string.tracking_format, state.currentPass?.name ?: ""),
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
val txOk = if (trackingState.txConnected) "TX" else ""
|
val connections = listOfNotNull(
|
||||||
val rxOk = if (trackingState.rxConnected) "RX" else ""
|
"TX".takeIf { state.txConnected }, "RX".takeIf { state.rxConnected }
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
|
text = connections.joinToString("/"),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="true" />
|
||||||
|
</network-security-config>
|
||||||
+1
-1
@@ -34,9 +34,9 @@ internal class ApplicationPlugin : Plugin<Project> {
|
|||||||
implementation(project(":feature:map"))
|
implementation(project(":feature:map"))
|
||||||
implementation(project(":feature:passes"))
|
implementation(project(":feature:passes"))
|
||||||
implementation(project(":feature:radar"))
|
implementation(project(":feature:radar"))
|
||||||
implementation(project(":feature:radiocontrol"))
|
|
||||||
implementation(project(":feature:satellites"))
|
implementation(project(":feature:satellites"))
|
||||||
implementation(project(":feature:settings"))
|
implementation(project(":feature:settings"))
|
||||||
|
implementation(project(":feature:status"))
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
implementation(libs.compose.material3.adaptive)
|
implementation(libs.compose.material3.adaptive)
|
||||||
implementation(libs.compose.navigation3)
|
implementation(libs.compose.navigation3)
|
||||||
|
|||||||
@@ -5,8 +5,49 @@ plugins {
|
|||||||
alias(libs.plugins.google.ksp) apply false
|
alias(libs.plugins.google.ksp) apply false
|
||||||
alias(libs.plugins.kotlin.jvm) apply false
|
alias(libs.plugins.kotlin.jvm) apply false
|
||||||
alias(libs.plugins.kotlin.serialization) apply false
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
alias(libs.plugins.other.detekt)
|
||||||
|
}
|
||||||
|
|
||||||
|
val detektCheck = tasks.register<io.gitlab.arturbosch.detekt.Detekt>("detektCheck") {
|
||||||
|
description = "Checks that source code satisfies detekt rules."
|
||||||
|
autoCorrect = false
|
||||||
|
}
|
||||||
|
|
||||||
|
val detektApply = tasks.register<io.gitlab.arturbosch.detekt.Detekt>("detektApply") {
|
||||||
|
description = "Applies code formatting rules to source code in-place."
|
||||||
|
autoCorrect = true
|
||||||
|
}
|
||||||
|
|
||||||
|
configure(listOf(detektCheck, detektApply)) {
|
||||||
|
configure {
|
||||||
|
group = "verification"
|
||||||
|
parallel = true
|
||||||
|
ignoreFailures = false
|
||||||
|
setSource(file(rootDir))
|
||||||
|
|
||||||
|
// Custom detekt config
|
||||||
|
config.setFrom("$projectDir/config/detekt/detekt.yml")
|
||||||
|
|
||||||
|
// Use default configuration on top of custom config
|
||||||
|
// (new detect rules will work out of the box after upgrading detekt version)
|
||||||
|
buildUponDefaultConfig = true
|
||||||
|
|
||||||
|
// Runs detekt for all files in the Gradle project and all subprojects without
|
||||||
|
// a need to configure detekt plugin in every subproject.
|
||||||
|
include("**/*.kt", "**/*.kts")
|
||||||
|
exclude("**/resources/**", "**/build/**", "**/generated/**", "**/testing/**")
|
||||||
|
|
||||||
|
reports {
|
||||||
|
html.required.set(true)
|
||||||
|
xml.required.set(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
detektPlugins(libs.other.detekt.formatting)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register("clean", Delete::class.java) {
|
tasks.register("clean", Delete::class.java) {
|
||||||
|
description = "Cleans the build directory"
|
||||||
delete(rootProject.layout.buildDirectory)
|
delete(rootProject.layout.buildDirectory)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,808 @@
|
|||||||
|
build:
|
||||||
|
maxIssues: 0
|
||||||
|
excludeCorrectable: false
|
||||||
|
weights:
|
||||||
|
# complexity: 2
|
||||||
|
# LongParameterList: 1
|
||||||
|
# style: 1
|
||||||
|
# comments: 1
|
||||||
|
|
||||||
|
config:
|
||||||
|
validation: true
|
||||||
|
warningsAsErrors: false
|
||||||
|
checkExhaustiveness: false
|
||||||
|
# when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]'
|
||||||
|
excludes: ''
|
||||||
|
|
||||||
|
processors:
|
||||||
|
active: true
|
||||||
|
exclude:
|
||||||
|
- 'DetektProgressListener'
|
||||||
|
# - 'KtFileCountProcessor'
|
||||||
|
# - 'PackageCountProcessor'
|
||||||
|
# - 'ClassCountProcessor'
|
||||||
|
# - 'FunctionCountProcessor'
|
||||||
|
# - 'PropertyCountProcessor'
|
||||||
|
# - 'ProjectComplexityProcessor'
|
||||||
|
# - 'ProjectCognitiveComplexityProcessor'
|
||||||
|
# - 'ProjectLLOCProcessor'
|
||||||
|
# - 'ProjectCLOCProcessor'
|
||||||
|
# - 'ProjectLOCProcessor'
|
||||||
|
# - 'ProjectSLOCProcessor'
|
||||||
|
# - 'LicenseHeaderLoaderExtension'
|
||||||
|
|
||||||
|
console-reports:
|
||||||
|
active: true
|
||||||
|
exclude:
|
||||||
|
- 'ProjectStatisticsReport'
|
||||||
|
- 'ComplexityReport'
|
||||||
|
- 'NotificationReport'
|
||||||
|
- 'FindingsReport'
|
||||||
|
- 'FileBasedFindingsReport'
|
||||||
|
# - 'LiteFindingsReport'
|
||||||
|
|
||||||
|
output-reports:
|
||||||
|
active: true
|
||||||
|
exclude:
|
||||||
|
# - 'TxtOutputReport'
|
||||||
|
# - 'XmlOutputReport'
|
||||||
|
- 'HtmlOutputReport'
|
||||||
|
# - 'MdOutputReport'
|
||||||
|
# - 'SarifOutputReport'
|
||||||
|
|
||||||
|
comments:
|
||||||
|
active: true
|
||||||
|
AbsentOrWrongFileLicense:
|
||||||
|
active: false
|
||||||
|
licenseTemplateFile: 'license.template'
|
||||||
|
licenseTemplateIsRegex: false
|
||||||
|
CommentOverPrivateFunction:
|
||||||
|
active: false
|
||||||
|
CommentOverPrivateProperty:
|
||||||
|
active: false
|
||||||
|
DeprecatedBlockTag:
|
||||||
|
active: false
|
||||||
|
EndOfSentenceFormat:
|
||||||
|
active: false
|
||||||
|
endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)'
|
||||||
|
KDocReferencesNonPublicProperty:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
OutdatedDocumentation:
|
||||||
|
active: false
|
||||||
|
matchTypeParameters: true
|
||||||
|
matchDeclarationsOrder: true
|
||||||
|
allowParamOnConstructorProperties: false
|
||||||
|
UndocumentedPublicClass:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
searchInNestedClass: true
|
||||||
|
searchInInnerClass: true
|
||||||
|
searchInInnerObject: true
|
||||||
|
searchInInnerInterface: true
|
||||||
|
searchInProtectedClass: false
|
||||||
|
UndocumentedPublicFunction:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
searchProtectedFunction: false
|
||||||
|
UndocumentedPublicProperty:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
searchProtectedProperty: false
|
||||||
|
|
||||||
|
complexity:
|
||||||
|
active: true
|
||||||
|
CognitiveComplexMethod:
|
||||||
|
active: false
|
||||||
|
threshold: 15
|
||||||
|
ComplexCondition:
|
||||||
|
active: true
|
||||||
|
threshold: 4
|
||||||
|
ComplexInterface:
|
||||||
|
active: false
|
||||||
|
threshold: 10
|
||||||
|
includeStaticDeclarations: false
|
||||||
|
includePrivateDeclarations: false
|
||||||
|
ignoreOverloaded: false
|
||||||
|
CyclomaticComplexMethod:
|
||||||
|
active: true
|
||||||
|
threshold: 15
|
||||||
|
ignoreSingleWhenExpression: false
|
||||||
|
ignoreSimpleWhenEntries: false
|
||||||
|
ignoreNestingFunctions: false
|
||||||
|
nestingFunctions:
|
||||||
|
- 'also'
|
||||||
|
- 'apply'
|
||||||
|
- 'forEach'
|
||||||
|
- 'isNotNull'
|
||||||
|
- 'ifNull'
|
||||||
|
- 'let'
|
||||||
|
- 'run'
|
||||||
|
- 'use'
|
||||||
|
- 'with'
|
||||||
|
# Composables describe UI trees declaratively and naturally accumulate more branches than
|
||||||
|
# regular functions. Exclude them here and rely on code review + decomposition instead.
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*Screen.kt', '**/*Slots.kt' ]
|
||||||
|
LabeledExpression:
|
||||||
|
active: false
|
||||||
|
ignoredLabels: [ ]
|
||||||
|
LargeClass:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*Screen.kt', '**/*Slots.kt' ]
|
||||||
|
threshold: 600
|
||||||
|
LongMethod:
|
||||||
|
active: true
|
||||||
|
# Composable UI functions are declarative trees; 60 lines is too strict for them.
|
||||||
|
# Screen/Slots files are excluded — keep them reasonable via code review.
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*Screen.kt', '**/*Slots.kt' ]
|
||||||
|
threshold: 60
|
||||||
|
LongParameterList:
|
||||||
|
active: false
|
||||||
|
functionThreshold: 6
|
||||||
|
constructorThreshold: 7
|
||||||
|
ignoreDefaultParameters: false
|
||||||
|
ignoreDataClasses: true
|
||||||
|
ignoreAnnotatedParameter: [ ]
|
||||||
|
MethodOverloading:
|
||||||
|
active: false
|
||||||
|
threshold: 6
|
||||||
|
NamedArguments:
|
||||||
|
active: false
|
||||||
|
threshold: 3
|
||||||
|
ignoreArgumentsMatchingNames: false
|
||||||
|
NestedBlockDepth:
|
||||||
|
active: true
|
||||||
|
threshold: 4
|
||||||
|
NestedScopeFunctions:
|
||||||
|
active: false
|
||||||
|
threshold: 1
|
||||||
|
functions:
|
||||||
|
- 'kotlin.apply'
|
||||||
|
- 'kotlin.run'
|
||||||
|
- 'kotlin.with'
|
||||||
|
- 'kotlin.let'
|
||||||
|
- 'kotlin.also'
|
||||||
|
ReplaceSafeCallChainWithRun:
|
||||||
|
active: false
|
||||||
|
StringLiteralDuplication:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
threshold: 3
|
||||||
|
ignoreAnnotation: true
|
||||||
|
excludeStringsWithLessThan5Characters: true
|
||||||
|
ignoreStringsRegex: '$^'
|
||||||
|
TooManyFunctions:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
thresholdInFiles: 11
|
||||||
|
thresholdInClasses: 11
|
||||||
|
thresholdInInterfaces: 11
|
||||||
|
thresholdInObjects: 11
|
||||||
|
thresholdInEnums: 11
|
||||||
|
ignoreDeprecated: false
|
||||||
|
ignorePrivate: false
|
||||||
|
ignoreOverridden: false
|
||||||
|
|
||||||
|
coroutines:
|
||||||
|
active: true
|
||||||
|
GlobalCoroutineUsage:
|
||||||
|
active: false
|
||||||
|
InjectDispatcher:
|
||||||
|
active: true
|
||||||
|
dispatcherNames:
|
||||||
|
- 'IO'
|
||||||
|
- 'Default'
|
||||||
|
- 'Unconfined'
|
||||||
|
RedundantSuspendModifier:
|
||||||
|
active: true
|
||||||
|
SleepInsteadOfDelay:
|
||||||
|
active: true
|
||||||
|
SuspendFunSwallowedCancellation:
|
||||||
|
active: false
|
||||||
|
SuspendFunWithCoroutineScopeReceiver:
|
||||||
|
active: false
|
||||||
|
SuspendFunWithFlowReturnType:
|
||||||
|
active: true
|
||||||
|
|
||||||
|
empty-blocks:
|
||||||
|
active: true
|
||||||
|
EmptyCatchBlock:
|
||||||
|
active: true
|
||||||
|
allowedExceptionNameRegex: '_|(ignore|expected).*'
|
||||||
|
EmptyClassBlock:
|
||||||
|
active: true
|
||||||
|
EmptyDefaultConstructor:
|
||||||
|
active: true
|
||||||
|
EmptyDoWhileBlock:
|
||||||
|
active: true
|
||||||
|
EmptyElseBlock:
|
||||||
|
active: true
|
||||||
|
EmptyFinallyBlock:
|
||||||
|
active: true
|
||||||
|
EmptyForBlock:
|
||||||
|
active: true
|
||||||
|
EmptyFunctionBlock:
|
||||||
|
active: true
|
||||||
|
ignoreOverridden: true
|
||||||
|
EmptyIfBlock:
|
||||||
|
active: true
|
||||||
|
EmptyInitBlock:
|
||||||
|
active: true
|
||||||
|
EmptyKtFile:
|
||||||
|
active: true
|
||||||
|
EmptySecondaryConstructor:
|
||||||
|
active: true
|
||||||
|
EmptyTryBlock:
|
||||||
|
active: true
|
||||||
|
EmptyWhenBlock:
|
||||||
|
active: true
|
||||||
|
EmptyWhileBlock:
|
||||||
|
active: true
|
||||||
|
|
||||||
|
exceptions:
|
||||||
|
active: true
|
||||||
|
ExceptionRaisedInUnexpectedLocation:
|
||||||
|
active: true
|
||||||
|
methodNames:
|
||||||
|
- 'equals'
|
||||||
|
- 'finalize'
|
||||||
|
- 'hashCode'
|
||||||
|
- 'toString'
|
||||||
|
InstanceOfCheckForException:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
NotImplementedDeclaration:
|
||||||
|
active: false
|
||||||
|
ObjectExtendsThrowable:
|
||||||
|
active: false
|
||||||
|
PrintStackTrace:
|
||||||
|
active: true
|
||||||
|
RethrowCaughtException:
|
||||||
|
active: true
|
||||||
|
ReturnFromFinally:
|
||||||
|
active: true
|
||||||
|
ignoreLabeled: false
|
||||||
|
SwallowedException:
|
||||||
|
active: true
|
||||||
|
ignoredExceptionTypes:
|
||||||
|
- 'InterruptedException'
|
||||||
|
- 'MalformedURLException'
|
||||||
|
- 'NumberFormatException'
|
||||||
|
- 'ParseException'
|
||||||
|
allowedExceptionNameRegex: '_|(ignore|expected).*'
|
||||||
|
ThrowingExceptionFromFinally:
|
||||||
|
active: true
|
||||||
|
ThrowingExceptionInMain:
|
||||||
|
active: false
|
||||||
|
ThrowingExceptionsWithoutMessageOrCause:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
exceptions:
|
||||||
|
- 'ArrayIndexOutOfBoundsException'
|
||||||
|
- 'Exception'
|
||||||
|
- 'IllegalArgumentException'
|
||||||
|
- 'IllegalMonitorStateException'
|
||||||
|
- 'IllegalStateException'
|
||||||
|
- 'IndexOutOfBoundsException'
|
||||||
|
- 'NullPointerException'
|
||||||
|
- 'RuntimeException'
|
||||||
|
- 'Throwable'
|
||||||
|
ThrowingNewInstanceOfSameException:
|
||||||
|
active: true
|
||||||
|
TooGenericExceptionCaught:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
exceptionNames:
|
||||||
|
- 'ArrayIndexOutOfBoundsException'
|
||||||
|
- 'Error'
|
||||||
|
- 'Exception'
|
||||||
|
- 'IllegalMonitorStateException'
|
||||||
|
- 'IndexOutOfBoundsException'
|
||||||
|
- 'NullPointerException'
|
||||||
|
- 'RuntimeException'
|
||||||
|
- 'Throwable'
|
||||||
|
allowedExceptionNameRegex: '_|(ignore|expected).*'
|
||||||
|
TooGenericExceptionThrown:
|
||||||
|
active: true
|
||||||
|
exceptionNames:
|
||||||
|
- 'Error'
|
||||||
|
- 'Exception'
|
||||||
|
- 'RuntimeException'
|
||||||
|
- 'Throwable'
|
||||||
|
|
||||||
|
formatting:
|
||||||
|
active: true
|
||||||
|
ArgumentListWrapping:
|
||||||
|
active: false
|
||||||
|
ParameterListWrapping:
|
||||||
|
active: false
|
||||||
|
NoWildcardImports:
|
||||||
|
active: false
|
||||||
|
MaximumLineLength:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
|
||||||
|
naming:
|
||||||
|
active: true
|
||||||
|
BooleanPropertyNaming:
|
||||||
|
active: false
|
||||||
|
allowedPattern: '^(is|has|are)'
|
||||||
|
ClassNaming:
|
||||||
|
active: true
|
||||||
|
classPattern: '[A-Z][a-zA-Z0-9_]*'
|
||||||
|
ConstructorParameterNaming:
|
||||||
|
active: true
|
||||||
|
parameterPattern: '[a-z][A-Za-z0-9]*'
|
||||||
|
privateParameterPattern: '[a-z][A-Za-z0-9]*'
|
||||||
|
excludeClassPattern: '$^'
|
||||||
|
EnumNaming:
|
||||||
|
active: true
|
||||||
|
enumEntryPattern: '[A-Z][_a-zA-Z0-9]*'
|
||||||
|
ForbiddenClassName:
|
||||||
|
active: false
|
||||||
|
forbiddenName: [ ]
|
||||||
|
FunctionMaxLength:
|
||||||
|
active: false
|
||||||
|
maximumFunctionNameLength: 30
|
||||||
|
FunctionMinLength:
|
||||||
|
active: false
|
||||||
|
minimumFunctionNameLength: 3
|
||||||
|
FunctionNaming:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
functionPattern: '[a-zA-Z][a-zA-Z0-9]*'
|
||||||
|
excludeClassPattern: '$^'
|
||||||
|
FunctionParameterNaming:
|
||||||
|
active: true
|
||||||
|
parameterPattern: '[a-zA-Z][A-Za-z0-9]*'
|
||||||
|
excludeClassPattern: '$^'
|
||||||
|
InvalidPackageDeclaration:
|
||||||
|
active: true
|
||||||
|
rootPackage: ''
|
||||||
|
requireRootInDeclaration: false
|
||||||
|
LambdaParameterNaming:
|
||||||
|
active: false
|
||||||
|
parameterPattern: '[a-z][A-Za-z0-9]*|_'
|
||||||
|
MatchingDeclarationName:
|
||||||
|
active: false
|
||||||
|
mustBeFirst: false
|
||||||
|
MemberNameEqualsClassName:
|
||||||
|
active: true
|
||||||
|
ignoreOverridden: true
|
||||||
|
NoNameShadowing:
|
||||||
|
active: true
|
||||||
|
NonBooleanPropertyPrefixedWithIs:
|
||||||
|
active: false
|
||||||
|
ObjectPropertyNaming:
|
||||||
|
active: true
|
||||||
|
constantPattern: '[A-Za-z][_A-Za-z0-9]*'
|
||||||
|
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
|
||||||
|
privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*'
|
||||||
|
PackageNaming:
|
||||||
|
active: true
|
||||||
|
packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*'
|
||||||
|
TopLevelPropertyNaming:
|
||||||
|
active: true
|
||||||
|
constantPattern: '[A-Z][_A-Z0-9]*'
|
||||||
|
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
|
||||||
|
privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*'
|
||||||
|
VariableMaxLength:
|
||||||
|
active: false
|
||||||
|
maximumVariableNameLength: 64
|
||||||
|
VariableMinLength:
|
||||||
|
active: false
|
||||||
|
minimumVariableNameLength: 1
|
||||||
|
VariableNaming:
|
||||||
|
active: true
|
||||||
|
variablePattern: '[a-zA-Z][A-Za-z0-9]*'
|
||||||
|
privateVariablePattern: '(_)?[a-zA-Z][A-Za-z0-9]*'
|
||||||
|
excludeClassPattern: '$^'
|
||||||
|
|
||||||
|
performance:
|
||||||
|
active: true
|
||||||
|
ArrayPrimitive:
|
||||||
|
active: true
|
||||||
|
CouldBeSequence:
|
||||||
|
active: false
|
||||||
|
threshold: 3
|
||||||
|
ForEachOnRange:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
SpreadOperator:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
UnnecessaryPartOfBinaryExpression:
|
||||||
|
active: false
|
||||||
|
UnnecessaryTemporaryInstantiation:
|
||||||
|
active: true
|
||||||
|
|
||||||
|
potential-bugs:
|
||||||
|
active: true
|
||||||
|
AvoidReferentialEquality:
|
||||||
|
active: true
|
||||||
|
forbiddenTypePatterns:
|
||||||
|
- 'kotlin.String'
|
||||||
|
CastNullableToNonNullableType:
|
||||||
|
active: false
|
||||||
|
CastToNullableType:
|
||||||
|
active: false
|
||||||
|
Deprecation:
|
||||||
|
active: false
|
||||||
|
DontDowncastCollectionTypes:
|
||||||
|
active: false
|
||||||
|
DoubleMutabilityForCollection:
|
||||||
|
active: true
|
||||||
|
mutableTypes:
|
||||||
|
- 'kotlin.collections.MutableList'
|
||||||
|
- 'kotlin.collections.MutableMap'
|
||||||
|
- 'kotlin.collections.MutableSet'
|
||||||
|
- 'java.util.ArrayList'
|
||||||
|
- 'java.util.LinkedHashSet'
|
||||||
|
- 'java.util.HashSet'
|
||||||
|
- 'java.util.LinkedHashMap'
|
||||||
|
- 'java.util.HashMap'
|
||||||
|
ElseCaseInsteadOfExhaustiveWhen:
|
||||||
|
active: false
|
||||||
|
ignoredSubjectTypes: [ ]
|
||||||
|
EqualsAlwaysReturnsTrueOrFalse:
|
||||||
|
active: true
|
||||||
|
EqualsWithHashCodeExist:
|
||||||
|
active: true
|
||||||
|
ExitOutsideMain:
|
||||||
|
active: false
|
||||||
|
ExplicitGarbageCollectionCall:
|
||||||
|
active: true
|
||||||
|
HasPlatformType:
|
||||||
|
active: true
|
||||||
|
IgnoredReturnValue:
|
||||||
|
active: true
|
||||||
|
restrictToConfig: true
|
||||||
|
returnValueAnnotations:
|
||||||
|
- 'CheckResult'
|
||||||
|
- '*.CheckResult'
|
||||||
|
- 'CheckReturnValue'
|
||||||
|
- '*.CheckReturnValue'
|
||||||
|
ignoreReturnValueAnnotations:
|
||||||
|
- 'CanIgnoreReturnValue'
|
||||||
|
- '*.CanIgnoreReturnValue'
|
||||||
|
returnValueTypes:
|
||||||
|
- 'kotlin.sequences.Sequence'
|
||||||
|
- 'kotlinx.coroutines.flow.*Flow'
|
||||||
|
- 'java.util.stream.*Stream'
|
||||||
|
ignoreFunctionCall: [ ]
|
||||||
|
ImplicitDefaultLocale:
|
||||||
|
active: true
|
||||||
|
ImplicitUnitReturnType:
|
||||||
|
active: false
|
||||||
|
allowExplicitReturnType: true
|
||||||
|
InvalidRange:
|
||||||
|
active: true
|
||||||
|
IteratorHasNextCallsNextMethod:
|
||||||
|
active: true
|
||||||
|
IteratorNotThrowingNoSuchElementException:
|
||||||
|
active: true
|
||||||
|
LateinitUsage:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
ignoreOnClassesPattern: ''
|
||||||
|
MapGetWithNotNullAssertionOperator:
|
||||||
|
active: true
|
||||||
|
MissingPackageDeclaration:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/*.kts' ]
|
||||||
|
NullCheckOnMutableProperty:
|
||||||
|
active: false
|
||||||
|
NullableToStringCall:
|
||||||
|
active: false
|
||||||
|
PropertyUsedBeforeDeclaration:
|
||||||
|
active: false
|
||||||
|
UnconditionalJumpStatementInLoop:
|
||||||
|
active: false
|
||||||
|
UnnecessaryNotNullCheck:
|
||||||
|
active: false
|
||||||
|
UnnecessaryNotNullOperator:
|
||||||
|
active: true
|
||||||
|
UnnecessarySafeCall:
|
||||||
|
active: true
|
||||||
|
UnreachableCatchBlock:
|
||||||
|
active: true
|
||||||
|
UnreachableCode:
|
||||||
|
active: true
|
||||||
|
UnsafeCallOnNullableType:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
UnsafeCast:
|
||||||
|
active: true
|
||||||
|
UnusedUnaryOperator:
|
||||||
|
active: true
|
||||||
|
UselessPostfixExpression:
|
||||||
|
active: true
|
||||||
|
WrongEqualsTypeParameter:
|
||||||
|
active: true
|
||||||
|
|
||||||
|
style:
|
||||||
|
active: true
|
||||||
|
AlsoCouldBeApply:
|
||||||
|
active: false
|
||||||
|
BracesOnIfStatements:
|
||||||
|
active: false
|
||||||
|
singleLine: 'never'
|
||||||
|
multiLine: 'always'
|
||||||
|
BracesOnWhenStatements:
|
||||||
|
active: false
|
||||||
|
singleLine: 'necessary'
|
||||||
|
multiLine: 'consistent'
|
||||||
|
CanBeNonNullable:
|
||||||
|
active: false
|
||||||
|
CascadingCallWrapping:
|
||||||
|
active: false
|
||||||
|
includeElvis: true
|
||||||
|
ClassOrdering:
|
||||||
|
active: false
|
||||||
|
CollapsibleIfStatements:
|
||||||
|
active: false
|
||||||
|
DataClassContainsFunctions:
|
||||||
|
active: false
|
||||||
|
conversionFunctionPrefix:
|
||||||
|
- 'to'
|
||||||
|
allowOperators: false
|
||||||
|
DataClassShouldBeImmutable:
|
||||||
|
active: false
|
||||||
|
DestructuringDeclarationWithTooManyEntries:
|
||||||
|
active: true
|
||||||
|
maxDestructuringEntries: 3
|
||||||
|
DoubleNegativeLambda:
|
||||||
|
active: false
|
||||||
|
negativeFunctions:
|
||||||
|
- reason: 'Use `takeIf` instead.'
|
||||||
|
value: 'takeUnless'
|
||||||
|
- reason: 'Use `all` instead.'
|
||||||
|
value: 'none'
|
||||||
|
negativeFunctionNameParts:
|
||||||
|
- 'not'
|
||||||
|
- 'non'
|
||||||
|
EqualsNullCall:
|
||||||
|
active: true
|
||||||
|
EqualsOnSignatureLine:
|
||||||
|
active: false
|
||||||
|
ExplicitCollectionElementAccessMethod:
|
||||||
|
active: false
|
||||||
|
ExplicitItLambdaParameter:
|
||||||
|
active: true
|
||||||
|
ExpressionBodySyntax:
|
||||||
|
active: false
|
||||||
|
includeLineWrapping: false
|
||||||
|
ForbiddenAnnotation:
|
||||||
|
active: false
|
||||||
|
annotations:
|
||||||
|
- reason: 'it is a java annotation. Use `Suppress` instead.'
|
||||||
|
value: 'java.lang.SuppressWarnings'
|
||||||
|
- reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.'
|
||||||
|
value: 'java.lang.Deprecated'
|
||||||
|
- reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.'
|
||||||
|
value: 'java.lang.annotation.Documented'
|
||||||
|
- reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.'
|
||||||
|
value: 'java.lang.annotation.Target'
|
||||||
|
- reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.'
|
||||||
|
value: 'java.lang.annotation.Retention'
|
||||||
|
- reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.'
|
||||||
|
value: 'java.lang.annotation.Repeatable'
|
||||||
|
- reason: 'Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265'
|
||||||
|
value: 'java.lang.annotation.Inherited'
|
||||||
|
ForbiddenComment:
|
||||||
|
active: true
|
||||||
|
comments:
|
||||||
|
- reason: 'Forbidden FIXME todo marker in comment, please fix the problem.'
|
||||||
|
value: 'FIXME:'
|
||||||
|
- reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.'
|
||||||
|
value: 'STOPSHIP:'
|
||||||
|
- reason: 'Forbidden TODO todo marker in comment, please do the changes.'
|
||||||
|
value: 'TODO:'
|
||||||
|
allowedPatterns: ''
|
||||||
|
ForbiddenImport:
|
||||||
|
active: false
|
||||||
|
imports: [ ]
|
||||||
|
forbiddenPatterns: ''
|
||||||
|
ForbiddenMethodCall:
|
||||||
|
active: false
|
||||||
|
methods:
|
||||||
|
- reason: 'print does not allow you to configure the output stream. Use a logger instead.'
|
||||||
|
value: 'kotlin.io.print'
|
||||||
|
- reason: 'println does not allow you to configure the output stream. Use a logger instead.'
|
||||||
|
value: 'kotlin.io.println'
|
||||||
|
ForbiddenSuppress:
|
||||||
|
active: false
|
||||||
|
rules: [ ]
|
||||||
|
ForbiddenVoid:
|
||||||
|
active: true
|
||||||
|
ignoreOverridden: false
|
||||||
|
ignoreUsageInGenerics: false
|
||||||
|
FunctionOnlyReturningConstant:
|
||||||
|
active: true
|
||||||
|
ignoreOverridableFunction: true
|
||||||
|
ignoreActualFunction: true
|
||||||
|
excludedFunctions: [ ]
|
||||||
|
LoopWithTooManyJumpStatements:
|
||||||
|
active: true
|
||||||
|
maxJumpCount: 1
|
||||||
|
MagicNumber:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts' ]
|
||||||
|
ignoreNumbers:
|
||||||
|
- '-1'
|
||||||
|
- '0'
|
||||||
|
- '1'
|
||||||
|
- '2'
|
||||||
|
ignoreHashCodeFunction: true
|
||||||
|
ignorePropertyDeclaration: false
|
||||||
|
ignoreLocalVariableDeclaration: false
|
||||||
|
ignoreConstantDeclaration: true
|
||||||
|
ignoreCompanionObjectPropertyDeclaration: true
|
||||||
|
ignoreAnnotation: false
|
||||||
|
ignoreNamedArgument: true
|
||||||
|
ignoreEnums: false
|
||||||
|
ignoreRanges: false
|
||||||
|
ignoreExtensionFunctions: true
|
||||||
|
MandatoryBracesLoops:
|
||||||
|
active: false
|
||||||
|
MaxChainedCallsOnSameLine:
|
||||||
|
active: false
|
||||||
|
maxChainedCalls: 5
|
||||||
|
MaxLineLength:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts' ]
|
||||||
|
maxLineLength: 120
|
||||||
|
excludePackageStatements: true
|
||||||
|
excludeImportStatements: true
|
||||||
|
excludeCommentStatements: true
|
||||||
|
excludeRawStrings: true
|
||||||
|
MayBeConst:
|
||||||
|
active: true
|
||||||
|
ModifierOrder:
|
||||||
|
active: true
|
||||||
|
MultilineLambdaItParameter:
|
||||||
|
active: false
|
||||||
|
MultilineRawStringIndentation:
|
||||||
|
active: false
|
||||||
|
indentSize: 4
|
||||||
|
trimmingMethods:
|
||||||
|
- 'trimIndent'
|
||||||
|
- 'trimMargin'
|
||||||
|
NestedClassesVisibility:
|
||||||
|
active: true
|
||||||
|
NewLineAtEndOfFile:
|
||||||
|
active: true
|
||||||
|
NoTabs:
|
||||||
|
active: false
|
||||||
|
NullableBooleanCheck:
|
||||||
|
active: false
|
||||||
|
ObjectLiteralToLambda:
|
||||||
|
active: true
|
||||||
|
OptionalAbstractKeyword:
|
||||||
|
active: true
|
||||||
|
OptionalUnit:
|
||||||
|
active: false
|
||||||
|
OptionalWhenBraces:
|
||||||
|
active: false
|
||||||
|
PreferToOverPairSyntax:
|
||||||
|
active: false
|
||||||
|
ProtectedMemberInFinalClass:
|
||||||
|
active: true
|
||||||
|
RedundantExplicitType:
|
||||||
|
active: false
|
||||||
|
RedundantHigherOrderMapUsage:
|
||||||
|
active: true
|
||||||
|
RedundantVisibilityModifierRule:
|
||||||
|
active: false
|
||||||
|
ReturnCount:
|
||||||
|
active: true
|
||||||
|
max: 3
|
||||||
|
excludedFunctions:
|
||||||
|
- 'equals'
|
||||||
|
excludeLabeled: false
|
||||||
|
excludeReturnFromLambda: true
|
||||||
|
# Guard clauses (early returns for preconditions) are idiomatic Kotlin and should not
|
||||||
|
# count toward the return limit. E.g. `if (x == null) return null` at function top.
|
||||||
|
excludeGuardClauses: true
|
||||||
|
SafeCast:
|
||||||
|
active: true
|
||||||
|
SerialVersionUIDInSerializableClass:
|
||||||
|
active: true
|
||||||
|
SpacingBetweenPackageAndImports:
|
||||||
|
active: false
|
||||||
|
StringShouldBeRawString:
|
||||||
|
active: false
|
||||||
|
maxEscapedCharacterCount: 2
|
||||||
|
ignoredCharacters: [ ]
|
||||||
|
ThrowsCount:
|
||||||
|
active: true
|
||||||
|
max: 2
|
||||||
|
excludeGuardClauses: false
|
||||||
|
TrailingWhitespace:
|
||||||
|
active: false
|
||||||
|
TrimMultilineRawString:
|
||||||
|
active: false
|
||||||
|
trimmingMethods:
|
||||||
|
- 'trimIndent'
|
||||||
|
- 'trimMargin'
|
||||||
|
UnderscoresInNumericLiterals:
|
||||||
|
active: false
|
||||||
|
acceptableLength: 4
|
||||||
|
allowNonStandardGrouping: false
|
||||||
|
UnnecessaryAbstractClass:
|
||||||
|
active: true
|
||||||
|
UnnecessaryAnnotationUseSiteTarget:
|
||||||
|
active: false
|
||||||
|
UnnecessaryApply:
|
||||||
|
active: true
|
||||||
|
UnnecessaryBackticks:
|
||||||
|
active: false
|
||||||
|
UnnecessaryBracesAroundTrailingLambda:
|
||||||
|
active: false
|
||||||
|
UnnecessaryFilter:
|
||||||
|
active: true
|
||||||
|
UnnecessaryInheritance:
|
||||||
|
active: true
|
||||||
|
UnnecessaryInnerClass:
|
||||||
|
active: false
|
||||||
|
UnnecessaryLet:
|
||||||
|
active: false
|
||||||
|
UnnecessaryParentheses:
|
||||||
|
active: false
|
||||||
|
allowForUnclearPrecedence: false
|
||||||
|
UntilInsteadOfRangeTo:
|
||||||
|
active: false
|
||||||
|
UnusedImports:
|
||||||
|
active: false
|
||||||
|
UnusedParameter:
|
||||||
|
active: true
|
||||||
|
allowedNames: 'ignored|expected'
|
||||||
|
UnusedPrivateClass:
|
||||||
|
active: true
|
||||||
|
UnusedPrivateMember:
|
||||||
|
active: true
|
||||||
|
allowedNames: '[A-Z][a-zA-Z0-9]*Preview'
|
||||||
|
UnusedPrivateProperty:
|
||||||
|
active: true
|
||||||
|
allowedNames: '_|ignored|expected|serialVersionUID'
|
||||||
|
UseAnyOrNoneInsteadOfFind:
|
||||||
|
active: true
|
||||||
|
UseArrayLiteralsInAnnotations:
|
||||||
|
active: true
|
||||||
|
UseCheckNotNull:
|
||||||
|
active: true
|
||||||
|
UseCheckOrError:
|
||||||
|
active: true
|
||||||
|
UseDataClass:
|
||||||
|
active: false
|
||||||
|
allowVars: false
|
||||||
|
UseEmptyCounterpart:
|
||||||
|
active: false
|
||||||
|
UseIfEmptyOrIfBlank:
|
||||||
|
active: false
|
||||||
|
UseIfInsteadOfWhen:
|
||||||
|
active: false
|
||||||
|
ignoreWhenContainingVariableDeclaration: false
|
||||||
|
UseIsNullOrEmpty:
|
||||||
|
active: true
|
||||||
|
UseLet:
|
||||||
|
active: false
|
||||||
|
UseOrEmpty:
|
||||||
|
active: true
|
||||||
|
UseRequire:
|
||||||
|
active: true
|
||||||
|
UseRequireNotNull:
|
||||||
|
active: true
|
||||||
|
UseSumOfInsteadOfFlatMapSize:
|
||||||
|
active: false
|
||||||
|
UselessCallOnNotNull:
|
||||||
|
active: true
|
||||||
|
UtilityClassWithPublicConstructor:
|
||||||
|
active: true
|
||||||
|
VarCouldBeVal:
|
||||||
|
active: true
|
||||||
|
ignoreLateinitVar: false
|
||||||
|
WildcardImport:
|
||||||
|
active: false
|
||||||
|
excludeImports:
|
||||||
|
- 'java.util.*'
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
@@ -11,5 +12,6 @@
|
|||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -19,6 +19,7 @@ package com.rtbishop.look4sat.core.data.database
|
|||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
|
import androidx.room.MapColumn
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
@@ -39,13 +40,30 @@ interface Look4SatDao {
|
|||||||
@Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)")
|
@Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)")
|
||||||
suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry>
|
suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry>
|
||||||
|
|
||||||
|
@Query("SELECT catnum, epoch FROM entries")
|
||||||
|
suspend fun getEntriesEpochs(): Map<@MapColumn("catnum") Int, @MapColumn("epoch") Double>
|
||||||
|
|
||||||
|
@Query("SELECT catnum, name FROM entries")
|
||||||
|
suspend fun getEntriesNames(): Map<@MapColumn("catnum") Int, @MapColumn("name") String>
|
||||||
|
|
||||||
|
@Query("UPDATE entries SET name = :name WHERE catnum = :catnum")
|
||||||
|
suspend fun renameEntry(catnum: Int, name: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM entries WHERE catnum IN (:ids)")
|
||||||
|
suspend fun deleteEntriesWithIds(ids: List<Int>)
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertEntries(entries: List<SatEntry>)
|
suspend fun insertEntries(entries: List<SatEntry>)
|
||||||
|
|
||||||
@Query("DELETE FROM entries")
|
@Query("DELETE FROM entries")
|
||||||
suspend fun deleteEntries()
|
suspend fun deleteEntries()
|
||||||
|
|
||||||
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT catnum FROM radios WHERE isAlive = 1
|
||||||
|
AND (downlinkMode IN (:modes) OR uplinkMode IN (:modes))
|
||||||
|
"""
|
||||||
|
)
|
||||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM radios")
|
@Query("SELECT COUNT(*) FROM radios")
|
||||||
@@ -60,4 +78,7 @@ interface Look4SatDao {
|
|||||||
|
|
||||||
@Query("DELETE FROM radios")
|
@Query("DELETE FROM radios")
|
||||||
suspend fun deleteRadios()
|
suspend fun deleteRadios()
|
||||||
|
|
||||||
|
@Query("DELETE FROM radios WHERE isCustom = 0")
|
||||||
|
suspend fun deleteManagedRadios()
|
||||||
}
|
}
|
||||||
@@ -19,19 +19,23 @@ package com.rtbishop.look4sat.core.data.database
|
|||||||
|
|
||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
|
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
|
||||||
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
|
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
|
||||||
|
|
||||||
@Database(entities = [SatEntry::class, SatRadio::class], version = 1, exportSchema = false)
|
const val DATABASE_NAME = "Look4SatDBv400"
|
||||||
|
|
||||||
|
@Database(entities = [SatEntry::class, SatRadio::class], version = 2, exportSchema = false)
|
||||||
abstract class Look4SatDb : RoomDatabase() {
|
abstract class Look4SatDb : RoomDatabase() {
|
||||||
abstract fun look4SatDao(): Look4SatDao
|
abstract fun look4SatDao(): Look4SatDao
|
||||||
}
|
}
|
||||||
|
|
||||||
//val MIGRATION_1_2 = object : Migration(1, 2) {
|
/** Adds the mean motion derivative, needed to tell decayed satellites apart, and marks the
|
||||||
// override fun migrate(database: SupportSQLiteDatabase) {
|
* transceivers that were imported from a file, so that remote updates leave them alone. */
|
||||||
// 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))")
|
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||||
// 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")
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
// database.execSQL("DROP TABLE entries")
|
db.execSQL("ALTER TABLE entries ADD COLUMN ndot REAL NOT NULL DEFAULT 0.0")
|
||||||
// database.execSQL("ALTER TABLE entries_backup RENAME TO entries")
|
db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0")
|
||||||
// }
|
}
|
||||||
//}
|
}
|
||||||
@@ -30,5 +30,6 @@ data class SatEntry(
|
|||||||
val argper: Double,
|
val argper: Double,
|
||||||
val meanan: Double,
|
val meanan: Double,
|
||||||
val catnum: Int,
|
val catnum: Int,
|
||||||
val bstar: Double
|
val bstar: Double,
|
||||||
|
val ndot: Double = 0.0
|
||||||
)
|
)
|
||||||
@@ -32,5 +32,7 @@ data class SatRadio(
|
|||||||
val uplinkHigh: Long?,
|
val uplinkHigh: Long?,
|
||||||
val uplinkMode: String?,
|
val uplinkMode: String?,
|
||||||
val isInverted: Boolean,
|
val isInverted: Boolean,
|
||||||
val catnum: Int?
|
val catnum: Int?,
|
||||||
|
/** Set for manually imported transceivers, which remote updates must not replace. */
|
||||||
|
val isCustom: Boolean = false
|
||||||
)
|
)
|
||||||
+7
-4
@@ -17,6 +17,9 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.data.framework
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
|
||||||
object Ft817CatProtocol {
|
object Ft817CatProtocol {
|
||||||
|
|
||||||
const val CMD_SET_FREQ: Byte = 0x01
|
const val CMD_SET_FREQ: Byte = 0x01
|
||||||
@@ -50,7 +53,7 @@ object Ft817CatProtocol {
|
|||||||
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
|
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
|
||||||
val freq10Hz = frequencyHz / 10
|
val freq10Hz = frequencyHz / 10
|
||||||
val bcd = ByteArray(4)
|
val bcd = ByteArray(4)
|
||||||
val digits = String.format("%08d", freq10Hz)
|
val digits = String.format(Locale.US, "%08d", freq10Hz)
|
||||||
for (i in 0 until 4) {
|
for (i in 0 until 4) {
|
||||||
val high = digits[i * 2] - '0'
|
val high = digits[i * 2] - '0'
|
||||||
val low = digits[i * 2 + 1] - '0'
|
val low = digits[i * 2 + 1] - '0'
|
||||||
@@ -78,8 +81,8 @@ object Ft817CatProtocol {
|
|||||||
* 67.0 Hz → 670 (in 0.1 Hz) → BCD [0x06, 0x70]
|
* 67.0 Hz → 670 (in 0.1 Hz) → BCD [0x06, 0x70]
|
||||||
*/
|
*/
|
||||||
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
|
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
|
||||||
val tone01Hz = (toneHz * 10).toLong()
|
val tone01Hz = (toneHz * 10).roundToLong()
|
||||||
val digits = String.format("%04d", tone01Hz)
|
val digits = String.format(Locale.US, "%04d", tone01Hz)
|
||||||
val bcd = ByteArray(2)
|
val bcd = ByteArray(2)
|
||||||
for (i in 0 until 2) {
|
for (i in 0 until 2) {
|
||||||
val high = digits[i * 2] - '0'
|
val high = digits[i * 2] - '0'
|
||||||
@@ -95,7 +98,7 @@ object Ft817CatProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun buildSetModeCommand(mode: String): ByteArray? {
|
fun buildSetModeCommand(mode: String): ByteArray? {
|
||||||
val modeByte = MODE_TO_BYTE[mode.uppercase()] ?: return null
|
val modeByte = MODE_TO_BYTE[mode.uppercase(Locale.US)] ?: return null
|
||||||
return byteArrayOf(modeByte, 0x00, 0x00, 0x00, CMD_SET_MODE)
|
return byteArrayOf(modeByte, 0x00, 0x00, 0x00, CMD_SET_MODE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+52
-15
@@ -29,6 +29,7 @@ import kotlinx.coroutines.withContext
|
|||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class Ft817Controller(
|
class Ft817Controller(
|
||||||
private val bluetoothManager: BluetoothManager,
|
private val bluetoothManager: BluetoothManager,
|
||||||
@@ -39,10 +40,12 @@ class Ft817Controller(
|
|||||||
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
|
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
|
||||||
private val ioMutex = Mutex()
|
private val ioMutex = Mutex()
|
||||||
private val commandDelayMs = 200L
|
private val commandDelayMs = 200L
|
||||||
|
private val maxAckReadFailures = 3
|
||||||
|
|
||||||
private var socket: BluetoothSocket? = null
|
private var socket: BluetoothSocket? = null
|
||||||
private var outputStream: OutputStream? = null
|
private var outputStream: OutputStream? = null
|
||||||
private var inputStream: InputStream? = null
|
private var inputStream: InputStream? = null
|
||||||
|
private var ackReadFailureCount = 0
|
||||||
|
|
||||||
override var isConnected: Boolean = false
|
override var isConnected: Boolean = false
|
||||||
private set
|
private set
|
||||||
@@ -57,6 +60,7 @@ class Ft817Controller(
|
|||||||
socket = btSocket
|
socket = btSocket
|
||||||
outputStream = btSocket.outputStream
|
outputStream = btSocket.outputStream
|
||||||
inputStream = btSocket.inputStream
|
inputStream = btSocket.inputStream
|
||||||
|
ackReadFailureCount = 0
|
||||||
isConnected = true
|
isConnected = true
|
||||||
Log.i(tag, "Connected to $deviceAddress")
|
Log.i(tag, "Connected to $deviceAddress")
|
||||||
true
|
true
|
||||||
@@ -79,6 +83,7 @@ class Ft817Controller(
|
|||||||
inputStream = null
|
inputStream = null
|
||||||
outputStream = null
|
outputStream = null
|
||||||
socket = null
|
socket = null
|
||||||
|
ackReadFailureCount = 0
|
||||||
isConnected = false
|
isConnected = false
|
||||||
Log.i(tag, "Disconnected from $deviceAddress")
|
Log.i(tag, "Disconnected from $deviceAddress")
|
||||||
}
|
}
|
||||||
@@ -112,8 +117,8 @@ class Ft817Controller(
|
|||||||
ioMutex.withLock {
|
ioMutex.withLock {
|
||||||
val sent = sendCommand(Ft817CatProtocol.buildReadFreqModeCommand())
|
val sent = sendCommand(Ft817CatProtocol.buildReadFreqModeCommand())
|
||||||
if (!sent) return@withContext null
|
if (!sent) return@withContext null
|
||||||
delay(commandDelayMs)
|
delay(commandDelayMs.milliseconds)
|
||||||
val response = readResponse(5) ?: return@withContext null
|
val response = readResponse() ?: return@withContext null
|
||||||
Ft817CatProtocol.parseReadResponse(response)
|
Ft817CatProtocol.parseReadResponse(response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,10 +132,11 @@ class Ft817Controller(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun sendCommand(bytes: ByteArray): Boolean {
|
private suspend fun sendCommand(bytes: ByteArray): Boolean {
|
||||||
return try {
|
return withContext(Dispatchers.IO) {
|
||||||
outputStream?.write(bytes) ?: return false
|
try {
|
||||||
|
outputStream?.write(bytes) ?: return@withContext false
|
||||||
outputStream?.flush()
|
outputStream?.flush()
|
||||||
delay(commandDelayMs)
|
delay(commandDelayMs.milliseconds)
|
||||||
true
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(tag, "Send error: ${e.message}")
|
Log.e(tag, "Send error: ${e.message}")
|
||||||
@@ -138,26 +144,56 @@ class Ft817Controller(
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Send command and read the 1-byte ACK response (0x00 = OK). */
|
/** Send command and read the 1-byte ACK response (0x00 = OK). */
|
||||||
private suspend fun sendCommandWithAck(bytes: ByteArray): Boolean {
|
private suspend fun sendCommandWithAck(bytes: ByteArray): Boolean {
|
||||||
if (!sendCommand(bytes)) return false
|
if (!sendCommand(bytes)) return false
|
||||||
return try {
|
return withContext(Dispatchers.IO) {
|
||||||
val ack = inputStream?.read() ?: return false
|
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
|
ack == 0x00
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(tag, "ACK read error: ${e.message}")
|
ackReadFailureCount += 1
|
||||||
true // command was sent, ACK read failed - continue anyway
|
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 fun readResponse(length: Int): ByteArray? {
|
private suspend fun readResponse(): ByteArray? {
|
||||||
return try {
|
return withContext(Dispatchers.IO) {
|
||||||
val buffer = ByteArray(length)
|
try {
|
||||||
|
val responseSize = 5
|
||||||
|
val buffer = ByteArray(responseSize)
|
||||||
var read = 0
|
var read = 0
|
||||||
while (read < length) {
|
while (read < responseSize) {
|
||||||
val count = inputStream?.read(buffer, read, length - read) ?: return null
|
val count = inputStream?.read(buffer, read, responseSize - read) ?: run {
|
||||||
if (count < 0) return null
|
isConnected = false
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
if (count < 0) {
|
||||||
|
Log.i(tag, "Response stream closed by remote device")
|
||||||
|
isConnected = false
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
read += count
|
read += count
|
||||||
}
|
}
|
||||||
buffer
|
buffer
|
||||||
@@ -167,4 +203,5 @@ class Ft817Controller(
|
|||||||
null
|
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,353 @@
|
|||||||
|
/*
|
||||||
|
* 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]
|
||||||
|
* The IC-705 does not include a mode byte; if one is present it is parsed, otherwise mode is empty.
|
||||||
|
*/
|
||||||
|
fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? {
|
||||||
|
if (payload.size < 5) return null
|
||||||
|
val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5))
|
||||||
|
val mode = if (payload.size > 5) BYTE_TO_MODE[payload[5]] ?: return null else ""
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
+62
-23
@@ -17,8 +17,10 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.data.framework
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
@@ -31,18 +33,30 @@ class NetworkReporter(
|
|||||||
private val rotatorServer: String,
|
private val rotatorServer: String,
|
||||||
private val rotatorPort: Int,
|
private val rotatorPort: Int,
|
||||||
private val frequencyServer: String,
|
private val frequencyServer: String,
|
||||||
private val frequencyPort: Int
|
private val frequencyPort: Int,
|
||||||
|
private val frequencyOffsetHz: Long = 0L
|
||||||
) : IReporter {
|
) : IReporter {
|
||||||
|
|
||||||
private val writeMutex = Mutex()
|
private val writeMutex = Mutex()
|
||||||
|
private val connectionMutex = Mutex()
|
||||||
|
private val frequencyCommands = Channel<String>(Channel.CONFLATED)
|
||||||
|
|
||||||
private var rotatorSocket: SocketChannel? = null
|
private var rotatorSocket: SocketChannel? = null
|
||||||
private var rotatorConnected = false
|
private var rotatorConnected = false
|
||||||
private var rotatorConnecting = false
|
|
||||||
|
|
||||||
private var frequencySocket: SocketChannel? = null
|
private var frequencySocket: SocketChannel? = null
|
||||||
private var frequencyConnected = false
|
private var frequencyConnected = false
|
||||||
private var frequencyConnecting = false
|
|
||||||
|
init {
|
||||||
|
// Keep only the latest frequency command to avoid stale backlog and effective lag.
|
||||||
|
reporterScope.launch {
|
||||||
|
for (command in frequencyCommands) {
|
||||||
|
ensureFrequencyConnected()
|
||||||
|
if (!frequencyConnected) continue
|
||||||
|
write(frequencySocket, command) { resetFrequencyConnection() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
|
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
|
||||||
reporterScope.launch {
|
reporterScope.launch {
|
||||||
@@ -58,46 +72,43 @@ class NetworkReporter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun reportFrequency(format: String, frequency: Long) {
|
override fun reportFrequency(format: String, frequency: Long) {
|
||||||
reporterScope.launch {
|
val clampedOffset = frequencyOffsetHz.coerceIn(
|
||||||
ensureFrequencyConnected()
|
Constants.FREQ_OFFSET_MIN_HZ,
|
||||||
if (!frequencyConnected) return@launch
|
Constants.FREQ_OFFSET_MAX_HZ
|
||||||
|
)
|
||||||
|
val correctedFreq = frequency.coerceAtLeast(0L).safeAdd(clampedOffset).coerceAtLeast(0L)
|
||||||
val command = format
|
val command = format
|
||||||
.replace($$"$FREQ", frequency.toString())
|
.replace($$"$FREQ", correctedFreq.toString())
|
||||||
.unescapeControlChars()
|
.unescapeControlChars()
|
||||||
write(frequencySocket, command) { frequencyConnected = false }
|
frequencyCommands.trySend(command)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureRotatorConnected() {
|
private suspend fun ensureRotatorConnected() {
|
||||||
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
|
connectionMutex.withLock {
|
||||||
reporterScope.launch {
|
if (rotatorConnected || rotatorServer.isBlank()) return
|
||||||
try {
|
try {
|
||||||
rotatorConnecting = true
|
resetRotatorConnection()
|
||||||
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
||||||
rotatorConnected = true
|
rotatorConnected = true
|
||||||
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
|
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter rotator connect error: ${e.message}")
|
println("NetworkReporter rotator connect error: ${e.message}")
|
||||||
rotatorConnected = false
|
resetRotatorConnection()
|
||||||
} finally {
|
|
||||||
rotatorConnecting = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureFrequencyConnected() {
|
private suspend fun ensureFrequencyConnected() {
|
||||||
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
|
connectionMutex.withLock {
|
||||||
reporterScope.launch {
|
if (frequencyConnected || frequencyServer.isBlank()) return
|
||||||
try {
|
try {
|
||||||
frequencyConnecting = true
|
resetFrequencyConnection()
|
||||||
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
||||||
frequencyConnected = true
|
frequencyConnected = true
|
||||||
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
|
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter frequency connect error: ${e.message}")
|
println("NetworkReporter frequency connect error: ${e.message}")
|
||||||
frequencyConnected = false
|
resetFrequencyConnection()
|
||||||
} finally {
|
|
||||||
frequencyConnecting = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,14 +117,42 @@ class NetworkReporter(
|
|||||||
try {
|
try {
|
||||||
writeMutex.withLock {
|
writeMutex.withLock {
|
||||||
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
|
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
|
||||||
|
while (buffer.hasRemaining()) {
|
||||||
socket?.write(buffer)
|
socket?.write(buffer)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter write error: ${e.message}")
|
println("NetworkReporter write error: ${e.message}")
|
||||||
onError()
|
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 =
|
private fun String.unescapeControlChars(): String =
|
||||||
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
|
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
|
||||||
}
|
}
|
||||||
+283
-84
@@ -19,8 +19,10 @@ package com.rtbishop.look4sat.core.data.framework
|
|||||||
|
|
||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
import android.util.Log
|
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.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
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.IRadioController
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
@@ -29,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
|
|||||||
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -44,6 +47,8 @@ class RadioTrackingService(
|
|||||||
) : IRadioTrackingService {
|
) : IRadioTrackingService {
|
||||||
|
|
||||||
private val tag = "RadioTracking"
|
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())
|
private val _state = MutableStateFlow(RadioTrackingState())
|
||||||
override val state: StateFlow<RadioTrackingState> = _state
|
override val state: StateFlow<RadioTrackingState> = _state
|
||||||
|
|
||||||
@@ -51,28 +56,48 @@ class RadioTrackingService(
|
|||||||
private var rxController: IRadioController? = null
|
private var rxController: IRadioController? = null
|
||||||
private var trackingJob: Job? = null
|
private var trackingJob: Job? = null
|
||||||
|
|
||||||
|
// ── Connection ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
override suspend fun connectRadios() {
|
override suspend fun connectRadios() {
|
||||||
// Disconnect old controllers if any
|
|
||||||
txController?.disconnect()
|
txController?.disconnect()
|
||||||
rxController?.disconnect()
|
rxController?.disconnect()
|
||||||
|
|
||||||
// Read current addresses from settings
|
|
||||||
val rcSettings = settingsRepo.radioControlSettings.value
|
val rcSettings = settingsRepo.radioControlSettings.value
|
||||||
val txAddr = rcSettings.txRadioAddress
|
val txAddr = rcSettings.txRadioAddress
|
||||||
val rxAddr = rcSettings.rxRadioAddress
|
val rxAddr = rcSettings.rxRadioAddress
|
||||||
|
val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705
|
||||||
|
val isSplit = isIcom && rcSettings.splitMode
|
||||||
|
|
||||||
Log.i(tag, "Connecting TX=$txAddr RX=$rxAddr")
|
Log.i(tag, "connectRadios model=${rcSettings.radioModel} split=$isSplit TX=$txAddr RX=$rxAddr")
|
||||||
|
|
||||||
if (txAddr.isBlank() && rxAddr.isBlank()) {
|
if (isSplit) {
|
||||||
_state.update { it.copy(errorMessage = "No radio addresses configured. Set them in Settings → FT-817.") }
|
// Single-radio split mode: only TX slot is used
|
||||||
|
if (txAddr.isBlank()) {
|
||||||
|
_state.update { it.copy(errorMessage = "No radio address configured in Settings") }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val tx = makeController(isIcom, txAddr)
|
||||||
val tx = Ft817Controller(bluetoothManager, txAddr)
|
txController = tx
|
||||||
val rx = Ft817Controller(bluetoothManager, rxAddr)
|
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
|
txController = tx
|
||||||
rxController = rx
|
rxController = rx
|
||||||
|
|
||||||
_state.update { it.copy(errorMessage = null) }
|
_state.update { it.copy(errorMessage = null) }
|
||||||
val txOk = if (txAddr.isNotBlank()) tx.connect() else false
|
val txOk = if (txAddr.isNotBlank()) tx.connect() else false
|
||||||
val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false
|
val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false
|
||||||
@@ -88,7 +113,13 @@ class RadioTrackingService(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
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() {
|
override suspend fun disconnectRadios() {
|
||||||
stopTracking()
|
stopTracking()
|
||||||
@@ -96,15 +127,11 @@ class RadioTrackingService(
|
|||||||
rxController?.disconnect()
|
rxController?.disconnect()
|
||||||
txController = null
|
txController = null
|
||||||
rxController = null
|
rxController = null
|
||||||
_state.update {
|
_state.update { it.copy(txConnected = false, rxConnected = false, isActive = false) }
|
||||||
it.copy(
|
|
||||||
txConnected = false,
|
|
||||||
rxConnected = false,
|
|
||||||
isActive = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tracking ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) {
|
override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) {
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -115,26 +142,44 @@ class RadioTrackingService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
trackingJob?.cancel()
|
trackingJob?.cancel()
|
||||||
trackingJob = appScope.launch {
|
|
||||||
// Set modes on both radios at tracking start
|
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 tx = txController
|
||||||
val rx = rxController
|
val rx = rxController
|
||||||
|
|
||||||
|
// Initial setup: set band/mode/CTCSS on both radios
|
||||||
val txMode = transponder.uplinkMode
|
val txMode = transponder.uplinkMode
|
||||||
val rxMode = transponder.downlinkMode
|
val rxMode = transponder.downlinkMode
|
||||||
?: transponder.uplinkMode?.let {
|
?: transponder.uplinkMode?.let {
|
||||||
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log.i(tag, "DualRadio start: txMode=$txMode rxMode=$rxMode")
|
||||||
|
|
||||||
if (tx != null && tx.isConnected && txMode != null) {
|
if (tx != null && tx.isConnected && txMode != null) {
|
||||||
|
Log.d(tag, "Setting TX mode: $txMode")
|
||||||
tx.setMode(txMode)
|
tx.setMode(txMode)
|
||||||
Log.i(tag, "TX mode set to $txMode")
|
|
||||||
}
|
}
|
||||||
if (rx != null && rx.isConnected && rxMode != null) {
|
if (rx != null && rx.isConnected && rxMode != null) {
|
||||||
|
Log.d(tag, "Setting RX mode: $rxMode")
|
||||||
rx.setMode(rxMode)
|
rx.setMode(rxMode)
|
||||||
Log.i(tag, "RX mode set to $rxMode")
|
|
||||||
}
|
}
|
||||||
// Set CTCSS if FM
|
|
||||||
if (txMode?.uppercase() == "FM") {
|
if (txMode?.uppercase() == "FM") {
|
||||||
_state.value.ctcssTone?.let { tone ->
|
_state.value.ctcssTone?.let { tone ->
|
||||||
|
Log.d(tag, "Setting CTCSS: ${tone}Hz")
|
||||||
tx?.setCtcssTone(tone)
|
tx?.setCtcssTone(tone)
|
||||||
tx?.setCtcssMode(true)
|
tx?.setCtcssMode(true)
|
||||||
}
|
}
|
||||||
@@ -143,11 +188,11 @@ class RadioTrackingService(
|
|||||||
|
|
||||||
var lastSetTxFreq = 0.0
|
var lastSetTxFreq = 0.0
|
||||||
var lastSetRxFreq = 0.0
|
var lastSetRxFreq = 0.0
|
||||||
var tuningRadio = "" // "", "tx", or "rx" - which radio the user is tuning
|
var tuningRadio = ""
|
||||||
var lastReadFreq = 0L
|
var lastReadFreq = 0L
|
||||||
var stableCount = 0
|
var stableCount = 0
|
||||||
|
|
||||||
while (isActive) {
|
while (currentCoroutineContext().isActive) {
|
||||||
val currentState = _state.value
|
val currentState = _state.value
|
||||||
if (!currentState.isActive) break
|
if (!currentState.isActive) break
|
||||||
|
|
||||||
@@ -155,39 +200,29 @@ class RadioTrackingService(
|
|||||||
val xpdr = currentState.selectedTransponder ?: break
|
val xpdr = currentState.selectedTransponder ?: break
|
||||||
var txBaseFreq = currentState.txBaseFrequencyHz
|
var txBaseFreq = currentState.txBaseFrequencyHz
|
||||||
val stationPos = settingsRepo.stationPosition.value
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
val timeNow = System.currentTimeMillis()
|
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis())
|
||||||
|
val txNow = txController
|
||||||
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
|
val rxNow = rxController
|
||||||
val tx = txController
|
|
||||||
val rx = rxController
|
|
||||||
val hasUplink = txBaseFreq != null
|
|
||||||
val c = com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT
|
|
||||||
val v = pos.distanceRate * 1000.0
|
val v = pos.distanceRate * 1000.0
|
||||||
|
|
||||||
if (tuningRadio.isNotEmpty()) {
|
if (tuningRadio.isNotEmpty()) {
|
||||||
// --- User is tuning: keep reading, wait for stabilization ---
|
val radio = if (tuningRadio == "tx") txNow else rxNow
|
||||||
val radio = if (tuningRadio == "tx") tx else rx
|
|
||||||
if (radio != null && radio.isConnected) {
|
if (radio != null && radio.isConnected) {
|
||||||
val readResult = radio.readFrequencyAndMode()
|
val read = radio.readFrequencyAndMode()
|
||||||
if (readResult != null) {
|
if (read != null) {
|
||||||
val (freq, _) = readResult
|
val (freq, _) = read
|
||||||
if (kotlin.math.abs(freq - lastReadFreq) <= 20) {
|
if (kotlin.math.abs(freq - lastReadFreq) <= 20) stableCount++
|
||||||
stableCount++
|
else { stableCount = 0; lastReadFreq = freq }
|
||||||
} else {
|
|
||||||
stableCount = 0
|
|
||||||
lastReadFreq = freq
|
|
||||||
}
|
|
||||||
// Stable for 2 reads → user stopped turning
|
|
||||||
if (stableCount >= 2) {
|
if (stableCount >= 2) {
|
||||||
if (tuningRadio == "tx" && txBaseFreq != null) {
|
if (tuningRadio == "tx" && txBaseFreq != null) {
|
||||||
val newBase = (freq.toDouble() * c / (c + v)).toLong()
|
val newBase = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong()
|
||||||
if (newBase > 0) {
|
if (newBase > 0) {
|
||||||
txBaseFreq = newBase
|
txBaseFreq = newBase
|
||||||
_state.update { it.copy(txBaseFrequencyHz = newBase) }
|
_state.update { it.copy(txBaseFrequencyHz = newBase) }
|
||||||
Log.i(tag, "TX tuning done → base=$newBase")
|
Log.i(tag, "TX tuning done → base=$newBase")
|
||||||
}
|
}
|
||||||
} else if (tuningRadio == "rx") {
|
} else if (tuningRadio == "rx") {
|
||||||
val rxNominal = (freq.toDouble() * c / (c - v)).toLong()
|
val rxNominal = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong()
|
||||||
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
|
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
|
||||||
if (newTxBase != null && newTxBase > 0) {
|
if (newTxBase != null && newTxBase > 0) {
|
||||||
txBaseFreq = newTxBase
|
txBaseFreq = newTxBase
|
||||||
@@ -203,62 +238,48 @@ class RadioTrackingService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// --- Normal tracking: read, detect changes, command ---
|
// Detect manual dial changes
|
||||||
|
if (txBaseFreq != null && txNow != null && txNow.isConnected && lastSetTxFreq > 0.0) {
|
||||||
// TX dial feedback
|
val read = txNow.readFrequencyAndMode()
|
||||||
if (hasUplink && tx != null && tx.isConnected && lastSetTxFreq > 0.0) {
|
if (read != null && kotlin.math.abs(read.first - lastSetTxFreq) >= 20.0) {
|
||||||
val readResult = tx.readFrequencyAndMode()
|
|
||||||
if (readResult != null) {
|
|
||||||
val (actualTxFreq, _) = readResult
|
|
||||||
if (kotlin.math.abs(actualTxFreq - lastSetTxFreq) >= 20.0) {
|
|
||||||
tuningRadio = "tx"
|
tuningRadio = "tx"
|
||||||
lastReadFreq = actualTxFreq
|
lastReadFreq = read.first
|
||||||
stableCount = 0
|
stableCount = 0
|
||||||
Log.i(tag, "TX tuning detected (read=$actualTxFreq, lastSet=$lastSetTxFreq)")
|
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()
|
||||||
// RX dial feedback (only if TX not tuning)
|
if (read != null && kotlin.math.abs(read.first - lastSetRxFreq) >= 20.0) {
|
||||||
if (tuningRadio.isEmpty() && rx != null && rx.isConnected && lastSetRxFreq > 0.0) {
|
|
||||||
val readResult = rx.readFrequencyAndMode()
|
|
||||||
if (readResult != null) {
|
|
||||||
val (actualRxFreq, _) = readResult
|
|
||||||
if (kotlin.math.abs(actualRxFreq - lastSetRxFreq) >= 20.0) {
|
|
||||||
tuningRadio = "rx"
|
tuningRadio = "rx"
|
||||||
lastReadFreq = actualRxFreq
|
lastReadFreq = read.first
|
||||||
stableCount = 0
|
stableCount = 0
|
||||||
Log.i(tag, "RX tuning detected (read=$actualRxFreq, lastSet=$lastSetRxFreq)")
|
Log.i(tag, "RX tuning detected (read=${read.first}, lastSet=$lastSetRxFreq)")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute Doppler-corrected frequencies
|
|
||||||
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
|
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
|
||||||
val rxBaseFreq = if (txBaseFreq != null) {
|
val rxBaseFreq = if (txBaseFreq != null) {
|
||||||
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
|
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
|
||||||
} else {
|
} else xpdr.downlinkLow
|
||||||
xpdr.downlinkLow
|
|
||||||
}
|
|
||||||
val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) }
|
val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) }
|
||||||
|
|
||||||
// Command radios (only when not tuning)
|
|
||||||
if (tuningRadio.isEmpty()) {
|
if (tuningRadio.isEmpty()) {
|
||||||
if (tx != null && tx.isConnected && txRadioFreq != null) {
|
if (txNow != null && txNow.isConnected && txRadioFreq != null) {
|
||||||
tx.setFrequency(txRadioFreq)
|
txNow.setFrequency(txRadioFreq)
|
||||||
lastSetTxFreq = txRadioFreq.toDouble()
|
lastSetTxFreq = txRadioFreq.toDouble()
|
||||||
}
|
}
|
||||||
if (rx != null && rx.isConnected && rxRadioFreq != null) {
|
if (rxNow != null && rxNow.isConnected && rxRadioFreq != null) {
|
||||||
rx.setFrequency(rxRadioFreq)
|
rxNow.setFrequency(rxRadioFreq)
|
||||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
txConnected = tx?.isConnected ?: false,
|
txConnected = txNow?.isConnected ?: false,
|
||||||
rxConnected = rx?.isConnected ?: false,
|
rxConnected = rxNow?.isConnected ?: false,
|
||||||
txFrequencyHz = txRadioFreq,
|
txFrequencyHz = txRadioFreq,
|
||||||
rxFrequencyHz = rxRadioFreq,
|
rxFrequencyHz = rxRadioFreq,
|
||||||
azimuth = Math.toDegrees(pos.azimuth),
|
azimuth = Math.toDegrees(pos.azimuth),
|
||||||
@@ -266,12 +287,196 @@ class RadioTrackingService(
|
|||||||
distance = pos.distance
|
distance = pos.distance
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
delay(1000)
|
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() {
|
override fun stopTracking() {
|
||||||
trackingJob?.cancel()
|
trackingJob?.cancel()
|
||||||
trackingJob = null
|
trackingJob = null
|
||||||
@@ -288,7 +493,6 @@ class RadioTrackingService(
|
|||||||
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||||
}
|
}
|
||||||
rxMode?.let { rx?.setMode(it) }
|
rxMode?.let { rx?.setMode(it) }
|
||||||
|
|
||||||
if (transponder.uplinkMode?.uppercase() == "FM") {
|
if (transponder.uplinkMode?.uppercase() == "FM") {
|
||||||
_state.value.ctcssTone?.let { tone ->
|
_state.value.ctcssTone?.let { tone ->
|
||||||
tx?.setCtcssTone(tone)
|
tx?.setCtcssTone(tone)
|
||||||
@@ -302,13 +506,9 @@ class RadioTrackingService(
|
|||||||
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
// Show nominal frequencies immediately
|
|
||||||
val rxNominal = if (txCenter != null) {
|
val rxNominal = if (txCenter != null) {
|
||||||
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
||||||
} else {
|
} else transponder.downlinkLow
|
||||||
// Downlink-only transponder (beacon etc.) - use downlink directly
|
|
||||||
transponder.downlinkLow
|
|
||||||
}
|
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
selectedTransponder = transponder,
|
selectedTransponder = transponder,
|
||||||
@@ -353,5 +553,4 @@ class RadioTrackingService(
|
|||||||
}
|
}
|
||||||
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
+73
-16
@@ -1,17 +1,38 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
package com.rtbishop.look4sat.core.data.injection
|
package com.rtbishop.look4sat.core.data.injection
|
||||||
|
|
||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.hardware.Sensor
|
|
||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
import android.location.LocationManager
|
import android.location.LocationManager
|
||||||
import android.view.WindowManager
|
import androidx.core.content.pm.PackageInfoCompat
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
|
import com.rtbishop.look4sat.core.data.database.DATABASE_NAME
|
||||||
|
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
|
||||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
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.NetworkReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
||||||
|
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
|
||||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
||||||
@@ -20,7 +41,10 @@ import com.rtbishop.look4sat.core.data.repository.SettingsRepo
|
|||||||
import com.rtbishop.look4sat.core.data.source.LocalSource
|
import com.rtbishop.look4sat.core.data.source.LocalSource
|
||||||
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
||||||
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
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.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.IDatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
@@ -33,6 +57,8 @@ import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
|||||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
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.usecase.IShowToast
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
@@ -44,12 +70,14 @@ import okhttp3.OkHttpClient
|
|||||||
class MainContainer(private val context: Context) : IMainContainer {
|
class MainContainer(private val context: Context) : IMainContainer {
|
||||||
|
|
||||||
private val localSource = provideLocalSource()
|
private val localSource = provideLocalSource()
|
||||||
|
private val remoteSource = provideRemoteSource()
|
||||||
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
|
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
|
||||||
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
|
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
|
||||||
override val settingsRepo = provideSettingsRepo()
|
override val settingsRepo = provideSettingsRepo()
|
||||||
override val selectionRepo = provideSelectionRepo()
|
override val selectionRepo = provideSelectionRepo()
|
||||||
override val satelliteRepo = provideSatelliteRepo()
|
override val satelliteRepo = provideSatelliteRepo()
|
||||||
override val databaseRepo = provideDatabaseRepo()
|
override val databaseRepo = provideDatabaseRepo()
|
||||||
|
override val amSatRepo by lazy { AmSatRepository(remoteSource) }
|
||||||
override val radioTrackingService: IRadioTrackingService by lazy {
|
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||||
@@ -59,6 +87,10 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
|
|
||||||
override fun provideShowToast(): IShowToast = ShowToast(context)
|
override fun provideShowToast(): IShowToast = ShowToast(context)
|
||||||
|
|
||||||
|
override fun provideAudioCapture(): IAudioCapture = AudioCapture()
|
||||||
|
|
||||||
|
override fun provideSaveImage(): ISaveImage = SaveImage(context)
|
||||||
|
|
||||||
override fun provideBluetoothReporter(): IReporter {
|
override fun provideBluetoothReporter(): IReporter {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
val rc = settingsRepo.rcSettings.value
|
val rc = settingsRepo.rcSettings.value
|
||||||
@@ -77,44 +109,67 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
rc.rotatorAddress,
|
rc.rotatorAddress,
|
||||||
rc.rotatorPort.toIntOrNull() ?: 0,
|
rc.rotatorPort.toIntOrNull() ?: 0,
|
||||||
rc.frequencyAddress,
|
rc.frequencyAddress,
|
||||||
rc.frequencyPort.toIntOrNull() ?: 0
|
rc.frequencyPort.toIntOrNull() ?: 0,
|
||||||
|
rc.frequencyOffsetHz
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun provideTxRadioController(): IRadioController {
|
override fun provideTxRadioController(): IRadioController {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
val address = settingsRepo.radioControlSettings.value.txRadioAddress
|
val settings = settingsRepo.radioControlSettings.value
|
||||||
return Ft817Controller(manager, address)
|
val address = settings.txRadioAddress
|
||||||
|
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
|
||||||
|
Ic705Controller(manager, address)
|
||||||
|
} else {
|
||||||
|
Ft817Controller(manager, address)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun provideRxRadioController(): IRadioController {
|
override fun provideRxRadioController(): IRadioController {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
val address = settingsRepo.radioControlSettings.value.rxRadioAddress
|
val settings = settingsRepo.radioControlSettings.value
|
||||||
return Ft817Controller(manager, address)
|
val address = settings.rxRadioAddress
|
||||||
|
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
|
||||||
|
Ic705Controller(manager, address)
|
||||||
|
} else {
|
||||||
|
Ft817Controller(manager, address)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun provideSensorsRepo(): ISensorsRepo {
|
override fun provideSensorsRepo(): ISensorsRepo {
|
||||||
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||||
val sensor = manager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
val displayManager = context.getSystemService(DisplayManager::class.java)
|
||||||
val window = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
return SensorsRepo(manager, displayManager)
|
||||||
return SensorsRepo(manager,sensor,window)
|
}
|
||||||
|
|
||||||
|
override fun providePairedBluetoothDevices(): List<Pair<String, String>> = buildList {
|
||||||
|
try {
|
||||||
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
manager.adapter?.bondedDevices?.forEach { add(Pair(it.name ?: "Unknown", it.address ?: "")) }
|
||||||
|
} catch (_: SecurityException) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideDatabaseRepo(): IDatabaseRepo {
|
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||||
val dbDispatcher = Dispatchers.Default
|
val dbDispatcher = Dispatchers.Default
|
||||||
val dataParser = DataParser(dbDispatcher)
|
val dataParser = DataParser(dbDispatcher)
|
||||||
val remoteSource = provideRemoteSource()
|
|
||||||
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideLocalSource(): ILocalSource {
|
private fun provideLocalSource(): ILocalSource {
|
||||||
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
|
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, DATABASE_NAME)
|
||||||
val database = builder.fallbackToDestructiveMigration(false).build()
|
val database = builder.addMigrations(MIGRATION_1_2).fallbackToDestructiveMigration(false).build()
|
||||||
return LocalSource(database.look4SatDao())
|
return LocalSource(database.look4SatDao())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideRemoteSource(): IRemoteSource {
|
private fun provideRemoteSource(): IRemoteSource {
|
||||||
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
|
val version = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
||||||
|
val userAgent = "Look4Sat/$version (+https://github.com/rt-bishop/Look4Sat)"
|
||||||
|
// Data providers ask clients to identify themselves, so that they can reach out to the
|
||||||
|
// developer instead of silently blocking every user of the app behind a misbehaving request
|
||||||
|
val client = OkHttpClient.Builder().addInterceptor { chain ->
|
||||||
|
chain.proceed(chain.request().newBuilder().header("User-Agent", userAgent).build())
|
||||||
|
}.build()
|
||||||
|
return RemoteSource(Dispatchers.IO, context.contentResolver, client)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideSatelliteRepo(): ISatelliteRepo {
|
private fun provideSatelliteRepo(): ISatelliteRepo {
|
||||||
@@ -129,7 +184,9 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||||
val appPrefsFileName = "${context.packageName}_preferences"
|
val appPrefsFileName = "${context.packageName}_preferences"
|
||||||
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
||||||
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
return SettingsRepo(manager, appPreferences, appVersionName)
|
val appVersionName = packageInfo.versionName ?: "4.0.4"
|
||||||
|
val appVersionCode = PackageInfoCompat.getLongVersionCode(packageInfo)
|
||||||
|
return SettingsRepo(manager, appPreferences, appVersionName, appVersionCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
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.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 isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||||
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
|
||||||
|
val nowSec = System.currentTimeMillis() / 1000
|
||||||
|
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
|
||||||
|
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
|
||||||
|
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
|
||||||
|
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
|
||||||
|
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return@withContext null
|
||||||
|
val names = parseCatalog(catalogJson)
|
||||||
|
val reports = parseReports(reportsJson)
|
||||||
|
|
||||||
|
if (names.isEmpty() && reports.isEmpty()) return@withContext null
|
||||||
|
|
||||||
|
val statuses = buildStatuses(names, reports, nowSec)
|
||||||
|
val reportMap = reports.associate { it.id to toSatReport(it) }
|
||||||
|
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (5 days x 12 slots) per catalog satellite, slotting reports by age. */
|
||||||
|
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||||
|
val byName = reports.groupBy { it.name }
|
||||||
|
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
|
val labels = (0 until 3).map { d ->
|
||||||
|
utc.timeInMillis = (nowSec - d * 86400L) * 1000
|
||||||
|
formatDayLabel(utc)
|
||||||
|
}
|
||||||
|
return names.map { name ->
|
||||||
|
val slots = (0 until 36).map { slotIdx ->
|
||||||
|
val slotStart = nowSec - (slotIdx + 1) * 7200L
|
||||||
|
val slotEnd = nowSec - slotIdx * 7200L
|
||||||
|
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
|
||||||
|
if (inSlot.isEmpty()) {
|
||||||
|
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
|
||||||
|
} else {
|
||||||
|
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
|
||||||
|
SatSlot(
|
||||||
|
statusColor = statusColorOf(newest.report),
|
||||||
|
count = inSlot.size,
|
||||||
|
reportIds = inSlot.map { it.id }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val days = (0 until 3).map { d ->
|
||||||
|
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
|
||||||
|
}
|
||||||
|
SatStatus(name = name, days = days)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDayLabel(calendar: Calendar): String {
|
||||||
|
return if (Locale.getDefault().language == Locale.CHINESE.language) {
|
||||||
|
"${calendar.get(Calendar.MONTH) + 1}月${calendar.get(Calendar.DAY_OF_MONTH)}日"
|
||||||
|
} else {
|
||||||
|
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||||
|
"${monthAbbr[calendar.get(Calendar.MONTH)]} ${calendar.get(Calendar.DAY_OF_MONTH)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toSatReport(r: ApiReport): SatReport {
|
||||||
|
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
|
cal.timeInMillis = r.reportedTimeUtcSec * 1000
|
||||||
|
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
|
||||||
|
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
|
||||||
|
val y = cal.get(Calendar.YEAR)
|
||||||
|
val mo = (cal.get(Calendar.MONTH) + 1).toString().padStart(2, '0')
|
||||||
|
val d = cal.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0')
|
||||||
|
return SatReport(
|
||||||
|
id = r.id,
|
||||||
|
statusText = r.report,
|
||||||
|
call = r.callsign,
|
||||||
|
grid = r.gridSquare,
|
||||||
|
dateUtc = "$y-$mo-$d",
|
||||||
|
timeUtc = "$hh:$mm UTC"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map status text to color value (for UI rendering). */
|
||||||
|
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
|
||||||
|
"heard", "crew active" -> ACTIVE_BLUE
|
||||||
|
"telemetry only" -> TLM_ORANGE
|
||||||
|
"not heard" -> NOT_HEARD_PINK
|
||||||
|
else -> CONFLICT_DEEP_ORANGE
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// AMSAT official status colors (from amsat.org/status)
|
||||||
|
private const val ACTIVE_BLUE = 0xFF648FFF
|
||||||
|
private const val TLM_ORANGE = 0xFFFFB000
|
||||||
|
private const val NOT_HEARD_PINK = 0xFFDC267F
|
||||||
|
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
|
||||||
|
private const val NO_REPORT_GRAY = 0xFFC0C0C0
|
||||||
|
}
|
||||||
|
}
|
||||||
+130
-46
@@ -23,7 +23,6 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
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 com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -40,60 +39,65 @@ class DatabaseRepo(
|
|||||||
private val settingsRepo: ISettingsRepo
|
private val settingsRepo: ISettingsRepo
|
||||||
) : IDatabaseRepo {
|
) : IDatabaseRepo {
|
||||||
|
|
||||||
private companion object {
|
|
||||||
val tleTypes = setOf("Amsat", "R4UAB", "Other")
|
|
||||||
val zippedTleTypes = setOf("McCants", "Classified")
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun updateTLEFromFile(uri: String) = withContext(dispatcher) {
|
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
||||||
|
var importedCount = 0
|
||||||
remoteSource.getFileStream(uri)?.let { stream ->
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
val entries = dataParser.parseTLEStream(stream)
|
val entries = mergeEntries(listOf(parseSatelliteStream(uri, unwrapIfZipped(uri, stream))))
|
||||||
localSource.insertEntries(entries)
|
insertFresherEntries(entries)
|
||||||
settingsRepo.setSatelliteTypeIds("Other", entries.map { it.catnum })
|
// report what the file contained: a valid file holding only stale data is not an error
|
||||||
|
importedCount = entries.size
|
||||||
}
|
}
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
|
importedCount
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateTransceiversFromFile(uri: String) = withContext(dispatcher) {
|
override suspend fun updateTransceiversFromFile(uri: String): Int = withContext(dispatcher) {
|
||||||
remoteSource.getFileStream(uri)
|
var importedCount = 0
|
||||||
?.let { dataParser.parseJSONStream(it) }
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
?.takeIf { it.isNotEmpty() }
|
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
||||||
?.let {
|
localSource.insertRadios(transceivers, isCustom = true)
|
||||||
localSource.deleteRadios()
|
importedCount = transceivers.size
|
||||||
localSource.insertRadios(it)
|
|
||||||
}
|
}
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
|
importedCount
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||||
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
val settings = settingsRepo.dataSourcesSettings.value
|
||||||
val tleUrls = buildMap {
|
fun normalizeUrl(url: String) = if (url.startsWith("http")) url else "https://$url"
|
||||||
putAll(Sources.satelliteDataUrls)
|
val tleUrls = settings.satelliteUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) }
|
||||||
if (dataSourcesSettings.useCustomTLE) put("Other", dataSourcesSettings.tleUrl)
|
val radioUrls = settings.transceiversUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) }
|
||||||
}.filterValues { it.isNotEmpty() }
|
// launch all network requests concurrently, keeping the raw url as key for status reporting
|
||||||
|
val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } }
|
||||||
val radioUrls = buildList {
|
val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } }
|
||||||
add(Sources.RADIO_DATA_URL)
|
val tleResults = tleJobs.awaitAll()
|
||||||
if (dataSourcesSettings.useCustomTransceivers) add(dataSourcesSettings.transceiversUrl)
|
val radioResults = radioJobs.awaitAll()
|
||||||
|
// report the HTTP status code of every source (200, 404, ...)
|
||||||
|
settingsRepo.updateDataSourcesStatus(
|
||||||
|
(tleResults + radioResults).associate { (url, result) -> url to result.code }
|
||||||
|
)
|
||||||
|
// parse fetched data concurrently, merging satellites by freshness instead of source order
|
||||||
|
val parsedPerSource = tleResults.map { (url, result) ->
|
||||||
|
result.stream?.let { val nUrl = normalizeUrl(url); parseSatelliteStream(nUrl, unwrapIfZipped(nUrl, it)) }.orEmpty()
|
||||||
}
|
}
|
||||||
|
val importedRadios = radioResults.flatMap { (url, result) ->
|
||||||
// launch all network requests concurrently
|
result.stream?.let { val nUrl = normalizeUrl(url); dataParser.parseJSONStream(unwrapIfZipped(nUrl, it)) }.orEmpty()
|
||||||
val tleJobs = tleUrls.map { (type, url) -> async { type to remoteSource.getNetworkStream(url) } }
|
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
|
||||||
val radioJobs = radioUrls.map { url -> async { remoteSource.getNetworkStream(url) } }
|
// insert parsed data into the database
|
||||||
|
insertFresherEntries(mergeEntries(parsedPerSource))
|
||||||
// parse satellite data
|
// transceivers are a full snapshot: sources publish active entries only, so a retired
|
||||||
val importedEntries = tleJobs.awaitAll().flatMap { (type, stream) ->
|
// transceiver simply disappears from the feed and has to be dropped locally as well.
|
||||||
stream?.let { parseSatelliteStream(type, it) }.orEmpty().also { satellites ->
|
// Imported ones are kept: no source can refresh them, so nothing would bring them back
|
||||||
settingsRepo.setSatelliteTypeIds(type, satellites.map { it.catnum })
|
if (importedRadios.isNotEmpty()) {
|
||||||
|
localSource.deleteManagedRadios()
|
||||||
|
localSource.insertRadios(importedRadios, isCustom = false)
|
||||||
}
|
}
|
||||||
}
|
// keep the previous timestamp when every source failed, so the next launch retries
|
||||||
|
val hasFetchedData = parsedPerSource.any { entries -> entries.isNotEmpty() } || importedRadios.isNotEmpty()
|
||||||
// parse radio data
|
val previousTimestamp = settingsRepo.databaseState.value.updateTimestamp
|
||||||
val importedRadios = radioJobs.awaitAll().filterNotNull().flatMap { dataParser.parseJSONStream(it) }
|
if (hasFetchedData) pruneStaleEntries()
|
||||||
|
setUpdateSuccessful(if (hasFetchedData) System.currentTimeMillis() else previousTimestamp)
|
||||||
localSource.insertEntries(importedEntries)
|
|
||||||
localSource.insertRadios(importedRadios)
|
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun clearAllData() = withContext(dispatcher) {
|
override suspend fun clearAllData() = withContext(dispatcher) {
|
||||||
@@ -102,10 +106,87 @@ class DatabaseRepo(
|
|||||||
setUpdateSuccessful(0L)
|
setUpdateSuccessful(0L)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun parseSatelliteStream(type: String, stream: InputStream): List<OrbitalData> = when (type) {
|
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
|
||||||
in tleTypes -> dataParser.parseTLEStream(stream)
|
val bufferedStream = stream.buffered()
|
||||||
in zippedTleTypes -> dataParser.parseTLEStream(ZipInputStream(stream).apply { nextEntry })
|
return when {
|
||||||
else -> dataParser.parseCSVStream(stream)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges the data of every source: orbital elements always come from the set with the newest
|
||||||
|
* epoch, while the name comes from the first source that provides it. Source order is thus a
|
||||||
|
* naming preference only, which also keeps names stable when sources leapfrog each other.
|
||||||
|
*/
|
||||||
|
private fun mergeEntries(sourceEntries: List<List<OrbitalData>>): List<OrbitalData> {
|
||||||
|
val preferredNames = mutableMapOf<Int, String>()
|
||||||
|
val freshestEntries = mutableMapOf<Int, OrbitalData>()
|
||||||
|
sourceEntries.forEach { entries ->
|
||||||
|
entries.forEach { entry ->
|
||||||
|
val name = entry.name.trim()
|
||||||
|
if (name.isNotBlank()) preferredNames.getOrPut(entry.catnum) { name }
|
||||||
|
val current = freshestEntries[entry.catnum]
|
||||||
|
if (current == null || entry.epochDaynum > current.epochDaynum) {
|
||||||
|
freshestEntries[entry.catnum] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return freshestEntries.values.map { entry ->
|
||||||
|
val name = preferredNames[entry.catnum]
|
||||||
|
if (name == null || name == entry.name) entry else entry.copy(name = name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stores the entries that are newer than the ones already saved, renaming the rest in place. */
|
||||||
|
private suspend fun insertFresherEntries(entries: List<OrbitalData>) {
|
||||||
|
val storedEpochs = localSource.getEntriesEpochs()
|
||||||
|
val (fresherEntries, staleEntries) = entries.partition { entry ->
|
||||||
|
val storedEpoch = storedEpochs[entry.catnum]
|
||||||
|
storedEpoch == null || entry.epochDaynum > OrbitalData.epochToDaynum(storedEpoch)
|
||||||
|
}
|
||||||
|
localSource.insertEntries(fresherEntries)
|
||||||
|
// a reordered source list has to rename satellites right away, even the ones holding
|
||||||
|
// elements that are newer than the ones just parsed
|
||||||
|
if (staleEntries.isNotEmpty()) {
|
||||||
|
val storedNames = localSource.getEntriesNames()
|
||||||
|
val renamedEntries = staleEntries.filter { entry -> entry.name != storedNames[entry.catnum] }
|
||||||
|
if (renamedEntries.isNotEmpty()) {
|
||||||
|
localSource.renameEntries(renamedEntries.associate { it.catnum to it.name })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops satellites that no enabled source has refreshed for a month: they either decayed or
|
||||||
|
* disappeared from every catalog. Manually imported data ages out the same way, and every
|
||||||
|
* source republishes active satellites well within that window.
|
||||||
|
*/
|
||||||
|
private suspend fun pruneStaleEntries() {
|
||||||
|
val currentDaynum = OrbitalData.timeToDaynum(System.currentTimeMillis())
|
||||||
|
val staleIds = localSource.getEntriesEpochs()
|
||||||
|
.filterValues { epoch -> currentDaynum - OrbitalData.epochToDaynum(epoch) > 30.0 }
|
||||||
|
.keys.toList()
|
||||||
|
if (staleIds.isNotEmpty()) localSource.deleteEntriesWithIds(staleIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
||||||
@@ -113,4 +194,7 @@ class DatabaseRepo(
|
|||||||
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
|
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
|
||||||
}
|
}
|
||||||
+74
-12
@@ -34,8 +34,13 @@ import kotlinx.coroutines.coroutineScope
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.TimeZone
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class SatelliteRepo(
|
class SatelliteRepo(
|
||||||
private val dispatcher: CoroutineDispatcher,
|
private val dispatcher: CoroutineDispatcher,
|
||||||
@@ -52,13 +57,33 @@ class SatelliteRepo(
|
|||||||
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
|
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
|
||||||
override val satellites: StateFlow<List<OrbitalObject>> = _satellites
|
override val satellites: StateFlow<List<OrbitalObject>> = _satellites
|
||||||
|
|
||||||
|
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 getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||||
|
|
||||||
override suspend fun initRepository() = withContext(dispatcher) {
|
override suspend fun initRepository() = withContext(dispatcher) {
|
||||||
settingsRepo.selectedIds.collect { selectedIds ->
|
combine(
|
||||||
|
settingsRepo.selectedIds,
|
||||||
|
settingsRepo.stationPosition,
|
||||||
|
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged()
|
||||||
|
) { selectedIds, _, _ -> selectedIds }
|
||||||
|
.collect { selectedIds ->
|
||||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||||
val (_, hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
|
val settings = settingsRepo.passesSettings.value
|
||||||
calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
|
calculatePasses(
|
||||||
|
time = System.currentTimeMillis(),
|
||||||
|
hoursAhead = settings.hoursAhead,
|
||||||
|
minElevation = settings.minElevation,
|
||||||
|
aosStartMinute = settings.aosStartMinute,
|
||||||
|
aosEndMinute = settings.aosEndMinute,
|
||||||
|
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||||
|
modes = settingsRepo.selectedSatModes.value
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +123,15 @@ class SatelliteRepo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>) {
|
override suspend fun calculatePasses(
|
||||||
|
time: Long,
|
||||||
|
hoursAhead: Int,
|
||||||
|
minElevation: Double,
|
||||||
|
aosStartMinute: Int,
|
||||||
|
aosEndMinute: Int,
|
||||||
|
invertAosTimeWindow: Boolean,
|
||||||
|
modes: List<String>
|
||||||
|
) {
|
||||||
_isCalculating.value = true
|
_isCalculating.value = true
|
||||||
// Normalize to the start of the current minute so that coarse 60-second stepping
|
// 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
|
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
|
||||||
@@ -123,33 +156,62 @@ class SatelliteRepo(
|
|||||||
val newPasses = ArrayList<OrbitalPass>()
|
val newPasses = ArrayList<OrbitalPass>()
|
||||||
for (list in passLists) {
|
for (list in passLists) {
|
||||||
for (pass in list) {
|
for (pass in list) {
|
||||||
if (pass.losTime > time && pass.aosTime < timeFuture && pass.maxElevation > minElevation) {
|
if (
|
||||||
|
pass.losTime > time
|
||||||
|
&& pass.aosTime < timeFuture
|
||||||
|
&& pass.maxElevation > minElevation
|
||||||
|
&& (pass.isDeepSpace || isAosInRange(pass.aosTime, aosStartMinute, aosEndMinute, invertAosTimeWindow))
|
||||||
|
) {
|
||||||
newPasses.add(pass)
|
newPasses.add(pass)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
newPasses.sortBy { it.aosTime }
|
newPasses.sortBy { it.aosTime }
|
||||||
delay(1000) // Simulate loading time for better UX
|
delay(1000.milliseconds) // Simulate loading time for better UX
|
||||||
_passes.update { newPasses }
|
_passes.update { newPasses }
|
||||||
}
|
}
|
||||||
_isCalculating.value = false
|
_isCalculating.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isAosInRange(
|
||||||
|
aosTime: Long,
|
||||||
|
aosStartMinute: Int,
|
||||||
|
aosEndMinute: Int,
|
||||||
|
invertAosTimeWindow: Boolean
|
||||||
|
): Boolean {
|
||||||
|
// Must match the timezone used to display pass times (UTC toggle vs device local)
|
||||||
|
val tz = if (settingsRepo.otherSettings.value.stateOfUtc) {
|
||||||
|
TimeZone.getTimeZone("UTC")
|
||||||
|
} else {
|
||||||
|
TimeZone.getDefault()
|
||||||
|
}
|
||||||
|
val offsetMillis = tz.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 !in (aosEndMinute + 1)..<aosStartMinute
|
||||||
|
}
|
||||||
|
return if (invertAosTimeWindow) !inRange else inRange
|
||||||
|
}
|
||||||
|
|
||||||
private fun OrbitalObject.getPasses(pos: GeoPos, time: Long, hours: Int): List<OrbitalPass> {
|
private fun OrbitalObject.getPasses(pos: GeoPos, time: Long, hours: Int): List<OrbitalPass> {
|
||||||
val passes = mutableListOf<OrbitalPass>()
|
val passes = mutableListOf<OrbitalPass>()
|
||||||
val endDate = time + hours * 60L * 60L * 1000L
|
val endDate = time + hours * 60L * 60L * 1000L
|
||||||
val quarterOrbitMin = (this.data.orbitalPeriod / 4.0).toInt()
|
val quarterOrbitMin = (this.data.orbitalPeriod / 4.0).toInt()
|
||||||
|
val decayed = this.data.hasDecayed(time)
|
||||||
var startDate = time
|
var startDate = time
|
||||||
var shouldRewind = true
|
var shouldRewind = true
|
||||||
var lastAosDate: Long
|
var lastAosDate: Long
|
||||||
var count = 0
|
var count = 0
|
||||||
if (this.willBeSeen(pos)) {
|
if (this.willBeSeen(pos)) {
|
||||||
if (this.data.isDeepSpace) {
|
if (this.data.isDeepSpace) {
|
||||||
passes.add(getGeoPass(this, pos, time))
|
passes.add(getGeoPass(this, pos, time, decayed))
|
||||||
} else {
|
} else {
|
||||||
do {
|
do {
|
||||||
if (count > 0) shouldRewind = false
|
if (count > 0) shouldRewind = false
|
||||||
val pass = getLeoPass(this, pos, startDate, shouldRewind)
|
val pass = getLeoPass(this, pos, startDate, shouldRewind, decayed)
|
||||||
lastAosDate = pass.aosTime
|
lastAosDate = pass.aosTime
|
||||||
passes.add(pass)
|
passes.add(pass)
|
||||||
startDate = pass.losTime + (quarterOrbitMin * 3) * 60L * 1000L
|
startDate = pass.losTime + (quarterOrbitMin * 3) * 60L * 1000L
|
||||||
@@ -160,17 +222,17 @@ class SatelliteRepo(
|
|||||||
return passes
|
return passes
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPass {
|
private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, decayed: Boolean): OrbitalPass {
|
||||||
val satPos = sat.getPosition(pos, time)
|
val satPos = sat.getPosition(pos, time)
|
||||||
val aos = time - 24 * 60L * 60L * 1000L
|
val aos = time - 24 * 60L * 60L * 1000L
|
||||||
val los = time + 24 * 60L * 60L * 1000L // val tca = (aos + los) / 2
|
val los = time + 24 * 60L * 60L * 1000L // val tca = (aos + los) / 2
|
||||||
val az = satPos.azimuth.toDegrees().round(1)
|
val az = satPos.azimuth.toDegrees().round(1)
|
||||||
val elev = satPos.elevation.toDegrees().round(1)
|
val elev = satPos.elevation.toDegrees().round(1)
|
||||||
val alt = satPos.altitude
|
val alt = satPos.altitude
|
||||||
return OrbitalPass(aos, az, los, az, alt.toInt(), elev, sat)
|
return OrbitalPass(aos, az, los, az, alt.toInt(), elev, sat, hasDecayed = decayed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getLeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, rewind: Boolean): OrbitalPass {
|
private fun getLeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, rewind: Boolean, decayed: Boolean): OrbitalPass {
|
||||||
val quarterOrbitMin = (sat.data.orbitalPeriod / 4.0).toInt()
|
val quarterOrbitMin = (sat.data.orbitalPeriod / 4.0).toInt()
|
||||||
var calendarTimeMillis = time
|
var calendarTimeMillis = time
|
||||||
var elevation: Double
|
var elevation: Double
|
||||||
@@ -234,6 +296,6 @@ class SatelliteRepo(
|
|||||||
val alt = tcaPos.altitude
|
val alt = tcaPos.altitude
|
||||||
|
|
||||||
val elev = maxElevation.toDegrees().round(1)
|
val elev = maxElevation.toDegrees().round(1)
|
||||||
return OrbitalPass(aos, aosAz, los, losAz, alt.toInt(), elev, sat)
|
return OrbitalPass(aos, aosAz, los, losAz, alt.toInt(), elev, sat, hasDecayed = decayed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+35
-15
@@ -38,16 +38,16 @@ class SelectionRepo(
|
|||||||
) : ISelectionRepo {
|
) : ISelectionRepo {
|
||||||
|
|
||||||
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
||||||
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
|
|
||||||
private val currentQuery = MutableStateFlow("")
|
private val currentQuery = MutableStateFlow("")
|
||||||
|
|
||||||
// Resolve type IDs once when types change, then filter items reactively.
|
// Resolve sat IDs once when modes change, then filter items reactively.
|
||||||
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
||||||
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
// Directly observe settingsRepo.selectedSatModes to ensure real-time sync across screens.
|
||||||
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
private val itemsWithModes = settingsRepo.selectedSatModes.flatMapLatest { list: List<String> ->
|
||||||
|
val catnumSet: Set<Int>? = if (list.isEmpty()) {
|
||||||
null // null = no filtering
|
null // null = no filtering
|
||||||
} else {
|
} else {
|
||||||
val ids = settingsRepo.getSatelliteTypesIds(types)
|
val ids = localSource.getIdsWithModes(list)
|
||||||
if (ids.isEmpty()) null else ids.toHashSet()
|
if (ids.isEmpty()) null else ids.toHashSet()
|
||||||
}
|
}
|
||||||
currentItems.map { items ->
|
currentItems.map { items ->
|
||||||
@@ -56,14 +56,18 @@ class SelectionRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
||||||
itemsWithTypes.map { items -> filterByQuery(items, query) }
|
itemsWithModes.map { items ->
|
||||||
|
filterByQuery(items, query).sortedWith(
|
||||||
|
compareByDescending<SatItem> { it.isSelected }
|
||||||
|
.thenBy { it.name }
|
||||||
|
.thenBy { it.catnum }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getCurrentTypes() = currentTypes.value
|
override fun getCurrentModes() = settingsRepo.selectedSatModes.value
|
||||||
|
|
||||||
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
|
override fun getModesList() = Sources.satelliteModes
|
||||||
removeAt(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
||||||
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
|
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
|
||||||
@@ -73,9 +77,8 @@ class SelectionRepo(
|
|||||||
return@withContext itemsWithQuery
|
return@withContext itemsWithQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setTypes(types: List<String>) {
|
override suspend fun setModes(modes: List<String>) {
|
||||||
currentTypes.value = types
|
settingsRepo.setSelectedSatModes(modes)
|
||||||
settingsRepo.setSelectedTypes(types)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setQuery(query: String) {
|
override suspend fun setQuery(query: String) {
|
||||||
@@ -111,12 +114,29 @@ class SelectionRepo(
|
|||||||
/**
|
/**
|
||||||
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
|
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
|
||||||
* and lowercases the query once up front instead of per-item.
|
* and lowercases the query once up front instead of per-item.
|
||||||
|
*
|
||||||
|
* Fuzzy search: the query is split into space-separated tokens and every
|
||||||
|
* token must appear in the satellite name after both sides are normalized
|
||||||
|
* (lowercased, non-alphanumeric separators such as dashes, spaces, brackets
|
||||||
|
* and dots stripped). This makes "ao7" match "AO-7 (AMSAT-OSCAR 7)" and
|
||||||
|
* "iss zarya" match "ISS (ZARYA)" — exact continuous-substring matching
|
||||||
|
* previously failed whenever the name contained a separator the query lacked.
|
||||||
*/
|
*/
|
||||||
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
|
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
|
||||||
if (query.isBlank()) return items
|
if (query.isBlank()) return items
|
||||||
val catnum = query.toIntOrNull()
|
val catnum = query.toIntOrNull()
|
||||||
if (catnum != null) return items.filter { it.catnum == catnum }
|
if (catnum != null) return items.filter { it.catnum == catnum }
|
||||||
val lowerQuery = query.lowercase()
|
val tokens = query.split(' ')
|
||||||
return items.filter { it.name.lowercase().contains(lowerQuery) }
|
.map { normalizeForSearch(it) }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
if (tokens.isEmpty()) return items
|
||||||
|
return items.filter { item ->
|
||||||
|
val normalizedName = normalizeForSearch(item.name)
|
||||||
|
tokens.all { normalizedName.contains(it) }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lowercases and strips all non-alphanumeric chars for fuzzy matching. */
|
||||||
|
private fun normalizeForSearch(text: String): String =
|
||||||
|
text.lowercase().filter { it.isLetterOrDigit() }
|
||||||
}
|
}
|
||||||
@@ -22,8 +22,9 @@ import android.hardware.Sensor
|
|||||||
import android.hardware.SensorEvent
|
import android.hardware.SensorEvent
|
||||||
import android.hardware.SensorEventListener
|
import android.hardware.SensorEventListener
|
||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
|
import android.view.Display
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import android.view.WindowManager
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
@@ -31,13 +32,16 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlin.math.round
|
import kotlin.math.round
|
||||||
|
|
||||||
|
private const val SMOOTHING_FACTOR = 0.15f
|
||||||
|
private const val SENSOR_RATE_US = 16_000
|
||||||
|
|
||||||
class SensorsRepo(
|
class SensorsRepo(
|
||||||
private val sensorManager: SensorManager,
|
private val sensorManager: SensorManager,
|
||||||
private val sensor: Sensor?,
|
private val displayManager: DisplayManager?
|
||||||
private val windowManager: WindowManager
|
) : ISensorsRepo, SensorEventListener {
|
||||||
) : SensorEventListener, ISensorsRepo {
|
|
||||||
|
|
||||||
private val _orientation = MutableStateFlow(Pair(0f, 0f))
|
private val _sensorData = MutableStateFlow(Pair(0f, 0f))
|
||||||
|
private val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
||||||
private val rotationMatrix = FloatArray(9)
|
private val rotationMatrix = FloatArray(9)
|
||||||
private val tempMatrix = FloatArray(9)
|
private val tempMatrix = FloatArray(9)
|
||||||
private val orientationValues = FloatArray(3)
|
private val orientationValues = FloatArray(3)
|
||||||
@@ -45,11 +49,7 @@ class SensorsRepo(
|
|||||||
private var smoothPitch = 0f
|
private var smoothPitch = 0f
|
||||||
private var hasInitialReading = false
|
private var hasInitialReading = false
|
||||||
|
|
||||||
companion object {
|
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
|
||||||
private const val SMOOTHING_FACTOR = 0.15f
|
|
||||||
}
|
|
||||||
|
|
||||||
override val orientation: StateFlow<Pair<Float, Float>> = _orientation
|
|
||||||
|
|
||||||
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
||||||
return GeomagneticField(
|
return GeomagneticField(
|
||||||
@@ -62,7 +62,7 @@ class SensorsRepo(
|
|||||||
|
|
||||||
override fun enableSensor() {
|
override fun enableSensor() {
|
||||||
hasInitialReading = false
|
hasInitialReading = false
|
||||||
sensor?.let { sensorManager.registerListener(this, it, 8000) }
|
sensor?.let { sensorManager.registerListener(this, it, SENSOR_RATE_US) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun disableSensor() = sensorManager.unregisterListener(this)
|
override fun disableSensor() = sensorManager.unregisterListener(this)
|
||||||
@@ -70,16 +70,11 @@ class SensorsRepo(
|
|||||||
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
|
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
|
||||||
|
|
||||||
override fun onSensorChanged(event: SensorEvent) {
|
override fun onSensorChanged(event: SensorEvent) {
|
||||||
if (event.sensor == sensor) updateOrientation(event.values)
|
if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) handleSensorEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDisplayRotation(): Int {
|
private fun getDisplayRotation(): Int {
|
||||||
return try {
|
return displayManager?.getDisplay(Display.DEFAULT_DISPLAY)?.rotation ?: Surface.ROTATION_0
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
windowManager.defaultDisplay.rotation
|
|
||||||
} catch (_: Exception) {
|
|
||||||
Surface.ROTATION_0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun remapForRotation(rotation: Int) {
|
private fun remapForRotation(rotation: Int) {
|
||||||
@@ -101,30 +96,23 @@ class SensorsRepo(
|
|||||||
if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
|
if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateOrientation(rotationVector: FloatArray) {
|
private fun handleSensorEvent(event: SensorEvent) {
|
||||||
SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector)
|
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
|
||||||
remapForRotation(getDisplayRotation())
|
remapForRotation(getDisplayRotation())
|
||||||
SensorManager.getOrientation(rotationMatrix, orientationValues)
|
SensorManager.getOrientation(rotationMatrix, orientationValues)
|
||||||
val azimuth = (orientationValues[0] * RAD2DEG).toFloat()
|
val azimuth = normalizeAzimuth((orientationValues[0] * RAD2DEG).toFloat())
|
||||||
val pitch = (orientationValues[1] * RAD2DEG).toFloat()
|
val pitch = (orientationValues[1] * RAD2DEG).toFloat()
|
||||||
val magneticAzimuth = (azimuth + 360f) % 360f
|
|
||||||
|
|
||||||
if (!hasInitialReading) {
|
if (!hasInitialReading) {
|
||||||
smoothAzimuth = magneticAzimuth
|
smoothAzimuth = azimuth
|
||||||
smoothPitch = pitch
|
smoothPitch = pitch
|
||||||
hasInitialReading = true
|
hasInitialReading = true
|
||||||
} else {
|
} else {
|
||||||
smoothAzimuth = lowPassAngle(smoothAzimuth, magneticAzimuth)
|
smoothAzimuth = lowPassAngle(smoothAzimuth, azimuth)
|
||||||
smoothPitch = lowPass(smoothPitch, pitch)
|
smoothPitch = lowPass(smoothPitch, pitch)
|
||||||
}
|
}
|
||||||
|
_sensorData.value = Pair(round(smoothAzimuth * 10) / 10, round(smoothPitch * 10) / 10)
|
||||||
_orientation.value = Pair(
|
|
||||||
round(smoothAzimuth * 10) / 10,
|
|
||||||
round(smoothPitch * 10) / 10
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Standard exponential low-pass filter. */
|
|
||||||
private fun lowPass(previous: Float, current: Float): Float {
|
private fun lowPass(previous: Float, current: Float): Float {
|
||||||
return previous + SMOOTHING_FACTOR * (current - previous)
|
return previous + SMOOTHING_FACTOR * (current - previous)
|
||||||
}
|
}
|
||||||
@@ -135,9 +123,10 @@ class SensorsRepo(
|
|||||||
*/
|
*/
|
||||||
private fun lowPassAngle(previous: Float, current: Float): Float {
|
private fun lowPassAngle(previous: Float, current: Float): Float {
|
||||||
var delta = current - previous
|
var delta = current - previous
|
||||||
// Normalise delta into the range (-180, 180]
|
|
||||||
while (delta > 180f) delta -= 360f
|
while (delta > 180f) delta -= 360f
|
||||||
while (delta <= -180f) delta += 360f
|
while (delta <= -180f) delta += 360f
|
||||||
return (previous + SMOOTHING_FACTOR * delta + 360f) % 360f
|
return normalizeAzimuth(previous + SMOOTHING_FACTOR * delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun normalizeAzimuth(value: Float): Float = (value + 360f) % 360f
|
||||||
}
|
}
|
||||||
+183
-55
@@ -29,19 +29,24 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
|||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||||
import com.rtbishop.look4sat.core.domain.utility.round
|
import com.rtbishop.look4sat.core.domain.utility.round
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
class SettingsRepo(
|
class SettingsRepo(
|
||||||
private val locationManager: LocationManager,
|
private val locationManager: LocationManager,
|
||||||
private val preferences: SharedPreferences,
|
private val preferences: SharedPreferences,
|
||||||
override val appVersionName: String
|
override val appVersionName: String,
|
||||||
|
override val appVersionCode: Long,
|
||||||
) : ISettingsRepo, LocationListenerCompat {
|
) : ISettingsRepo, LocationListenerCompat {
|
||||||
|
|
||||||
private val keyBluetoothRotatorAddress = "bluetoothAddress"
|
private val keyBluetoothRotatorAddress = "bluetoothAddress"
|
||||||
@@ -54,6 +59,9 @@ class SettingsRepo(
|
|||||||
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
|
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
|
||||||
private val keyFilterHoursAhead = "filterHoursAhead"
|
private val keyFilterHoursAhead = "filterHoursAhead"
|
||||||
private val keyFilterMinElevation = "filterMinElevation"
|
private val keyFilterMinElevation = "filterMinElevation"
|
||||||
|
private val keyFilterAosStartMinute = "filterAosStartMinute"
|
||||||
|
private val keyFilterAosEndMinute = "filterAosEndMinute"
|
||||||
|
private val keyFilterAosInvert = "filterAosInvert"
|
||||||
private val keyNumberOfRadios = "numberOfRadios"
|
private val keyNumberOfRadios = "numberOfRadios"
|
||||||
private val keyNumberOfSatellites = "numberOfSatellites"
|
private val keyNumberOfSatellites = "numberOfSatellites"
|
||||||
private val keyRotatorAddress = "rotatorAddress"
|
private val keyRotatorAddress = "rotatorAddress"
|
||||||
@@ -64,14 +72,15 @@ class SettingsRepo(
|
|||||||
private val keyFrequencyAddress = "frequencyAddress"
|
private val keyFrequencyAddress = "frequencyAddress"
|
||||||
private val keyFrequencyPort = "frequencyPort"
|
private val keyFrequencyPort = "frequencyPort"
|
||||||
private val keyFrequencyFormat = "frequencyFormat"
|
private val keyFrequencyFormat = "frequencyFormat"
|
||||||
|
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
|
||||||
private val keySelectedIds = "selectedIds"
|
private val keySelectedIds = "selectedIds"
|
||||||
private val keySelectedTypes = "selectedTypes"
|
private val keySelectedSatModes = "selectedSatModes"
|
||||||
private val keySelectedModes = "selectedModes"
|
|
||||||
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
||||||
private val keyStateOfSensors = "stateOfSensors"
|
private val keyStateOfSensors = "stateOfSensors"
|
||||||
private val keyStateOfSweep = "stateOfSweep"
|
private val keyStateOfSweep = "stateOfSweep"
|
||||||
private val keyStateOfUtc = "stateOfUtc"
|
private val keyStateOfUtc = "stateOfUtc"
|
||||||
private val keyStateOfLightTheme = "stateOfLightTheme"
|
private val keyStateOfLightTheme = "stateOfLightTheme"
|
||||||
|
private val keyStateOfNightMode = "stateOfNightMode"
|
||||||
private val keyStationAltitude = "stationAltitude"
|
private val keyStationAltitude = "stationAltitude"
|
||||||
private val keyStationLatitude = "stationLatitude"
|
private val keyStationLatitude = "stationLatitude"
|
||||||
private val keyStationLongitude = "stationLongitude"
|
private val keyStationLongitude = "stationLongitude"
|
||||||
@@ -79,18 +88,38 @@ class SettingsRepo(
|
|||||||
private val keyStationTimestamp = "stationTimestamp"
|
private val keyStationTimestamp = "stationTimestamp"
|
||||||
private val keyUpdateTimestamp = "updateTimestamp"
|
private val keyUpdateTimestamp = "updateTimestamp"
|
||||||
private val keyShouldSeeWarning = "shouldSeeWarning"
|
private val keyShouldSeeWarning = "shouldSeeWarning"
|
||||||
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew"
|
||||||
private val keyUseCustomTle = "useCustomTle"
|
private val keySstvMode = "sstvMode"
|
||||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
private val keyLowElevation = "lowElevation"
|
||||||
private val keyTleUrl = "tleUrl"
|
private val keyHighElevation = "highElevation"
|
||||||
private val keyTransceiversUrl = "transceiversUrl"
|
private val keyRadarCompassOffset = "radarCompassOffset"
|
||||||
|
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
|
||||||
|
private val keySatellitesUrls = "satellitesUrls"
|
||||||
|
private val keyTransceiversUrls = "transceiversUrls"
|
||||||
|
private val keySatellitesUrlsEnabled = "satellitesUrlsEnabled"
|
||||||
|
private val keyTransceiversUrlsEnabled = "transceiversUrlsEnabled"
|
||||||
|
private val keySettingsVersion = "settingsVersion"
|
||||||
private val separatorComma = ","
|
private val separatorComma = ","
|
||||||
|
private val separatorUrl = "\n"
|
||||||
|
|
||||||
|
init {
|
||||||
|
// The meaning of the data source order changed, so the stored lists are dropped and the defaults
|
||||||
|
// are applied again. The update timestamp is cleared as well, to refresh the migrated data
|
||||||
|
// on the first launch instead of waiting up to 48 hours for the automatic update.
|
||||||
|
if (preferences.getInt(keySettingsVersion, 0) < appVersionCode) {
|
||||||
|
preferences.edit {
|
||||||
|
listOf(keyShouldSeeWhatsNew, keySatellitesUrls, keyTransceiversUrls, keyUpdateTimestamp)
|
||||||
|
.forEach { key -> remove(key) }
|
||||||
|
putInt(keySettingsVersion, appVersionCode.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//region # Satellites selection settings
|
//region # Satellites selection settings
|
||||||
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
||||||
private val _typesSelection = MutableStateFlow(getSelectedTypes())
|
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
|
||||||
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
|
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
|
||||||
override val selectedTypes: StateFlow<List<String>> = _typesSelection
|
override val selectedSatModes: StateFlow<List<String>> = _satelliteModeSelection
|
||||||
|
|
||||||
override fun setSelectedIds(ids: List<Int>) {
|
override fun setSelectedIds(ids: List<Int>) {
|
||||||
val selectionString = ids.joinToString(separatorComma)
|
val selectionString = ids.joinToString(separatorComma)
|
||||||
@@ -98,10 +127,10 @@ class SettingsRepo(
|
|||||||
_satelliteSelection.value = ids
|
_satelliteSelection.value = ids
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setSelectedTypes(types: List<String>) {
|
override fun setSelectedSatModes(modes: List<String>) {
|
||||||
val typesString = types.joinToString(separatorComma)
|
val modesString = modes.joinToString(separatorComma)
|
||||||
preferences.edit { putString(keySelectedTypes, typesString) }
|
preferences.edit { putString(keySelectedSatModes, modesString) }
|
||||||
_typesSelection.value = types
|
_satelliteModeSelection.value = modes
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSelectedIds(): List<Int> {
|
private fun getSelectedIds(): List<Int> {
|
||||||
@@ -110,10 +139,10 @@ class SettingsRepo(
|
|||||||
return selectionString.split(separatorComma).map { it.toInt() }
|
return selectionString.split(separatorComma).map { it.toInt() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSelectedTypes(): List<String> {
|
private fun getSelectedSatModes(): List<String> {
|
||||||
val typesString = preferences.getString(keySelectedTypes, null)
|
val modesString = preferences.getString(keySelectedSatModes, null)
|
||||||
if (typesString.isNullOrEmpty()) return listOf("Amateur")
|
if (modesString.isNullOrEmpty()) return emptyList()
|
||||||
return typesString.split(separatorComma)
|
return modesString.split(separatorComma).sorted()
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -125,7 +154,9 @@ class SettingsRepo(
|
|||||||
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
|
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
|
||||||
putInt(keyFilterHoursAhead, settings.hoursAhead)
|
putInt(keyFilterHoursAhead, settings.hoursAhead)
|
||||||
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
|
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
|
||||||
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
|
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
||||||
|
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
||||||
|
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
||||||
_passesSettings.value = settings
|
_passesSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,9 +164,17 @@ class SettingsRepo(
|
|||||||
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
|
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
|
||||||
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
|
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
|
||||||
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
|
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
|
||||||
val selectedModesString = preferences.getString(keySelectedModes, null)
|
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
||||||
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
|
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
||||||
return PassesSettings(showDeepSpace, hoursAhead, minElevation, selectedModes)
|
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
|
||||||
|
return PassesSettings(
|
||||||
|
showDeepSpace,
|
||||||
|
hoursAhead,
|
||||||
|
minElevation,
|
||||||
|
aosStartMinute,
|
||||||
|
aosEndMinute,
|
||||||
|
invertAosTimeWindow
|
||||||
|
)
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -214,25 +253,6 @@ class SettingsRepo(
|
|||||||
private val _databaseState = MutableStateFlow(getDatabaseState())
|
private val _databaseState = MutableStateFlow(getDatabaseState())
|
||||||
override val databaseState: StateFlow<DatabaseState> = _databaseState
|
override val databaseState: StateFlow<DatabaseState> = _databaseState
|
||||||
|
|
||||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> {
|
|
||||||
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 {
|
override fun updateDatabaseState(state: DatabaseState) = preferences.edit {
|
||||||
putInt(keyNumberOfSatellites, state.numberOfSatellites)
|
putInt(keyNumberOfSatellites, state.numberOfSatellites)
|
||||||
@@ -277,6 +297,10 @@ class SettingsRepo(
|
|||||||
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
||||||
|
|
||||||
override fun updateRCSettings(settings: RCSettings) {
|
override fun updateRCSettings(settings: RCSettings) {
|
||||||
|
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
|
||||||
|
Constants.FREQ_OFFSET_MIN_HZ,
|
||||||
|
Constants.FREQ_OFFSET_MAX_HZ
|
||||||
|
)
|
||||||
preferences.edit {
|
preferences.edit {
|
||||||
putBoolean(keyRotatorState, settings.rotatorState)
|
putBoolean(keyRotatorState, settings.rotatorState)
|
||||||
putString(keyRotatorAddress, settings.rotatorAddress)
|
putString(keyRotatorAddress, settings.rotatorAddress)
|
||||||
@@ -286,6 +310,7 @@ class SettingsRepo(
|
|||||||
putString(keyFrequencyAddress, settings.frequencyAddress)
|
putString(keyFrequencyAddress, settings.frequencyAddress)
|
||||||
putString(keyFrequencyPort, settings.frequencyPort)
|
putString(keyFrequencyPort, settings.frequencyPort)
|
||||||
putString(keyFrequencyFormat, settings.frequencyFormat)
|
putString(keyFrequencyFormat, settings.frequencyFormat)
|
||||||
|
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
|
||||||
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
||||||
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
||||||
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
||||||
@@ -294,7 +319,7 @@ class SettingsRepo(
|
|||||||
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
||||||
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
||||||
}
|
}
|
||||||
_rcSettings.value = settings
|
_rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRCSettings(): RCSettings = RCSettings(
|
private fun getRCSettings(): RCSettings = RCSettings(
|
||||||
@@ -306,6 +331,8 @@ class SettingsRepo(
|
|||||||
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
|
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
|
||||||
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
|
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
|
||||||
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
|
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
|
||||||
|
frequencyOffsetHz = preferences.getLong(keyFrequencyOffsetHz, 0L)
|
||||||
|
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ),
|
||||||
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
|
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
|
||||||
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
||||||
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
||||||
@@ -329,8 +356,14 @@ class SettingsRepo(
|
|||||||
putBoolean(keyStateOfSweep, new.stateOfSweep)
|
putBoolean(keyStateOfSweep, new.stateOfSweep)
|
||||||
putBoolean(keyStateOfUtc, new.stateOfUtc)
|
putBoolean(keyStateOfUtc, new.stateOfUtc)
|
||||||
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
|
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
|
||||||
|
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
|
||||||
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
||||||
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
||||||
|
putString(keySstvMode, new.sstvMode)
|
||||||
|
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
||||||
|
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||||
|
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
|
||||||
|
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
|
||||||
}
|
}
|
||||||
new
|
new
|
||||||
}
|
}
|
||||||
@@ -342,8 +375,14 @@ class SettingsRepo(
|
|||||||
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
|
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
|
||||||
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
|
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
|
||||||
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
|
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
|
||||||
|
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
||||||
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
||||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true)
|
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||||
|
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||||
|
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
||||||
|
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
|
||||||
|
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
|
||||||
|
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f)
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -352,21 +391,73 @@ class SettingsRepo(
|
|||||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
|
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
|
||||||
|
|
||||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
|
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
|
||||||
|
// Normalize the enabled lists so they are positionally aligned with the URL lists.
|
||||||
|
// Missing entries default to enabled (true), keeping the persisted "one flag per URL"
|
||||||
|
// invariant intact even when a default empty list is used to construct the model.
|
||||||
|
val normalized = settings.copy(
|
||||||
|
satelliteEnabled = alignFlags(settings.satelliteUrls, settings.satelliteEnabled),
|
||||||
|
transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled)
|
||||||
|
)
|
||||||
preferences.edit {
|
preferences.edit {
|
||||||
putBoolean(keyUseCustomTle, settings.useCustomTLE)
|
putString(keySatellitesUrls, normalized.satelliteUrls.joinToString(separatorUrl))
|
||||||
putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers)
|
putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
|
||||||
putString(keyTleUrl, settings.tleUrl)
|
putString(keySatellitesUrlsEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
|
||||||
putString(keyTransceiversUrl, settings.transceiversUrl)
|
putString(keyTransceiversUrlsEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
|
||||||
}
|
}
|
||||||
_dataSourcesSettings.value = settings
|
_dataSourcesSettings.value = normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
private fun getDataSourcesSettings(): DataSourcesSettings {
|
||||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
|
val (satUrls, satEnabled) = parseSources(
|
||||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
|
preferences.getString(keySatellitesUrls, null),
|
||||||
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
|
preferences.getString(keySatellitesUrlsEnabled, null),
|
||||||
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
|
Sources.satelliteDataUrls
|
||||||
)
|
)
|
||||||
|
val (txUrls, txEnabled) = parseSources(
|
||||||
|
preferences.getString(keyTransceiversUrls, null),
|
||||||
|
preferences.getString(keyTransceiversUrlsEnabled, null),
|
||||||
|
Sources.transceiversDataUrls
|
||||||
|
)
|
||||||
|
return DataSourcesSettings(
|
||||||
|
satelliteUrls = satUrls,
|
||||||
|
transceiversUrls = txUrls,
|
||||||
|
satelliteEnabled = satEnabled,
|
||||||
|
transceiversEnabled = txEnabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSources(
|
||||||
|
storedUrls: String?,
|
||||||
|
storedEnabled: String?,
|
||||||
|
defaults: List<String>
|
||||||
|
): Pair<List<String>, List<Boolean>> {
|
||||||
|
if (storedUrls == null) return defaults to defaults.map { true }
|
||||||
|
val urls = storedUrls.split(separatorUrl)
|
||||||
|
val flags = if (storedEnabled.isNullOrEmpty()) emptyList() else storedEnabled.split(separatorComma)
|
||||||
|
val filteredUrls = mutableListOf<String>()
|
||||||
|
val filteredFlags = mutableListOf<Boolean>()
|
||||||
|
urls.forEachIndexed { index, url ->
|
||||||
|
if (url.isNotBlank()) {
|
||||||
|
filteredUrls.add(url)
|
||||||
|
filteredFlags.add(flags.getOrNull(index)?.toBoolean() ?: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filteredUrls to filteredFlags
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun alignFlags(urls: List<String>, flags: List<Boolean>): List<Boolean> {
|
||||||
|
if (flags.size >= urls.size) return flags.take(urls.size)
|
||||||
|
return flags + List(urls.size - flags.size) { true }
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # Data sources status
|
||||||
|
private val _dataSourcesStatus = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||||
|
override val dataSourcesStatus: StateFlow<Map<String, Int>> = _dataSourcesStatus
|
||||||
|
|
||||||
|
override fun updateDataSourcesStatus(status: Map<String, Int>) {
|
||||||
|
_dataSourcesStatus.value = status
|
||||||
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Radio control settings
|
//region # Radio control settings
|
||||||
@@ -377,6 +468,7 @@ class SettingsRepo(
|
|||||||
private val keyTxRadioName = "txRadioName"
|
private val keyTxRadioName = "txRadioName"
|
||||||
private val keyRxRadioName = "rxRadioName"
|
private val keyRxRadioName = "rxRadioName"
|
||||||
private val keyRadioBaudRate = "radioBaudRate"
|
private val keyRadioBaudRate = "radioBaudRate"
|
||||||
|
private val keyRadioSplitMode = "radioSplitMode"
|
||||||
|
|
||||||
private val _radioControlSettings = MutableStateFlow(getRadioControlSettings())
|
private val _radioControlSettings = MutableStateFlow(getRadioControlSettings())
|
||||||
override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings
|
override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings
|
||||||
@@ -390,18 +482,54 @@ class SettingsRepo(
|
|||||||
putString(keyTxRadioName, settings.txRadioName)
|
putString(keyTxRadioName, settings.txRadioName)
|
||||||
putString(keyRxRadioName, settings.rxRadioName)
|
putString(keyRxRadioName, settings.rxRadioName)
|
||||||
putInt(keyRadioBaudRate, settings.baudRate)
|
putInt(keyRadioBaudRate, settings.baudRate)
|
||||||
|
putBoolean(keyRadioSplitMode, settings.splitMode)
|
||||||
}
|
}
|
||||||
_radioControlSettings.value = settings
|
_radioControlSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings(
|
private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings(
|
||||||
enabled = preferences.getBoolean(keyRadioControlEnabled, false),
|
enabled = preferences.getBoolean(keyRadioControlEnabled, false),
|
||||||
radioModel = preferences.getString(keyRadioModel, null) ?: "Yaesu FT-817/818",
|
radioModel = preferences.getString(keyRadioModel, null) ?: RadioControlSettings.MODEL_YAESU_FT817,
|
||||||
txRadioAddress = preferences.getString(keyTxRadioAddress, null) ?: "",
|
txRadioAddress = preferences.getString(keyTxRadioAddress, null) ?: "",
|
||||||
rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "",
|
rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "",
|
||||||
txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio",
|
txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio",
|
||||||
rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio",
|
rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio",
|
||||||
baudRate = preferences.getInt(keyRadioBaudRate, 4800)
|
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
|
||||||
|
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val keySatelliteOffsets = "satelliteOffsets"
|
||||||
|
|
||||||
|
override fun getSatelliteOffset(catnum: Int): String {
|
||||||
|
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||||
|
return try {
|
||||||
|
JSONObject(json).optString(catnum.toString(), "")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setSatelliteOffset(catnum: Int, offset: String) {
|
||||||
|
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||||
|
val updated = try {
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
|
||||||
|
obj.toString()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
"""{"$catnum": "$offset"}"""
|
||||||
|
}
|
||||||
|
preferences.edit { putString(keySatelliteOffsets, updated) }
|
||||||
|
}
|
||||||
|
|
||||||
|
//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
|
//endregion
|
||||||
}
|
}
|
||||||
@@ -43,6 +43,16 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
return selectedOrbitalObjects
|
return selectedOrbitalObjects
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getEntriesEpochs() = look4SatDao.getEntriesEpochs()
|
||||||
|
|
||||||
|
override suspend fun getEntriesNames() = look4SatDao.getEntriesNames()
|
||||||
|
|
||||||
|
override suspend fun renameEntries(names: Map<Int, String>) =
|
||||||
|
names.forEach { (catnum, name) -> look4SatDao.renameEntry(catnum, name) }
|
||||||
|
|
||||||
|
override suspend fun deleteEntriesWithIds(ids: List<Int>) =
|
||||||
|
ids.chunked(999).forEach { idsPart -> look4SatDao.deleteEntriesWithIds(idsPart) }
|
||||||
|
|
||||||
override suspend fun insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity())
|
override suspend fun insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity())
|
||||||
|
|
||||||
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
|
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
|
||||||
@@ -51,12 +61,12 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
|
|
||||||
private fun FrameworkEntry.toDomain() = OrbitalData(
|
private fun FrameworkEntry.toDomain() = OrbitalData(
|
||||||
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
||||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar
|
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun OrbitalData.toEntity() = FrameworkEntry(
|
private fun OrbitalData.toEntity() = FrameworkEntry(
|
||||||
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
||||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar
|
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() }
|
private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() }
|
||||||
@@ -73,16 +83,17 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
return look4SatDao.getRadiosWithId(id).toDomainRadios()
|
return look4SatDao.getRadiosWithId(id).toDomainRadios()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
|
||||||
look4SatDao.deleteRadios()
|
look4SatDao.insertRadios(radios.map { radio -> radio.toFramework(isCustom) })
|
||||||
look4SatDao.insertRadios(radios.toFrameworkRadios())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteRadios() = look4SatDao.deleteRadios()
|
override suspend fun deleteRadios() = look4SatDao.deleteRadios()
|
||||||
|
|
||||||
private fun DomainRadio.toFramework() = FrameworkRadio(
|
override suspend fun deleteManagedRadios() = look4SatDao.deleteManagedRadios()
|
||||||
|
|
||||||
|
private fun DomainRadio.toFramework(isCustom: Boolean = false) = FrameworkRadio(
|
||||||
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
||||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
|
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, isCustom
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun FrameworkRadio.toDomain() = DomainRadio(
|
private fun FrameworkRadio.toDomain() = DomainRadio(
|
||||||
@@ -90,8 +101,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
|
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() }
|
private fun List<FrameworkRadio>.toDomainRadios() = this.map { radio -> radio.toDomain() }
|
||||||
|
|
||||||
//endregion
|
//endregion
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ package com.rtbishop.look4sat.core.data.source
|
|||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
class RemoteSource(
|
class RemoteSource(
|
||||||
@@ -42,12 +45,74 @@ class RemoteSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
override suspend fun getNetworkStream(url: String): NetworkResult = withContext(dispatcher) {
|
||||||
try {
|
try {
|
||||||
val networkRequest = Request.Builder().url(url).build()
|
val networkRequest = Request.Builder().url(url).build()
|
||||||
httpClient.newCall(networkRequest).execute().body.byteStream()
|
val response = httpClient.newCall(networkRequest).execute()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val code = response.code
|
||||||
|
response.close()
|
||||||
|
return@withContext NetworkResult(code, null)
|
||||||
|
}
|
||||||
|
// Return the body stream directly as the caller is responsible for closing it
|
||||||
|
// That returns the connection to OkHttp's pool
|
||||||
|
val body = response.body
|
||||||
|
if (body == null) {
|
||||||
|
response.close()
|
||||||
|
return@withContext NetworkResult(response.code, null)
|
||||||
|
}
|
||||||
|
NetworkResult(response.code, body.byteStream().buffered())
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
println("RemoteSource network stream exception: $exception")
|
println("RemoteSource network stream exception: $exception")
|
||||||
|
NetworkResult(NetworkResult.CONNECTION_ERROR, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAmSatCatalog(): String? = withContext(dispatcher) {
|
||||||
|
try {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://www.amsat.org/status/api/v1/catalog.php")
|
||||||
|
.header("User-Agent", "Look4Sat")
|
||||||
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@use null
|
||||||
|
response.body.string()
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
println("RemoteSource getAmSatCatalog exception: $exception")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = withContext(dispatcher) {
|
||||||
|
try {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit")
|
||||||
|
.header("User-Agent", "Look4Sat")
|
||||||
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@use null
|
||||||
|
response.body.string()
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
println("RemoteSource getAmSatReports exception: $exception")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = withContext(dispatcher) {
|
||||||
|
try {
|
||||||
|
val body = payloadJson.toRequestBody("application/json; charset=utf-8".toMediaType())
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://www.amsat.org/status/api/v1/reports.php")
|
||||||
|
.header("User-Agent", "Look4Sat")
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
response.code to response.body.string()
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
println("RemoteSource submitAmSatReport exception: $exception")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-19
@@ -1,30 +1,29 @@
|
|||||||
package com.rtbishop.look4sat.core.data
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.data.framework.Ft817CatProtocol
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import kotlin.test.assertContentEquals
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertNotNull
|
|
||||||
import kotlin.test.assertNull
|
|
||||||
|
|
||||||
class Ft817CatProtocolTest {
|
class Ft817CatProtocolTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeFrequencyBcd_145500000() {
|
fun encodeFrequencyBcd_145500000() {
|
||||||
val bcd = Ft817CatProtocol.encodeFrequencyBcd(145500000L)
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(145500000L)
|
||||||
assertContentEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00), bcd)
|
assertArrayEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeFrequencyBcd_435100000() {
|
fun encodeFrequencyBcd_435100000() {
|
||||||
val bcd = Ft817CatProtocol.encodeFrequencyBcd(435100000L)
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(435100000L)
|
||||||
assertContentEquals(byteArrayOf(0x43, 0x51, 0x00, 0x00), bcd)
|
assertArrayEquals(byteArrayOf(0x43, 0x51, 0x00, 0x00), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeFrequencyBcd_7074000() {
|
fun encodeFrequencyBcd_7074000() {
|
||||||
val bcd = Ft817CatProtocol.encodeFrequencyBcd(7074000L)
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(7074000L)
|
||||||
assertContentEquals(byteArrayOf(0x00, 0x70, 0x74, 0x00), bcd)
|
assertArrayEquals(byteArrayOf(0x00, 0x70, 0x74, 0x00), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -42,21 +41,21 @@ class Ft817CatProtocolTest {
|
|||||||
val cmd = Ft817CatProtocol.buildSetFreqCommand(145500000L)
|
val cmd = Ft817CatProtocol.buildSetFreqCommand(145500000L)
|
||||||
assertEquals(5, cmd.size)
|
assertEquals(5, cmd.size)
|
||||||
assertEquals(0x01.toByte(), cmd[4])
|
assertEquals(0x01.toByte(), cmd[4])
|
||||||
assertContentEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01), cmd)
|
assertArrayEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun buildSetModeCommand_usb() {
|
fun buildSetModeCommand_usb() {
|
||||||
val cmd = Ft817CatProtocol.buildSetModeCommand("USB")
|
val cmd = Ft817CatProtocol.buildSetModeCommand("USB")
|
||||||
assertNotNull(cmd)
|
assertNotNull(cmd)
|
||||||
assertContentEquals(byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x07), cmd)
|
assertArrayEquals(byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x07), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun buildSetModeCommand_fm() {
|
fun buildSetModeCommand_fm() {
|
||||||
val cmd = Ft817CatProtocol.buildSetModeCommand("FM")
|
val cmd = Ft817CatProtocol.buildSetModeCommand("FM")
|
||||||
assertNotNull(cmd)
|
assertNotNull(cmd)
|
||||||
assertContentEquals(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x07), cmd)
|
assertArrayEquals(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x07), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -67,19 +66,19 @@ class Ft817CatProtocolTest {
|
|||||||
@Test
|
@Test
|
||||||
fun encodeCtcssTone_67_0() {
|
fun encodeCtcssTone_67_0() {
|
||||||
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(67.0)
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(67.0)
|
||||||
assertContentEquals(byteArrayOf(0x06, 0x70), bcd)
|
assertArrayEquals(byteArrayOf(0x06, 0x70), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeCtcssTone_74_4() {
|
fun encodeCtcssTone_74_4() {
|
||||||
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(74.4)
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(74.4)
|
||||||
assertContentEquals(byteArrayOf(0x07, 0x44), bcd)
|
assertArrayEquals(byteArrayOf(0x07, 0x44), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeCtcssTone_141_3() {
|
fun encodeCtcssTone_141_3() {
|
||||||
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(141.3)
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(141.3)
|
||||||
assertContentEquals(byteArrayOf(0x14, 0x13), bcd)
|
assertArrayEquals(byteArrayOf(0x14, 0x13), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -87,13 +86,13 @@ class Ft817CatProtocolTest {
|
|||||||
val cmd = Ft817CatProtocol.buildSetCtcssToneCommand(67.0)
|
val cmd = Ft817CatProtocol.buildSetCtcssToneCommand(67.0)
|
||||||
assertEquals(5, cmd.size)
|
assertEquals(5, cmd.size)
|
||||||
assertEquals(0x0B.toByte(), cmd[4])
|
assertEquals(0x0B.toByte(), cmd[4])
|
||||||
assertContentEquals(byteArrayOf(0x06, 0x70, 0x00, 0x00, 0x0B), cmd)
|
assertArrayEquals(byteArrayOf(0x06, 0x70, 0x00, 0x00, 0x0B), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun buildCtcssModeCommand_enable() {
|
fun buildCtcssModeCommand_enable() {
|
||||||
val cmd = Ft817CatProtocol.buildCtcssModeCommand(true)
|
val cmd = Ft817CatProtocol.buildCtcssModeCommand(true)
|
||||||
assertContentEquals(byteArrayOf(0x2A, 0x00, 0x00, 0x00, 0x0A), cmd)
|
assertArrayEquals(byteArrayOf(0x2A, 0x00, 0x00, 0x00, 0x0A), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -108,6 +107,7 @@ class Ft817CatProtocolTest {
|
|||||||
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01)
|
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01)
|
||||||
val result = Ft817CatProtocol.parseReadResponse(response)
|
val result = Ft817CatProtocol.parseReadResponse(response)
|
||||||
assertNotNull(result)
|
assertNotNull(result)
|
||||||
|
result ?: return
|
||||||
assertEquals(145500000L, result.first)
|
assertEquals(145500000L, result.first)
|
||||||
assertEquals("USB", result.second)
|
assertEquals("USB", result.second)
|
||||||
}
|
}
|
||||||
@@ -117,6 +117,7 @@ class Ft817CatProtocolTest {
|
|||||||
val response = byteArrayOf(0x14, 0x60, 0x00, 0x00, 0x08)
|
val response = byteArrayOf(0x14, 0x60, 0x00, 0x00, 0x08)
|
||||||
val result = Ft817CatProtocol.parseReadResponse(response)
|
val result = Ft817CatProtocol.parseReadResponse(response)
|
||||||
assertNotNull(result)
|
assertNotNull(result)
|
||||||
|
result ?: return
|
||||||
assertEquals(146000000L, result.first)
|
assertEquals(146000000L, result.first)
|
||||||
assertEquals("FM", result.second)
|
assertEquals("FM", result.second)
|
||||||
}
|
}
|
||||||
@@ -135,7 +136,7 @@ class Ft817CatProtocolTest {
|
|||||||
@Test
|
@Test
|
||||||
fun buildPttCommands() {
|
fun buildPttCommands() {
|
||||||
val on = Ft817CatProtocol.buildPttOnCommand()
|
val on = Ft817CatProtocol.buildPttOnCommand()
|
||||||
assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x08), on)
|
assertArrayEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x08), on)
|
||||||
|
|
||||||
val off = Ft817CatProtocol.buildPttOffCommand()
|
val off = Ft817CatProtocol.buildPttOffCommand()
|
||||||
assertEquals(0x88.toByte(), off[4])
|
assertEquals(0x88.toByte(), off[4])
|
||||||
@@ -144,6 +145,6 @@ class Ft817CatProtocolTest {
|
|||||||
@Test
|
@Test
|
||||||
fun buildReadCommand() {
|
fun buildReadCommand() {
|
||||||
val cmd = Ft817CatProtocol.buildReadFreqModeCommand()
|
val cmd = Ft817CatProtocol.buildReadFreqModeCommand()
|
||||||
assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x03), cmd)
|
assertArrayEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x03), cmd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class IcomCivProtocolTest {
|
||||||
|
|
||||||
|
private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseFreqModePayload_ic705ReadFreqReply() {
|
||||||
|
// Reply captured from an IC-705 to CMD 0x03: frequency only, no mode byte
|
||||||
|
val reply = bytes(0xFE, 0xFE, 0xE0, 0xA4, 0x03, 0x60, 0x74, 0x95, 0x45, 0x01, 0xFD)
|
||||||
|
val response = IcomCivProtocol.parseResponse(reply, IcomCivProtocol.CMD_READ_FREQ)
|
||||||
|
assertNotNull(response)
|
||||||
|
assertEquals(145957460L to "", IcomCivProtocol.parseFreqModePayload(response!!.payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseFreqModePayload_withModeByte() {
|
||||||
|
assertEquals(
|
||||||
|
435611000L to "USB",
|
||||||
|
IcomCivProtocol.parseFreqModePayload(bytes(0x00, 0x10, 0x61, 0x35, 0x04, 0x01, 0x01))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseFreqModePayload_tooShort() {
|
||||||
|
assertNull(IcomCivProtocol.parseFreqModePayload(bytes(0x60, 0x74, 0x95, 0x45)))
|
||||||
|
}
|
||||||
|
}
|
||||||
+433
@@ -0,0 +1,433 @@
|
|||||||
|
/*
|
||||||
|
* 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.source.NetworkResult
|
||||||
|
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
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class DatabaseRepoTest {
|
||||||
|
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
private val dataParser = DataParser(dispatcher)
|
||||||
|
|
||||||
|
// fixtures carry a current epoch: stale entries are pruned on every remote update
|
||||||
|
private val todayEpoch = LocalDate.now().let { "%02d%03d".format(it.year % 100, it.dayOfYear) }
|
||||||
|
private val fresherEpoch = "$todayEpoch.71955234".toDouble()
|
||||||
|
|
||||||
|
@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)
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `remote update takes the freshest elements and the preferred name`() = runTest(dispatcher) {
|
||||||
|
val primaryUrl = "https://example.com/primary.txt"
|
||||||
|
val secondaryUrl = "https://example.com/secondary.txt"
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
networkStreams[primaryUrl] = { validTleStream() }
|
||||||
|
networkStreams[secondaryUrl] = { fresherTleStream() }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo(
|
||||||
|
dataSources = DataSourcesSettings(
|
||||||
|
satelliteUrls = listOf(primaryUrl, secondaryUrl),
|
||||||
|
transceiversUrls = emptyList()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
|
||||||
|
repository.updateFromRemote()
|
||||||
|
|
||||||
|
val entry = localSource.insertedEntries.single { it.catnum == 25544 }
|
||||||
|
assertEquals("ISS (ZARYA)", entry.name)
|
||||||
|
assertEquals(fresherEpoch, entry.epoch, 1e-8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `manual satellite import does not overwrite fresher data`() = runTest(dispatcher) {
|
||||||
|
val freshUri = "content://look4sat/import/fresh"
|
||||||
|
val staleUri = "content://look4sat/import/stale"
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
fileStreams[freshUri] = { fresherTleStream() }
|
||||||
|
fileStreams[staleUri] = { validTleStream() }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo()
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
|
||||||
|
assertEquals(1, repository.updateTLEFromFile(freshUri))
|
||||||
|
assertEquals(1, repository.updateTLEFromFile(staleUri))
|
||||||
|
assertEquals(fresherEpoch, localSource.insertedEntries.single().epoch, 1e-8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `remote update drops transceivers that are no longer published`() = runTest(dispatcher) {
|
||||||
|
val radioUrl = "https://example.com/transmitters.json"
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource()
|
||||||
|
val settingsRepo = FakeSettingsRepo(
|
||||||
|
dataSources = DataSourcesSettings(
|
||||||
|
satelliteUrls = emptyList(),
|
||||||
|
transceiversUrls = listOf(radioUrl)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
|
||||||
|
remoteSource.networkStreams[radioUrl] = { twoRadiosStream() }
|
||||||
|
repository.updateFromRemote()
|
||||||
|
assertEquals(2, localSource.insertedRadios.size)
|
||||||
|
|
||||||
|
remoteSource.networkStreams[radioUrl] = { singleRadioStream() }
|
||||||
|
repository.updateFromRemote()
|
||||||
|
assertEquals(listOf("uuid-alive"), localSource.insertedRadios.map { it.uuid })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `remote update renames satellites even when their elements are not newer`() = runTest(dispatcher) {
|
||||||
|
val preferredUrl = "https://example.com/preferred.txt"
|
||||||
|
val freshUrl = "https://example.com/fresh.txt"
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
networkStreams[freshUrl] = { fresherTleStream() }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo(
|
||||||
|
dataSources = DataSourcesSettings(
|
||||||
|
satelliteUrls = listOf(freshUrl),
|
||||||
|
transceiversUrls = emptyList()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
repository.updateFromRemote()
|
||||||
|
assertEquals("ISS", localSource.insertedEntries.single().name)
|
||||||
|
|
||||||
|
// the user moves a source with older elements, but a better name, to the top of the list
|
||||||
|
remoteSource.networkStreams[preferredUrl] = { validTleStream() }
|
||||||
|
settingsRepo.dataSourcesSettings.value = DataSourcesSettings(
|
||||||
|
satelliteUrls = listOf(preferredUrl, freshUrl),
|
||||||
|
transceiversUrls = emptyList()
|
||||||
|
)
|
||||||
|
repository.updateFromRemote()
|
||||||
|
|
||||||
|
val entry = localSource.insertedEntries.single()
|
||||||
|
assertEquals("ISS (ZARYA)", entry.name)
|
||||||
|
assertEquals(fresherEpoch, entry.epoch, 1e-8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `failed remote update records the attempt without claiming success`() = runTest(dispatcher) {
|
||||||
|
val failingUrl = "https://example.com/offline.txt"
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource()
|
||||||
|
val settingsRepo = FakeSettingsRepo(
|
||||||
|
dataSources = DataSourcesSettings(
|
||||||
|
satelliteUrls = listOf(failingUrl),
|
||||||
|
transceiversUrls = emptyList()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
settingsRepo.databaseState.value = DatabaseState(0, 0, 1_000L)
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
repository.updateFromRemote()
|
||||||
|
|
||||||
|
// the update timestamp stays put so the data is not presented as fresh, while the recorded
|
||||||
|
// attempt keeps the next app launch from hammering a source that is already failing
|
||||||
|
assertEquals(1_000L, settingsRepo.databaseState.value.updateTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `remote update keeps the transceivers imported from a file`() = runTest(dispatcher) {
|
||||||
|
val radioUri = "content://imported.json"
|
||||||
|
val radioUrl = "https://example.com/transmitters.json"
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
fileStreams[radioUri] = { customRadioStream() }
|
||||||
|
networkStreams[radioUrl] = { singleRadioStream() }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo(
|
||||||
|
dataSources = DataSourcesSettings(
|
||||||
|
satelliteUrls = emptyList(),
|
||||||
|
transceiversUrls = listOf(radioUrl)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
repository.updateTransceiversFromFile(radioUri)
|
||||||
|
repository.updateFromRemote()
|
||||||
|
|
||||||
|
// the remote snapshot replaces what the sources publish, the imported entry stays put
|
||||||
|
assertEquals(setOf("uuid-custom", "uuid-alive"), localSource.insertedRadios.map { it.uuid }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
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,${LocalDate.now()}T12: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 $todayEpoch.51955234 .00001288 00000+0 31985-4 0 9990
|
||||||
|
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
|
||||||
|
private fun fresherTleStream(): InputStream = """
|
||||||
|
ISS
|
||||||
|
1 25544U 98067A $todayEpoch.71955234 .00001288 00000+0 31985-4 0 9990
|
||||||
|
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
|
||||||
|
private fun twoRadiosStream(): InputStream = """
|
||||||
|
[$radioAlive,{"uuid":"uuid-retired","description":"Retired","alive":true,"downlink_low":437800000,
|
||||||
|
"mode":"FM","invert":false,"norad_cat_id":25544}]
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
|
||||||
|
private fun singleRadioStream(): InputStream = "[$radioAlive]".byteInputStream()
|
||||||
|
|
||||||
|
private fun customRadioStream(): InputStream = """
|
||||||
|
[{"uuid":"uuid-custom","description":"Local beacon","alive":true,"downlink_low":144800000,
|
||||||
|
"mode":"FM","invert":false,"norad_cat_id":25544}]
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val radioAlive = """{"uuid":"uuid-alive","description":"Voice repeater","alive":true,
|
||||||
|
"downlink_low":145800000,"mode":"FM","invert":false,"norad_cat_id":25544}"""
|
||||||
|
|
||||||
|
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): NetworkResult {
|
||||||
|
val stream = networkStreams[url]?.invoke()
|
||||||
|
return if (stream != null) NetworkResult(200, stream) else NetworkResult(404, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAmSatCatalog(): String? = null
|
||||||
|
|
||||||
|
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||||
|
|
||||||
|
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeLocalSource : ILocalSource {
|
||||||
|
val insertedEntries = mutableListOf<OrbitalData>()
|
||||||
|
val insertedRadios = mutableListOf<SatRadio>()
|
||||||
|
private val customRadios = mutableListOf<String>()
|
||||||
|
|
||||||
|
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 getEntriesEpochs(): Map<Int, Double> =
|
||||||
|
insertedEntries.associate { entry -> entry.catnum to entry.epoch }
|
||||||
|
|
||||||
|
override suspend fun getEntriesNames(): Map<Int, String> =
|
||||||
|
insertedEntries.associate { entry -> entry.catnum to entry.name }
|
||||||
|
|
||||||
|
override suspend fun renameEntries(names: Map<Int, String>) = names.forEach { (catnum, name) ->
|
||||||
|
val index = insertedEntries.indexOfFirst { entry -> entry.catnum == catnum }
|
||||||
|
if (index >= 0) insertedEntries[index] = insertedEntries[index].copy(name = name)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteEntriesWithIds(ids: List<Int>) {
|
||||||
|
insertedEntries.removeAll { entry -> entry.catnum in ids }
|
||||||
|
}
|
||||||
|
|
||||||
|
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>, isCustom: Boolean) {
|
||||||
|
insertedRadios.removeAll { stored -> radios.any { radio -> radio.uuid == stored.uuid } }
|
||||||
|
insertedRadios += radios
|
||||||
|
if (isCustom) customRadios += radios.map { radio -> radio.uuid }
|
||||||
|
else customRadios -= radios.map { radio -> radio.uuid }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteRadios() {
|
||||||
|
insertedRadios.clear()
|
||||||
|
customRadios.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteManagedRadios() {
|
||||||
|
insertedRadios.removeAll { radio -> radio.uuid !in customRadios }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo {
|
||||||
|
|
||||||
|
override val appVersionName: String = "test"
|
||||||
|
override val appVersionCode: Long = 1L
|
||||||
|
|
||||||
|
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
|
override val selectedSatModes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
|
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||||
|
PassesSettings(hoursAhead = 24, minElevation = 0.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||||
|
|
||||||
|
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||||
|
|
||||||
|
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||||
|
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||||
|
OtherSettings(false, false, false, false, false, false, false, false)
|
||||||
|
)
|
||||||
|
|
||||||
|
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
|
||||||
|
|
||||||
|
override val dataSourcesStatus: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||||
|
|
||||||
|
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||||
|
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||||
|
|
||||||
|
override fun setSelectedSatModes(modes: List<String>) = 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 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 updateDataSourcesStatus(status: Map<String, Int>) {
|
||||||
|
dataSourcesStatus.value = status
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||||
|
|
||||||
|
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||||
|
|
||||||
|
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||||
|
|
||||||
|
override fun getAmSatCallsign(): String = ""
|
||||||
|
|
||||||
|
override fun setAmSatCallsign(callsign: String) = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||||
|
return DataSourcesSettings(
|
||||||
|
satelliteUrls = emptyList(),
|
||||||
|
transceiversUrls = emptyList()
|
||||||
|
)
|
||||||
|
}
|
||||||
+253
@@ -0,0 +1,253 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatItem
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class SelectionRepoTest {
|
||||||
|
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown mode values do not crash and do not filter out entries`() = runTest(dispatcher) {
|
||||||
|
val localSource = FakeLocalSource(
|
||||||
|
entries = listOf(
|
||||||
|
SatItem(25544, "ISS (ZARYA)", false),
|
||||||
|
SatItem(40967, "TIANGONG", false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val settingsRepo = FakeSettingsRepo(selectedModes = listOf("REMOVED_MODE"))
|
||||||
|
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
|
||||||
|
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setModes(listOf("REMOVED_MODE"))
|
||||||
|
|
||||||
|
val items = flow.first()
|
||||||
|
|
||||||
|
assertEquals(listOf(25544, 40967), items.map { it.catnum })
|
||||||
|
assertEquals(listOf("REMOVED_MODE"), repository.getCurrentModes())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selected satellites are shown first`() = runTest(dispatcher) {
|
||||||
|
val localSource = FakeLocalSource(
|
||||||
|
entries = listOf(
|
||||||
|
SatItem(44444, "Zeta", false),
|
||||||
|
SatItem(25544, "Alpha", false),
|
||||||
|
SatItem(40967, "Beta", false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val settingsRepo = FakeSettingsRepo(selectedModes = emptyList())
|
||||||
|
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
|
||||||
|
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setSelection(listOf(40967), true)
|
||||||
|
|
||||||
|
val items = flow.first()
|
||||||
|
|
||||||
|
assertEquals(listOf(40967, 25544, 44444), items.map { it.catnum })
|
||||||
|
assertEquals(listOf(true, false, false), items.map { it.isSelected })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `query without separators matches name with dashes spaces brackets`() = runTest(dispatcher) {
|
||||||
|
val repository = repoWithSearchEntries()
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setQuery("ao7")
|
||||||
|
val items = flow.first()
|
||||||
|
// "ao7" is a substring of the normalized "AO-73 (FUNcube-1)" too, so a
|
||||||
|
// fuzzy search legitimately returns both AO-7 (first) and AO-73. The key
|
||||||
|
// guarantee is that AO-7 — unreachable before because of dashes/brackets
|
||||||
|
// — is now found.
|
||||||
|
assertEquals(7530, items.first().catnum)
|
||||||
|
assertEquals(listOf(7530, 39444), items.map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `query without separators matches name with only dashes`() = runTest(dispatcher) {
|
||||||
|
val repository = repoWithSearchEntries()
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setQuery("fo29")
|
||||||
|
assertEquals(listOf(99999), flow.first().map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `space-separated tokens all must match in any order`() = runTest(dispatcher) {
|
||||||
|
val repository = repoWithSearchEntries()
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setQuery("zarya iss")
|
||||||
|
assertEquals(listOf(25544), flow.first().map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `partial token query matches substring`() = runTest(dispatcher) {
|
||||||
|
val repository = repoWithSearchEntries()
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setQuery("funcube")
|
||||||
|
assertEquals(listOf(39444), flow.first().map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unmatched query returns nothing`() = runTest(dispatcher) {
|
||||||
|
val repository = repoWithSearchEntries()
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setQuery("zzzznomatch")
|
||||||
|
assertEquals(emptyList<Int>(), flow.first().map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun repoWithSearchEntries(): SelectionRepo {
|
||||||
|
val localSource = FakeLocalSource(
|
||||||
|
entries = listOf(
|
||||||
|
SatItem(25544, "ISS (ZARYA)", false),
|
||||||
|
SatItem(7530, "AO-7 (AMSAT-OSCAR 7)", false),
|
||||||
|
SatItem(39444, "AO-73 (FUNcube-1)", false),
|
||||||
|
SatItem(43803, "JO-97 (BIRDS-3)", false),
|
||||||
|
SatItem(99999, "FO-29", false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return SelectionRepo(dispatcher, localSource, FakeSettingsRepo(selectedModes = emptyList()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeLocalSource(
|
||||||
|
private val entries: List<SatItem>
|
||||||
|
) : ILocalSource {
|
||||||
|
override suspend fun getEntriesTotal(): Int = entries.size
|
||||||
|
|
||||||
|
override suspend fun getEntriesList(): List<SatItem> = entries
|
||||||
|
|
||||||
|
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun getEntriesEpochs(): Map<Int, Double> = emptyMap()
|
||||||
|
|
||||||
|
override suspend fun getEntriesNames(): Map<Int, String> = emptyMap()
|
||||||
|
|
||||||
|
override suspend fun renameEntries(names: Map<Int, String>) = Unit
|
||||||
|
|
||||||
|
override suspend fun deleteEntriesWithIds(ids: List<Int>) = Unit
|
||||||
|
|
||||||
|
override suspend fun insertEntries(entries: List<com.rtbishop.look4sat.core.domain.predict.OrbitalData>) = Unit
|
||||||
|
|
||||||
|
override suspend fun deleteEntries() = Unit
|
||||||
|
|
||||||
|
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun getRadiosTotal(): Int = 0
|
||||||
|
|
||||||
|
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) = Unit
|
||||||
|
|
||||||
|
override suspend fun deleteRadios() = Unit
|
||||||
|
|
||||||
|
override suspend fun deleteManagedRadios() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeSettingsRepo(
|
||||||
|
selectedModes: List<String>
|
||||||
|
) : ISettingsRepo {
|
||||||
|
|
||||||
|
override val appVersionName: String = "test"
|
||||||
|
override val appVersionCode: Long = 1L
|
||||||
|
|
||||||
|
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
|
override val selectedSatModes: MutableStateFlow<List<String>> = MutableStateFlow(selectedModes)
|
||||||
|
|
||||||
|
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||||
|
PassesSettings(hoursAhead = 24, minElevation = 0.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||||
|
|
||||||
|
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||||
|
|
||||||
|
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||||
|
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||||
|
OtherSettings(false, false, false, false, false, false, false, false)
|
||||||
|
)
|
||||||
|
|
||||||
|
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
|
||||||
|
DataSourcesSettings(emptyList(), emptyList())
|
||||||
|
)
|
||||||
|
|
||||||
|
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||||
|
|
||||||
|
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||||
|
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||||
|
|
||||||
|
override fun setSelectedSatModes(modes: List<String>) {
|
||||||
|
selectedSatModes.value = modes
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setPassesSettings(settings: PassesSettings) = Unit
|
||||||
|
|
||||||
|
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
|
||||||
|
|
||||||
|
override fun setStationPosition(): Boolean = true
|
||||||
|
|
||||||
|
override fun setStationPosition(locator: String): Boolean = true
|
||||||
|
|
||||||
|
override fun updateDatabaseState(state: DatabaseState) {
|
||||||
|
databaseState.value = state
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateRCSettings(settings: RCSettings) = Unit
|
||||||
|
|
||||||
|
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
|
||||||
|
|
||||||
|
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
|
||||||
|
|
||||||
|
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+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
|
||||||
|
}
|
||||||
@@ -20,17 +20,19 @@ package com.rtbishop.look4sat.core.domain.model
|
|||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** Only the uuid is mandatory: third party sources rarely publish the full SatNOGS schema, and a
|
||||||
|
* missing optional field should not cost the whole transceiver. */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SatRadio(
|
data class SatRadio(
|
||||||
@SerialName("uuid") val uuid: String,
|
@SerialName("uuid") val uuid: String,
|
||||||
@SerialName("description") val info: String,
|
@SerialName("description") val info: String = "",
|
||||||
@SerialName("alive") val isAlive: Boolean,
|
@SerialName("alive") val isAlive: Boolean = true,
|
||||||
@SerialName("downlink_low") val downlinkLow: Long?,
|
@SerialName("downlink_low") val downlinkLow: Long? = null,
|
||||||
@SerialName("downlink_high") val downlinkHigh: Long?,
|
@SerialName("downlink_high") val downlinkHigh: Long? = null,
|
||||||
@SerialName("mode") val downlinkMode: String?,
|
@SerialName("mode") val downlinkMode: String? = null,
|
||||||
@SerialName("uplink_low") val uplinkLow: Long?,
|
@SerialName("uplink_low") val uplinkLow: Long? = null,
|
||||||
@SerialName("uplink_high") val uplinkHigh: Long?,
|
@SerialName("uplink_high") val uplinkHigh: Long? = null,
|
||||||
@SerialName("uplink_mode") val uplinkMode: String?,
|
@SerialName("uplink_mode") val uplinkMode: String? = null,
|
||||||
@SerialName("invert") val isInverted: Boolean,
|
@SerialName("invert") val isInverted: Boolean = false,
|
||||||
@SerialName("norad_cat_id") val catnum: Int?
|
@SerialName("norad_cat_id") val catnum: Int? = null
|
||||||
)
|
)
|
||||||
@@ -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, 5 days of state */
|
||||||
|
data class SatStatus(
|
||||||
|
val name: String, // "AO-123_[FM]"
|
||||||
|
val days: List<SatDay> // 5 天(新→旧)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Overall page parse result */
|
||||||
|
data class SatStatusPage(
|
||||||
|
val fetchedAtUtcMs: Long,
|
||||||
|
val statuses: List<SatStatus>,
|
||||||
|
val reports: Map<String, SatReport> // id → 报告
|
||||||
|
)
|
||||||
@@ -27,7 +27,9 @@ data class PassesSettings(
|
|||||||
val showDeepSpace: Boolean = true,
|
val showDeepSpace: Boolean = true,
|
||||||
val hoursAhead: Int,
|
val hoursAhead: Int,
|
||||||
val minElevation: Double,
|
val minElevation: Double,
|
||||||
val selectedModes: List<String>
|
val aosStartMinute: Int = 0,
|
||||||
|
val aosEndMinute: Int = 23 * 60 + 59,
|
||||||
|
val invertAosTimeWindow: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RCSettings(
|
data class RCSettings(
|
||||||
@@ -39,6 +41,7 @@ data class RCSettings(
|
|||||||
val frequencyAddress: String,
|
val frequencyAddress: String,
|
||||||
val frequencyPort: String,
|
val frequencyPort: String,
|
||||||
val frequencyFormat: String,
|
val frequencyFormat: String,
|
||||||
|
val frequencyOffsetHz: Long = 0L,
|
||||||
val bluetoothRotatorState: Boolean,
|
val bluetoothRotatorState: Boolean,
|
||||||
val bluetoothRotatorFormat: String,
|
val bluetoothRotatorFormat: String,
|
||||||
val bluetoothRotatorName: String,
|
val bluetoothRotatorName: String,
|
||||||
@@ -54,16 +57,31 @@ data class OtherSettings(
|
|||||||
val stateOfSweep: Boolean,
|
val stateOfSweep: Boolean,
|
||||||
val stateOfUtc: Boolean,
|
val stateOfUtc: Boolean,
|
||||||
val stateOfLightTheme: Boolean,
|
val stateOfLightTheme: Boolean,
|
||||||
|
val stateOfNightMode: Boolean = false,
|
||||||
val shouldSeeWarning: Boolean,
|
val shouldSeeWarning: Boolean,
|
||||||
val shouldSeeWhatsNew: Boolean
|
val shouldSeeWhatsNew: Boolean,
|
||||||
|
val sstvMode: String = "Auto",
|
||||||
|
val lowElevation: Double = 15.0,
|
||||||
|
val highElevation: Double = 45.0,
|
||||||
|
val radarCompassOffset: Float = 0f,
|
||||||
|
val radarCompassOffsetElev: Float = 0f
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data source URLs (TLE / transceivers) and their per-source enabled flags.
|
||||||
|
* [satelliteEnabled] and [transceiversEnabled] must be positionally aligned with the
|
||||||
|
* corresponding URL list: the flag at index [i] applies to the URL at index [i].
|
||||||
|
* Missing flags are treated as enabled (see [isSatelliteEnabled]/[isTransceiverEnabled]).
|
||||||
|
*/
|
||||||
data class DataSourcesSettings(
|
data class DataSourcesSettings(
|
||||||
val useCustomTLE: Boolean,
|
val satelliteUrls: List<String>,
|
||||||
val useCustomTransceivers: Boolean,
|
val transceiversUrls: List<String>,
|
||||||
val tleUrl: String,
|
val satelliteEnabled: List<Boolean> = emptyList(),
|
||||||
val transceiversUrl: String
|
val transceiversEnabled: List<Boolean> = emptyList()
|
||||||
)
|
) {
|
||||||
|
fun isSatelliteEnabled(index: Int): Boolean = satelliteEnabled.getOrElse(index) { true }
|
||||||
|
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
|
||||||
|
}
|
||||||
|
|
||||||
data class RadioControlSettings(
|
data class RadioControlSettings(
|
||||||
val enabled: Boolean,
|
val enabled: Boolean,
|
||||||
@@ -72,12 +90,20 @@ data class RadioControlSettings(
|
|||||||
val rxRadioAddress: String,
|
val rxRadioAddress: String,
|
||||||
val txRadioName: String,
|
val txRadioName: String,
|
||||||
val rxRadioName: String,
|
val rxRadioName: String,
|
||||||
val baudRate: Int
|
val baudRate: Int,
|
||||||
|
/** IC-705 only: use single-radio split-VFO mode instead of two radios. */
|
||||||
|
val splitMode: Boolean = false
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val SUPPORTED_RADIOS = listOf(
|
const val MODEL_YAESU_FT817 = "Yaesu FT-817/818"
|
||||||
"Yaesu FT-817/818",
|
const val MODEL_YAESU_FT857 = "Yaesu FT-857/897"
|
||||||
"Yaesu FT-857/897"
|
const val MODEL_ICOM_IC705 = "Icom IC-705"
|
||||||
)
|
|
||||||
|
val SUPPORTED_RADIOS = listOf(MODEL_YAESU_FT817, MODEL_YAESU_FT857, MODEL_ICOM_IC705)
|
||||||
|
|
||||||
|
/** Baud rates available for Yaesu radios. */
|
||||||
|
val BAUD_RATES_YAESU = listOf(4800, 9600, 38400)
|
||||||
|
/** Baud rates available for Icom IC-705 (higher speeds supported via CI-V USB/BT). */
|
||||||
|
val BAUD_RATES_ICOM = listOf(4800, 9600, 19200, 38400, 57600, 115200)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+671
@@ -0,0 +1,671 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.predict
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.toRadians
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.acos
|
||||||
|
import kotlin.math.asin
|
||||||
|
import kotlin.math.atan
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.log10
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
import kotlin.math.tan
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone celestial computations extracted from PREDICT v2.2.5.
|
||||||
|
* Provides Sun position, Moon position, satellite visibility classification,
|
||||||
|
* orbital metadata, RA/Dec conversion, and rise/set finding for Sun and Moon.
|
||||||
|
*
|
||||||
|
* All angles are in degrees unless noted. Time is Unix epoch milliseconds.
|
||||||
|
*
|
||||||
|
* Shared math utilities (thetaGJD, modulus, mod2PI, deltaET, millisToDaynum,
|
||||||
|
* solarPositionECI, eciToGeodetic) live in OrbitalMath.kt in the same package.
|
||||||
|
*/
|
||||||
|
object CelestialComputer {
|
||||||
|
|
||||||
|
// ── Result types ──
|
||||||
|
|
||||||
|
/** Sun position as seen from a ground observer. */
|
||||||
|
data class SunPosition(
|
||||||
|
val azimuth: Double, // degrees, 0=N, 90=E
|
||||||
|
val elevation: Double, // degrees, >0 above horizon
|
||||||
|
val distance: Double, // normalized: 1.0 + ((range - AU) / AU)
|
||||||
|
val rangeRate: Double, // km/s
|
||||||
|
val latitude: Double, // sub-solar point latitude, degrees
|
||||||
|
val longitude: Double, // sub-solar point longitude, degrees
|
||||||
|
val rightAscension: Double, // degrees
|
||||||
|
val declination: Double // degrees
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Moon position as seen from a ground observer. */
|
||||||
|
data class MoonPosition(
|
||||||
|
val azimuth: Double, // degrees, 0=N, 90=E
|
||||||
|
val elevation: Double, // degrees
|
||||||
|
val rightAscension: Double, // degrees
|
||||||
|
val declination: Double, // degrees
|
||||||
|
val gha: Double, // Greenwich Hour Angle, degrees
|
||||||
|
val angularDiameter: Double, // apparent diameter relative to Earth's diameter
|
||||||
|
val radialVelocity: Double // m/s, Doppler radial velocity for EME
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3-state satellite visibility classification.
|
||||||
|
* - [VISIBLE]: satellite is sunlit, observer is in darkness (sun below -12°) — optically visible
|
||||||
|
* - [DAYLIGHT]: satellite is sunlit, observer is in daylight
|
||||||
|
* - [ECLIPSED]: satellite is in Earth's shadow
|
||||||
|
*/
|
||||||
|
enum class SatVisibility { VISIBLE, DAYLIGHT, ECLIPSED }
|
||||||
|
|
||||||
|
/** Orbital metadata not typically included in pass data. */
|
||||||
|
data class OrbitalMetadata(
|
||||||
|
val footprintDiameter: Double, // km, ground coverage circle diameter
|
||||||
|
val orbitNumber: Long, // current orbit/revolution number
|
||||||
|
val betaAngle: Double, // degrees, angle between orbital plane and Sun
|
||||||
|
val orbitalPhase: Double // 0-256 phase within current orbit
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Sun position ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the Sun's full position as seen from [observer] at [timeMillis].
|
||||||
|
* Includes az/el, RA/Dec, sub-solar lat/lon, range, and range rate.
|
||||||
|
* Based on FindSun() from PREDICT v2.2.5.
|
||||||
|
*/
|
||||||
|
fun getSunPosition(observer: GeoPos, timeMillis: Long): SunPosition {
|
||||||
|
val daynum = millisToDaynum(timeMillis)
|
||||||
|
val julUtc = daynum + 2444238.5
|
||||||
|
val sunVec = solarPositionECI(julUtc)
|
||||||
|
val zeroVel = doubleArrayOf(0.0, 0.0, 0.0)
|
||||||
|
val obsGeo = observerGeodetic(observer)
|
||||||
|
|
||||||
|
// Az, El, Range, RangeRate
|
||||||
|
val obsSet = computeObsAngles(julUtc, sunVec, zeroVel, obsGeo)
|
||||||
|
|
||||||
|
// Lat/Lon of sub-solar point
|
||||||
|
val latLon = eciToGeodetic(julUtc, sunVec)
|
||||||
|
|
||||||
|
// RA/Dec
|
||||||
|
val raDec = calculateRADec(julUtc, sunVec, zeroVel, obsGeo)
|
||||||
|
|
||||||
|
return SunPosition(
|
||||||
|
azimuth = obsSet[0].toDegrees(),
|
||||||
|
elevation = obsSet[1].toDegrees(),
|
||||||
|
distance = 1.0 + ((obsSet[2] - ASTRONOMICAL_UNIT) / ASTRONOMICAL_UNIT),
|
||||||
|
rangeRate = 1000.0 * obsSet[3],
|
||||||
|
latitude = latLon[0].toDegrees(),
|
||||||
|
longitude = latLon[1].toDegrees().let { if (it > 180.0) it - 360.0 else it },
|
||||||
|
rightAscension = raDec[0].toDegrees(),
|
||||||
|
declination = raDec[1].toDegrees()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Moon position ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the Moon's position as seen from [observer] at [timeMillis].
|
||||||
|
* Full Meeus lunar ephemeris from PREDICT v2.2.5 with expanded terms
|
||||||
|
* and radial velocity approximation for EME Doppler.
|
||||||
|
*/
|
||||||
|
fun getMoonPosition(observer: GeoPos, timeMillis: Long): MoonPosition {
|
||||||
|
val daynum = millisToDaynum(timeMillis)
|
||||||
|
val jd = daynum + 2444238.5
|
||||||
|
var t = (jd - 2415020.0) / 36525.0
|
||||||
|
val t2 = t * t
|
||||||
|
val t3 = t2 * t
|
||||||
|
|
||||||
|
var l1 = 270.434164 + 481267.8831 * t - 0.001133 * t2 + 0.0000019 * t3
|
||||||
|
var mSun = 358.475833 + 35999.0498 * t - 0.00015 * t2 - 0.0000033 * t3
|
||||||
|
var m1 = 296.104608 + 477198.8491 * t + 0.009192 * t2 + 0.0000144 * t3
|
||||||
|
var d = 350.737486 + 445267.1142 * t - 0.001436 * t2 + 0.0000019 * t3
|
||||||
|
var ff = 11.250889 + 483202.0251 * t - 0.003211 * t2 - 0.0000003 * t3
|
||||||
|
val om = (259.183275 - 1934.142 * t + 0.002078 * t2 + 0.0000022 * t3) * DEG2RAD
|
||||||
|
|
||||||
|
val correction512 = sin((51.2 + 20.2 * t) * DEG2RAD)
|
||||||
|
val ss = 0.003964 * sin((346.56 + 132.87 * t - 0.0091731 * t2) * DEG2RAD)
|
||||||
|
l1 += 0.000233 * correction512 + ss + 0.001964 * sin(om)
|
||||||
|
mSun -= 0.001778 * correction512
|
||||||
|
m1 += 0.000817 * correction512 + ss + 0.002541 * sin(om)
|
||||||
|
d += 0.002011 * correction512 + ss + 0.001964 * sin(om)
|
||||||
|
ff += ss - 0.024691 * sin(om) - 0.004328 * sin(om + (275.05 - 2.3 * t) * DEG2RAD)
|
||||||
|
|
||||||
|
val ex = 1.0 - 0.002495 * t - 0.00000752 * t2
|
||||||
|
l1 = primeAngle(l1); mSun = primeAngle(mSun); m1 = primeAngle(m1)
|
||||||
|
d = primeAngle(d); ff = primeAngle(ff)
|
||||||
|
|
||||||
|
val mR = mSun * DEG2RAD
|
||||||
|
val m1R = m1 * DEG2RAD
|
||||||
|
val dR = d * DEG2RAD
|
||||||
|
val ffR = ff * DEG2RAD
|
||||||
|
|
||||||
|
// Ecliptic longitude — expanded v225 terms
|
||||||
|
var l = l1 + 6.28875 * sin(m1R) + 1.274018 * sin(2 * dR - m1R) + 0.658309 * sin(2 * dR)
|
||||||
|
l += 0.213616 * sin(2 * m1R) - ex * 0.185596 * sin(mR) - 0.114336 * sin(2 * ffR)
|
||||||
|
l += 0.058793 * sin(2 * dR - 2 * m1R) + ex * 0.057212 * sin(2 * dR - mR - m1R) + 0.05332 * sin(2 * dR + m1R)
|
||||||
|
l += ex * 0.045874 * sin(2 * dR - mR) + ex * 0.041024 * sin(m1R - mR) - 0.034718 * sin(dR)
|
||||||
|
l -= ex * 0.030465 * sin(mR + m1R) + 0.015326 * sin(2 * dR - 2 * ffR) - 0.012528 * sin(2 * ffR + m1R)
|
||||||
|
l -= 0.01098 * sin(2 * ffR - m1R) + 0.010674 * sin(4 * dR - m1R) + 0.010034 * sin(3 * m1R)
|
||||||
|
l += 0.008548 * sin(4 * dR - 2 * m1R) - ex * 0.00791 * sin(mR - m1R + 2 * dR)
|
||||||
|
l -= ex * 0.006783 * sin(2 * dR + mR)
|
||||||
|
l += 0.005162 * sin(m1R - dR) + ex * 0.005 * sin(mR + dR) + ex * 0.004049 * sin(m1R - mR + 2 * dR)
|
||||||
|
l += 0.003996 * sin(2 * m1R + 2 * dR) + 0.003862 * sin(4 * dR) + 0.003665 * sin(2 * dR - 3 * m1R)
|
||||||
|
l += ex * 0.002695 * sin(2 * m1R - mR) + 0.002602 * sin(m1R - 2 * ffR - 2 * dR)
|
||||||
|
l += ex * 0.002396 * sin(2 * dR - mR - 2 * m1R)
|
||||||
|
l -= 0.002349 * sin(m1R + dR) + ex * ex * 0.002249 * sin(2 * dR - 2 * mR)
|
||||||
|
l -= ex * 0.002125 * sin(2 * m1R + mR)
|
||||||
|
l -= ex * ex * 0.002079 * sin(2 * mR) + ex * ex * 0.002059 * sin(2 * dR - m1R - 2 * mR)
|
||||||
|
l -= 0.001773 * sin(m1R + 2 * dR - 2 * ffR)
|
||||||
|
l += ex * 0.00122 * sin(4 * dR - mR - m1R) - 0.00111 * sin(2 * m1R + 2 * ffR) + 0.000892 * sin(m1R - 3 * dR)
|
||||||
|
l -= ex * 0.000811 * sin(mR + m1R + 2 * dR) + ex * 0.000761 * sin(4 * dR - mR - 2 * m1R)
|
||||||
|
l += ex * ex * 0.000717 * sin(m1R - 2 * mR)
|
||||||
|
l += ex * ex * 0.000704 * sin(m1R - 2 * mR - 2 * dR) + ex * 0.000693 * sin(mR - 2 * m1R + 2 * dR)
|
||||||
|
l += ex * 0.000598 * sin(2 * dR - mR - 2 * ffR) + 0.00055 * sin(m1R + 4 * dR)
|
||||||
|
l += 0.000538 * sin(4 * m1R) + ex * 0.000521 * sin(4 * dR - mR) + 0.000486 * sin(2 * m1R - dR)
|
||||||
|
l -= 0.001595 * sin(2 * ffR + 2 * dR)
|
||||||
|
|
||||||
|
// Ecliptic latitude — expanded v225 terms
|
||||||
|
var b =
|
||||||
|
5.128189 * sin(ffR) + 0.280606 * sin(m1R + ffR) + 0.277693 * sin(m1R - ffR) + 0.173238 * sin(2 * dR - ffR)
|
||||||
|
b += 0.055413 * sin(2 * dR + ffR - m1R) + 0.046272 * sin(2 * dR - ffR - m1R) + 0.032573 * sin(2 * dR + ffR)
|
||||||
|
b += 0.017198 * sin(2 * m1R + ffR) + 9.266999e-03 * sin(2 * dR + m1R - ffR) + 0.008823 * sin(2 * m1R - ffR)
|
||||||
|
b += ex * 0.008247 * sin(2 * dR - mR - ffR) + 0.004323 * sin(2 * dR - ffR - 2 * m1R)
|
||||||
|
b += 0.0042 * sin(2 * dR + ffR + m1R)
|
||||||
|
b += ex * 0.003372 * sin(ffR - mR - 2 * dR) + ex * 0.002472 * sin(2 * dR + ffR - mR - m1R)
|
||||||
|
b += ex * 0.002222 * sin(2 * dR + ffR - mR)
|
||||||
|
b += 0.002072 * sin(2 * dR - ffR - mR - m1R) + ex * 0.001877 * sin(ffR - mR + m1R)
|
||||||
|
b += 0.001828 * sin(4 * dR - ffR - m1R)
|
||||||
|
b -= ex * 0.001803 * sin(ffR + mR) - 0.00175 * sin(3 * ffR)
|
||||||
|
b += ex * 0.00157 * sin(m1R - mR - ffR) - 0.001487 * sin(ffR + dR)
|
||||||
|
b -= ex * 0.001481 * sin(ffR + mR + m1R) + ex * 0.001417 * sin(ffR - mR - m1R)
|
||||||
|
b += ex * 0.00135 * sin(ffR - mR) + 0.00133 * sin(ffR - dR)
|
||||||
|
b += 0.001106 * sin(ffR + 3 * m1R) + 0.00102 * sin(4 * dR - ffR) + 0.000833 * sin(ffR + 4 * dR - m1R)
|
||||||
|
b += 0.000781 * sin(m1R - 3 * ffR) + 0.00067 * sin(ffR + 4 * dR - 2 * m1R)
|
||||||
|
b += 0.000606 * sin(2 * dR - 3 * ffR)
|
||||||
|
b += 0.000597 * sin(2 * dR + 2 * m1R - ffR) + ex * 0.000492 * sin(2 * dR + m1R - mR - ffR)
|
||||||
|
b += 0.00045 * sin(2 * m1R - ffR - 2 * dR)
|
||||||
|
b += 0.000439 * sin(3 * m1R - ffR) + 0.000423 * sin(ffR + 2 * dR + 2 * m1R)
|
||||||
|
b += 0.000422 * sin(2 * dR - ffR - 3 * m1R)
|
||||||
|
b -= ex * 0.000367 * sin(mR + ffR + 2 * dR - m1R) - ex * 0.000353 * sin(mR + ffR + 2 * dR)
|
||||||
|
b += 0.000331 * sin(ffR + 4 * dR)
|
||||||
|
b += ex * 0.000317 * sin(2 * dR + ffR - mR + m1R) + ex * ex * 0.000306 * sin(2 * dR - 2 * mR - ffR)
|
||||||
|
b -= 0.000283 * sin(m1R + 3 * ffR)
|
||||||
|
|
||||||
|
val w1 = 0.0004664 * cos(om)
|
||||||
|
val w2 = 0.0000754 * cos(om + (275.05 - 2.3 * t) * DEG2RAD)
|
||||||
|
val bt = b * (1.0 - w1 - w2)
|
||||||
|
|
||||||
|
// Parallax — expanded v225 terms
|
||||||
|
var p =
|
||||||
|
0.950724 + 0.051818 * cos(m1R) + 0.009531 * cos(2 * dR - m1R) + 0.007843 * cos(2 * dR) + 0.002824 * cos(2 * m1R)
|
||||||
|
p += 0.000857 * cos(2 * dR + m1R) + ex * 0.000533 * cos(2 * dR - mR) + ex * 0.000401 * cos(2 * dR - mR - m1R)
|
||||||
|
p += 0.000173 * cos(3 * m1R) + 0.000167 * cos(4 * dR - m1R) - ex * 0.000111 * cos(mR)
|
||||||
|
p += 0.000103 * cos(4 * dR - 2 * m1R) - 0.000084 * cos(2 * m1R - 2 * dR) - ex * 0.000083 * cos(2 * dR + mR)
|
||||||
|
p += 0.000079 * cos(2 * dR + 2 * m1R)
|
||||||
|
p += 0.000072 * cos(4 * dR) + ex * 0.000064 * cos(2 * dR - mR + m1R) - ex * 0.000063 * cos(2 * dR + mR - m1R)
|
||||||
|
p += ex * 0.000041 * cos(mR + dR) + ex * 0.000035 * cos(2 * m1R - mR) - 0.000033 * cos(3 * m1R - 2 * dR)
|
||||||
|
p -= 0.00003 * cos(m1R + dR) - 0.000029 * cos(2 * ffR - 2 * dR) - ex * 0.000029 * cos(2 * m1R + mR)
|
||||||
|
p += ex * ex * 0.000026 * cos(2 * dR - 2 * mR) - 0.000023 * cos(2 * ffR - 2 * dR + m1R)
|
||||||
|
p += ex * 0.000019 * cos(4 * dR - mR - m1R)
|
||||||
|
|
||||||
|
val bRad = bt * DEG2RAD
|
||||||
|
val lm = l * DEG2RAD
|
||||||
|
val moonDx = 3.0 / (PI * p)
|
||||||
|
|
||||||
|
// Ecliptic → equatorial
|
||||||
|
val z = (jd - 2415020.5) / 365.2422
|
||||||
|
val ob = (23.452294 - (0.46845 * z + 5.9e-07 * z * z) / 3600.0).toRadians()
|
||||||
|
val dec = asin(sin(bRad) * cos(ob) + cos(bRad) * sin(ob) * sin(lm))
|
||||||
|
var ra = acos(cos(bRad) * cos(lm) / cos(dec)); if (lm > PI) ra = TWO_PI - ra
|
||||||
|
|
||||||
|
val n = observer.latitude * DEG2RAD
|
||||||
|
t = (jd - 2451545.0) / 36525.0
|
||||||
|
var teg = 280.46061837 + 360.98564736629 * (jd - 2451545.0) + (0.000387933 * t - t * t / 38710000.0) * t
|
||||||
|
while (teg > 360.0) teg -= 360.0
|
||||||
|
// LST = GMST + east longitude (positive east convention)
|
||||||
|
val th = mod2PI((teg + observer.longitude) * DEG2RAD)
|
||||||
|
val h = th - ra
|
||||||
|
val azVal = atan2(sin(h), cos(h) * sin(n) - tan(dec) * cos(n)) + PI
|
||||||
|
val el = asin(sin(n) * sin(dec) + cos(n) * cos(dec) * cos(h))
|
||||||
|
|
||||||
|
// Moon radial velocity approximation (from "Amateur Radio Software", GM4ANB, RSGB 1985)
|
||||||
|
val mm = fixAngle(1.319238 + daynum * 0.228027135)
|
||||||
|
val radT2 = 0.10976
|
||||||
|
val radT1 = mm + radT2 * sin(mm)
|
||||||
|
var dv = 0.01255 * moonDx * moonDx * sin(radT1) * (1.0 + radT2 * cos(mm))
|
||||||
|
dv *= 4449.0
|
||||||
|
val earthR = 6378.0
|
||||||
|
val moonDist = 384401.0
|
||||||
|
val radT3 = earthR * moonDist * (cos(dec) * cos(n) * sin(h)) /
|
||||||
|
sqrt(moonDist * moonDist - moonDist * earthR * sin(el))
|
||||||
|
val moonDv = dv + radT3 * 0.0753125
|
||||||
|
|
||||||
|
val moonRa = ra / DEG2RAD
|
||||||
|
var moonGha = teg - moonRa
|
||||||
|
if (moonGha < 0.0) moonGha += 360.0
|
||||||
|
|
||||||
|
return MoonPosition(
|
||||||
|
azimuth = azVal / DEG2RAD,
|
||||||
|
elevation = el / DEG2RAD,
|
||||||
|
rightAscension = moonRa,
|
||||||
|
declination = dec / DEG2RAD,
|
||||||
|
gha = moonGha,
|
||||||
|
angularDiameter = moonDx,
|
||||||
|
radialVelocity = moonDv
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Satellite visibility ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify satellite visibility given its eclipse state and the Sun's elevation
|
||||||
|
* at the observer's location.
|
||||||
|
*
|
||||||
|
* @param isEclipsed whether the satellite is in Earth's shadow
|
||||||
|
* @param sunElevationDeg Sun elevation at observer in degrees
|
||||||
|
* @param satElevationDeg satellite elevation at observer in degrees (must be >= 0)
|
||||||
|
*/
|
||||||
|
fun classifyVisibility(
|
||||||
|
isEclipsed: Boolean,
|
||||||
|
sunElevationDeg: Double,
|
||||||
|
satElevationDeg: Double
|
||||||
|
): SatVisibility {
|
||||||
|
if (isEclipsed) return SatVisibility.ECLIPSED
|
||||||
|
return if (sunElevationDeg <= -12.0 && satElevationDeg >= 0.0) SatVisibility.VISIBLE
|
||||||
|
else SatVisibility.DAYLIGHT
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Orbital metadata ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute orbital metadata for a satellite at its current position.
|
||||||
|
*
|
||||||
|
* @param altitudeKm satellite altitude in km
|
||||||
|
* @param meanMotion revolutions per day from TLE
|
||||||
|
* @param bstar drag term from TLE
|
||||||
|
* @param meanAnomaly mean anomaly at epoch (radians)
|
||||||
|
* @param revNumAtEpoch revolution number at TLE epoch
|
||||||
|
* @param ageDays days since TLE epoch (julUTC - julEpoch)
|
||||||
|
* @param phase orbital phase in radians (from SGP4/SDP4 output)
|
||||||
|
* @param satPosECI satellite ECI position [x, y, z]
|
||||||
|
* @param satVelECI satellite ECI velocity [vx, vy, vz]
|
||||||
|
* @param sunPosECI sun ECI position [x, y, z]
|
||||||
|
*/
|
||||||
|
fun computeOrbitalMetadata(
|
||||||
|
altitudeKm: Double,
|
||||||
|
meanMotion: Double,
|
||||||
|
bstar: Double,
|
||||||
|
meanAnomaly: Double,
|
||||||
|
revNumAtEpoch: Int,
|
||||||
|
ageDays: Double,
|
||||||
|
phase: Double,
|
||||||
|
satPosECI: DoubleArray,
|
||||||
|
satVelECI: DoubleArray,
|
||||||
|
sunPosECI: DoubleArray
|
||||||
|
): OrbitalMetadata {
|
||||||
|
// Footprint diameter (km)
|
||||||
|
val footprint = 12756.33 * acos(EARTH_RADIUS / (EARTH_RADIUS + altitudeKm))
|
||||||
|
|
||||||
|
// Orbit number
|
||||||
|
val xmnpda = 1.44E3
|
||||||
|
val orbitNum = floor(
|
||||||
|
(meanMotion * xmnpda / TWO_PI + ageDays * bstar) * ageDays + meanAnomaly / TWO_PI
|
||||||
|
).toLong() + revNumAtEpoch
|
||||||
|
|
||||||
|
// Beta angle: angle between orbital plane and Sun direction
|
||||||
|
// Orbital plane normal = cross(pos, vel)
|
||||||
|
val nx = satPosECI[1] * satVelECI[2] - satPosECI[2] * satVelECI[1]
|
||||||
|
val ny = satPosECI[2] * satVelECI[0] - satPosECI[0] * satVelECI[2]
|
||||||
|
val nz = satPosECI[0] * satVelECI[1] - satPosECI[1] * satVelECI[0]
|
||||||
|
val nMag = sqrt(nx * nx + ny * ny + nz * nz)
|
||||||
|
val sMag = sqrt(sunPosECI[0] * sunPosECI[0] + sunPosECI[1] * sunPosECI[1] + sunPosECI[2] * sunPosECI[2])
|
||||||
|
val dotNS = nx * sunPosECI[0] + ny * sunPosECI[1] + nz * sunPosECI[2]
|
||||||
|
val betaAngle = if (nMag > 0 && sMag > 0) {
|
||||||
|
(PI / 2.0 - acos(dotNS / (nMag * sMag))).toDegrees()
|
||||||
|
} else 0.0
|
||||||
|
|
||||||
|
// Phase (0-256 scale, matching PREDICT convention)
|
||||||
|
val orbitalPhase = 256.0 * (phase / TWO_PI)
|
||||||
|
|
||||||
|
return OrbitalMetadata(footprint, orbitNum, betaAngle, orbitalPhase)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Satellite status checks ──
|
||||||
|
|
||||||
|
/** Check if a satellite is geostationary (mean motion ≈ 1.0027 rev/day). */
|
||||||
|
fun isGeostationary(meanMotion: Double): Boolean = abs(meanMotion - 1.0027) < 0.0002
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a satellite has likely decayed based on drag and time since epoch.
|
||||||
|
*
|
||||||
|
* @param meanMotion revolutions per day
|
||||||
|
* @param drag first derivative of mean motion / 2 (from TLE line 1)
|
||||||
|
* @param epochDaynum TLE epoch as daynum (days since 31Dec79)
|
||||||
|
* @param currentDaynum current time as daynum
|
||||||
|
*/
|
||||||
|
fun hasDecayed(meanMotion: Double, drag: Double, epochDaynum: Double, currentDaynum: Double): Boolean {
|
||||||
|
return epochDaynum + ((16.666666 - meanMotion) / (10.0 * abs(drag))) < currentDaynum
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rise/Set finding ──
|
||||||
|
|
||||||
|
/** Rise and set times for a celestial body. */
|
||||||
|
data class RiseSetTimes(
|
||||||
|
val riseTimeMillis: Long, // 0 if not found
|
||||||
|
val setTimeMillis: Long // 0 if not found
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the next sunrise and sunset times from [startMillis] for [observer].
|
||||||
|
* Uses elevation threshold of -0.8333° to match the standard civil definition:
|
||||||
|
* upper limb on geometric horizon with standard atmospheric refraction (~0.57°)
|
||||||
|
* and solar semidiameter (~0.27°) corrections applied, matching USNO/timeanddate.com.
|
||||||
|
*/
|
||||||
|
fun findSunRiseSet(observer: GeoPos, startMillis: Long): RiseSetTimes {
|
||||||
|
// Standard civil threshold: center elevation when upper limb meets geometric horizon
|
||||||
|
// -0.8333° = standard refraction (~0.5667°) + solar semidiameter (~0.2667°)
|
||||||
|
val threshold = 0.8333
|
||||||
|
var daynum = millisToDaynum(startMillis)
|
||||||
|
var sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
|
||||||
|
// Phase 1: if sun is above threshold, fast-forward to well past sunset into night
|
||||||
|
if (sunPos.elevation > -threshold) {
|
||||||
|
var guard = 0
|
||||||
|
while (sunPos.elevation > -threshold && guard++ < 500) {
|
||||||
|
daynum += 0.008 // fixed ~11.5 min steps past the setting sun
|
||||||
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
// Now advance until sun is clearly below minimum (deep night)
|
||||||
|
guard = 0
|
||||||
|
while (sunPos.elevation > -12.0 && guard++ < 500) {
|
||||||
|
daynum += 0.02
|
||||||
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: advance until sun starts rising toward threshold (elevation increasing)
|
||||||
|
var guard = 0
|
||||||
|
while (sunPos.elevation < -threshold && guard++ < 500) {
|
||||||
|
daynum += 0.008
|
||||||
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: converge symmetrically on elevation = -threshold (sunrise)
|
||||||
|
var sunrise = 0.0
|
||||||
|
guard = 0
|
||||||
|
while (sunrise == 0.0 && guard++ < 200) {
|
||||||
|
val delta = sunPos.elevation + threshold
|
||||||
|
if (abs(delta) < 0.01) {
|
||||||
|
sunrise = daynum
|
||||||
|
} else {
|
||||||
|
daynum -= 0.004 * delta
|
||||||
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sunrise == 0.0) sunrise = daynum
|
||||||
|
|
||||||
|
// Phase 4: fast-forward through the day until sun drops back below threshold.
|
||||||
|
// Start from just after sunrise (small offset) so the sun is clearly above
|
||||||
|
// the threshold. This prevents a bug where Phase 3 converges to a point
|
||||||
|
// slightly below -threshold, causing Phase 4 to skip and Phase 5 to converge
|
||||||
|
// to the same time as sunrise, producing identical sunrise/sunset times.
|
||||||
|
daynum = sunrise + 0.001
|
||||||
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
guard = 0
|
||||||
|
while (sunPos.elevation > -threshold && guard++ < 500) {
|
||||||
|
daynum += 0.008
|
||||||
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 5: converge symmetrically on elevation = -threshold (sunset)
|
||||||
|
var sunset = 0.0
|
||||||
|
guard = 0
|
||||||
|
while (sunset == 0.0 && guard++ < 200) {
|
||||||
|
val delta = sunPos.elevation + threshold
|
||||||
|
if (abs(delta) < 0.01) {
|
||||||
|
sunset = daynum
|
||||||
|
} else {
|
||||||
|
daynum += 0.004 * delta
|
||||||
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sunset == 0.0) sunset = daynum
|
||||||
|
|
||||||
|
return RiseSetTimes(daynumToMillis(sunrise), daynumToMillis(sunset))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the next moonrise and moonset times from [startMillis] for [observer].
|
||||||
|
* Uses the adaptive iteration from PREDICT v2.2.5's PredictMoon().
|
||||||
|
*/
|
||||||
|
fun findMoonRiseSet(observer: GeoPos, startMillis: Long): RiseSetTimes {
|
||||||
|
var daynum = millisToDaynum(startMillis)
|
||||||
|
var moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||||
|
|
||||||
|
// If moon is already up, move forward until it sets
|
||||||
|
var guard = 0
|
||||||
|
if (moonPos.elevation > 0) {
|
||||||
|
while (moonPos.elevation > 0 && guard++ < 1000) {
|
||||||
|
daynum += 0.004 * sin(DEG2RAD * (moonPos.elevation + 0.5))
|
||||||
|
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
daynum += 0.4
|
||||||
|
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
// Find moonrise
|
||||||
|
var moonrise = 0.0
|
||||||
|
guard = 0
|
||||||
|
while (moonrise == 0.0 && guard++ < 1000) {
|
||||||
|
if (abs(moonPos.elevation) < 0.03) {
|
||||||
|
moonrise = daynum
|
||||||
|
} else {
|
||||||
|
daynum -= 0.004 * moonPos.elevation
|
||||||
|
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (moonrise == 0.0) moonrise = daynum
|
||||||
|
|
||||||
|
// Find moonset from moonrise
|
||||||
|
daynum = moonrise
|
||||||
|
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||||
|
guard = 0
|
||||||
|
while (moonPos.elevation > -1 && guard++ < 1000) {
|
||||||
|
daynum += 0.04 * cos(DEG2RAD * (moonPos.elevation + 0.5))
|
||||||
|
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
var moonset = 0.0
|
||||||
|
guard = 0
|
||||||
|
while (moonset == 0.0 && guard++ < 1000) {
|
||||||
|
if (abs(moonPos.elevation) < 0.03) {
|
||||||
|
moonset = daynum
|
||||||
|
} else {
|
||||||
|
daynum += 0.004 * moonPos.elevation
|
||||||
|
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (moonset == 0.0) moonset = daynum
|
||||||
|
|
||||||
|
return RiseSetTimes(daynumToMillis(moonrise), daynumToMillis(moonset))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Visual magnitude estimation ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate the apparent visual magnitude of a satellite.
|
||||||
|
*
|
||||||
|
* Uses the standard formula from McCants/Heavens-Above:
|
||||||
|
* apparentMag = stdMag + 5 * log10(range / 1000) - 15 * log10(cos(phaseAngle / 2))
|
||||||
|
*
|
||||||
|
* @param rangeKm slant range from observer to satellite in km
|
||||||
|
* @param phaseAngleDeg Sun-satellite-observer angle in degrees
|
||||||
|
* @param stdMag intrinsic/standard magnitude (default 4.0)
|
||||||
|
* @return estimated apparent visual magnitude
|
||||||
|
*/
|
||||||
|
fun estimateVisualMagnitude(rangeKm: Double, phaseAngleDeg: Double, stdMag: Double = 4.0): Double {
|
||||||
|
if (rangeKm <= 0) return stdMag
|
||||||
|
val halfPhaseRad = phaseAngleDeg.toRadians() / 2.0
|
||||||
|
val cosHalfPhase = cos(halfPhaseRad)
|
||||||
|
val phaseTerm = if (cosHalfPhase > 1e-6) -15.0 * log10(cosHalfPhase) else 99.0
|
||||||
|
return stdMag + 5.0 * log10(rangeKm / 1000.0) + phaseTerm
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the phase angle (Sun-satellite-observer) in degrees.
|
||||||
|
*
|
||||||
|
* @param satPosECI satellite ECI position [x, y, z] in km
|
||||||
|
* @param sunPosECI sun ECI position [x, y, z] in km
|
||||||
|
* @param obsPosECI observer ECI position [x, y, z] in km
|
||||||
|
* @return phase angle in degrees (0 = fully illuminated face toward observer)
|
||||||
|
*/
|
||||||
|
fun computePhaseAngle(satPosECI: DoubleArray, sunPosECI: DoubleArray, obsPosECI: DoubleArray): Double {
|
||||||
|
val toSunX = sunPosECI[0] - satPosECI[0]
|
||||||
|
val toSunY = sunPosECI[1] - satPosECI[1]
|
||||||
|
val toSunZ = sunPosECI[2] - satPosECI[2]
|
||||||
|
val toObsX = obsPosECI[0] - satPosECI[0]
|
||||||
|
val toObsY = obsPosECI[1] - satPosECI[1]
|
||||||
|
val toObsZ = obsPosECI[2] - satPosECI[2]
|
||||||
|
val dot = toSunX * toObsX + toSunY * toObsY + toSunZ * toObsZ
|
||||||
|
val magSun = sqrt(toSunX * toSunX + toSunY * toSunY + toSunZ * toSunZ)
|
||||||
|
val magObs = sqrt(toObsX * toObsX + toObsY * toObsY + toObsZ * toObsZ)
|
||||||
|
if (magSun == 0.0 || magObs == 0.0) return 90.0
|
||||||
|
val cosAngle = (dot / (magSun * magObs)).coerceIn(-1.0, 1.0)
|
||||||
|
return acos(cosAngle).toDegrees()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Doppler ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute Doppler shift for a given base frequency and range rate.
|
||||||
|
*
|
||||||
|
* @param frequencyHz base frequency in Hz
|
||||||
|
* @param rangeRateKmS range rate in km/s (negative = approaching)
|
||||||
|
* @return shifted frequency in Hz
|
||||||
|
*/
|
||||||
|
fun dopplerShift(frequencyHz: Double, rangeRateKmS: Double): Double {
|
||||||
|
return frequencyHz * (299792.458 - rangeRateKmS) / 299792.458
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal helpers ──
|
||||||
|
|
||||||
|
private fun observerGeodetic(pos: GeoPos): DoubleArray {
|
||||||
|
// [lat_rad, lon_rad, alt_km] — longitude positive east, matching OrbitalObject convention.
|
||||||
|
// LST = thetaGJD(julUtc) + obsGeo[1] = GMST + lon_rad (correct).
|
||||||
|
return doubleArrayOf(pos.latitude * DEG2RAD, pos.longitude * DEG2RAD, pos.altitude / 1000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert az/el observation to Right Ascension / Declination.
|
||||||
|
* Returns [ra_rad, dec_rad].
|
||||||
|
* Based on Calculate_RADec() from PREDICT v2.2.5 (Escobal method).
|
||||||
|
*/
|
||||||
|
private fun calculateRADec(
|
||||||
|
julUtc: Double,
|
||||||
|
targetPos: DoubleArray,
|
||||||
|
targetVel: DoubleArray,
|
||||||
|
obsGeo: DoubleArray
|
||||||
|
): DoubleArray {
|
||||||
|
val obsSet = computeObsAngles(julUtc, targetPos, targetVel, obsGeo)
|
||||||
|
val az = obsSet[0]
|
||||||
|
val el = obsSet[1]
|
||||||
|
val phi = obsGeo[0]
|
||||||
|
val theta = mod2PI(thetaGJD(julUtc) + obsGeo[1])
|
||||||
|
val sinTheta = sin(theta)
|
||||||
|
val cosTheta = cos(theta)
|
||||||
|
val sinPhi = sin(phi)
|
||||||
|
val cosPhi = cos(phi)
|
||||||
|
val lxh = -cos(az) * cos(el)
|
||||||
|
val lyh = sin(az) * cos(el)
|
||||||
|
val lzh = sin(el)
|
||||||
|
val sx = sinPhi * cosTheta
|
||||||
|
val ex2 = -sinTheta
|
||||||
|
val zx = cosTheta * cosPhi
|
||||||
|
val sy = sinPhi * sinTheta
|
||||||
|
val zy = sinTheta * cosPhi
|
||||||
|
val sz = -cosPhi
|
||||||
|
val lx = sx * lxh + ex2 * lyh + zx * lzh
|
||||||
|
val ly = sy * lxh + cosTheta * lyh + zy * lzh
|
||||||
|
val lz = sz * lxh + 0.0 * lyh + sinPhi * lzh
|
||||||
|
val dec = asin(lz)
|
||||||
|
val cosDelta = sqrt(1.0 - lz * lz)
|
||||||
|
val sinAlpha = ly / cosDelta
|
||||||
|
val cosAlpha = lx / cosDelta
|
||||||
|
val ra = mod2PI(atan2(sinAlpha, cosAlpha))
|
||||||
|
return doubleArrayOf(ra, dec)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute observer look-angles (az, el, range, rangeRate) to a target at ECI position.
|
||||||
|
* Returns [azimuth_rad, elevation_rad, range_km, rangeRate_km/s].
|
||||||
|
* Azimuth is north-referenced (0=N, π/2=E), matching OrbitalObject's convention.
|
||||||
|
*/
|
||||||
|
private fun computeObsAngles(
|
||||||
|
julUtc: Double,
|
||||||
|
targetPos: DoubleArray,
|
||||||
|
targetVel: DoubleArray,
|
||||||
|
obsGeo: DoubleArray // [lat_rad, lon_rad, alt_km]
|
||||||
|
): DoubleArray {
|
||||||
|
val theta = mod2PI(thetaGJD(julUtc) + obsGeo[1])
|
||||||
|
val c = 1.0 / sqrt(1 + FLAT_FACT * (FLAT_FACT - 2) * sin(obsGeo[0]).pow(2))
|
||||||
|
val sq = (1 - FLAT_FACT).pow(2) * c
|
||||||
|
val achcp = (EARTH_RADIUS * c + obsGeo[2]) * cos(obsGeo[0])
|
||||||
|
val ox = achcp * cos(theta)
|
||||||
|
val oy = achcp * sin(theta)
|
||||||
|
val oz = (EARTH_RADIUS * sq + obsGeo[2]) * sin(obsGeo[0])
|
||||||
|
val ovx = -MFACTOR * oy
|
||||||
|
val ovy = MFACTOR * ox
|
||||||
|
|
||||||
|
val rx = targetPos[0] - ox
|
||||||
|
val ry = targetPos[1] - oy
|
||||||
|
val rz = targetPos[2] - oz
|
||||||
|
val rMag = sqrt(rx * rx + ry * ry + rz * rz)
|
||||||
|
val rvx = targetVel[0] - ovx
|
||||||
|
val rvy = targetVel[1] - ovy
|
||||||
|
val rvz = targetVel[2]
|
||||||
|
|
||||||
|
val sinLat = sin(obsGeo[0])
|
||||||
|
val cosLat = cos(obsGeo[0])
|
||||||
|
val sinTheta = sin(theta)
|
||||||
|
val cosTheta = cos(theta)
|
||||||
|
val topS = sinLat * cosTheta * rx + sinLat * sinTheta * ry - cosLat * rz
|
||||||
|
val topE = -sinTheta * rx + cosTheta * ry
|
||||||
|
val topZ = cosLat * cosTheta * rx + cosLat * sinTheta * ry + sinLat * rz
|
||||||
|
|
||||||
|
// Match north-based convention (0=N, 90=E) used by OrbitalObject.calculateObs
|
||||||
|
// Must use atan(-topE / topS) not atan2(-topE, topS) — they differ in quadrant handling
|
||||||
|
var azim = atan(-topE / topS)
|
||||||
|
if (topS > 0.0) azim += PI
|
||||||
|
if (azim < 0.0) azim += TWO_PI
|
||||||
|
val el = asin(topZ / rMag)
|
||||||
|
val rangeRate = (rx * rvx + ry * rvy + rz * rvz) / rMag
|
||||||
|
|
||||||
|
return doubleArrayOf(azim, el, rMag, rangeRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MFACTOR = 7.292115E-5
|
||||||
|
|
||||||
|
private fun primeAngle(x: Double) = x - 360.0 * floor(x / 360.0)
|
||||||
|
|
||||||
|
private fun fixAngle(x: Double): Double {
|
||||||
|
var a = x; while (a > TWO_PI) a -= TWO_PI; return a
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ const val ASTRONOMICAL_UNIT = 1.49597870691E8
|
|||||||
const val DEG2RAD = 0.017453292519943295
|
const val DEG2RAD = 0.017453292519943295
|
||||||
const val RAD2DEG = 57.29577951308232
|
const val RAD2DEG = 57.29577951308232
|
||||||
const val EARTH_RADIUS = 6378.137
|
const val EARTH_RADIUS = 6378.137
|
||||||
|
const val EARTH_ROT_PER_SID_DAY = 1.00273790934
|
||||||
const val EPSILON = 1.0E-12
|
const val EPSILON = 1.0E-12
|
||||||
const val FLAT_FACT = 3.35281066474748E-3
|
const val FLAT_FACT = 3.35281066474748E-3
|
||||||
const val J3_HARMONIC = -2.53881E-6
|
const val J3_HARMONIC = -2.53881E-6
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ data class OrbitalData(
|
|||||||
val argper: Double,
|
val argper: Double,
|
||||||
val meanan: Double,
|
val meanan: Double,
|
||||||
val catnum: Int,
|
val catnum: Int,
|
||||||
val bstar: Double
|
val bstar: Double,
|
||||||
|
val ndot: Double = 0.0
|
||||||
) {
|
) {
|
||||||
val xincl: Double = incl * DEG2RAD
|
val xincl: Double = incl * DEG2RAD
|
||||||
val xnodeo: Double = raan * DEG2RAD
|
val xnodeo: Double = raan * DEG2RAD
|
||||||
@@ -36,5 +37,42 @@ data class OrbitalData(
|
|||||||
val xno: Double = meanmo * TWO_PI / MIN_PER_DAY
|
val xno: Double = meanmo * TWO_PI / MIN_PER_DAY
|
||||||
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
|
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
|
||||||
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
|
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
|
||||||
|
|
||||||
|
/** Absolute age of this element set, used to pick the freshest data among several sources. */
|
||||||
|
val epochDaynum: Double = epochToDaynum(epoch)
|
||||||
|
|
||||||
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
|
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
|
||||||
|
|
||||||
|
/** Check if satellite has likely decayed by the given time. */
|
||||||
|
fun hasDecayed(currentTimeMillis: Long): Boolean {
|
||||||
|
if (ndot == 0.0) return false
|
||||||
|
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, timeToDaynum(currentTimeMillis))
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Days since 31 Dec 1979, the reference point every daynum in the app is based on. */
|
||||||
|
fun timeToDaynum(timeMillis: Long): Double = (timeMillis - 315446400000L) / 86400000.0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Days since 31 Dec 1979 for a TLE style YYDDD.ffffffff epoch. Raw epochs are not
|
||||||
|
* comparable across decades (a 1999 epoch reads as 99xxx, a 2026 one as 26xxx), so
|
||||||
|
* freshness checks must always go through this conversion.
|
||||||
|
*/
|
||||||
|
fun epochToDaynum(epoch: Double): Double {
|
||||||
|
var year = kotlin.math.floor(epoch * 1E-3)
|
||||||
|
val day = (epoch * 1E-3 - year) * 1000.0
|
||||||
|
year = if (year < 57) year + 2000 else year + 1900
|
||||||
|
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
|
||||||
|
val jan1Jd = julianDateOfYear(year)
|
||||||
|
return jan1Jd + day - 2444238.5
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun julianDateOfYear(theYear: Double): Double {
|
||||||
|
val aYear = theYear - 1
|
||||||
|
val a = kotlin.math.floor(aYear / 100).toLong()
|
||||||
|
val b = 2 - a + a / 4
|
||||||
|
val i = kotlin.math.floor(365.25 * aYear).toLong()
|
||||||
|
return i + (30.6001 * 14).toLong() + 1720994.5 + b
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.predict
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
// ── Shared orbital math utilities ──
|
||||||
|
// Used by both CelestialComputer (sun/moon/celestial) and OrbitalObject (SGP4/SDP4).
|
||||||
|
// Package-internal — not part of the public API.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Greenwich Mean Sidereal Time from Julian Date, in radians [0, 2π).
|
||||||
|
* Identical algorithm used in PREDICT v2.2.5 for both solar and satellite calculations.
|
||||||
|
*/
|
||||||
|
internal fun thetaGJD(jd: Double): Double {
|
||||||
|
val ut = fraction(jd + 0.5)
|
||||||
|
val aJD = jd - ut
|
||||||
|
val tu = (aJD - 2451545.0) / 36525.0
|
||||||
|
var gmst = 24110.54841 + tu * (8640184.812866 + tu * (0.093104 - tu * 6.2E-6))
|
||||||
|
gmst = modulus(gmst + SEC_PER_DAY * EARTH_ROT_PER_SID_DAY * ut, SEC_PER_DAY)
|
||||||
|
return TWO_PI * gmst / SEC_PER_DAY
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fractional part of [arg]. */
|
||||||
|
internal fun fraction(arg: Double): Double = arg - floor(arg)
|
||||||
|
|
||||||
|
/** Modulo: returns [arg1] mod [arg2], result always in [0, arg2). */
|
||||||
|
internal fun modulus(arg1: Double, arg2: Double): Double {
|
||||||
|
var r = arg1
|
||||||
|
val i = floor(r / arg2).toInt()
|
||||||
|
r -= i * arg2
|
||||||
|
if (r < 0.0) r += arg2
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reduce [value] to [0, 2π). */
|
||||||
|
internal fun mod2PI(value: Double): Double {
|
||||||
|
var r = value
|
||||||
|
val i = (r / TWO_PI).toInt()
|
||||||
|
r -= i * TWO_PI
|
||||||
|
if (r < 0.0) r += TWO_PI
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delta-ET: difference between Universal Time and Ephemeris Time (seconds).
|
||||||
|
* Based on least-squares fit from 1950 to 1991 (PREDICT v2.2.5).
|
||||||
|
*/
|
||||||
|
internal fun deltaET(year: Double): Double =
|
||||||
|
26.465 + 0.747622 * (year - 1950) + 1.886913 * sin(TWO_PI * (year - 1975) / 33)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Unix epoch milliseconds to daynum (days since 31 Dec 1979 00:00:00 UTC).
|
||||||
|
*/
|
||||||
|
internal fun millisToDaynum(timeMillis: Long): Double =
|
||||||
|
(timeMillis - 315446400000L) / 86400000.0
|
||||||
|
|
||||||
|
/** Convert daynum back to Unix epoch milliseconds. */
|
||||||
|
internal fun daynumToMillis(daynum: Double): Long =
|
||||||
|
((daynum + 3651.0) * 86400000.0).toLong()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the Sun's ECI position vector at [julUtc] (Julian UTC).
|
||||||
|
* Returns [x, y, z, magnitude] in km.
|
||||||
|
* Based on Calculate_Solar_Position() / FindSun() from PREDICT v2.2.5.
|
||||||
|
*/
|
||||||
|
internal fun solarPositionECI(julUtc: Double): DoubleArray {
|
||||||
|
val mjd = julUtc - 2415020.0
|
||||||
|
val year = 1900 + mjd / 365.25
|
||||||
|
val t = (mjd + deltaET(year) / SEC_PER_DAY) / 36525.0
|
||||||
|
val mDeg = mod360(358.47583 + mod360(35999.04975 * t) - (0.000150 + 0.0000033 * t) * t * t)
|
||||||
|
val m = mDeg * DEG2RAD
|
||||||
|
val lDeg = mod360(279.69668 + mod360(36000.76892 * t) + 0.0003025 * t * t)
|
||||||
|
val l = lDeg * DEG2RAD
|
||||||
|
val e = 0.01675104 - (0.0000418 + 0.000000126 * t) * t
|
||||||
|
val cDeg = (1.919460 - (0.004789 + 0.000014 * t) * t) * sin(m) +
|
||||||
|
(0.020094 - 0.000100 * t) * sin(2 * m) + 0.000293 * sin(3 * m)
|
||||||
|
val c = cDeg * DEG2RAD
|
||||||
|
val oDeg = mod360(259.18 - 1934.142 * t)
|
||||||
|
val o = oDeg * DEG2RAD
|
||||||
|
val lsa = mod2PI(l + c - (0.00569 - 0.00479 * sin(o)) * DEG2RAD)
|
||||||
|
val nu = mod2PI(m + c)
|
||||||
|
var r = 1.0000002 * (1.0 - e * e) / (1.0 + e * cos(nu))
|
||||||
|
val epsDeg = 23.452294 - (0.0130125 + (0.00000164 - 0.000000503 * t) * t) * t + 0.00256 * cos(o)
|
||||||
|
val eps = epsDeg * DEG2RAD
|
||||||
|
r *= ASTRONOMICAL_UNIT
|
||||||
|
return doubleArrayOf(r * cos(lsa), r * sin(lsa) * cos(eps), r * sin(lsa) * sin(eps), r)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert ECI position [eciPos] = [x, y, z] (km) to geodetic [lat_rad, lon_rad, alt_km].
|
||||||
|
* Based on Calculate_LatLonAlt() from PREDICT v2.2.5.
|
||||||
|
*/
|
||||||
|
internal fun eciToGeodetic(julUtc: Double, eciPos: DoubleArray): DoubleArray {
|
||||||
|
val thetaPos = atan2(eciPos[1], eciPos[0])
|
||||||
|
val lon = mod2PI(thetaPos - thetaGJD(julUtc))
|
||||||
|
val r = sqrt(eciPos[0] * eciPos[0] + eciPos[1] * eciPos[1])
|
||||||
|
val e2 = FLAT_FACT * (2.0 - FLAT_FACT)
|
||||||
|
var lat = atan2(eciPos[2], r)
|
||||||
|
var phi: Double
|
||||||
|
var c: Double
|
||||||
|
var i = 0
|
||||||
|
do {
|
||||||
|
phi = lat
|
||||||
|
c = 1.0 / sqrt(1.0 - e2 * sin(phi) * sin(phi))
|
||||||
|
lat = atan2(eciPos[2] + EARTH_RADIUS * c * e2 * sin(phi), r)
|
||||||
|
} while (i++ < 10 && abs(lat - phi) >= 1E-10)
|
||||||
|
val alt = r / cos(lat) - EARTH_RADIUS * c
|
||||||
|
if (lat > PI_2) lat -= TWO_PI
|
||||||
|
return doubleArrayOf(lat, lon, alt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private helpers
|
||||||
|
|
||||||
|
private fun mod360(x: Double): Double {
|
||||||
|
var r = x
|
||||||
|
val i = (r / 360.0).toInt()
|
||||||
|
r -= i * 360.0
|
||||||
|
if (r < 0.0) r += 360.0
|
||||||
|
return r
|
||||||
|
}
|
||||||
+8
-42
@@ -314,14 +314,8 @@ abstract class OrbitalObject(val data: OrbitalData) {
|
|||||||
return 1.0 / value
|
return 1.0 / value
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculates the modulus of 2 * PI
|
// Delegates to package-level mod2PI in OrbitalMath.kt
|
||||||
internal fun mod2PI(value: Double): Double {
|
internal fun mod2PI(value: Double): Double = com.rtbishop.look4sat.core.domain.predict.mod2PI(value)
|
||||||
var retVal = value
|
|
||||||
val i = (retVal / TWO_PI).toInt()
|
|
||||||
retVal -= i * TWO_PI
|
|
||||||
if (retVal < 0.0) retVal += TWO_PI
|
|
||||||
return retVal
|
|
||||||
}
|
|
||||||
|
|
||||||
// Solves Keplers' Equation
|
// Solves Keplers' Equation
|
||||||
internal fun converge(temp: DoubleArray, axn: Double, ayn: Double, capu: Double) {
|
internal fun converge(temp: DoubleArray, axn: Double, ayn: Double, capu: Double) {
|
||||||
@@ -423,19 +417,8 @@ abstract class OrbitalObject(val data: OrbitalData) {
|
|||||||
return acos(dot(v1, v2) / (v1.w * v2.w))
|
return acos(dot(v1, v2) / (v1.w * v2.w))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Delegates to package-level deltaET in OrbitalMath.kt
|
||||||
* The function Delta_ET has been added to allow calculations on the
|
private fun deltaEt(year: Double): Double = deltaET(year)
|
||||||
* position of the sun. It provides the difference between UT (approximately
|
|
||||||
* the same as UTC) and ET (now referred to as TDT) This function is based
|
|
||||||
* on the least squares fit of data from 1950 to 1991 and will need to be
|
|
||||||
* updated periodically.
|
|
||||||
*
|
|
||||||
* Values determined using data from 1950-1991 in the 1990 Astronomical
|
|
||||||
* Almanac. See DELTA_ET.WQ1 for details.
|
|
||||||
*/
|
|
||||||
private fun deltaEt(year: Double): Double {
|
|
||||||
return 26.465 + 0.747622 * (year - 1950) + (1.886913 * sin(TWO_PI * (year - 1975) / 33))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun radians(degrees: Double): Double {
|
private fun radians(degrees: Double): Double {
|
||||||
return degrees * DEG2RAD
|
return degrees * DEG2RAD
|
||||||
@@ -446,23 +429,13 @@ abstract class OrbitalObject(val data: OrbitalData) {
|
|||||||
return v1.x * v2.x + v1.y * v2.y + v1.z * v2.z
|
return v1.x * v2.x + v1.y * v2.y + v1.z * v2.z
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns fractional part of double argument
|
|
||||||
private fun fraction(arg: Double): Double {
|
|
||||||
return arg - floor(arg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculates scalar magnitude of a vector4 argument
|
// Calculates scalar magnitude of a vector4 argument
|
||||||
private fun magnitude(v: Vector4) {
|
private fun magnitude(v: Vector4) {
|
||||||
v.w = sqrt(sqr(v.x) + sqr(v.y) + sqr(v.z))
|
v.w = sqrt(sqr(v.x) + sqr(v.y) + sqr(v.z))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun modulus(arg1: Double, arg2: Double = SEC_PER_DAY): Double {
|
private fun modulus(arg1: Double, arg2: Double = SEC_PER_DAY): Double =
|
||||||
var returnValue = arg1
|
com.rtbishop.look4sat.core.domain.predict.modulus(arg1, arg2)
|
||||||
val i = floor(returnValue / arg2).toInt()
|
|
||||||
returnValue -= i * arg2
|
|
||||||
if (returnValue < 0.0) returnValue += arg2
|
|
||||||
return returnValue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multiplies the vector v1 by the scalar k
|
// Multiplies the vector v1 by the scalar k
|
||||||
private fun scaleVector(k: Double, v: Vector4) {
|
private fun scaleVector(k: Double, v: Vector4) {
|
||||||
@@ -470,13 +443,6 @@ abstract class OrbitalObject(val data: OrbitalData) {
|
|||||||
magnitude(v)
|
magnitude(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun thetaGJD(theJD: Double): Double {
|
// Delegates to package-level thetaGJD in OrbitalMath.kt
|
||||||
val earthRotPerSidDay = 1.00273790934
|
private fun thetaGJD(theJD: Double): Double = com.rtbishop.look4sat.core.domain.predict.thetaGJD(theJD)
|
||||||
val ut = fraction(theJD + 0.5)
|
|
||||||
val aJD = theJD - ut
|
|
||||||
val tu = (aJD - 2451545.0) / 36525.0
|
|
||||||
var gmst = 24110.54841 + tu * (8640184.812866 + tu * (0.093104 - tu * 6.2E-6))
|
|
||||||
gmst = modulus(gmst + SEC_PER_DAY * earthRotPerSidDay * ut)
|
|
||||||
return TWO_PI * gmst / SEC_PER_DAY
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -25,9 +25,10 @@ data class OrbitalPass(
|
|||||||
val altitude: Int = 1000,
|
val altitude: Int = 1000,
|
||||||
val maxElevation: Double = 75.0,
|
val maxElevation: Double = 75.0,
|
||||||
val orbitalObject: OrbitalObject,
|
val orbitalObject: OrbitalObject,
|
||||||
val progress: Float = 0.0f
|
val progress: Float = 0.0f,
|
||||||
|
val hasDecayed: Boolean = false
|
||||||
) {
|
) {
|
||||||
val catNum: Int = orbitalObject.data.catnum
|
val catNum: Int = orbitalObject.data.catnum
|
||||||
val name: String = orbitalObject.data.name
|
val name: String = if (hasDecayed) "${orbitalObject.data.name} (decayed?)" else orbitalObject.data.name
|
||||||
val isDeepSpace: Boolean = orbitalObject.data.isDeepSpace
|
val isDeepSpace: Boolean = orbitalObject.data.isDeepSpace
|
||||||
}
|
}
|
||||||
@@ -63,10 +63,8 @@ data class OrbitalPos(
|
|||||||
val sinBeta = sin(beta)
|
val sinBeta = sin(beta)
|
||||||
for (azimuth in 0..720) {
|
for (azimuth in 0..720) {
|
||||||
val rads = azimuth * DEG2RAD
|
val rads = azimuth * DEG2RAD
|
||||||
val sinRads = sin(rads)
|
val lat = asin(sinLat * cosBeta + cosLat * sinBeta * cos(rads))
|
||||||
val cosRads = cos(rads)
|
val lon = longitude + atan2(sin(rads) * sinBeta * cosLat, cosBeta - sinLat * sin(lat))
|
||||||
val lat = asin(sinLat * cosBeta + cosLat * sinBeta * cosRads)
|
|
||||||
val lon = longitude + atan2(sinRads * sinBeta * cosLat, cosBeta - sinLat * sin(lat))
|
|
||||||
rangeCirclePoints.add(GeoPos(lat * RAD2DEG, lon * RAD2DEG))
|
rangeCirclePoints.add(GeoPos(lat * RAD2DEG, lon * RAD2DEG))
|
||||||
}
|
}
|
||||||
return rangeCirclePoints
|
return rangeCirclePoints
|
||||||
|
|||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
|
|
||||||
|
/** AMSAT satellite status data source */
|
||||||
|
interface IAmSatRepository {
|
||||||
|
/** Fetch and parse the AMSAT status page; null on failure */
|
||||||
|
suspend fun fetchStatus(): SatStatusPage?
|
||||||
|
|
||||||
|
/** Submit a public AMSAT satellite status report. */
|
||||||
|
suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult
|
||||||
|
}
|
||||||
+2
-2
@@ -18,8 +18,8 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.repository
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
interface IDatabaseRepo {
|
interface IDatabaseRepo {
|
||||||
suspend fun updateTLEFromFile(uri: String)
|
suspend fun updateTLEFromFile(uri: String): Int
|
||||||
suspend fun updateTransceiversFromFile(uri: String)
|
suspend fun updateTransceiversFromFile(uri: String): Int
|
||||||
suspend fun updateFromRemote()
|
suspend fun updateFromRemote()
|
||||||
suspend fun clearAllData()
|
suspend fun clearAllData()
|
||||||
}
|
}
|
||||||
+24
-1
@@ -1,6 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
package com.rtbishop.look4sat.core.domain.repository
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
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.usecase.IShowToast
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
||||||
@@ -10,6 +29,8 @@ interface IMainContainer {
|
|||||||
val selectionRepo: ISelectionRepo
|
val selectionRepo: ISelectionRepo
|
||||||
val satelliteRepo: ISatelliteRepo
|
val satelliteRepo: ISatelliteRepo
|
||||||
val databaseRepo: IDatabaseRepo
|
val databaseRepo: IDatabaseRepo
|
||||||
|
val amSatRepo: IAmSatRepository
|
||||||
|
val radioTrackingService: IRadioTrackingService
|
||||||
fun provideAddToCalendar(): IAddToCalendar
|
fun provideAddToCalendar(): IAddToCalendar
|
||||||
fun provideShowToast(): IShowToast
|
fun provideShowToast(): IShowToast
|
||||||
fun provideBluetoothReporter(): IReporter
|
fun provideBluetoothReporter(): IReporter
|
||||||
@@ -17,7 +38,9 @@ interface IMainContainer {
|
|||||||
fun provideSensorsRepo(): ISensorsRepo
|
fun provideSensorsRepo(): ISensorsRepo
|
||||||
fun provideTxRadioController(): IRadioController
|
fun provideTxRadioController(): IRadioController
|
||||||
fun provideRxRadioController(): IRadioController
|
fun provideRxRadioController(): IRadioController
|
||||||
val radioTrackingService: IRadioTrackingService
|
fun provideAudioCapture(): IAudioCapture
|
||||||
|
fun provideSaveImage(): ISaveImage
|
||||||
|
fun providePairedBluetoothDevices(): List<Pair<String, String>>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IContainerProvider {
|
interface IContainerProvider {
|
||||||
|
|||||||
+47
@@ -38,4 +38,51 @@ interface IRadioController {
|
|||||||
suspend fun pttOn(): Boolean
|
suspend fun pttOn(): Boolean
|
||||||
|
|
||||||
suspend fun pttOff(): Boolean
|
suspend fun pttOff(): Boolean
|
||||||
|
|
||||||
|
// ── Extended operations (IC-705 / CI-V) ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select the band matching [frequencyHz] via the band stacking register.
|
||||||
|
* Must be called before [setFrequency] and [setMode] when first tracking.
|
||||||
|
* Default: no-op (Yaesu radios auto-switch band via frequency).
|
||||||
|
*/
|
||||||
|
suspend fun setBand(frequencyHz: Long): Boolean = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select the active VFO.
|
||||||
|
* @param vfoA true → VFO-A (main/RX), false → VFO-B (sub/TX in split).
|
||||||
|
*/
|
||||||
|
suspend fun setVfo(vfoA: Boolean): Boolean = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable SPLIT mode (TX on sub-VFO, RX on main VFO).
|
||||||
|
* Default: not supported.
|
||||||
|
*/
|
||||||
|
suspend fun setSplitMode(enabled: Boolean): Boolean = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the frequency of the currently active VFO (IC-705: CMD 0x25 sub 0x00).
|
||||||
|
* Default: delegates to [setFrequency].
|
||||||
|
*/
|
||||||
|
suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = setFrequency(frequencyHz)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the frequency of the inactive/TX VFO (IC-705: CMD 0x25 sub 0x01).
|
||||||
|
* Sent every tracking cycle alongside [setWorkingFrequency] in split mode.
|
||||||
|
* Default: delegates to [setWorkingFrequency].
|
||||||
|
*/
|
||||||
|
suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = setWorkingFrequency(frequencyHz)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the frequency of the currently active VFO (IC-705: CMD 0x25 sub 0x00).
|
||||||
|
* Default: delegates to [readFrequencyAndMode].
|
||||||
|
*/
|
||||||
|
suspend fun readWorkingFrequency(): Long? = readFrequencyAndMode()?.first
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the frequency of the inactive/TX VFO (IC-705: CMD 0x25 sub 0x01).
|
||||||
|
* Used for tuning detection in split mode.
|
||||||
|
* Default: delegates to [readWorkingFrequency].
|
||||||
|
*/
|
||||||
|
suspend fun readTxVfoFrequency(): Long? = readWorkingFrequency()
|
||||||
}
|
}
|
||||||
+15
-1
@@ -34,11 +34,25 @@ interface ISatelliteRepo {
|
|||||||
/** Whether the repo is currently calculating passes. */
|
/** Whether the repo is currently calculating passes. */
|
||||||
val isCalculating: StateFlow<Boolean>
|
val isCalculating: StateFlow<Boolean>
|
||||||
|
|
||||||
|
/** Currently selected pass (catNum + aosTime), persisted across screen navigations. */
|
||||||
|
val selectedPass: StateFlow<Pair<Int, Long>>
|
||||||
|
|
||||||
|
/** Set the currently selected pass. */
|
||||||
|
fun selectPass(catNum: Int, aosTime: Long)
|
||||||
|
|
||||||
/** Load satellite objects from DB based on the current selection. */
|
/** Load satellite objects from DB based on the current selection. */
|
||||||
suspend fun initRepository()
|
suspend fun initRepository()
|
||||||
|
|
||||||
/** Recalculate passes with the given filter parameters. */
|
/** Recalculate passes with the given filter parameters. */
|
||||||
suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>)
|
suspend fun calculatePasses(
|
||||||
|
time: Long,
|
||||||
|
hoursAhead: Int,
|
||||||
|
minElevation: Double,
|
||||||
|
aosStartMinute: Int,
|
||||||
|
aosEndMinute: Int,
|
||||||
|
invertAosTimeWindow: Boolean,
|
||||||
|
modes: List<String>
|
||||||
|
)
|
||||||
|
|
||||||
/** Get the current position of a single satellite. */
|
/** Get the current position of a single satellite. */
|
||||||
suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos
|
suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos
|
||||||
|
|||||||
+3
-3
@@ -21,10 +21,10 @@ import com.rtbishop.look4sat.core.domain.model.SatItem
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface ISelectionRepo {
|
interface ISelectionRepo {
|
||||||
fun getCurrentTypes(): List<String>
|
fun getCurrentModes(): List<String>
|
||||||
fun getTypesList(): List<String>
|
fun getModesList(): List<String>
|
||||||
suspend fun getEntriesFlow(): Flow<List<SatItem>>
|
suspend fun getEntriesFlow(): Flow<List<SatItem>>
|
||||||
suspend fun setTypes(types: List<String>)
|
suspend fun setModes(modes: List<String>)
|
||||||
suspend fun setQuery(query: String)
|
suspend fun setQuery(query: String)
|
||||||
suspend fun setSelection(selectAll: Boolean)
|
suspend fun setSelection(selectAll: Boolean)
|
||||||
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
|
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
interface ISensorsRepo {
|
interface ISensorsRepo {
|
||||||
val orientation: StateFlow<Pair<Float, Float>>
|
val sensorData: StateFlow<Pair<Float, Float>>
|
||||||
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
||||||
fun enableSensor()
|
fun enableSensor()
|
||||||
fun disableSensor()
|
fun disableSensor()
|
||||||
|
|||||||
+18
-4
@@ -29,12 +29,13 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
interface ISettingsRepo {
|
interface ISettingsRepo {
|
||||||
|
|
||||||
val appVersionName: String
|
val appVersionName: String
|
||||||
|
val appVersionCode: Long
|
||||||
|
|
||||||
//region # Satellites selection settings
|
//region # Satellites selection settings
|
||||||
val selectedIds: StateFlow<List<Int>>
|
val selectedIds: StateFlow<List<Int>>
|
||||||
val selectedTypes: StateFlow<List<String>>
|
val selectedSatModes: StateFlow<List<String>>
|
||||||
fun setSelectedIds(ids: List<Int>)
|
fun setSelectedIds(ids: List<Int>)
|
||||||
fun setSelectedTypes(types: List<String>)
|
fun setSelectedSatModes(modes: List<String>)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Passes filter settings
|
//region # Passes filter settings
|
||||||
@@ -51,8 +52,6 @@ interface ISettingsRepo {
|
|||||||
|
|
||||||
//region # Database update settings
|
//region # Database update settings
|
||||||
val databaseState: StateFlow<DatabaseState>
|
val databaseState: StateFlow<DatabaseState>
|
||||||
fun getSatelliteTypesIds(types: List<String>): List<Int>
|
|
||||||
fun setSatelliteTypeIds(type: String, ids: List<Int>)
|
|
||||||
fun updateDatabaseState(state: DatabaseState)
|
fun updateDatabaseState(state: DatabaseState)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -73,8 +72,23 @@ interface ISettingsRepo {
|
|||||||
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
//region # Data sources status
|
||||||
|
val dataSourcesStatus: StateFlow<Map<String, Int>>
|
||||||
|
fun updateDataSourcesStatus(status: Map<String, Int>)
|
||||||
|
//endregion
|
||||||
|
|
||||||
//region # Radio control settings
|
//region # Radio control settings
|
||||||
val radioControlSettings: StateFlow<RadioControlSettings>
|
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||||
fun updateRadioControlSettings(settings: RadioControlSettings)
|
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
//region # Per-satellite calculator offset settings
|
||||||
|
fun getSatelliteOffset(catnum: Int): String
|
||||||
|
fun setSatelliteOffset(catnum: Int, offset: String)
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # AMSAT status report settings
|
||||||
|
fun getAmSatCallsign(): String
|
||||||
|
fun setAmSatCallsign(callsign: String)
|
||||||
|
//endregion
|
||||||
}
|
}
|
||||||
@@ -26,11 +26,18 @@ interface ILocalSource {
|
|||||||
suspend fun getEntriesTotal(): Int
|
suspend fun getEntriesTotal(): Int
|
||||||
suspend fun getEntriesList(): List<SatItem>
|
suspend fun getEntriesList(): List<SatItem>
|
||||||
suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject>
|
suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject>
|
||||||
|
suspend fun getEntriesEpochs(): Map<Int, Double>
|
||||||
|
suspend fun getEntriesNames(): Map<Int, String>
|
||||||
|
suspend fun renameEntries(names: Map<Int, String>)
|
||||||
|
suspend fun deleteEntriesWithIds(ids: List<Int>)
|
||||||
suspend fun insertEntries(entries: List<OrbitalData>)
|
suspend fun insertEntries(entries: List<OrbitalData>)
|
||||||
suspend fun deleteEntries()
|
suspend fun deleteEntries()
|
||||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||||
suspend fun getRadiosTotal(): Int
|
suspend fun getRadiosTotal(): Int
|
||||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||||
suspend fun insertRadios(radios: List<SatRadio>)
|
suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean)
|
||||||
suspend fun deleteRadios()
|
suspend fun deleteRadios()
|
||||||
|
|
||||||
|
/** Drops the transceivers provided by the remote sources, keeping the imported ones. */
|
||||||
|
suspend fun deleteManagedRadios()
|
||||||
}
|
}
|
||||||
+12
-1
@@ -19,7 +19,18 @@ package com.rtbishop.look4sat.core.domain.source
|
|||||||
|
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
|
/** Result of a network download: the HTTP status code and the response body stream. */
|
||||||
|
data class NetworkResult(val code: Int, val stream: InputStream?) {
|
||||||
|
companion object {
|
||||||
|
/** Code used when a request fails before any HTTP response is received. */
|
||||||
|
const val CONNECTION_ERROR = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface IRemoteSource {
|
interface IRemoteSource {
|
||||||
suspend fun getFileStream(uri: String): InputStream?
|
suspend fun getFileStream(uri: String): InputStream?
|
||||||
suspend fun getNetworkStream(url: String): InputStream?
|
suspend fun getNetworkStream(url: String): NetworkResult
|
||||||
|
suspend fun getAmSatCatalog(): String?
|
||||||
|
suspend fun getAmSatReports(hours: Int, limit: Int): String?
|
||||||
|
suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>?
|
||||||
}
|
}
|
||||||
@@ -18,35 +18,27 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.source
|
package com.rtbishop.look4sat.core.domain.source
|
||||||
|
|
||||||
object Sources {
|
object Sources {
|
||||||
const val RADIO_DATA_URL = "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
// Ordered from the most specific to the most generic source: the first source that knows a
|
||||||
val satelliteDataUrls = mapOf(
|
// satellite gets to name it, with the full catalog acting as the fallback.
|
||||||
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
val satelliteDataUrls = listOf(
|
||||||
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
"live.ariss.org/iss.txt",
|
||||||
"Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv",
|
"r4uab.ru/satonline.txt",
|
||||||
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
|
"mmccants.org/tles/classfd.zip",
|
||||||
"Education" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=education&FORMAT=csv",
|
"amsat.org/tle/current/nasabare.txt",
|
||||||
"Engineer" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=engineering&FORMAT=csv",
|
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||||
"Geostationary" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=geo&FORMAT=csv",
|
"db.satnogs.org/api/tle/?format=3le"
|
||||||
"Globalstar" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=globalstar&FORMAT=csv",
|
)
|
||||||
"GNSS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=gnss&FORMAT=csv",
|
val transceiversDataUrls = listOf(
|
||||||
"Intelsat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=intelsat&FORMAT=csv",
|
"r4uab.ru/transmitters.json",
|
||||||
"Iridium" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=iridium-NEXT&FORMAT=csv",
|
"db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||||
"Military" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=military&FORMAT=csv",
|
)
|
||||||
"New" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=last-30-days&FORMAT=csv",
|
val satelliteModes = listOf(
|
||||||
"OneWeb" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=oneweb&FORMAT=csv",
|
"4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK",
|
||||||
"Orbcomm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=orbcomm&FORMAT=csv",
|
"BPSK PMT-A3", "CERTO", "CW", "DATV", "DBPSK", "DOKA", "DPSK", "DQPSK", "DSB", "DSTAR",
|
||||||
"Resource" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=resource&FORMAT=csv",
|
"DUV", "DVB-S2", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5", "FSK AX.100 Mode 6",
|
||||||
"SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv",
|
"FSK AX.25 G3RUH", "FT8", "GENESIS FSK", "GFSK", "GFSK Pkst", "GFSK Rktr", "GFSK/BPSK",
|
||||||
"Science" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=science&FORMAT=csv",
|
"GMSK", "GMSK USP", "HRPT", "LoRa", "LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5",
|
||||||
"Spire" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=spire&FORMAT=csv",
|
"MSK AX.100 Mode 6", "OFDM", "OQPSK", "PPM", "PSK", "PSK31", "PSK63", "QPSK", "QPSK31",
|
||||||
"Starlink" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=csv",
|
"QPSK63", "SIDLOC", "SQPSK", "SSDV", "SSTV", "UNKNOWN", "USB", "WSJT"
|
||||||
"Swarm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=swarm&FORMAT=csv",
|
|
||||||
"Weather" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=csv",
|
|
||||||
"X-Comm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=x-comm&FORMAT=csv",
|
|
||||||
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
|
|
||||||
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
|
||||||
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
|
|
||||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
|
||||||
"Other" to "" // key for sats filter
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,816 @@
|
|||||||
|
/*
|
||||||
|
* 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.sstv
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.round
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
class SstvFrame(
|
||||||
|
val scopePixels: IntArray?,
|
||||||
|
val scopeWidth: Int,
|
||||||
|
val scopeHeight: Int,
|
||||||
|
val imagePixels: IntArray?,
|
||||||
|
val imageWidth: Int,
|
||||||
|
val imageHeight: Int,
|
||||||
|
val modeName: String,
|
||||||
|
val imageComplete: Boolean,
|
||||||
|
val inputRms: Float,
|
||||||
|
val appliedGain: Float,
|
||||||
|
val syncHitRate: Float,
|
||||||
|
val predictedLineBursts: Int,
|
||||||
|
val maxPredictedStreak: Int,
|
||||||
|
val timingErrorSamples: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoder quality metrics for diagnostics and logging. Useful for profiling decode
|
||||||
|
* performance on noisy recordings.
|
||||||
|
*/
|
||||||
|
data class SstvQualityMetrics(
|
||||||
|
val syncHitRate: Float,
|
||||||
|
val predictedLineBursts: Int,
|
||||||
|
val maxPredictedStreak: Int,
|
||||||
|
val timingErrorSamples: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class LineRecoveryStrategy {
|
||||||
|
Look4SatLimited,
|
||||||
|
Robot36Compatible
|
||||||
|
}
|
||||||
|
|
||||||
|
class SstvDiagnosticsHandle internal constructor(
|
||||||
|
val enabled: Boolean,
|
||||||
|
val metrics: StateFlow<SstvQualityMetrics?>
|
||||||
|
)
|
||||||
|
|
||||||
|
class SstvDecoder(
|
||||||
|
sampleRate: Int = 44100,
|
||||||
|
scopeWidth: Int = 320,
|
||||||
|
scopeHeight: Int = 256,
|
||||||
|
private val channelSelect: Int = 0,
|
||||||
|
targetRmsLevel: Float = 0.25f,
|
||||||
|
private val includeScopeData: Boolean = false,
|
||||||
|
private val enableRmsNormalization: Boolean = true,
|
||||||
|
preFilterCutoffHz: Double = 500.0,
|
||||||
|
enablePreFilter: Boolean = true,
|
||||||
|
private val enableDiagnosticsHandle: Boolean = false,
|
||||||
|
lineRecoveryStrategy: LineRecoveryStrategy = LineRecoveryStrategy.Look4SatLimited
|
||||||
|
) {
|
||||||
|
private val scopeBuffer = PixelBuffer(scopeWidth, scopeHeight * 2)
|
||||||
|
private val imageBuffer = PixelBuffer(scopeWidth, scopeHeight)
|
||||||
|
private val decoder = DecoderEngine(scopeBuffer, imageBuffer, "Raw", sampleRate, lineRecoveryStrategy)
|
||||||
|
private val _frames = MutableSharedFlow<SstvFrame>(
|
||||||
|
replay = 1,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||||
|
)
|
||||||
|
private var lastInputRms = 0f
|
||||||
|
private var lastAppliedGain = 1f
|
||||||
|
private val _qualityMetrics = MutableStateFlow<SstvQualityMetrics?>(null)
|
||||||
|
private val preFilter = if (enablePreFilter) HighPassFilter(preFilterCutoffHz, sampleRate.toDouble()) else null
|
||||||
|
private val diagnosticsHandle = SstvDiagnosticsHandle(enableDiagnosticsHandle, _qualityMetrics)
|
||||||
|
val frames: SharedFlow<SstvFrame> = _frames
|
||||||
|
val supportedModes: List<String> = decoder.allModes.map { it.name }
|
||||||
|
|
||||||
|
suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) {
|
||||||
|
// Optional pre-filtering: remove DC offset and subsonic noise that can mask
|
||||||
|
// weak signals and corrupt the RMS normalization baseline.
|
||||||
|
preFilter?.apply(samples)
|
||||||
|
|
||||||
|
// Optional RMS normalization: bring input to a consistent level so the
|
||||||
|
// FM demodulator operates in a predictable region. However, this amplifies
|
||||||
|
// noise proportionally. Aggressive RMS targets (e.g., 0.25) can hurt weak
|
||||||
|
// signals by boosting noise floor. Safer defaults: 0.35-0.50 for noisy inputs.
|
||||||
|
// Disable entirely for direct line-level inputs (e.g., receiver discriminator).
|
||||||
|
val gain = if (enableRmsNormalization) normalise(samples) else GainInfo(0f, 1f)
|
||||||
|
lastInputRms = gain.inputRms
|
||||||
|
lastAppliedGain = gain.appliedGain
|
||||||
|
val hasNewLines = decoder.process(samples, channelSelect)
|
||||||
|
if (hasNewLines) emitFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun lockMode(modeName: String) = decoder.setMode(modeName)
|
||||||
|
|
||||||
|
fun clearPixels() {
|
||||||
|
imageBuffer.line = -1
|
||||||
|
imageBuffer.pixels.fill(0)
|
||||||
|
decoder.resetQuality()
|
||||||
|
if (enableDiagnosticsHandle) _qualityMetrics.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDiagnosticsHandle(): SstvDiagnosticsHandle? = diagnosticsHandle.takeIf { it.enabled }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export quality metrics for logging/diagnostics. Useful for profiling decode
|
||||||
|
* performance on noisy recordings. Returns null before first frame is emitted.
|
||||||
|
*/
|
||||||
|
@Suppress("unused")
|
||||||
|
fun getQualityMetrics(): SstvQualityMetrics? {
|
||||||
|
if (enableDiagnosticsHandle) return _qualityMetrics.value
|
||||||
|
val q = decoder.quality()
|
||||||
|
return SstvQualityMetrics(
|
||||||
|
syncHitRate = q.syncHitRate,
|
||||||
|
predictedLineBursts = q.predictedLineBursts,
|
||||||
|
maxPredictedStreak = q.maxPredictedStreak,
|
||||||
|
timingErrorSamples = q.timingErrorSamples
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitFrame() {
|
||||||
|
val imageWidth = imageBuffer.width
|
||||||
|
val imageHeight = imageBuffer.height
|
||||||
|
val quality = decoder.quality()
|
||||||
|
if (enableDiagnosticsHandle) {
|
||||||
|
_qualityMetrics.value = SstvQualityMetrics(
|
||||||
|
syncHitRate = quality.syncHitRate,
|
||||||
|
predictedLineBursts = quality.predictedLineBursts,
|
||||||
|
maxPredictedStreak = quality.maxPredictedStreak,
|
||||||
|
timingErrorSamples = quality.timingErrorSamples
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val imageComplete = imageBuffer.line >= imageHeight && imageBuffer.line > 0
|
||||||
|
// Copy only the active image region — imageBuffer.pixels is pre-allocated
|
||||||
|
// at the maximum possible size (PD-290: 800×616), so we must not copyOf()
|
||||||
|
// the entire array and send padding pixels to the observer.
|
||||||
|
val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf(imageWidth * imageHeight) else null
|
||||||
|
val modeName = decoder.currentMode.name
|
||||||
|
val scopePixels = if (includeScopeData) scopeBuffer.pixels.copyOf() else null
|
||||||
|
val scopeWidth = if (includeScopeData) scopeBuffer.width else 0
|
||||||
|
val scopeHeight = if (includeScopeData) scopeBuffer.height else 0
|
||||||
|
_frames.tryEmit(
|
||||||
|
SstvFrame(
|
||||||
|
scopePixels = scopePixels,
|
||||||
|
scopeWidth = scopeWidth,
|
||||||
|
scopeHeight = scopeHeight,
|
||||||
|
imagePixels = imagePixels,
|
||||||
|
imageWidth = imageWidth,
|
||||||
|
imageHeight = imageHeight,
|
||||||
|
modeName = modeName,
|
||||||
|
imageComplete = imageComplete,
|
||||||
|
inputRms = lastInputRms,
|
||||||
|
appliedGain = lastAppliedGain,
|
||||||
|
syncHitRate = quality.syncHitRate,
|
||||||
|
predictedLineBursts = quality.predictedLineBursts,
|
||||||
|
maxPredictedStreak = quality.maxPredictedStreak,
|
||||||
|
timingErrorSamples = quality.timingErrorSamples
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target RMS level for the normalizer. 0.25 leaves headroom while keeping the
|
||||||
|
// FM demodulator well above its noise floor regardless of input gain.
|
||||||
|
// TUNING GUIDE:
|
||||||
|
// - 0.20-0.25: Aggressive, best for clean direct-coupled inputs, worst for mic noise
|
||||||
|
// - 0.35-0.40: Moderate, good balance for typical phone/mic inputs (RECOMMENDED)
|
||||||
|
// - 0.50-0.60: Conservative, best for noisy environments, reduces amplitude resolution
|
||||||
|
// Disable RMS normalization entirely if using a professional receiver discriminator output.
|
||||||
|
private val targetRms = targetRmsLevel.coerceIn(0.05f, 0.8f)
|
||||||
|
|
||||||
|
private class GainInfo(
|
||||||
|
val inputRms: Float,
|
||||||
|
val appliedGain: Float
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bring the buffer to a fixed RMS so that microphone and direct-coupled inputs
|
||||||
|
// both decode reliably. The guard prevents amplifying pure silence into noise.
|
||||||
|
private fun normalise(buffer: FloatArray): GainInfo {
|
||||||
|
var sumSq = 0f
|
||||||
|
for (s in buffer) sumSq += s * s
|
||||||
|
val rms = sqrt(sumSq / buffer.size)
|
||||||
|
if (rms > 1e-6f) {
|
||||||
|
val gain = targetRms / rms
|
||||||
|
for (i in buffer.indices) buffer[i] *= gain
|
||||||
|
return GainInfo(rms, gain)
|
||||||
|
}
|
||||||
|
return GainInfo(rms, 1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple high-pass filter to remove DC offset and subsonic interference before RMS
|
||||||
|
* normalization. Improves noise floor estimation and prevents low-freq noise from
|
||||||
|
* corrupting the gain calculation.
|
||||||
|
*
|
||||||
|
* Design: First-order butterworth (pole at cutoff frequency). Fast, minimal latency,
|
||||||
|
* suitable for real-time preprocessing.
|
||||||
|
*/
|
||||||
|
internal class HighPassFilter(cutoffHz: Double, sampleRateHz: Double) {
|
||||||
|
private val alpha: Float
|
||||||
|
private var prevInput = 0f
|
||||||
|
private var prevOutput = 0f
|
||||||
|
|
||||||
|
init {
|
||||||
|
// First-order pole placement: alpha = wc / (wc + ws) where wc = 2*pi*fc, ws = 2*pi*fs
|
||||||
|
val omega = 2f * PI.toFloat() * (cutoffHz / sampleRateHz).toFloat()
|
||||||
|
alpha = omega / (omega + 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun apply(buffer: FloatArray) {
|
||||||
|
for (i in buffer.indices) {
|
||||||
|
val input = buffer[i]
|
||||||
|
prevOutput = alpha * (prevOutput + input - prevInput)
|
||||||
|
buffer[i] = prevOutput
|
||||||
|
prevInput = input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class DecoderQuality(
|
||||||
|
val syncHitRate: Float,
|
||||||
|
val predictedLineBursts: Int,
|
||||||
|
val maxPredictedStreak: Int,
|
||||||
|
val timingErrorSamples: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
internal enum class SyncPulseWidth { FiveMs, NineMs, TwentyMs }
|
||||||
|
|
||||||
|
internal class SyncPulseDetector(sampleRate: Int) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val SYNC_FREQ = 1200.0
|
||||||
|
const val BLACK_FREQ = 1500.0
|
||||||
|
const val WHITE_FREQ = 2300.0
|
||||||
|
}
|
||||||
|
|
||||||
|
var detectedWidth: SyncPulseWidth = SyncPulseWidth.NineMs; private set
|
||||||
|
var pulseOffset: Int = 0; private set
|
||||||
|
var freqOffset: Float = 0f; private set
|
||||||
|
|
||||||
|
private val bandwidth = WHITE_FREQ - BLACK_FREQ
|
||||||
|
private val fm = FmDemodulator(bandwidth, sampleRate.toDouble())
|
||||||
|
private val min5ms: Int = round(0.0025 * sampleRate).toInt()
|
||||||
|
private val max5ms: Int = round(0.007 * sampleRate).toInt()
|
||||||
|
private val max9ms: Int = round(0.0145 * sampleRate).toInt()
|
||||||
|
private val max20ms: Int = round(0.025 * sampleRate).toInt()
|
||||||
|
private val filterDelay: Int
|
||||||
|
private val avgFilter: MovingAverage
|
||||||
|
private val delayLine: Delay
|
||||||
|
private val lowPass: ComplexFirFilter
|
||||||
|
private val oscillator: Phasor
|
||||||
|
private val syncFreqValue: Float
|
||||||
|
private val syncFreqTolerance: Float
|
||||||
|
private val trigger: SchmittTrigger
|
||||||
|
private var counter = 0
|
||||||
|
private var baseBand = Complex()
|
||||||
|
|
||||||
|
init {
|
||||||
|
val filterLen = round(0.0025 * sampleRate).toInt() or 1
|
||||||
|
filterDelay = (filterLen - 1) / 2
|
||||||
|
avgFilter = MovingAverage(filterLen)
|
||||||
|
delayLine = Delay(filterLen)
|
||||||
|
val loFreq = 1000.0
|
||||||
|
val hiFreq = 2800.0
|
||||||
|
val cutoff = (hiFreq - loFreq) / 2
|
||||||
|
val lpLen = round(0.002 * sampleRate).toInt() or 1
|
||||||
|
lowPass = ComplexFirFilter(lpLen)
|
||||||
|
for (i in 0 until lpLen)
|
||||||
|
lowPass.taps[i] = (WindowFunctions.kaiser(2.0, i, lpLen) * WindowFunctions.sinc(
|
||||||
|
cutoff,
|
||||||
|
sampleRate.toDouble(),
|
||||||
|
i,
|
||||||
|
lpLen
|
||||||
|
)).toFloat()
|
||||||
|
val center = (loFreq + hiFreq) / 2
|
||||||
|
oscillator = Phasor(-center, sampleRate.toDouble())
|
||||||
|
syncFreqValue = ((SYNC_FREQ - center) * 2 / bandwidth).toFloat()
|
||||||
|
syncFreqTolerance = (50 * 2 / bandwidth).toFloat()
|
||||||
|
val porchFreq = 1500.0
|
||||||
|
val hiThresh = (SYNC_FREQ + porchFreq) / 2
|
||||||
|
val loThresh = (SYNC_FREQ + hiThresh) / 2
|
||||||
|
trigger = SchmittTrigger(
|
||||||
|
((loThresh - center) * 2 / bandwidth).toFloat(),
|
||||||
|
((hiThresh - center) * 2 / bandwidth).toFloat()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun process(buffer: FloatArray, channelSelect: Int): Boolean {
|
||||||
|
var detected = false
|
||||||
|
val channels = if (channelSelect > 0) 2 else 1
|
||||||
|
// NOTE: buffer[i] is overwritten in-place with the FM-demodulated frequency
|
||||||
|
// value for every mono sample (channelSelect == 0). DecoderEngine.process()
|
||||||
|
// reads back these values to populate scanLineBuffer. Callers must not reuse
|
||||||
|
// the buffer after this call.
|
||||||
|
for (i in 0 until buffer.size / channels) {
|
||||||
|
when (channelSelect) {
|
||||||
|
1 -> baseBand.set(buffer[2 * i])
|
||||||
|
2 -> baseBand.set(buffer[2 * i + 1])
|
||||||
|
3 -> baseBand.set(buffer[2 * i] + buffer[2 * i + 1])
|
||||||
|
4 -> baseBand.set(buffer[2 * i], buffer[2 * i + 1])
|
||||||
|
else -> baseBand.set(buffer[i])
|
||||||
|
}
|
||||||
|
baseBand = lowPass.filter(baseBand.mul(oscillator.rotate()))
|
||||||
|
val freq = fm.demodulate(baseBand)
|
||||||
|
val avg = avgFilter.avg(freq)
|
||||||
|
val delayed = delayLine.push(avg)
|
||||||
|
buffer[i] = freq
|
||||||
|
if (!trigger.process(avg)) {
|
||||||
|
++counter
|
||||||
|
} else if (counter !in min5ms..max20ms || abs(delayed - syncFreqValue) > syncFreqTolerance) {
|
||||||
|
counter = 0
|
||||||
|
} else {
|
||||||
|
detectedWidth = when {
|
||||||
|
counter < max5ms -> SyncPulseWidth.FiveMs
|
||||||
|
counter < max9ms -> SyncPulseWidth.NineMs
|
||||||
|
else -> SyncPulseWidth.TwentyMs
|
||||||
|
}
|
||||||
|
pulseOffset = i - filterDelay
|
||||||
|
freqOffset = delayed - syncFreqValue
|
||||||
|
detected = true
|
||||||
|
counter = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return detected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class DecoderEngine(
|
||||||
|
private val scopeBuffer: PixelBuffer,
|
||||||
|
private val imageBuffer: PixelBuffer,
|
||||||
|
rawName: String,
|
||||||
|
sampleRate: Int,
|
||||||
|
lineRecoveryStrategy: LineRecoveryStrategy
|
||||||
|
) {
|
||||||
|
private val pixelBuffer = PixelBuffer(800, 2)
|
||||||
|
private val detector = SyncPulseDetector(sampleRate)
|
||||||
|
private val pulseFilter: MovingAverage
|
||||||
|
private val pulseFilterDelay: Int
|
||||||
|
private val scanLineBuffer: FloatArray
|
||||||
|
private val scratch: FloatArray
|
||||||
|
private val sync5ms = IntArray(5)
|
||||||
|
private val sync9ms = IntArray(5)
|
||||||
|
private val sync20ms = IntArray(5)
|
||||||
|
private val lines5ms = IntArray(4)
|
||||||
|
private val lines9ms = IntArray(4)
|
||||||
|
private val lines20ms = IntArray(4)
|
||||||
|
private val offsets5ms = FloatArray(5)
|
||||||
|
private val offsets9ms = FloatArray(5)
|
||||||
|
private val offsets20ms = FloatArray(5)
|
||||||
|
private val visFreqs = FloatArray(10)
|
||||||
|
private val scanLineMin: Int
|
||||||
|
private val syncTolerance: Int
|
||||||
|
private val lineTolerance: Int
|
||||||
|
private val leaderLen: Int
|
||||||
|
private val leaderTol: Int
|
||||||
|
private val transition: Int
|
||||||
|
private val visBitLen: Int
|
||||||
|
private val visLen: Int
|
||||||
|
private val rawMode: SstvMode
|
||||||
|
private val modes5ms: ArrayList<SstvMode>
|
||||||
|
private val modes9ms: ArrayList<SstvMode>
|
||||||
|
private val modes20ms: ArrayList<SstvMode>
|
||||||
|
|
||||||
|
var currentMode: SstvMode; private set
|
||||||
|
val allModes: List<SstvMode> get() = modes5ms + modes9ms + modes20ms
|
||||||
|
|
||||||
|
private var lockMode = false
|
||||||
|
private var sample = 0
|
||||||
|
private var leaderBreak = 0
|
||||||
|
private var lastSync = 0
|
||||||
|
private var curLineSamples: Int
|
||||||
|
private var lastOffset = 0f
|
||||||
|
private var syncChecks = 0
|
||||||
|
private var syncHits = 0
|
||||||
|
private var predictedLineBursts = 0
|
||||||
|
private var predictedStreak = 0
|
||||||
|
private var maxPredictedStreak = 0
|
||||||
|
private var timingErrorSamples = 0
|
||||||
|
|
||||||
|
// Look4Sat strategy limits synthetic lines to avoid visible vertical collapse.
|
||||||
|
// Robot36 strategy preserves legacy behavior by allowing unlimited synthesis.
|
||||||
|
private val maxConsecutivePredictedLines = when (lineRecoveryStrategy) {
|
||||||
|
LineRecoveryStrategy.Look4SatLimited -> 2
|
||||||
|
LineRecoveryStrategy.Robot36Compatible -> Int.MAX_VALUE
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
imageBuffer.line = -1
|
||||||
|
// Pre-allocate for the largest possible mode (PD-290: 800×616 = 492 800 ints)
|
||||||
|
// so that handleHeader/processPulse can reuse the array with a fill(0) instead
|
||||||
|
// of allocating a fresh IntArray on every new image, reducing GC pressure.
|
||||||
|
imageBuffer.pixels = IntArray(800 * 616)
|
||||||
|
val pfLen = round(0.0025 * sampleRate).toInt() or 1
|
||||||
|
pulseFilterDelay = (pfLen - 1) / 2
|
||||||
|
pulseFilter = MovingAverage(pfLen)
|
||||||
|
scanLineBuffer = FloatArray(round(7.0 * sampleRate).toInt())
|
||||||
|
scratch = FloatArray(round(1.1 * sampleRate).toInt())
|
||||||
|
leaderLen = round(0.3 * sampleRate).toInt()
|
||||||
|
leaderTol = round(0.06 * sampleRate).toInt()
|
||||||
|
transition = round(0.0005 * sampleRate).toInt()
|
||||||
|
visBitLen = round(0.03 * sampleRate).toInt()
|
||||||
|
visLen = round(0.3 * sampleRate).toInt()
|
||||||
|
scanLineMin = round(0.05 * sampleRate).toInt()
|
||||||
|
syncTolerance = round(0.03 * sampleRate).toInt()
|
||||||
|
lineTolerance = round(0.001 * sampleRate).toInt()
|
||||||
|
rawMode = RawMode(rawName, sampleRate)
|
||||||
|
val robot36 = Robot36Mode(sampleRate)
|
||||||
|
currentMode = robot36
|
||||||
|
curLineSamples = robot36.scanLineSamples
|
||||||
|
modes5ms = arrayListOf(
|
||||||
|
RgbMode.wraaseSc2180(sampleRate),
|
||||||
|
RgbMode.martin("1", 44, 0.146432, sampleRate),
|
||||||
|
RgbMode.martin("2", 40, 0.073216, sampleRate)
|
||||||
|
)
|
||||||
|
modes9ms = arrayListOf(
|
||||||
|
robot36, Robot72Mode(sampleRate),
|
||||||
|
RgbMode.scottie("1", 60, 0.138240, sampleRate),
|
||||||
|
RgbMode.scottie("2", 56, 0.088064, sampleRate),
|
||||||
|
RgbMode.scottie("DX", 76, 0.3456, sampleRate)
|
||||||
|
)
|
||||||
|
modes20ms = arrayListOf(
|
||||||
|
PdMode("50", 93, 320, 256, 0.09152, sampleRate),
|
||||||
|
PdMode("90", 99, 320, 256, 0.17024, sampleRate),
|
||||||
|
PdMode("120", 95, 640, 496, 0.1216, sampleRate),
|
||||||
|
PdMode("160", 98, 512, 400, 0.195584, sampleRate),
|
||||||
|
PdMode("180", 96, 640, 496, 0.18304, sampleRate),
|
||||||
|
PdMode("240", 97, 640, 496, 0.24448, sampleRate),
|
||||||
|
PdMode("290", 94, 800, 616, 0.2288, sampleRate)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun process(recordBuffer: FloatArray, channelSelect: Int): Boolean {
|
||||||
|
var newLines = false
|
||||||
|
val detected = detector.process(recordBuffer, channelSelect)
|
||||||
|
syncChecks++
|
||||||
|
if (detected) {
|
||||||
|
syncHits++
|
||||||
|
predictedStreak = 0
|
||||||
|
}
|
||||||
|
var syncIdx = sample + detector.pulseOffset
|
||||||
|
val channels = if (channelSelect > 0) 2 else 1
|
||||||
|
for (j in 0 until recordBuffer.size / channels) {
|
||||||
|
if (sample >= scanLineBuffer.size) {
|
||||||
|
shift(curLineSamples)
|
||||||
|
syncIdx -= curLineSamples
|
||||||
|
if (sample >= scanLineBuffer.size) sample = scanLineBuffer.size - 1
|
||||||
|
}
|
||||||
|
scanLineBuffer[sample++] = recordBuffer[j]
|
||||||
|
}
|
||||||
|
if (detected) {
|
||||||
|
when (detector.detectedWidth) {
|
||||||
|
SyncPulseWidth.FiveMs -> newLines = processPulse(modes5ms, offsets5ms, sync5ms, lines5ms, syncIdx)
|
||||||
|
SyncPulseWidth.NineMs -> {
|
||||||
|
leaderBreak = syncIdx; newLines = processPulse(modes9ms, offsets9ms, sync9ms, lines9ms, syncIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncPulseWidth.TwentyMs -> {
|
||||||
|
leaderBreak = syncIdx; newLines = processPulse(modes20ms, offsets20ms, sync20ms, lines20ms, syncIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (handleHeader()) {
|
||||||
|
predictedStreak = 0
|
||||||
|
newLines = true
|
||||||
|
} else if (sample > lastSync + (curLineSamples * 5) / 4) {
|
||||||
|
newLines = decodePredictedLine()
|
||||||
|
}
|
||||||
|
return newLines
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMode(name: String) {
|
||||||
|
val mode = allModes.firstOrNull { it.name == name }
|
||||||
|
if (mode == currentMode) {
|
||||||
|
lockMode = true; return
|
||||||
|
}
|
||||||
|
if (mode != null) {
|
||||||
|
lockMode = true; imageBuffer.line = -1; currentMode = mode; curLineSamples = mode.scanLineSamples; return
|
||||||
|
}
|
||||||
|
lockMode = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun quality(): DecoderQuality {
|
||||||
|
val hitRate = if (syncChecks > 0) syncHits.toFloat() / syncChecks else 0f
|
||||||
|
return DecoderQuality(
|
||||||
|
syncHitRate = hitRate,
|
||||||
|
predictedLineBursts = predictedLineBursts,
|
||||||
|
maxPredictedStreak = maxPredictedStreak,
|
||||||
|
timingErrorSamples = timingErrorSamples
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetQuality() {
|
||||||
|
syncChecks = 0
|
||||||
|
syncHits = 0
|
||||||
|
predictedLineBursts = 0
|
||||||
|
predictedStreak = 0
|
||||||
|
maxPredictedStreak = 0
|
||||||
|
timingErrorSamples = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mean(a: IntArray): Double = a.sumOf { it.toDouble() } / a.size
|
||||||
|
private fun stdDev(a: IntArray, m: Double): Double {
|
||||||
|
var s = 0.0; for (v in a) s += (v - m) * (v - m); return sqrt(s / a.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun meanF(a: FloatArray): Float {
|
||||||
|
var s = 0f; for (v in a) s += v; return s / a.size
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detectMode(modes: ArrayList<SstvMode>, samples: Int): SstvMode {
|
||||||
|
var best: SstvMode = rawMode
|
||||||
|
var bestD = Int.MAX_VALUE
|
||||||
|
for (m in modes) {
|
||||||
|
val d = abs(samples - m.scanLineSamples); if (d <= lineTolerance && d < bestD) {
|
||||||
|
bestD = d; best = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeBuffer is twice the display height. Each decoded scan line is written
|
||||||
|
// to both the current rolling position (top half, wraps at height/2) and the
|
||||||
|
// same row offset in the bottom half. The UI displays a window that always
|
||||||
|
// spans the half-height boundary, giving a seamless non-wrapping scroll effect.
|
||||||
|
private fun copyUnscaled() {
|
||||||
|
val w = minOf(scopeBuffer.width, pixelBuffer.width)
|
||||||
|
for (row in 0 until pixelBuffer.height) {
|
||||||
|
val line = scopeBuffer.width * scopeBuffer.line
|
||||||
|
pixelBuffer.pixels.copyInto(scopeBuffer.pixels, line, row * pixelBuffer.width, row * pixelBuffer.width + w)
|
||||||
|
scopeBuffer.pixels.fill(0, line + w, line + scopeBuffer.width)
|
||||||
|
|
||||||
|
scopeBuffer.pixels.copyInto(
|
||||||
|
scopeBuffer.pixels,
|
||||||
|
scopeBuffer.width * (scopeBuffer.line + scopeBuffer.height / 2),
|
||||||
|
line,
|
||||||
|
line + scopeBuffer.width
|
||||||
|
)
|
||||||
|
scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyScaled(scale: Int) {
|
||||||
|
for (row in 0 until pixelBuffer.height) {
|
||||||
|
val line = scopeBuffer.width * scopeBuffer.line
|
||||||
|
for (col in 0 until pixelBuffer.width) for (i in 0 until scale) scopeBuffer.pixels[line + col * scale + i] =
|
||||||
|
pixelBuffer.pixels[pixelBuffer.width * row + col]
|
||||||
|
scopeBuffer.pixels.fill(0, line + pixelBuffer.width * scale, line + scopeBuffer.width)
|
||||||
|
|
||||||
|
scopeBuffer.pixels.copyInto(
|
||||||
|
scopeBuffer.pixels,
|
||||||
|
scopeBuffer.width * (scopeBuffer.line + scopeBuffer.height / 2),
|
||||||
|
line,
|
||||||
|
line + scopeBuffer.width
|
||||||
|
)
|
||||||
|
scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
|
||||||
|
repeat(scale - 1) {
|
||||||
|
|
||||||
|
scopeBuffer.pixels.copyInto(
|
||||||
|
scopeBuffer.pixels, scopeBuffer.width * scopeBuffer.line, line, line + scopeBuffer.width
|
||||||
|
)
|
||||||
|
|
||||||
|
scopeBuffer.pixels.copyInto(
|
||||||
|
scopeBuffer.pixels,
|
||||||
|
scopeBuffer.width * (scopeBuffer.line + scopeBuffer.height / 2),
|
||||||
|
line,
|
||||||
|
line + scopeBuffer.width
|
||||||
|
)
|
||||||
|
scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyLines(ok: Boolean) {
|
||||||
|
if (!ok) return
|
||||||
|
var finish = false
|
||||||
|
if (imageBuffer.line in 0 until imageBuffer.height && imageBuffer.width == pixelBuffer.width) {
|
||||||
|
val w = imageBuffer.width
|
||||||
|
for (row in 0 until pixelBuffer.height) {
|
||||||
|
if (imageBuffer.line >= imageBuffer.height) break
|
||||||
|
pixelBuffer.pixels.copyInto(imageBuffer.pixels, imageBuffer.line * w, row * w, row * w + w)
|
||||||
|
imageBuffer.line++
|
||||||
|
}
|
||||||
|
finish = imageBuffer.line == imageBuffer.height
|
||||||
|
}
|
||||||
|
val scale = scopeBuffer.width / pixelBuffer.width
|
||||||
|
if (scale <= 1) copyUnscaled() else copyScaled(scale)
|
||||||
|
if (finish) drawLines(0xff000000.toInt(), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodePredictedLine(): Boolean {
|
||||||
|
// Avoid long streaks of synthetic lines; once we exceed the cap we wait for
|
||||||
|
// real sync to reduce visible vertical compression on weak/noisy signals.
|
||||||
|
if (predictedStreak >= maxConsecutivePredictedLines) return false
|
||||||
|
val expectedSync = lastSync + curLineSamples
|
||||||
|
timingErrorSamples = sample - expectedSync
|
||||||
|
val decoded = currentMode.decodeScanLine(
|
||||||
|
pixelBuffer,
|
||||||
|
scratch,
|
||||||
|
scanLineBuffer,
|
||||||
|
scopeBuffer.width,
|
||||||
|
lastSync,
|
||||||
|
curLineSamples,
|
||||||
|
lastOffset
|
||||||
|
)
|
||||||
|
copyLines(decoded)
|
||||||
|
lastSync = expectedSync
|
||||||
|
predictedLineBursts++
|
||||||
|
predictedStreak++
|
||||||
|
if (predictedStreak > maxPredictedStreak) maxPredictedStreak = predictedStreak
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawLines(color: Int, count: Int) {
|
||||||
|
repeat(count) {
|
||||||
|
scopeBuffer.pixels.fill(
|
||||||
|
color,
|
||||||
|
scopeBuffer.line * scopeBuffer.width,
|
||||||
|
(scopeBuffer.line + 1) * scopeBuffer.width
|
||||||
|
)
|
||||||
|
scopeBuffer.pixels.fill(
|
||||||
|
color,
|
||||||
|
(scopeBuffer.line + scopeBuffer.height / 2) * scopeBuffer.width,
|
||||||
|
(scopeBuffer.line + 1 + scopeBuffer.height / 2) * scopeBuffer.width
|
||||||
|
)
|
||||||
|
scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adjust(pulses: IntArray, shift: Int) {
|
||||||
|
for (i in pulses.indices) pulses[i] -= shift
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shift(amount: Int) {
|
||||||
|
if ((amount <= 0) || (amount > sample)) return
|
||||||
|
sample -= amount; leaderBreak -= amount; lastSync -= amount
|
||||||
|
adjust(sync5ms, amount); adjust(sync9ms, amount); adjust(sync20ms, amount)
|
||||||
|
// Discard already-decoded samples by sliding the live region back to index 0.
|
||||||
|
// System.arraycopy handles the overlapping regions correctly and is a native
|
||||||
|
// memcpy on JVM, so this is fast despite moving the full remaining window.
|
||||||
|
scanLineBuffer.copyInto(scanLineBuffer, 0, amount, amount + sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleHeader(): Boolean {
|
||||||
|
if (leaderBreak < visBitLen + leaderTol || sample < leaderBreak + leaderLen + leaderTol + visLen + visBitLen) return false
|
||||||
|
val bp = leaderBreak; leaderBreak = 0
|
||||||
|
var preFreq = 0f
|
||||||
|
for (i in 0 until leaderTol) preFreq += scanLineBuffer[bp - visBitLen - leaderTol + i]
|
||||||
|
val toneFreq = 1900f
|
||||||
|
val center = 1900f
|
||||||
|
val tol = 50f
|
||||||
|
val halfBw = 400f
|
||||||
|
preFreq = preFreq * halfBw / leaderTol + center
|
||||||
|
if (abs(preFreq - toneFreq) > tol) return false
|
||||||
|
var ldrFreq = 0f
|
||||||
|
for (i in transition until leaderLen - leaderTol) ldrFreq += scanLineBuffer[bp + i]
|
||||||
|
val ldrOffset = ldrFreq / (leaderLen - transition - leaderTol)
|
||||||
|
ldrFreq = ldrOffset * halfBw + center
|
||||||
|
if (abs(ldrFreq - toneFreq) > tol) return false
|
||||||
|
val stopFreq = 1200f
|
||||||
|
val pulseThr = ((stopFreq + toneFreq) / 2 - center) / halfBw
|
||||||
|
var vBegin = bp + leaderLen - leaderTol
|
||||||
|
val vEnd = bp + leaderLen + leaderTol + visBitLen
|
||||||
|
repeat(pulseFilter.length) { pulseFilter.avg(scanLineBuffer[vBegin++] - ldrOffset) }
|
||||||
|
while (++vBegin < vEnd) if (pulseFilter.avg(scanLineBuffer[vBegin] - ldrOffset) < pulseThr) break
|
||||||
|
if (vBegin >= vEnd) return false
|
||||||
|
vBegin -= pulseFilterDelay
|
||||||
|
val visEnd = vBegin + visLen
|
||||||
|
visFreqs.fill(0f)
|
||||||
|
for (j in 0 until 10) for (i in transition until visBitLen - transition) visFreqs[j] += scanLineBuffer[vBegin + visBitLen * j + i] - ldrOffset
|
||||||
|
for (i in 0 until 10) visFreqs[i] = visFreqs[i] * halfBw / (visBitLen - 2 * transition) + center
|
||||||
|
if (abs(visFreqs[0] - stopFreq) > tol || abs(visFreqs[9] - stopFreq) > tol) return false
|
||||||
|
for (i in 1 until 9) if (abs(visFreqs[i] - 1100f) > tol && abs(visFreqs[i] - 1300f) > tol) return false
|
||||||
|
var vis = 0
|
||||||
|
for (i in 0 until 8) vis = vis or ((if (visFreqs[i + 1] < stopFreq) 1 else 0) shl i)
|
||||||
|
var chk = true; for (i in 0 until 8) chk = chk xor ((vis and (1 shl i)) != 0)
|
||||||
|
vis = vis and 127; if (!chk) return false
|
||||||
|
val syncThr = ((1200f + 1500f) / 2 - center) / halfBw
|
||||||
|
var sIdx = visEnd - visBitLen
|
||||||
|
val sMax = visEnd + visBitLen
|
||||||
|
repeat(pulseFilter.length) { pulseFilter.avg(scanLineBuffer[sIdx++] - ldrOffset) }
|
||||||
|
while (++sIdx < sMax) if (pulseFilter.avg(scanLineBuffer[sIdx] - ldrOffset) > syncThr) break
|
||||||
|
if (sIdx >= sMax) return false
|
||||||
|
sIdx -= pulseFilterDelay
|
||||||
|
val mode: SstvMode
|
||||||
|
val pulses: IntArray
|
||||||
|
val lines: IntArray
|
||||||
|
val f5 = modes5ms.firstOrNull { it.visCode == vis }
|
||||||
|
val f9 = modes9ms.firstOrNull { it.visCode == vis }
|
||||||
|
val f20 = modes20ms.firstOrNull { it.visCode == vis }
|
||||||
|
when {
|
||||||
|
f5 != null -> {
|
||||||
|
mode = f5; pulses = sync5ms; lines = lines5ms
|
||||||
|
}
|
||||||
|
|
||||||
|
f9 != null -> {
|
||||||
|
mode = f9; pulses = sync9ms; lines = lines9ms
|
||||||
|
}
|
||||||
|
|
||||||
|
f20 != null -> {
|
||||||
|
mode = f20; pulses = sync20ms; lines = lines20ms
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
if (!lockMode) drawLines(0xffff0000.toInt(), 8); return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lockMode && mode != currentMode) return false
|
||||||
|
mode.resetState()
|
||||||
|
imageBuffer.width = mode.width; imageBuffer.height = mode.height
|
||||||
|
imageBuffer.pixels.fill(0, 0, mode.width * mode.height); imageBuffer.line = 0
|
||||||
|
currentMode = mode
|
||||||
|
lastSync = sIdx + mode.firstSyncPulseIndex; curLineSamples = mode.scanLineSamples; lastOffset = ldrOffset
|
||||||
|
var oldest = lastSync - (pulses.size - 1) * curLineSamples
|
||||||
|
if (mode.firstSyncPulseIndex > 0) oldest -= curLineSamples
|
||||||
|
for (i in pulses.indices) pulses[i] = oldest + i * curLineSamples
|
||||||
|
lines.fill(curLineSamples)
|
||||||
|
shift(lastSync + mode.firstPixelSampleIndex)
|
||||||
|
drawLines(0xff00ff00.toInt(), 8); drawLines(0xff000000.toInt(), 10)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processPulse(
|
||||||
|
modes: ArrayList<SstvMode>,
|
||||||
|
freqOffs: FloatArray,
|
||||||
|
syncPulses: IntArray,
|
||||||
|
lineLen: IntArray,
|
||||||
|
latest: Int
|
||||||
|
): Boolean {
|
||||||
|
predictedStreak = 0
|
||||||
|
for (i in 1 until syncPulses.size) syncPulses[i - 1] = syncPulses[i]
|
||||||
|
syncPulses[syncPulses.size - 1] = latest
|
||||||
|
for (i in 1 until lineLen.size) lineLen[i - 1] = lineLen[i]
|
||||||
|
lineLen[lineLen.size - 1] = syncPulses.last() - syncPulses[syncPulses.size - 2]
|
||||||
|
for (i in 1 until freqOffs.size) freqOffs[i - 1] = freqOffs[i]
|
||||||
|
freqOffs[freqOffs.size - 1] = detector.freqOffset
|
||||||
|
if (lineLen[0] == 0) return false
|
||||||
|
val m = mean(lineLen)
|
||||||
|
val lineSamples = round(m).toInt()
|
||||||
|
if (lineSamples < scanLineMin || lineSamples > scratch.size) return false
|
||||||
|
if (stdDev(lineLen, m) > lineTolerance) return false
|
||||||
|
var changed = false
|
||||||
|
if (lockMode || imageBuffer.line in 0 until imageBuffer.height) {
|
||||||
|
if (currentMode != rawMode && abs(lineSamples - currentMode.scanLineSamples) > lineTolerance) return false
|
||||||
|
// Try continuous decoding
|
||||||
|
if (lockMode && imageBuffer.line == -1 && currentMode != rawMode) {
|
||||||
|
currentMode.resetState()
|
||||||
|
imageBuffer.width = currentMode.width
|
||||||
|
imageBuffer.height = currentMode.height
|
||||||
|
imageBuffer.pixels.fill(0, 0, currentMode.width * currentMode.height)
|
||||||
|
imageBuffer.line = 0
|
||||||
|
drawLines(0xff000000.toInt(), 10); drawLines(0xffffff00.toInt(), 8); drawLines(0xff000000.toInt(), 10)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val prev = currentMode; currentMode = detectMode(modes, lineSamples)
|
||||||
|
changed =
|
||||||
|
currentMode != prev || abs(curLineSamples - lineSamples) > lineTolerance || abs(lastSync + lineSamples - syncPulses.last()) > syncTolerance
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
drawLines(0xff000000.toInt(), 10); drawLines(0xff00ffff.toInt(), 8); drawLines(0xff000000.toInt(), 10)
|
||||||
|
}
|
||||||
|
val offset = meanF(freqOffs)
|
||||||
|
if (syncPulses[0] >= lineSamples && changed) {
|
||||||
|
val end = syncPulses[0]
|
||||||
|
val extra = end / lineSamples
|
||||||
|
val first = end - extra * lineSamples
|
||||||
|
var p = first; while (p < end) {
|
||||||
|
copyLines(
|
||||||
|
currentMode.decodeScanLine(
|
||||||
|
pixelBuffer,
|
||||||
|
scratch,
|
||||||
|
scanLineBuffer,
|
||||||
|
scopeBuffer.width,
|
||||||
|
p,
|
||||||
|
lineSamples,
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
); p += lineSamples
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val start = if (changed) 0 else lineLen.size - 1
|
||||||
|
for (i in start until lineLen.size) copyLines(
|
||||||
|
currentMode.decodeScanLine(
|
||||||
|
pixelBuffer,
|
||||||
|
scratch,
|
||||||
|
scanLineBuffer,
|
||||||
|
scopeBuffer.width,
|
||||||
|
syncPulses[i],
|
||||||
|
lineLen[i],
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lastSync = syncPulses.last(); curLineSamples = lineSamples; lastOffset = offset
|
||||||
|
shift(lastSync + currentMode.firstPixelSampleIndex)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/*
|
||||||
|
* 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.sstv
|
||||||
|
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.round
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
internal class Complex(var real: Float = 0f, var imag: Float = 0f) {
|
||||||
|
|
||||||
|
fun set(real: Float, imag: Float): Complex {
|
||||||
|
this.real = real; this.imag = imag; return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun set(real: Float): Complex = set(real, 0f)
|
||||||
|
|
||||||
|
fun abs(): Float = sqrt(real * real + imag * imag)
|
||||||
|
|
||||||
|
fun mul(other: Complex): Complex {
|
||||||
|
val tmp = real * other.real - imag * other.imag
|
||||||
|
imag = real * other.imag + imag * other.real
|
||||||
|
real = tmp
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun div(value: Float): Complex {
|
||||||
|
real /= value; imag /= value; return this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object WindowFunctions {
|
||||||
|
|
||||||
|
fun sinc(cutoff: Double, rate: Double, n: Int, nN: Int): Double {
|
||||||
|
val f = 2 * cutoff / rate
|
||||||
|
val x = n - (nN - 1) / 2.0
|
||||||
|
val fx = f * x
|
||||||
|
return if (fx == 0.0) f else f * sin(PI * fx) / (PI * fx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun kaiser(a: Double, n: Int, nN: Int): Double {
|
||||||
|
fun square(v: Double) = v * v
|
||||||
|
fun i0(x: Double): Double {
|
||||||
|
val terms = DoubleArray(35)
|
||||||
|
terms[0] = 1.0
|
||||||
|
var v = 1.0
|
||||||
|
for (m in 1 until 35) {
|
||||||
|
v *= x / (2 * m); terms[m] = square(v)
|
||||||
|
}
|
||||||
|
terms.sort()
|
||||||
|
var sum = 0.0; for (m in 34 downTo 0) sum += terms[m]
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
return i0(PI * a * sqrt(1 - square((2.0 * n) / (nN - 1) - 1))) / i0(PI * a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// O(1) ring buffer — simpler and faster than a segment tree for the short window
|
||||||
|
// lengths used here (≤512 samples). Float32 accumulated drift over such windows
|
||||||
|
// is ~6e-5, negligible for audio-frequency processing.
|
||||||
|
internal open class MovingSum(val length: Int) {
|
||||||
|
private val buf = FloatArray(length)
|
||||||
|
private var pos = 0
|
||||||
|
private var runningSum = 0f
|
||||||
|
|
||||||
|
fun add(input: Float) {
|
||||||
|
runningSum += input - buf[pos]
|
||||||
|
buf[pos] = input
|
||||||
|
if (++pos >= length) pos = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sum(): Float = runningSum
|
||||||
|
fun sum(input: Float): Float {
|
||||||
|
add(input); return sum()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class MovingAverage(length: Int) : MovingSum(length) {
|
||||||
|
fun avg(input: Float): Float = sum(input) / length
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class Ema {
|
||||||
|
private var alpha: Float = 1f
|
||||||
|
private var prev: Float = 0f
|
||||||
|
|
||||||
|
fun process(input: Float): Float {
|
||||||
|
prev = prev * (1 - alpha) + alpha * input; return prev
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCutoff(freq: Double, rate: Double, order: Int = 1) {
|
||||||
|
alpha = computeAlpha(freq, rate, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
prev = 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun computeAlpha(freq: Double, rate: Double, order: Int = 1): Float {
|
||||||
|
val x = cos(2 * PI * (freq.coerceAtMost(rate * 0.499)) / rate)
|
||||||
|
val discriminant = (x * (x - 4) + 3).coerceAtLeast(0.0)
|
||||||
|
return (x - 1 + sqrt(discriminant)).coerceIn(0.0, 1.0).pow(1.0 / order).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun withCutoff(freq: Double, rate: Double, order: Int = 1): Ema {
|
||||||
|
return Ema().also { it.setCutoff(freq, rate, order) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class Phasor(freq: Double, rate: Double) {
|
||||||
|
private val value = Complex(1f, 0f)
|
||||||
|
private val delta: Complex = run {
|
||||||
|
val omega = 2 * PI * freq / rate
|
||||||
|
Complex(cos(omega).toFloat(), sin(omega).toFloat())
|
||||||
|
}
|
||||||
|
private var count = 0
|
||||||
|
|
||||||
|
// Renormalize every 512 rotations to prevent magnitude drift accumulation,
|
||||||
|
// eliminating the per-sample sqrt without sacrificing demodulation accuracy.
|
||||||
|
fun rotate(): Complex {
|
||||||
|
value.mul(delta)
|
||||||
|
if (++count == 512) { value.div(value.abs()); count = 0 }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class FmDemodulator(bandwidth: Double, sampleRate: Double) {
|
||||||
|
private val scale = (sampleRate / (bandwidth * PI)).toFloat()
|
||||||
|
private val pi = PI.toFloat()
|
||||||
|
private val twoPi = (2 * PI).toFloat()
|
||||||
|
private var prev = 0f
|
||||||
|
|
||||||
|
fun demodulate(input: Complex): Float {
|
||||||
|
// Use fast polynomial atan2 instead of the exact trigonometric call.
|
||||||
|
// Max error ~0.005 rad translates to <1 Hz frequency error at 44100 Hz,
|
||||||
|
// well within the 50 Hz sync tolerance.
|
||||||
|
val phase = fastAtan2(input.imag, input.real)
|
||||||
|
var delta = phase - prev; prev = phase
|
||||||
|
if (delta < -pi) delta += twoPi else if (delta > pi) delta -= twoPi
|
||||||
|
return scale * delta
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rajan's polynomial approximation of atan2 — avoids a transcendental call
|
||||||
|
// in the per-sample hot path (~44 k calls/s at 44100 Hz sample rate).
|
||||||
|
private fun fastAtan2(y: Float, x: Float): Float {
|
||||||
|
val absY = kotlin.math.abs(y) + 1e-10f
|
||||||
|
val r: Float
|
||||||
|
val angle: Float
|
||||||
|
if (x >= 0f) {
|
||||||
|
r = (x - absY) / (x + absY)
|
||||||
|
angle = 0.1963f * r * r * r - 0.9817f * r + pi / 4f
|
||||||
|
} else {
|
||||||
|
r = (x + absY) / (absY - x)
|
||||||
|
angle = 0.1963f * r * r * r - 0.9817f * r + 3f * pi / 4f
|
||||||
|
}
|
||||||
|
return if (y < 0f) -angle else angle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class ComplexFirFilter(val length: Int) {
|
||||||
|
private val real = FloatArray(length)
|
||||||
|
private val imag = FloatArray(length)
|
||||||
|
private val sum = Complex()
|
||||||
|
private var pos = 0
|
||||||
|
|
||||||
|
val taps = FloatArray(length)
|
||||||
|
|
||||||
|
fun filter(input: Complex): Complex {
|
||||||
|
real[pos] = input.real; imag[pos] = input.imag
|
||||||
|
if (++pos >= length) pos = 0
|
||||||
|
sum.real = 0f; sum.imag = 0f
|
||||||
|
for (tap in taps) {
|
||||||
|
sum.real += tap * real[pos]; sum.imag += tap * imag[pos]; if (++pos >= length) pos = 0
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class Delay(val length: Int) {
|
||||||
|
private val buf = FloatArray(length)
|
||||||
|
private var pos = 0
|
||||||
|
|
||||||
|
fun push(input: Float): Float {
|
||||||
|
val tmp = buf[pos]; buf[pos] = input; if (++pos >= length) pos = 0; return tmp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class SchmittTrigger(private val low: Float, private val high: Float) {
|
||||||
|
private var state = false
|
||||||
|
|
||||||
|
fun process(input: Float): Boolean {
|
||||||
|
if (state) {
|
||||||
|
if (input < low) state = false
|
||||||
|
} else {
|
||||||
|
if (input > high) state = true
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object ColorConverter {
|
||||||
|
|
||||||
|
private fun clamp(v: Int) = v.coerceIn(0, 255)
|
||||||
|
private fun toInt(level: Float) = clamp(round(255 * level).toInt())
|
||||||
|
private fun compress(level: Float) = toInt(sqrt(level.coerceIn(0f, 1f)))
|
||||||
|
|
||||||
|
private fun yuv2rgb(yY: Int, uU: Int, vV: Int): Int {
|
||||||
|
val y = yY - 16
|
||||||
|
val u = uU - 128
|
||||||
|
val v = vV - 128
|
||||||
|
val r = clamp((298 * y + 409 * v + 128) shr 8)
|
||||||
|
val g = clamp((298 * y - 100 * u - 208 * v + 128) shr 8)
|
||||||
|
val b = clamp((298 * y + 516 * u + 128) shr 8)
|
||||||
|
return 0xff000000.toInt() or (r shl 16) or (g shl 8) or b
|
||||||
|
}
|
||||||
|
|
||||||
|
fun gray(level: Float): Int = 0xff000000.toInt() or (0x00010101 * compress(level))
|
||||||
|
fun rgb(r: Float, g: Float, b: Float): Int = 0xff000000.toInt() or (toInt(r) shl 16) or (toInt(g) shl 8) or toInt(b)
|
||||||
|
fun yuv2rgb(yY: Float, uU: Float, vV: Float): Int = yuv2rgb(toInt(yY), toInt(uU), toInt(vV))
|
||||||
|
fun yuv2rgb(packed: Int): Int = yuv2rgb((packed shr 16) and 0xff, (packed shr 8) and 0xff, packed and 0xff)
|
||||||
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
/*
|
||||||
|
* 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.sstv
|
||||||
|
|
||||||
|
import kotlin.math.round
|
||||||
|
|
||||||
|
internal class PixelBuffer(var width: Int, var height: Int) {
|
||||||
|
var pixels = IntArray(width * height)
|
||||||
|
var line = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed interface SstvMode {
|
||||||
|
val name: String
|
||||||
|
val visCode: Int
|
||||||
|
val width: Int
|
||||||
|
val height: Int
|
||||||
|
val firstPixelSampleIndex: Int
|
||||||
|
val firstSyncPulseIndex: Int
|
||||||
|
val scanLineSamples: Int
|
||||||
|
|
||||||
|
fun resetState() {}
|
||||||
|
|
||||||
|
fun decodeScanLine(
|
||||||
|
pixelBuffer: PixelBuffer,
|
||||||
|
scratch: FloatArray,
|
||||||
|
scanLine: FloatArray,
|
||||||
|
scopeWidth: Int,
|
||||||
|
syncPulseIndex: Int,
|
||||||
|
lineSamples: Int,
|
||||||
|
freqOffset: Float
|
||||||
|
): Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class RgbMode(
|
||||||
|
override val name: String,
|
||||||
|
override val visCode: Int,
|
||||||
|
private val hPixels: Int,
|
||||||
|
private val vPixels: Int,
|
||||||
|
override val firstSyncPulseIndex: Int,
|
||||||
|
override val scanLineSamples: Int,
|
||||||
|
override val firstPixelSampleIndex: Int,
|
||||||
|
private val redBegin: Int,
|
||||||
|
private val redLen: Int,
|
||||||
|
private val greenBegin: Int,
|
||||||
|
private val greenLen: Int,
|
||||||
|
private val blueBegin: Int,
|
||||||
|
private val blueLen: Int,
|
||||||
|
private val endSamples: Int,
|
||||||
|
) : SstvMode {
|
||||||
|
override val width get() = hPixels
|
||||||
|
override val height get() = vPixels
|
||||||
|
private val ema = Ema.withCutoff(hPixels.toDouble(), (2 * greenLen).toDouble(), 2)
|
||||||
|
|
||||||
|
override fun decodeScanLine(
|
||||||
|
pixelBuffer: PixelBuffer,
|
||||||
|
scratch: FloatArray,
|
||||||
|
scanLine: FloatArray,
|
||||||
|
scopeWidth: Int,
|
||||||
|
syncPulseIndex: Int,
|
||||||
|
lineSamples: Int,
|
||||||
|
freqOffset: Float
|
||||||
|
): Boolean {
|
||||||
|
val begin = firstPixelSampleIndex
|
||||||
|
if (syncPulseIndex + begin < 0 || syncPulseIndex + endSamples > scanLine.size) return false
|
||||||
|
ema.reset()
|
||||||
|
for (i in 0 until endSamples - begin) scratch[i] = ema.process(scanLine[syncPulseIndex + begin + i])
|
||||||
|
ema.reset()
|
||||||
|
for (i in endSamples - begin - 1 downTo 0) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
|
||||||
|
for (i in 0 until hPixels) {
|
||||||
|
val r = redBegin + (i * redLen) / hPixels
|
||||||
|
val g = greenBegin + (i * greenLen) / hPixels
|
||||||
|
val b = blueBegin + (i * blueLen) / hPixels
|
||||||
|
pixelBuffer.pixels[i] = ColorConverter.rgb(scratch[r], scratch[g], scratch[b])
|
||||||
|
}
|
||||||
|
pixelBuffer.width = hPixels; pixelBuffer.height = 1
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun martin(variant: String, code: Int, channelSec: Double, sampleRate: Int): RgbMode {
|
||||||
|
val sync = 0.004862
|
||||||
|
val sep = 0.000572
|
||||||
|
val scanLine = sync + sep + 3 * (channelSec + sep)
|
||||||
|
val gEnd = sep + channelSec
|
||||||
|
val bBegin = gEnd + sep
|
||||||
|
val bEnd = bBegin + channelSec
|
||||||
|
val rBegin = bEnd + sep
|
||||||
|
val rEnd = rBegin + channelSec
|
||||||
|
return fromSeconds(
|
||||||
|
name = "Martin $variant",
|
||||||
|
code = code,
|
||||||
|
firstSyncSec = 0.0,
|
||||||
|
scanLineSec = scanLine,
|
||||||
|
beginSec = sep,
|
||||||
|
rBeginSec = rBegin,
|
||||||
|
rEndSec = rEnd,
|
||||||
|
gBeginSec = sep,
|
||||||
|
gEndSec = gEnd,
|
||||||
|
bBeginSec = bBegin,
|
||||||
|
bEndSec = bEnd,
|
||||||
|
endSec = rEnd,
|
||||||
|
sr = sampleRate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scottie(variant: String, code: Int, channelSec: Double, sampleRate: Int): RgbMode {
|
||||||
|
val sync = 0.009
|
||||||
|
val sep = 0.0015
|
||||||
|
val firstSync = sync + 2 * (sep + channelSec)
|
||||||
|
val scanLine = sync + 3 * (channelSec + sep)
|
||||||
|
val bEnd = -sync
|
||||||
|
val bBegin = bEnd - channelSec
|
||||||
|
val gEnd = bBegin - sep
|
||||||
|
val gBegin = gEnd - channelSec
|
||||||
|
val rEnd = sep + channelSec
|
||||||
|
return fromSeconds(
|
||||||
|
name = "Scottie $variant",
|
||||||
|
code = code,
|
||||||
|
firstSyncSec = firstSync,
|
||||||
|
scanLineSec = scanLine,
|
||||||
|
beginSec = gBegin,
|
||||||
|
rBeginSec = sep,
|
||||||
|
rEndSec = rEnd,
|
||||||
|
gBeginSec = gBegin,
|
||||||
|
gEndSec = gEnd,
|
||||||
|
bBeginSec = bBegin,
|
||||||
|
bEndSec = bEnd,
|
||||||
|
endSec = rEnd,
|
||||||
|
sr = sampleRate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun wraaseSc2180(sampleRate: Int): RgbMode {
|
||||||
|
val sync = 0.0055225
|
||||||
|
val porch = 0.0005
|
||||||
|
val ch = 0.235
|
||||||
|
val scanLine = sync + porch + 3 * ch
|
||||||
|
val rEnd = porch + ch
|
||||||
|
val gEnd = rEnd + ch
|
||||||
|
val bEnd = gEnd + ch
|
||||||
|
return fromSeconds(
|
||||||
|
name = "Wraase SC2-180",
|
||||||
|
code = 55,
|
||||||
|
firstSyncSec = 0.0,
|
||||||
|
scanLineSec = scanLine,
|
||||||
|
beginSec = porch,
|
||||||
|
rBeginSec = porch,
|
||||||
|
rEndSec = rEnd,
|
||||||
|
gBeginSec = rEnd,
|
||||||
|
gEndSec = gEnd,
|
||||||
|
bBeginSec = gEnd,
|
||||||
|
bEndSec = bEnd,
|
||||||
|
endSec = bEnd,
|
||||||
|
sr = sampleRate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fromSeconds(
|
||||||
|
name: String, code: Int, w: Int = 320, h: Int = 256,
|
||||||
|
firstSyncSec: Double, scanLineSec: Double, beginSec: Double,
|
||||||
|
rBeginSec: Double, rEndSec: Double, gBeginSec: Double, gEndSec: Double,
|
||||||
|
bBeginSec: Double, bEndSec: Double, endSec: Double, sr: Int
|
||||||
|
): RgbMode {
|
||||||
|
val begin = round(beginSec * sr).toInt()
|
||||||
|
return RgbMode(
|
||||||
|
name = name, visCode = code, hPixels = w, vPixels = h,
|
||||||
|
firstSyncPulseIndex = round(firstSyncSec * sr).toInt(),
|
||||||
|
scanLineSamples = round(scanLineSec * sr).toInt(),
|
||||||
|
firstPixelSampleIndex = begin,
|
||||||
|
redBegin = round(rBeginSec * sr).toInt() - begin,
|
||||||
|
redLen = round((rEndSec - rBeginSec) * sr).toInt(),
|
||||||
|
greenBegin = round(gBeginSec * sr).toInt() - begin,
|
||||||
|
greenLen = round((gEndSec - gBeginSec) * sr).toInt(),
|
||||||
|
blueBegin = round(bBeginSec * sr).toInt() - begin,
|
||||||
|
blueLen = round((bEndSec - bBeginSec) * sr).toInt(),
|
||||||
|
endSamples = round(endSec * sr).toInt()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class Robot36Mode(sampleRate: Int) : SstvMode {
|
||||||
|
override val name = "Robot 36 Color"
|
||||||
|
override val visCode = 8
|
||||||
|
override val width = 320
|
||||||
|
override val height = 240
|
||||||
|
override val firstSyncPulseIndex = 0
|
||||||
|
override val scanLineSamples: Int
|
||||||
|
override val firstPixelSampleIndex: Int
|
||||||
|
|
||||||
|
private val lumSamples: Int
|
||||||
|
private val sepSamples: Int
|
||||||
|
private val chromSamples: Int
|
||||||
|
private val lumBegin: Int
|
||||||
|
private val sepBegin: Int
|
||||||
|
private val chromBegin: Int
|
||||||
|
private val end: Int
|
||||||
|
private val ema: Ema
|
||||||
|
private var lastEven = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
val syncPorch = 0.003
|
||||||
|
val lum = 0.088
|
||||||
|
val sep = 0.0045
|
||||||
|
val porch = 0.0015
|
||||||
|
val chrom = 0.044
|
||||||
|
scanLineSamples = round((0.009 + syncPorch + lum + sep + porch + chrom) * sampleRate).toInt()
|
||||||
|
lumSamples = round(lum * sampleRate).toInt(); sepSamples = round(sep * sampleRate).toInt()
|
||||||
|
chromSamples = round(chrom * sampleRate).toInt()
|
||||||
|
lumBegin = round(syncPorch * sampleRate).toInt(); firstPixelSampleIndex = lumBegin
|
||||||
|
sepBegin = round((syncPorch + lum) * sampleRate).toInt()
|
||||||
|
chromBegin = round((syncPorch + lum + sep + porch) * sampleRate).toInt()
|
||||||
|
end = round((syncPorch + lum + sep + porch + chrom) * sampleRate).toInt()
|
||||||
|
ema = Ema.withCutoff(width.toDouble(), (2 * lumSamples).toDouble(), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun resetState() {
|
||||||
|
lastEven = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun decodeScanLine(
|
||||||
|
pixelBuffer: PixelBuffer,
|
||||||
|
scratch: FloatArray,
|
||||||
|
scanLine: FloatArray,
|
||||||
|
scopeWidth: Int,
|
||||||
|
syncPulseIndex: Int,
|
||||||
|
lineSamples: Int,
|
||||||
|
freqOffset: Float
|
||||||
|
): Boolean {
|
||||||
|
if (syncPulseIndex + firstPixelSampleIndex < 0 || syncPulseIndex + end > scanLine.size) return false
|
||||||
|
var sep = 0f
|
||||||
|
for (i in 0 until sepSamples) sep += scanLine[syncPulseIndex + sepBegin + i]
|
||||||
|
sep = sep / sepSamples - freqOffset
|
||||||
|
var even = sep < 0
|
||||||
|
if (sep < -1.1f || (sep > -0.9f && sep < 0.9f) || sep > 1.1f) even = !lastEven
|
||||||
|
lastEven = even
|
||||||
|
ema.reset()
|
||||||
|
for (i in firstPixelSampleIndex until end) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
|
||||||
|
ema.reset()
|
||||||
|
for (i in end - 1 downTo firstPixelSampleIndex) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
|
||||||
|
for (i in 0 until width) {
|
||||||
|
val lPos = lumBegin + (i * lumSamples) / width
|
||||||
|
val cPos = chromBegin + (i * chromSamples) / width
|
||||||
|
if (even) {
|
||||||
|
// Even line: store Y in the red channel slot and Cr in the blue slot,
|
||||||
|
// using ColorConverter.rgb() as a convenient 3×byte packer (not RGB).
|
||||||
|
// The odd line will read these back and combine with its own Cb to
|
||||||
|
// produce the final YUV→RGB conversion for both rows.
|
||||||
|
pixelBuffer.pixels[i] = ColorConverter.rgb(scratch[lPos], 0f, scratch[cPos])
|
||||||
|
} else {
|
||||||
|
val evenYuv = pixelBuffer.pixels[i]
|
||||||
|
// Even pixel packing: 0xAARRGGBB → Y=RR, Cb=GG(unused), Cr=BB
|
||||||
|
// Odd pixel: Y=lPos, Cb=cPos, Cr=(borrowed from even's BB slot)
|
||||||
|
// Merge: take Y+Cr from even row (bits 0x00ff00ff) and Cb from odd (0x0000ff00).
|
||||||
|
val oddYuv = ColorConverter.rgb(scratch[lPos], scratch[cPos], 0f)
|
||||||
|
pixelBuffer.pixels[i] = ColorConverter.yuv2rgb((evenYuv and 0x00ff00ff) or (oddYuv and 0x0000ff00))
|
||||||
|
pixelBuffer.pixels[i + width] =
|
||||||
|
ColorConverter.yuv2rgb((oddYuv and 0x00ffff00) or (evenYuv and 0x000000ff))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pixelBuffer.width = width; pixelBuffer.height = 2
|
||||||
|
return !even
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class Robot72Mode(sampleRate: Int) : SstvMode {
|
||||||
|
override val name = "Robot 72 Color"
|
||||||
|
override val visCode = 12
|
||||||
|
override val width = 320
|
||||||
|
override val height = 240
|
||||||
|
override val firstSyncPulseIndex = 0
|
||||||
|
override val scanLineSamples: Int
|
||||||
|
override val firstPixelSampleIndex: Int
|
||||||
|
|
||||||
|
private val lumSamples: Int
|
||||||
|
private val chromSamples: Int
|
||||||
|
private val yBegin: Int
|
||||||
|
private val vBegin: Int
|
||||||
|
private val uBegin: Int
|
||||||
|
private val end: Int
|
||||||
|
private val ema: Ema
|
||||||
|
|
||||||
|
init {
|
||||||
|
val syncPorch = 0.003
|
||||||
|
val lum = 0.138
|
||||||
|
val sep = 0.0045
|
||||||
|
val porch = 0.0015
|
||||||
|
val chrom = 0.069
|
||||||
|
scanLineSamples = round((0.009 + syncPorch + lum + 2 * (sep + porch + chrom)) * sampleRate).toInt()
|
||||||
|
lumSamples = round(lum * sampleRate).toInt(); chromSamples = round(chrom * sampleRate).toInt()
|
||||||
|
yBegin = round(syncPorch * sampleRate).toInt(); firstPixelSampleIndex = yBegin
|
||||||
|
vBegin = round((syncPorch + lum + sep + porch) * sampleRate).toInt()
|
||||||
|
uBegin = round((syncPorch + lum + sep + porch + chrom + sep + porch) * sampleRate).toInt()
|
||||||
|
end = round((syncPorch + lum + 2 * (sep + porch + chrom)) * sampleRate).toInt()
|
||||||
|
ema = Ema.withCutoff(width.toDouble(), (2 * lumSamples).toDouble(), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun decodeScanLine(
|
||||||
|
pixelBuffer: PixelBuffer,
|
||||||
|
scratch: FloatArray,
|
||||||
|
scanLine: FloatArray,
|
||||||
|
scopeWidth: Int,
|
||||||
|
syncPulseIndex: Int,
|
||||||
|
lineSamples: Int,
|
||||||
|
freqOffset: Float
|
||||||
|
): Boolean {
|
||||||
|
if (syncPulseIndex + firstPixelSampleIndex < 0 || syncPulseIndex + end > scanLine.size) return false
|
||||||
|
ema.reset()
|
||||||
|
for (i in firstPixelSampleIndex until end) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
|
||||||
|
ema.reset()
|
||||||
|
for (i in end - 1 downTo firstPixelSampleIndex) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
|
||||||
|
for (i in 0 until width) {
|
||||||
|
val yP = yBegin + (i * lumSamples) / width
|
||||||
|
val uP = uBegin + (i * chromSamples) / width
|
||||||
|
val vP = vBegin + (i * chromSamples) / width
|
||||||
|
pixelBuffer.pixels[i] = ColorConverter.yuv2rgb(scratch[yP], scratch[uP], scratch[vP])
|
||||||
|
}
|
||||||
|
pixelBuffer.width = width; pixelBuffer.height = 1
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class PdMode(
|
||||||
|
variant: String,
|
||||||
|
override val visCode: Int,
|
||||||
|
private val hPixels: Int,
|
||||||
|
private val vPixels: Int,
|
||||||
|
channelSec: Double,
|
||||||
|
sampleRate: Int
|
||||||
|
) : SstvMode {
|
||||||
|
override val name = "PD $variant"
|
||||||
|
override val width get() = hPixels
|
||||||
|
override val height get() = vPixels
|
||||||
|
override val firstSyncPulseIndex = 0
|
||||||
|
override val scanLineSamples: Int
|
||||||
|
override val firstPixelSampleIndex: Int
|
||||||
|
|
||||||
|
private val chSamples: Int
|
||||||
|
private val yEvenBegin: Int
|
||||||
|
private val vAvgBegin: Int
|
||||||
|
private val uAvgBegin: Int
|
||||||
|
private val yOddBegin: Int
|
||||||
|
private val end: Int
|
||||||
|
private val ema: Ema
|
||||||
|
|
||||||
|
init {
|
||||||
|
val syncPorch = 0.00208
|
||||||
|
scanLineSamples = round((0.02 + syncPorch + 4 * channelSec) * sampleRate).toInt()
|
||||||
|
chSamples = round(channelSec * sampleRate).toInt()
|
||||||
|
yEvenBegin = round(syncPorch * sampleRate).toInt(); firstPixelSampleIndex = yEvenBegin
|
||||||
|
vAvgBegin = round((syncPorch + channelSec) * sampleRate).toInt()
|
||||||
|
uAvgBegin = round((syncPorch + 2 * channelSec) * sampleRate).toInt()
|
||||||
|
yOddBegin = round((syncPorch + 3 * channelSec) * sampleRate).toInt()
|
||||||
|
end = round((syncPorch + 4 * channelSec) * sampleRate).toInt()
|
||||||
|
ema = Ema.withCutoff(hPixels.toDouble(), (2 * chSamples).toDouble(), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun decodeScanLine(
|
||||||
|
pixelBuffer: PixelBuffer,
|
||||||
|
scratch: FloatArray,
|
||||||
|
scanLine: FloatArray,
|
||||||
|
scopeWidth: Int,
|
||||||
|
syncPulseIndex: Int,
|
||||||
|
lineSamples: Int,
|
||||||
|
freqOffset: Float
|
||||||
|
): Boolean {
|
||||||
|
if (syncPulseIndex + firstPixelSampleIndex < 0 || syncPulseIndex + end > scanLine.size) return false
|
||||||
|
ema.reset()
|
||||||
|
for (i in firstPixelSampleIndex until end) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
|
||||||
|
ema.reset()
|
||||||
|
for (i in end - 1 downTo firstPixelSampleIndex) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
|
||||||
|
for (i in 0 until hPixels) {
|
||||||
|
val pos = (i * chSamples) / hPixels
|
||||||
|
pixelBuffer.pixels[i] =
|
||||||
|
ColorConverter.yuv2rgb(scratch[pos + yEvenBegin], scratch[pos + uAvgBegin], scratch[pos + vAvgBegin])
|
||||||
|
pixelBuffer.pixels[i + hPixels] =
|
||||||
|
ColorConverter.yuv2rgb(scratch[pos + yOddBegin], scratch[pos + uAvgBegin], scratch[pos + vAvgBegin])
|
||||||
|
}
|
||||||
|
pixelBuffer.width = hPixels; pixelBuffer.height = 2
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class RawMode(override val name: String, sampleRate: Int) : SstvMode {
|
||||||
|
override val visCode = -1
|
||||||
|
override val width = -1
|
||||||
|
override val height = -1
|
||||||
|
override val firstPixelSampleIndex = 0
|
||||||
|
override val firstSyncPulseIndex = -1
|
||||||
|
override val scanLineSamples = -1
|
||||||
|
|
||||||
|
private val smallMax = round(0.125 * sampleRate).toInt()
|
||||||
|
private val medMax = round(0.175 * sampleRate).toInt()
|
||||||
|
private val ema = Ema()
|
||||||
|
|
||||||
|
override fun decodeScanLine(
|
||||||
|
pixelBuffer: PixelBuffer,
|
||||||
|
scratch: FloatArray,
|
||||||
|
scanLine: FloatArray,
|
||||||
|
scopeWidth: Int,
|
||||||
|
syncPulseIndex: Int,
|
||||||
|
lineSamples: Int,
|
||||||
|
freqOffset: Float
|
||||||
|
): Boolean {
|
||||||
|
if (syncPulseIndex < 0 || syncPulseIndex + lineSamples > scanLine.size) return false
|
||||||
|
var px = scopeWidth
|
||||||
|
if (lineSamples < smallMax) px /= 2
|
||||||
|
if (lineSamples < medMax) px /= 2
|
||||||
|
ema.setCutoff(px.toDouble(), (2 * lineSamples).toDouble(), 2); ema.reset()
|
||||||
|
for (i in 0 until lineSamples) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
|
||||||
|
ema.reset()
|
||||||
|
for (i in lineSamples - 1 downTo 0) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
|
||||||
|
for (i in 0 until px) pixelBuffer.pixels[i] = ColorConverter.gray(scratch[(i * lineSamples) / px])
|
||||||
|
pixelBuffer.width = px; pixelBuffer.height = 1
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun freqToLevel(frequency: Float, offset: Float): Float = 0.5f * (frequency - offset + 1f)
|
||||||
@@ -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.domain.usecase
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform abstraction for microphone audio capture.
|
||||||
|
* Produces a flow of mono Float PCM buffers at the configured sample rate.
|
||||||
|
*/
|
||||||
|
interface IAudioCapture {
|
||||||
|
val sampleRate: Int
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start capturing audio. Emits FloatArray buffers continuously until the flow is canceled.
|
||||||
|
* Caller is responsible for holding RECORD_AUDIO permission before calling this.
|
||||||
|
*/
|
||||||
|
fun audioFlow(): Flow<FloatArray>
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.usecase
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a decoded SSTV image to the device gallery.
|
||||||
|
* @return true if the image was saved successfully
|
||||||
|
*/
|
||||||
|
interface ISaveImage {
|
||||||
|
suspend operator fun invoke(pixels: IntArray, width: Int, height: Int, modeName: String): Boolean
|
||||||
|
}
|
||||||
@@ -33,10 +33,19 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
|||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
coerceInputValues = true
|
coerceInputValues = true
|
||||||
}
|
}
|
||||||
|
private val alpha5Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||||
|
private val celestrakCSVColumns = mapOf(
|
||||||
|
"OBJECT_NAME" to 0, "EPOCH" to 2, "MEAN_MOTION" to 3, "ECCENTRICITY" to 4,
|
||||||
|
"INCLINATION" to 5, "RA_OF_ASC_NODE" to 6, "ARG_OF_PERICENTER" to 7,
|
||||||
|
"MEAN_ANOMALY" to 8, "NORAD_CAT_ID" to 11, "BSTAR" to 14, "MEAN_MOTION_DOT" to 15
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
|
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
|
||||||
stream.bufferedReader().useLines { lines ->
|
stream.bufferedReader().useLines { lines ->
|
||||||
lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList()
|
val iterator = lines.iterator()
|
||||||
|
if (!iterator.hasNext()) return@useLines emptyList()
|
||||||
|
val columns = parseCSVColumns(iterator.next())
|
||||||
|
iterator.asSequence().mapNotNull { line -> parseCSV(line.split(","), columns) }.toList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,41 +67,68 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
|||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
private fun parseCSV(values: List<String>, columns: Map<String, Int>): OrbitalData? = runCatching {
|
||||||
|
fun value(column: String) = values[columns.getValue(column)].trim()
|
||||||
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
|
fun optionalValue(column: String) =
|
||||||
val name = values[0]
|
columns[column]?.let { index -> values.getOrNull(index) }?.trim()?.toDoubleOrNull() ?: 0.0
|
||||||
val timestamp = values[2]
|
|
||||||
val year = timestamp.substring(0, 4)
|
|
||||||
val month = timestamp.substring(5, 7).toInt()
|
|
||||||
val dayOfMonth = timestamp.substring(8, 10).toInt()
|
|
||||||
val dayInt = getDayOfYear(year.toInt(), month, dayOfMonth)
|
|
||||||
val day = dayInt.toString().padStart(3, '0')
|
|
||||||
val hour = timestamp.substring(11, 13).toInt() * 3600000
|
|
||||||
val min = timestamp.substring(14, 16).toInt() * 60000
|
|
||||||
val sec = timestamp.substring(17, 19).toInt() * 1000
|
|
||||||
val ms = timestamp.substring(20, 26).toInt() / 1000.0
|
|
||||||
val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1)
|
|
||||||
val epoch = "${year.substring(2)}$day$frac".toDouble()
|
|
||||||
OrbitalData(
|
OrbitalData(
|
||||||
name = name,
|
name = value("OBJECT_NAME"),
|
||||||
epoch = epoch,
|
epoch = parseTimestamp(value("EPOCH")),
|
||||||
meanmo = values[3].toDouble(),
|
meanmo = value("MEAN_MOTION").toDouble(),
|
||||||
eccn = values[4].toDouble(),
|
eccn = value("ECCENTRICITY").toDouble(),
|
||||||
incl = values[5].toDouble(),
|
incl = value("INCLINATION").toDouble(),
|
||||||
raan = values[6].toDouble(),
|
raan = value("RA_OF_ASC_NODE").toDouble(),
|
||||||
argper = values[7].toDouble(),
|
argper = value("ARG_OF_PERICENTER").toDouble(),
|
||||||
meanan = values[8].toDouble(),
|
meanan = value("MEAN_ANOMALY").toDouble(),
|
||||||
catnum = values[11].toInt(),
|
catnum = parseCatnum(value("NORAD_CAT_ID")),
|
||||||
bstar = values[14].toDouble()
|
bstar = optionalValue("BSTAR"),
|
||||||
|
ndot = optionalValue("MEAN_MOTION_DOT")
|
||||||
)
|
)
|
||||||
}.onFailure { println("CSV parsing exception: $it") }.getOrNull()
|
}.onFailure { println("CSV parsing exception: $it") }.getOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OMM columns are located by name, as providers agree on the names but not on the order.
|
||||||
|
* Falls back to the Celestrak layout when the header is missing or unrecognized.
|
||||||
|
*/
|
||||||
|
private fun parseCSVColumns(header: String): Map<String, Int> {
|
||||||
|
val columns = header.split(",").withIndex().associate { (index, name) ->
|
||||||
|
name.trim().trim('"').uppercase() to index
|
||||||
|
}
|
||||||
|
return if (columns.containsKey("NORAD_CAT_ID")) columns else celestrakCSVColumns
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO 8601 timestamp as a TLE style YYDDD.ffffffff epoch. Fractional seconds are optional and
|
||||||
|
* a trailing timezone marker is ignored, as not every provider formats the epoch alike.
|
||||||
|
*/
|
||||||
|
private fun parseTimestamp(timestamp: String): Double {
|
||||||
|
val year = timestamp.substring(0, 4).toInt()
|
||||||
|
val month = timestamp.substring(5, 7).toInt()
|
||||||
|
val dayOfMonth = timestamp.substring(8, 10).toInt()
|
||||||
|
val hours = timestamp.substring(11, 13).toInt()
|
||||||
|
val minutes = timestamp.substring(14, 16).toInt()
|
||||||
|
val seconds = timestamp.substring(17).takeWhile { it.isDigit() || it == '.' }.toDouble()
|
||||||
|
val dayFraction = (hours * 3600 + minutes * 60 + seconds) / 86400.0
|
||||||
|
return (year % 100) * 1000 + getDayOfYear(year, month, dayOfMonth) + dayFraction
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catalog numbers above 99999 do not fit the 5 digit TLE field, so they are encoded as Alpha-5:
|
||||||
|
* the leading two digits become a letter, with I and O skipped to avoid confusion with 1 and 0.
|
||||||
|
*/
|
||||||
|
private fun parseCatnum(value: String): Int {
|
||||||
|
val catnum = value.trim()
|
||||||
|
if (catnum.first().isDigit()) return catnum.toInt()
|
||||||
|
val alphaIndex = alpha5Alphabet.indexOf(catnum.first().uppercaseChar())
|
||||||
|
require(alphaIndex >= 0) { "Unknown Alpha-5 catalog number: $catnum" }
|
||||||
|
return (alphaIndex + 10) * 10000 + catnum.drop(1).trim().toInt()
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseTLE(tle: List<String>): OrbitalData? = runCatching {
|
private fun parseTLE(tle: List<String>): OrbitalData? = runCatching {
|
||||||
val line1 = tle[1]
|
val line1 = tle[1]
|
||||||
val line2 = tle[2]
|
val line2 = tle[2]
|
||||||
OrbitalData(
|
OrbitalData(
|
||||||
name = tle[0].trim(),
|
name = tle[0].trim().removePrefix("0 "),
|
||||||
epoch = line1.substring(18, 32).toDouble(),
|
epoch = line1.substring(18, 32).toDouble(),
|
||||||
meanmo = line2.substring(52, 63).toDouble(),
|
meanmo = line2.substring(52, 63).toDouble(),
|
||||||
eccn = line2.substring(26, 33).toDouble() / 1e7,
|
eccn = line2.substring(26, 33).toDouble() / 1e7,
|
||||||
@@ -100,12 +136,15 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
|||||||
raan = line2.substring(17, 25).toDouble(),
|
raan = line2.substring(17, 25).toDouble(),
|
||||||
argper = line2.substring(34, 42).toDouble(),
|
argper = line2.substring(34, 42).toDouble(),
|
||||||
meanan = line2.substring(43, 51).toDouble(),
|
meanan = line2.substring(43, 51).toDouble(),
|
||||||
catnum = line1.substring(2, 7).trim().toInt(),
|
catnum = parseCatnum(line1.substring(2, 7)),
|
||||||
bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble())
|
bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble()),
|
||||||
|
ndot = line1.substring(33, 43).trim().toDouble()
|
||||||
)
|
)
|
||||||
}.onFailure { println("TLE parsing exception: $it") }.getOrNull()
|
}.onFailure { println("TLE parsing exception: $it") }.getOrNull()
|
||||||
|
|
||||||
private fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
|
fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||||
|
|
||||||
|
fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
|
||||||
val daysInMonth = intArrayOf(31, if (isLeapYear(year)) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
val daysInMonth = intArrayOf(31, if (isLeapYear(year)) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
||||||
return daysInMonth.take(month - 1).sum() + dayOfMonth
|
return daysInMonth.take(month - 1).sum() + dayOfMonth
|
||||||
}
|
}
|
||||||
|
|||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.utility
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
||||||
|
*
|
||||||
|
* For a linear (passband) transponder, uplink and downlink frequencies are
|
||||||
|
* related by a fixed passband offset. When the satellite moves, both are
|
||||||
|
* Doppler-shifted. Given one, this computes the other:
|
||||||
|
*
|
||||||
|
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler)
|
||||||
|
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
|
||||||
|
*/
|
||||||
|
object DopplerFrequencyCalculator {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a downlink frequency, compute the Doppler-corrected uplink frequency.
|
||||||
|
* Returns null if the transponder is not a linear passband type.
|
||||||
|
*/
|
||||||
|
fun computeUplinkFromDownlink(
|
||||||
|
downlinkHz: Long,
|
||||||
|
transponder: SatRadio,
|
||||||
|
orbitalPos: OrbitalPos
|
||||||
|
): Long? {
|
||||||
|
if (!isLinearTransponder(transponder)) return null
|
||||||
|
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz, transponder) ?: return null
|
||||||
|
return orbitalPos.getUplinkFreq(baseUplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
|
||||||
|
* with an offset applied to the downlink (in Hz).
|
||||||
|
* Returns null if the transponder is not a linear passband type.
|
||||||
|
*
|
||||||
|
* The user-entered downlink frequency already includes the offset, so subtract
|
||||||
|
* it before mapping the downlink passband position back to the uplink.
|
||||||
|
*/
|
||||||
|
fun computeUplinkFromDownlinkWithOffset(
|
||||||
|
downlinkHz: Long,
|
||||||
|
transponder: SatRadio,
|
||||||
|
orbitalPos: OrbitalPos,
|
||||||
|
offsetHz: Long
|
||||||
|
): Long? {
|
||||||
|
if (!isLinearTransponder(transponder)) return null
|
||||||
|
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
|
||||||
|
return orbitalPos.getUplinkFreq(baseUplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an uplink frequency, compute the Doppler-corrected downlink frequency.
|
||||||
|
* Returns null if the transponder is not a linear passband type.
|
||||||
|
*/
|
||||||
|
fun computeDownlinkFromUplink(
|
||||||
|
uplinkHz: Long,
|
||||||
|
transponder: SatRadio,
|
||||||
|
orbitalPos: OrbitalPos
|
||||||
|
): Long? {
|
||||||
|
if (!isLinearTransponder(transponder)) return null
|
||||||
|
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
|
||||||
|
return orbitalPos.getDownlinkFreq(baseDownlink)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an uplink frequency, compute the Doppler-corrected downlink frequency
|
||||||
|
* with an offset applied to the downlink (in Hz).
|
||||||
|
* Returns null if the transponder is not a linear passband type.
|
||||||
|
*/
|
||||||
|
fun computeDownlinkFromUplinkWithOffset(
|
||||||
|
uplinkHz: Long,
|
||||||
|
transponder: SatRadio,
|
||||||
|
orbitalPos: OrbitalPos,
|
||||||
|
offsetHz: Long
|
||||||
|
): Long? {
|
||||||
|
if (!isLinearTransponder(transponder)) return null
|
||||||
|
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
|
||||||
|
return orbitalPos.getDownlinkFreq(baseDownlink + offsetHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if this transponder supports linear passband mapping. */
|
||||||
|
fun isLinearTransponder(transponder: SatRadio): Boolean {
|
||||||
|
val upLow = transponder.uplinkLow
|
||||||
|
val upHigh = transponder.uplinkHigh
|
||||||
|
val downLow = transponder.downlinkLow
|
||||||
|
val downHigh = transponder.downlinkHigh
|
||||||
|
return upLow != null && upHigh != null && downLow != null && downHigh != null
|
||||||
|
&& upLow != upHigh && downLow != downHigh
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for the radio entry that should drive the standalone Calculator page.
|
||||||
|
*
|
||||||
|
* A frequency range alone is not enough: some non-user-facing or drifting data entries
|
||||||
|
* can also have low/high frequencies. The calculator is meant for named linear
|
||||||
|
* transponders, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
|
||||||
|
*/
|
||||||
|
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
|
||||||
|
if (!isLinearTransponder(transponder)) return false
|
||||||
|
|
||||||
|
val info = transponder.info.lowercase(Locale.ENGLISH)
|
||||||
|
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
||||||
|
.joinToString(separator = " ")
|
||||||
|
.lowercase(Locale.ENGLISH)
|
||||||
|
val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
|
||||||
|
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
|
||||||
|
info.contains("xponder") || info.contains("xpdr")
|
||||||
|
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
|
||||||
|
|
||||||
|
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) ||
|
||||||
|
(hasLinearName && hasLinearMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes duplicate transponder entries that describe the same physical
|
||||||
|
* transponder with different mode labels (e.g. SatNOGS lists AO-7's Mode A
|
||||||
|
* as both "Lin SSB" and "Lin CW", and JO-97's U/V transponder as both
|
||||||
|
* "CW Transponder" and "SSB Transponder").
|
||||||
|
*
|
||||||
|
* Entries sharing the same uplink/downlink frequency range are considered
|
||||||
|
* the same transponder. The non-CW entry is preferred because its invert
|
||||||
|
* flag is more reliable (e.g. JO-97's CW entry wrongly has invert=false).
|
||||||
|
*/
|
||||||
|
fun deduplicateTransponders(radios: List<SatRadio>): List<SatRadio> {
|
||||||
|
return radios.groupBy { radio ->
|
||||||
|
listOf(radio.uplinkLow, radio.uplinkHigh, radio.downlinkLow, radio.downlinkHigh)
|
||||||
|
}.values.map { group ->
|
||||||
|
group.firstOrNull { it.downlinkMode?.equals("CW", ignoreCase = true) != true } ?: group.first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,21 @@ fun Double.round(decimals: Int): Double {
|
|||||||
return kotlin.math.round(this * multiplier) / multiplier
|
return kotlin.math.round(this * multiplier) / multiplier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun String.aprsPasscode(): Int {
|
||||||
|
val callsign = this.trim().uppercase().substringBefore('-') // commonly strip SSID
|
||||||
|
var hash = 0x73E2
|
||||||
|
var i = 0
|
||||||
|
while (i < callsign.length) {
|
||||||
|
hash = hash xor (callsign[i].code shl 8)
|
||||||
|
i++
|
||||||
|
if (i < callsign.length) {
|
||||||
|
hash = hash xor callsign[i].code
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hash and 0x7FFF
|
||||||
|
}
|
||||||
|
|
||||||
//fun String.getHash(type: String = "SHA-256"): String {
|
//fun String.getHash(type: String = "SHA-256"): String {
|
||||||
// val hexChars = "0123456789ABCDEF"
|
// val hexChars = "0123456789ABCDEF"
|
||||||
// val bytes = MessageDigest.getInstance(type).digest(this.toByteArray())
|
// val bytes = MessageDigest.getInstance(type).digest(this.toByteArray())
|
||||||
|
|||||||
@@ -17,11 +17,18 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.domain.utility
|
package com.rtbishop.look4sat.core.domain.utility
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.DEG2RAD
|
import com.rtbishop.look4sat.core.domain.predict.DEG2RAD
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
||||||
|
import kotlin.math.acos
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cos
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
private const val AVG_EARTH_RADIUS_KM = 6371.009
|
||||||
private const val MIN_LATITUDE = -85.05112877980658
|
private const val MIN_LATITUDE = -85.05112877980658
|
||||||
private const val MAX_LATITUDE = 85.05112877980658
|
private const val MAX_LATITUDE = 85.05112877980658
|
||||||
private const val MIN_LONGITUDE = -180.0
|
private const val MIN_LONGITUDE = -180.0
|
||||||
@@ -48,6 +55,27 @@ fun Double.toRadians(): Double = this * DEG2RAD
|
|||||||
// return MIN_LONGITUDE + (MAX_LONGITUDE - MIN_LONGITUDE) * this
|
// return MIN_LONGITUDE + (MAX_LONGITUDE - MIN_LONGITUDE) * this
|
||||||
//}
|
//}
|
||||||
|
|
||||||
|
// Great-circle distance between two positions in kilometers using the spherical law of cosines.
|
||||||
|
fun greatCircleDistanceKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
|
||||||
|
val lat1R = lat1.toRadians()
|
||||||
|
val lat2R = lat2.toRadians()
|
||||||
|
val lon1R = lon1.toRadians()
|
||||||
|
val lon2R = lon2.toRadians()
|
||||||
|
return acos(
|
||||||
|
sin(lat1R) * sin(lat2R) + cos(lat1R) * cos(lat2R) * cos(lon2R - lon1R)
|
||||||
|
) * AVG_EARTH_RADIUS_KM
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial bearing (azimuth) from position 1 to position 2, in degrees (0-360).
|
||||||
|
fun bearingDeg(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
|
||||||
|
val lat1R = lat1.toRadians()
|
||||||
|
val lat2R = lat2.toRadians()
|
||||||
|
val dLon = (lon2 - lon1).toRadians()
|
||||||
|
val y = sin(dLon) * cos(lat2R)
|
||||||
|
val x = cos(lat1R) * sin(lat2R) - sin(lat1R) * cos(lat2R) * cos(dLon)
|
||||||
|
return (atan2(y, x).toDegrees() + 360) % 360
|
||||||
|
}
|
||||||
|
|
||||||
fun clipLat(latitude: Double): Double {
|
fun clipLat(latitude: Double): Double {
|
||||||
return clip(latitude, MIN_LATITUDE, MAX_LATITUDE)
|
return clip(latitude, MIN_LATITUDE, MAX_LATITUDE)
|
||||||
}
|
}
|
||||||
@@ -59,6 +87,13 @@ fun clipLon(longitude: Double): Double {
|
|||||||
return clip(result, MIN_LONGITUDE, MAX_LONGITUDE)
|
return clip(result, MIN_LONGITUDE, MAX_LONGITUDE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun OrbitalPos.toMapGeoPos(): GeoPos {
|
||||||
|
return GeoPos(
|
||||||
|
latitude = clipLat(latitude.toDegrees()),
|
||||||
|
longitude = clipLon(longitude.toDegrees())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun clip(currentValue: Double, minValue: Double, maxValue: Double): Double {
|
private fun clip(currentValue: Double, minValue: Double, maxValue: Double): Double {
|
||||||
return min(max(currentValue, minValue), maxValue)
|
return min(max(currentValue, minValue), maxValue)
|
||||||
}
|
}
|
||||||
@@ -18,10 +18,12 @@
|
|||||||
package com.rtbishop.look4sat.core.domain
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.aprsPasscode
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
@ExperimentalCoroutinesApi
|
@ExperimentalCoroutinesApi
|
||||||
class DataParserTest {
|
class DataParserTest {
|
||||||
@@ -59,8 +61,92 @@ class DataParserTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
|
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
|
||||||
val parsedList = dataParser.parseCSVStream(validCSVStream)
|
val parsedList = dataParser.parseCSVStream(validCSVStream)
|
||||||
assert(parsedList[0].epoch == 21320.51955234)
|
assert(parsedList.size == 2)
|
||||||
assert(parsedList[1].epoch == 24069.23963816)
|
assert(abs(parsedList[0].epoch - 21320.51955234) < 1e-8)
|
||||||
|
assert(abs(parsedList[1].epoch - 24069.23963816) < 1e-8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given CSV stream with reordered columns returns valid data`() = runTest(testDispatcher) {
|
||||||
|
val csvStream = """
|
||||||
|
NORAD_CAT_ID,EPOCH,OBJECT_NAME,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY
|
||||||
|
25544,2021-11-16T12:28:09.322176,ISS (ZARYA),.31985E-4,.1288E-4,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val sat = dataParser.parseCSVStream(csvStream)[0]
|
||||||
|
assert(sat.name == "ISS (ZARYA)")
|
||||||
|
assert(sat.catnum == 25544)
|
||||||
|
assert(sat.meanmo == 15.48582035)
|
||||||
|
assert(sat.bstar == 0.31985E-4)
|
||||||
|
assert(abs(sat.epoch - 21320.51955234) < 1e-8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given CSV epoch without fractional seconds returns valid data`() = runTest(testDispatcher) {
|
||||||
|
val csvStream = """
|
||||||
|
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:09Z,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||||
|
ISS (ZARYA),1998-067A,2021-11-16T00:00:30,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val parsedList = dataParser.parseCSVStream(csvStream)
|
||||||
|
assert(parsedList.size == 2)
|
||||||
|
// the dropped microseconds are worth a few microdays, the rest of the epoch is intact
|
||||||
|
assert(abs(parsedList[0].epoch - 21320.51955234) < 1e-5)
|
||||||
|
// just past midnight: the old formatter produced a broken 21320.0E-4 style epoch here
|
||||||
|
assert(abs(parsedList[1].epoch - 21320.00034722) < 1e-8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given TLE stream with alpha-5 catalog number returns valid data`() = runTest(testDispatcher) {
|
||||||
|
val tleStream = """
|
||||||
|
NAVSTAR 43
|
||||||
|
1 T0111U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||||
|
2 T0111 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val sat = dataParser.parseTLEStream(tleStream)[0]
|
||||||
|
// A stands for 10 and I and O are skipped, so T is 27 and T0111 stands for 270111
|
||||||
|
assert(sat.catnum == 270111)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given valid CSV stream all orbital fields are parsed correctly`() = runTest(testDispatcher) {
|
||||||
|
val csvStream = """
|
||||||
|
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()
|
||||||
|
val sat = dataParser.parseCSVStream(csvStream)[0]
|
||||||
|
assert(sat.name == "ISS (ZARYA)")
|
||||||
|
assert(sat.catnum == 25544)
|
||||||
|
assert(sat.meanmo == 15.48582035)
|
||||||
|
assert(sat.eccn == 0.0004694)
|
||||||
|
assert(sat.incl == 51.6447)
|
||||||
|
assert(sat.raan == 309.4881)
|
||||||
|
assert(sat.argper == 203.6966)
|
||||||
|
assert(sat.meanan == 299.8876)
|
||||||
|
assert(sat.bstar == 0.31985E-4)
|
||||||
|
assert(sat.ndot == 0.1288E-4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given valid CSV stream ndot is parsed for decay detection`() = runTest(testDispatcher) {
|
||||||
|
val csvStream = """
|
||||||
|
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()
|
||||||
|
val sat = dataParser.parseCSVStream(csvStream)[0]
|
||||||
|
// ISS is healthy, should not be decayed even years later
|
||||||
|
assert(!sat.hasDecayed(System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given CSV with high drag satellite detects decay`() = runTest(testDispatcher) {
|
||||||
|
// Simulate a satellite with high drag and old epoch that should have decayed
|
||||||
|
val csvStream = """
|
||||||
|
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
|
||||||
|
DEBRIS,2020-001A,2020-01-15T00:00:00.000000,15.9,.001,51.0,100.0,200.0,300.0,0,U,99999,1,100,.5E-3,.05,0
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val sat = dataParser.parseCSVStream(csvStream)[0]
|
||||||
|
// High mean motion (15.9) + high drag (.05) + old epoch → should be decayed by now
|
||||||
|
assert(sat.hasDecayed(System.currentTimeMillis()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -71,10 +157,41 @@ class DataParserTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) {
|
fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) {
|
||||||
val parsedList = dataParser.parseTLEStream(validTLEStream)
|
val parsedList = dataParser.parseTLEStream(validTLEStream)
|
||||||
|
assert(parsedList.size == 2)
|
||||||
assert(parsedList[0].epoch == 21320.51955234)
|
assert(parsedList[0].epoch == 21320.51955234)
|
||||||
assert(parsedList[1].epoch == 24069.23963816)
|
assert(parsedList[1].epoch == 24069.23963816)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given valid TLE stream all orbital fields are parsed correctly`() = runTest(testDispatcher) {
|
||||||
|
val tleStream = """
|
||||||
|
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()
|
||||||
|
val sat = dataParser.parseTLEStream(tleStream)[0]
|
||||||
|
assert(sat.name == "ISS (ZARYA)")
|
||||||
|
assert(sat.catnum == 25544)
|
||||||
|
assert(sat.meanmo == 15.48582035)
|
||||||
|
assert(sat.eccn == 0.0004694)
|
||||||
|
assert(sat.incl == 51.6447)
|
||||||
|
assert(sat.raan == 309.4881)
|
||||||
|
assert(sat.argper == 203.6966)
|
||||||
|
assert(sat.meanan == 299.8876)
|
||||||
|
assert(sat.ndot == 0.00001288)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given valid TLE stream ndot is parsed for decay detection`() = runTest(testDispatcher) {
|
||||||
|
val tleStream = """
|
||||||
|
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()
|
||||||
|
val sat = dataParser.parseTLEStream(tleStream)[0]
|
||||||
|
assert(!sat.hasDecayed(System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Given invalid TLE stream returns empty list`() = runTest(testDispatcher) {
|
fun `Given invalid TLE stream returns empty list`() = runTest(testDispatcher) {
|
||||||
assert(dataParser.parseTLEStream(invalidTLEStream).isEmpty())
|
assert(dataParser.parseTLEStream(invalidTLEStream).isEmpty())
|
||||||
@@ -85,6 +202,43 @@ class DataParserTest {
|
|||||||
assert(dataParser.parseJSONStream(validJSONStream)[0].downlinkLow == 136658500L)
|
assert(dataParser.parseJSONStream(validJSONStream)[0].downlinkLow == 136658500L)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given valid JSON stream all radio fields are parsed correctly`() = runTest(testDispatcher) {
|
||||||
|
val jsonStream = """
|
||||||
|
[{"uuid":"UzPz4gcsNBPKPKAFPmer7g","description":"Upper side band (drifting)","alive":true,"type":"Transmitter","uplink_low":145900000,"uplink_high":146000000,"uplink_drift":null,"downlink_low":136658500,"downlink_high":136700000,"downlink_drift":null,"mode":"USB","mode_id":9,"uplink_mode":"FM","invert":true,"baud":null,"sat_id":"SCHX-0895-2361-9925-0309","norad_cat_id":965,"status":"active","updated":"2019-04-18T05:39:53.343316Z","citation":"CITATION NEEDED","service":"Unknown","coordination":"","coordination_url":""}]
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val radio = dataParser.parseJSONStream(jsonStream)[0]
|
||||||
|
assert(radio.uuid == "UzPz4gcsNBPKPKAFPmer7g")
|
||||||
|
assert(radio.info == "Upper side band (drifting)")
|
||||||
|
assert(radio.isAlive)
|
||||||
|
assert(radio.downlinkLow == 136658500L)
|
||||||
|
assert(radio.downlinkHigh == 136700000L)
|
||||||
|
assert(radio.downlinkMode == "USB")
|
||||||
|
assert(radio.uplinkLow == 145900000L)
|
||||||
|
assert(radio.uplinkHigh == 146000000L)
|
||||||
|
assert(radio.uplinkMode == "FM")
|
||||||
|
assert(radio.isInverted)
|
||||||
|
assert(radio.catnum == 965)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given JSON with null optional fields parses without error`() = runTest(testDispatcher) {
|
||||||
|
val jsonStream = """
|
||||||
|
[{"uuid":"abc123","description":"Beacon","alive":false,"type":"Transmitter","uplink_low":null,"uplink_high":null,"uplink_drift":null,"downlink_low":145800000,"downlink_high":null,"downlink_drift":null,"mode":null,"mode_id":null,"uplink_mode":null,"invert":false,"baud":null,"sat_id":"TEST","norad_cat_id":12345,"status":"active","updated":"2024-01-01T00:00:00Z","citation":"","service":"Unknown","coordination":"","coordination_url":""}]
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val radio = dataParser.parseJSONStream(jsonStream)[0]
|
||||||
|
assert(radio.uuid == "abc123")
|
||||||
|
assert(!radio.isAlive)
|
||||||
|
assert(radio.downlinkLow == 145800000L)
|
||||||
|
assert(radio.downlinkHigh == null)
|
||||||
|
assert(radio.downlinkMode == null)
|
||||||
|
assert(radio.uplinkLow == null)
|
||||||
|
assert(radio.uplinkHigh == null)
|
||||||
|
assert(radio.uplinkMode == null)
|
||||||
|
assert(!radio.isInverted)
|
||||||
|
assert(radio.catnum == 12345)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Given invalid JSON stream returns empty list`() = runTest(testDispatcher) {
|
fun `Given invalid JSON stream returns empty list`() = runTest(testDispatcher) {
|
||||||
assert(dataParser.parseJSONStream(invalidJSONStream).isEmpty())
|
assert(dataParser.parseJSONStream(invalidJSONStream).isEmpty())
|
||||||
@@ -96,10 +250,42 @@ class DataParserTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Function isLeapYear returns correct data`() = runTest(testDispatcher) {
|
fun `isLeapYear returns correct results`() {
|
||||||
val years = listOf(1900, 1984, 1994, 2016, 2022, 2024, 2042, 2048)
|
val years = listOf(1900, 1984, 1994, 2000, 2016, 2022, 2024, 2042, 2048, 2100)
|
||||||
val answers = listOf(false, true, false, true, false, true, false, true)
|
val expected = listOf(false, true, false, true, true, false, true, false, true, false)
|
||||||
val results = years.map { dataParser.isLeapYear(it) }
|
val results = years.map { dataParser.isLeapYear(it) }
|
||||||
assert(results == answers)
|
assert(results == expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getDayOfYear returns correct day for January 1st`() {
|
||||||
|
assert(dataParser.getDayOfYear(2024, 1, 1) == 1)
|
||||||
|
assert(dataParser.getDayOfYear(2023, 1, 1) == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getDayOfYear returns correct day for March 1st in leap and non-leap years`() {
|
||||||
|
// 2024 is leap: Jan(31) + Feb(29) + 1 = 61
|
||||||
|
assert(dataParser.getDayOfYear(2024, 3, 1) == 61)
|
||||||
|
// 2023 is not leap: Jan(31) + Feb(28) + 1 = 60
|
||||||
|
assert(dataParser.getDayOfYear(2023, 3, 1) == 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getDayOfYear returns correct day for December 31st`() {
|
||||||
|
assert(dataParser.getDayOfYear(2024, 12, 31) == 366) // leap year
|
||||||
|
assert(dataParser.getDayOfYear(2023, 12, 31) == 365) // non-leap year
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getDayOfYear returns correct day for November 16th`() {
|
||||||
|
// Matches the CSV test data epoch: 2021-11-16 → day 320
|
||||||
|
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `check APRS passcode calculation`() {
|
||||||
|
assert("M7LNB".aprsPasscode() == 12443)
|
||||||
|
assert("N0CALL".aprsPasscode() == 13023)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+273
@@ -0,0 +1,273 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DopplerFrequencyCalculatorTest {
|
||||||
|
|
||||||
|
private fun linearTransponder(
|
||||||
|
uuid: String = "linear",
|
||||||
|
upLow: Long = 145_000_000L,
|
||||||
|
upHigh: Long = 145_500_000L,
|
||||||
|
downLow: Long = 435_000_000L,
|
||||||
|
downHigh: Long? = 435_500_000L,
|
||||||
|
inverted: Boolean = false,
|
||||||
|
info: String = "Linear Transponder",
|
||||||
|
downlinkMode: String? = "USB",
|
||||||
|
uplinkMode: String? = "LSB"
|
||||||
|
) = SatRadio(
|
||||||
|
uuid = uuid, info = info, isAlive = true,
|
||||||
|
downlinkLow = downLow, downlinkHigh = downHigh,
|
||||||
|
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
|
||||||
|
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun fmTransponder() = SatRadio(
|
||||||
|
uuid = "fm", info = "FM Repeater", isAlive = true,
|
||||||
|
downlinkLow = 435_600_000L, downlinkHigh = null,
|
||||||
|
downlinkMode = "FM", uplinkLow = 145_900_000L, uplinkHigh = null,
|
||||||
|
uplinkMode = "FM", isInverted = false, catnum = 99999
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun pos(distanceRateKmS: Double = 0.0) = OrbitalPos().apply {
|
||||||
|
this.distanceRate = distanceRateKmS
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isLinearTransponder_returnsTrueForLinear() {
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isLinearTransponder(linearTransponder()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isLinearTransponder_returnsFalseForFM() {
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(fmTransponder()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isLinearTransponder_returnsFalseForNullDownlinkHigh() {
|
||||||
|
val xpdr = linearTransponder(downHigh = null)
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsTrueForLinearTransponderName() {
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(linearTransponder()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsTrueForSsbTransponderName() {
|
||||||
|
val xpdr = linearTransponder(info = "Mode V/U SSB Transponder", downlinkMode = "USB", uplinkMode = "LSB")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(xpdr))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsFalseForRangeEntryWithoutTransponderName() {
|
||||||
|
val driftingRangeEntry = linearTransponder(info = "Upper side band (drifting)")
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsTrueForAbbreviatedLinName() {
|
||||||
|
// AO-7 style: "Mode V/A (A) Lin SSB" — "Lin" abbreviation, no "transponder" word
|
||||||
|
val ao7Entry = linearTransponder(info = "Mode V/A (A) Lin SSB", downlinkMode = "USB", uplinkMode = "USB")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7Entry))
|
||||||
|
val ao7CwEntry = linearTransponder(info = "Mode V/A (A) Lin CW", downlinkMode = "CW", uplinkMode = "CW")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7CwEntry))
|
||||||
|
val ao7ModeBEntry = linearTransponder(info = "Mode U/V (B) Lin", downlinkMode = "USB", uplinkMode = "LSB")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7ModeBEntry))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsTrueForLinearWithoutTransponderWord() {
|
||||||
|
// AO-73 style: "Mode U/V Linear" — has "Linear" but no "transponder"
|
||||||
|
val ao73Entry = linearTransponder(info = "Mode U/V Linear", downlinkMode = "USB", uplinkMode = "LSB")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao73Entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsFalseForDownlinkContainingLinInsideWord() {
|
||||||
|
// "Downlink" contains "lin" but is not a linear-transponder name
|
||||||
|
val downlinkEntry = linearTransponder(info = "Mode U Downlink", downlinkMode = "FM", uplinkMode = "FM")
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(downlinkEntry))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deduplicateTransponders_mergesSameFrequencyRange() {
|
||||||
|
// AO-7's Mode A: same range, SSB and CW entries
|
||||||
|
val ssb = linearTransponder(
|
||||||
|
uuid = "ssb-uuid", info = "Mode V/A (A) Lin SSB",
|
||||||
|
downlinkMode = "USB", uplinkMode = "USB"
|
||||||
|
)
|
||||||
|
val cw = linearTransponder(
|
||||||
|
uuid = "cw-uuid", info = "Mode V/A (A) Lin CW",
|
||||||
|
downlinkMode = "CW", uplinkMode = "CW"
|
||||||
|
)
|
||||||
|
val modeB = linearTransponder(
|
||||||
|
uuid = "modeb-uuid", info = "Mode U/V (B) Lin",
|
||||||
|
upLow = 432_125_000L, upHigh = 432_175_000L,
|
||||||
|
downLow = 145_925_000L, downHigh = 145_975_000L,
|
||||||
|
downlinkMode = "USB", uplinkMode = "LSB"
|
||||||
|
)
|
||||||
|
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(ssb, cw, modeB))
|
||||||
|
assertEquals(2, result.size)
|
||||||
|
// SSB entry should be preferred over CW (same range)
|
||||||
|
assertEquals("ssb-uuid", result[0].uuid)
|
||||||
|
assertEquals("modeb-uuid", result[1].uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deduplicateTransponders_prefersNonCwEntry() {
|
||||||
|
// JO-97: CW entry has invert=false (wrong), SSB has invert=true (correct)
|
||||||
|
val cw = linearTransponder(
|
||||||
|
uuid = "cw-uuid", info = "U/V CW Transponder",
|
||||||
|
downlinkMode = "CW", uplinkMode = "CW",
|
||||||
|
upLow = 435_100_000L, upHigh = 435_120_000L,
|
||||||
|
downLow = 145_855_000L, downHigh = 145_875_000L
|
||||||
|
)
|
||||||
|
val ssb = linearTransponder(
|
||||||
|
uuid = "ssb-uuid", info = "U/V SSB Transponder",
|
||||||
|
downlinkMode = "USB", uplinkMode = "LSB",
|
||||||
|
upLow = 435_100_000L, upHigh = 435_120_000L,
|
||||||
|
downLow = 145_855_000L, downHigh = 145_875_000L,
|
||||||
|
inverted = true
|
||||||
|
)
|
||||||
|
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(cw, ssb))
|
||||||
|
assertEquals(1, result.size)
|
||||||
|
assertEquals("ssb-uuid", result[0].uuid)
|
||||||
|
// Verify the correct invert flag is preserved
|
||||||
|
assertTrue(result[0].isInverted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deduplicateTransponders_preservesUniqueEntries() {
|
||||||
|
val t1 = linearTransponder(uuid = "t1", upLow = 145_000_000L, upHigh = 145_500_000L,
|
||||||
|
downLow = 435_000_000L, downHigh = 435_500_000L)
|
||||||
|
val t2 = linearTransponder(uuid = "t2", upLow = 435_000_000L, upHigh = 435_500_000L,
|
||||||
|
downLow = 145_000_000L, downHigh = 145_500_000L)
|
||||||
|
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(t1, t2))
|
||||||
|
assertEquals(2, result.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_linear_noDoppler() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||||
|
assertNotNull(uplink)
|
||||||
|
assertEquals(145_200_000L, uplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeDownlinkFromUplink_linear_noDoppler() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_200_000L, xpdr, orbitalPos)
|
||||||
|
assertNotNull(downlink)
|
||||||
|
assertEquals(435_200_000L, downlink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() {
|
||||||
|
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate.
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(7.0)
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||||
|
assertNotNull(uplink)
|
||||||
|
assertTrue(uplink!! > 145_200_000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_fmTransponder_returnsNull() {
|
||||||
|
val orbitalPos = pos()
|
||||||
|
val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos)
|
||||||
|
assertNull(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeDownlinkFromUplink_fmTransponder_returnsNull() {
|
||||||
|
val orbitalPos = pos()
|
||||||
|
val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos)
|
||||||
|
assertNull(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeDownlinkFromUplink_withPositiveOffset_addsOffsetToDownlink() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||||
|
uplinkHz = 145_200_000L,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = 2_500L
|
||||||
|
)
|
||||||
|
assertEquals(435_202_500L, downlink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_withPositiveOffset_subtractsOffsetBeforeMapping() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||||
|
downlinkHz = 435_202_500L,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = 2_500L
|
||||||
|
)
|
||||||
|
assertEquals(145_200_000L, uplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeOffsetRoundTrip_handlesNegativeOffset() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||||
|
uplinkHz = 145_200_000L,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = -2_500L
|
||||||
|
)
|
||||||
|
assertEquals(435_197_500L, downlink)
|
||||||
|
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||||
|
downlinkHz = downlink!!,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = -2_500L
|
||||||
|
)
|
||||||
|
assertEquals(145_200_000L, uplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_invertedTransponder() {
|
||||||
|
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||||
|
assertNotNull(uplink)
|
||||||
|
assertEquals(145_300_000L, uplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_roundTrip() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(3.5)
|
||||||
|
val originalDownlink = 435_250_000L
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(originalDownlink, xpdr, orbitalPos)
|
||||||
|
assertNotNull(uplink)
|
||||||
|
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
|
||||||
|
assertNotNull(roundTripDownlink)
|
||||||
|
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
|
||||||
|
assertTrue("Round-trip error too large: $error", error < 10000)
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.predict
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.time.Instant
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
class CelestialComputerTest {
|
||||||
|
|
||||||
|
private data class RiseSetCase(
|
||||||
|
val name: String,
|
||||||
|
val observer: GeoPos,
|
||||||
|
val startIso: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `findSunRiseSet returns distinct sunrise and sunset for representative locations`() {
|
||||||
|
val cases = listOf(
|
||||||
|
RiseSetCase("Equator at March equinox", GeoPos(0.0, 0.0), "2026-03-20T00:00:00Z"),
|
||||||
|
RiseSetCase("Equator at September equinox", GeoPos(0.0, 0.0), "2026-09-23T00:00:00Z"),
|
||||||
|
RiseSetCase("Sydney winter", GeoPos(-33.8688, 151.2093), "2026-06-21T00:00:00Z"),
|
||||||
|
RiseSetCase("Buenos Aires winter", GeoPos(-34.6037, -58.3816), "2026-06-21T00:00:00Z"),
|
||||||
|
RiseSetCase("Cape Town winter", GeoPos(-33.9249, 18.4241), "2026-06-21T00:00:00Z"),
|
||||||
|
RiseSetCase("London summer", GeoPos(51.5074, -0.1278), "2026-06-21T00:00:00Z")
|
||||||
|
)
|
||||||
|
|
||||||
|
cases.forEach { testCase ->
|
||||||
|
val result = CelestialComputer.findSunRiseSet(testCase.observer, testCase.startIso.toMillis())
|
||||||
|
val daylightDuration = result.setTimeMillis - result.riseTimeMillis
|
||||||
|
|
||||||
|
assertTrue("${testCase.name}: sunrise should be non-zero", result.riseTimeMillis > 0L)
|
||||||
|
assertTrue("${testCase.name}: sunset should be non-zero", result.setTimeMillis > 0L)
|
||||||
|
assertTrue("${testCase.name}: sunset should be after sunrise", result.setTimeMillis > result.riseTimeMillis)
|
||||||
|
assertTrue("${testCase.name}: daylight duration should be longer than 1 hour", daylightDuration > HOUR_MILLIS)
|
||||||
|
assertTrue("${testCase.name}: daylight duration should be shorter than 24 hours", daylightDuration < DAY_MILLIS)
|
||||||
|
|
||||||
|
val riseElevation = CelestialComputer.getSunPosition(testCase.observer, result.riseTimeMillis).elevation
|
||||||
|
val setElevation = CelestialComputer.getSunPosition(testCase.observer, result.setTimeMillis).elevation
|
||||||
|
assertEquals("${testCase.name}: sunrise should converge near the standard threshold", SUNRISE_SET_THRESHOLD, riseElevation, 0.02)
|
||||||
|
assertEquals("${testCase.name}: sunset should converge near the standard threshold", SUNRISE_SET_THRESHOLD, setElevation, 0.02)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `findSunRiseSet does not return the same instant for equinox regression cases`() {
|
||||||
|
listOf("2026-03-20T00:00:00Z", "2026-09-23T00:00:00Z").forEach { startIso ->
|
||||||
|
val result = CelestialComputer.findSunRiseSet(GeoPos(0.0, 0.0), startIso.toMillis())
|
||||||
|
val separationMillis = abs(result.setTimeMillis - result.riseTimeMillis)
|
||||||
|
|
||||||
|
assertTrue("$startIso: sunrise and sunset should be separated", separationMillis > HOUR_MILLIS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toMillis(): Long = Instant.parse(this).toEpochMilli()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val SUNRISE_SET_THRESHOLD = -0.8333
|
||||||
|
private const val HOUR_MILLIS = 60L * 60L * 1000L
|
||||||
|
private const val DAY_MILLIS = 24L * HOUR_MILLIS
|
||||||
|
}
|
||||||
|
}
|
||||||
+424
@@ -0,0 +1,424 @@
|
|||||||
|
/*
|
||||||
|
* 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.presentation
|
||||||
|
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.LinearGradient
|
||||||
|
import android.graphics.RadialGradient
|
||||||
|
import android.graphics.Shader
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
|
import androidx.core.graphics.withRotation
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class BubblesStyle(
|
||||||
|
val bgColor: Color = Color.Black,
|
||||||
|
val bubbleCount: Int = 16,
|
||||||
|
val minBubbleCount: Int = 8,
|
||||||
|
val maxBubbleCount: Int = 16,
|
||||||
|
val adaptiveBubbleCount: Boolean = true,
|
||||||
|
val spawnIntervalMs: Long = 800L,
|
||||||
|
val bubbleRadiusFraction: Float = 0.24f,
|
||||||
|
val adaptiveSizing: Boolean = true,
|
||||||
|
val referenceMinSizePx: Float = 360f,
|
||||||
|
val referenceAreaPx: Float = 360f * 800f,
|
||||||
|
val minBubbleRadiusPx: Float = 16f,
|
||||||
|
val maxBubbleRadiusPx: Float = 256f,
|
||||||
|
val speedScale: Float = 0.99f,
|
||||||
|
val minVelocity: Float = 0.8f,
|
||||||
|
val maxVelocity: Float = 3.2f,
|
||||||
|
val hueRotationSpeedDps: Float = 60f, // degrees per second
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BubblesEffect(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
isRunning: Boolean = true,
|
||||||
|
style: BubblesStyle = BubblesStyle(),
|
||||||
|
) {
|
||||||
|
val renderer = remember { BubblesRenderer() }
|
||||||
|
var frameSignal by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
// Animation loop
|
||||||
|
LaunchedEffect(isRunning, style) {
|
||||||
|
if (!isRunning) return@LaunchedEffect
|
||||||
|
var previousNanos = 0L
|
||||||
|
while (isActive) {
|
||||||
|
withFrameNanos { now ->
|
||||||
|
if (previousNanos == 0L) previousNanos = now
|
||||||
|
val deltaSec = ((now - previousNanos).coerceAtMost(MAX_STEP_NANOS)).toFloat() / NANOS_TO_SECONDS
|
||||||
|
previousNanos = now
|
||||||
|
renderer.update(deltaSec, style)
|
||||||
|
frameSignal++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Canvas(modifier = modifier) {
|
||||||
|
frameSignal
|
||||||
|
renderer.ensureLayout(size)
|
||||||
|
renderer.draw(this, style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAX_STEP_NANOS = 16_666_667L // ~60 FPS
|
||||||
|
private const val NANOS_TO_SECONDS = 1_000_000_000f
|
||||||
|
private val SHELL_GRADIENT_STOPS = floatArrayOf(0f, 0.52f, 0.66f, 0.79f, 0.90f, 0.968f, 0.993f, 1f)
|
||||||
|
private val INNER_GRADIENT_STOPS = floatArrayOf(0f, 0.34f, 0.68f, 1f)
|
||||||
|
|
||||||
|
private data class Velocity(var x: Float, var y: Float)
|
||||||
|
|
||||||
|
private data class Bubble(
|
||||||
|
var x: Float,
|
||||||
|
var y: Float,
|
||||||
|
val radius: Float,
|
||||||
|
val velocity: Velocity,
|
||||||
|
val baseHue: Float, // 0-360, unique for each bubble
|
||||||
|
)
|
||||||
|
|
||||||
|
private class BubblesRenderer {
|
||||||
|
private var width = 0f
|
||||||
|
private var height = 0f
|
||||||
|
private val bubbles = mutableListOf<Bubble>()
|
||||||
|
private val random = Random(System.currentTimeMillis())
|
||||||
|
private var globalHueRotation = 0f // degrees, rotates all bubbles hues
|
||||||
|
private var spawnAccumulatorSec = 0f
|
||||||
|
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val shellColors = IntArray(8)
|
||||||
|
private val innerColors = IntArray(4)
|
||||||
|
|
||||||
|
fun ensureLayout(size: Size) {
|
||||||
|
val targetWidth = size.width
|
||||||
|
val targetHeight = size.height
|
||||||
|
if (targetWidth <= 0f || targetHeight <= 0f) return
|
||||||
|
val shouldRebuild = width != targetWidth || height != targetHeight
|
||||||
|
if (shouldRebuild) {
|
||||||
|
width = targetWidth
|
||||||
|
height = targetHeight
|
||||||
|
bubbles.clear()
|
||||||
|
spawnAccumulatorSec = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun spawnBubble(style: BubblesStyle): Boolean {
|
||||||
|
if (width <= 0f || height <= 0f) return false
|
||||||
|
val radius = resolveBubbleRadius(style)
|
||||||
|
val (velocityMin, velocityMax) = resolveVelocityRange(style)
|
||||||
|
val bubble = Bubble(
|
||||||
|
x = radius,
|
||||||
|
y = height - radius,
|
||||||
|
radius = radius,
|
||||||
|
velocity = Velocity(
|
||||||
|
x = randomInRange(velocityMin, velocityMax),
|
||||||
|
y = -randomInRange(velocityMin, velocityMax),
|
||||||
|
),
|
||||||
|
baseHue = random.nextFloat() * 360f, // Random starting hue for this bubble
|
||||||
|
)
|
||||||
|
bubbles.add(bubble)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(deltaSeconds: Float, style: BubblesStyle) {
|
||||||
|
if (width <= 0f || height <= 0f) return
|
||||||
|
|
||||||
|
spawnMissingBubbles(deltaSeconds, style)
|
||||||
|
|
||||||
|
// Rotate hue for all bubbles
|
||||||
|
globalHueRotation += style.hueRotationSpeedDps * deltaSeconds
|
||||||
|
if (globalHueRotation >= 360f) globalHueRotation -= 360f
|
||||||
|
val frameScale = deltaSeconds * 60f
|
||||||
|
for (i in bubbles.indices) {
|
||||||
|
val bubble = bubbles[i]
|
||||||
|
// Update position
|
||||||
|
bubble.x += bubble.velocity.x * frameScale
|
||||||
|
bubble.y += bubble.velocity.y * frameScale
|
||||||
|
// Bounce off walls
|
||||||
|
if (bubble.x > width - bubble.radius) {
|
||||||
|
bubble.x = width - bubble.radius
|
||||||
|
bubble.velocity.x *= -1
|
||||||
|
}
|
||||||
|
if (bubble.x < bubble.radius) {
|
||||||
|
bubble.x = bubble.radius
|
||||||
|
bubble.velocity.x *= -1
|
||||||
|
}
|
||||||
|
if (bubble.y > height - bubble.radius) {
|
||||||
|
bubble.y = height - bubble.radius
|
||||||
|
bubble.velocity.y *= -1
|
||||||
|
}
|
||||||
|
if (bubble.y < bubble.radius) {
|
||||||
|
bubble.y = bubble.radius
|
||||||
|
bubble.velocity.y *= -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Collision detection and resolution
|
||||||
|
for (i in bubbles.indices) {
|
||||||
|
for (j in (i + 1) until bubbles.size) {
|
||||||
|
val b1 = bubbles[i]
|
||||||
|
val b2 = bubbles[j]
|
||||||
|
if (isCollided(b1, b2)) resolveCollision(b1, b2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun spawnMissingBubbles(deltaSeconds: Float, style: BubblesStyle) {
|
||||||
|
val targetCount = resolveTargetBubbleCount(style)
|
||||||
|
if (bubbles.size >= targetCount) return
|
||||||
|
|
||||||
|
val spawnIntervalSec = style.spawnIntervalMs.coerceAtLeast(1L) / 1_000f
|
||||||
|
spawnAccumulatorSec += deltaSeconds
|
||||||
|
|
||||||
|
while (bubbles.size < targetCount && spawnAccumulatorSec >= spawnIntervalSec) {
|
||||||
|
if (!spawnBubble(style)) break
|
||||||
|
spawnAccumulatorSec -= spawnIntervalSec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun draw(scope: DrawScope, style: BubblesStyle) {
|
||||||
|
if (width <= 0f || height <= 0f) return
|
||||||
|
scope.drawRect(style.bgColor)
|
||||||
|
val canvas = scope.drawContext.canvas.nativeCanvas
|
||||||
|
for (i in bubbles.indices) {
|
||||||
|
drawBubbleWithGradient(canvas, bubbles[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawBubbleWithGradient(canvas: android.graphics.Canvas, bubble: Bubble) {
|
||||||
|
val hue = (bubble.baseHue + globalHueRotation) % 360f
|
||||||
|
val lit = 53.33f + maxOf(0f, (70f - kotlin.math.abs(244f - hue)) / 4f)
|
||||||
|
val rgb = hueToRgb(hue, lit)
|
||||||
|
val shellColor = mixWithWhite(rgb, 0.04f)
|
||||||
|
val innerColor = mixWithWhite(rgb, 0.10f)
|
||||||
|
val shimmerColor = mixWithWhite(rgb, 0.35f)
|
||||||
|
// Outer shell
|
||||||
|
val shellGradient = RadialGradient(
|
||||||
|
bubble.x, bubble.y, bubble.radius,
|
||||||
|
buildShellColors(shellColor),
|
||||||
|
SHELL_GRADIENT_STOPS,
|
||||||
|
Shader.TileMode.CLAMP
|
||||||
|
)
|
||||||
|
paint.style = Paint.Style.FILL
|
||||||
|
paint.shader = shellGradient
|
||||||
|
canvas.drawCircle(bubble.x, bubble.y, bubble.radius, paint)
|
||||||
|
// Subtle thin rim
|
||||||
|
paint.shader = null
|
||||||
|
paint.style = Paint.Style.STROKE
|
||||||
|
paint.strokeWidth = maxOf(1f, bubble.radius * 0.024f)
|
||||||
|
paint.color = colorWithAlpha(mixWithWhite(shellColor, 0.08f), 0.30f)
|
||||||
|
canvas.drawCircle(bubble.x, bubble.y, bubble.radius - paint.strokeWidth * 0.5f, paint)
|
||||||
|
// Top internal bubble
|
||||||
|
val innerTop = bubble.y - bubble.radius * 0.96f
|
||||||
|
val innerBottom = bubble.y + bubble.radius * 0.48f
|
||||||
|
val innerLeft = bubble.x - bubble.radius * 0.84f
|
||||||
|
val innerRight = bubble.x + bubble.radius * 0.84f
|
||||||
|
val innerGradient = LinearGradient(
|
||||||
|
bubble.x,
|
||||||
|
innerTop,
|
||||||
|
bubble.x,
|
||||||
|
innerBottom,
|
||||||
|
buildInnerColors(innerColor),
|
||||||
|
INNER_GRADIENT_STOPS,
|
||||||
|
Shader.TileMode.CLAMP
|
||||||
|
)
|
||||||
|
paint.style = Paint.Style.FILL
|
||||||
|
paint.shader = innerGradient
|
||||||
|
canvas.drawOval(
|
||||||
|
innerLeft,
|
||||||
|
innerTop,
|
||||||
|
innerRight,
|
||||||
|
innerBottom,
|
||||||
|
paint
|
||||||
|
)
|
||||||
|
// Top-left shimmer
|
||||||
|
paint.shader = null
|
||||||
|
paint.style = Paint.Style.FILL
|
||||||
|
paint.color = colorWithAlpha(shimmerColor, 0.95f)
|
||||||
|
val shimmerCx = bubble.x - bubble.radius * 0.40f
|
||||||
|
val shimmerCy = bubble.y - bubble.radius * 0.72f
|
||||||
|
val shimmerHalfWidth = bubble.radius * 0.06f
|
||||||
|
val shimmerHalfHeight = bubble.radius * 0.21f
|
||||||
|
canvas.withRotation(60f, shimmerCx, shimmerCy) {
|
||||||
|
drawOval(
|
||||||
|
shimmerCx - shimmerHalfWidth,
|
||||||
|
shimmerCy - shimmerHalfHeight,
|
||||||
|
shimmerCx + shimmerHalfWidth,
|
||||||
|
shimmerCy + shimmerHalfHeight,
|
||||||
|
paint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isCollided(bubble1: Bubble, bubble2: Bubble): Boolean {
|
||||||
|
val dx = bubble1.x - bubble2.x
|
||||||
|
val dy = bubble1.y - bubble2.y
|
||||||
|
val radius = (bubble1.radius + bubble2.radius) * 0.9f
|
||||||
|
return dx * dx + dy * dy < radius * radius
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveCollision(particle: Bubble, otherParticle: Bubble) {
|
||||||
|
val xVelocityDiff = particle.velocity.x - otherParticle.velocity.x
|
||||||
|
val yVelocityDiff = particle.velocity.y - otherParticle.velocity.y
|
||||||
|
val xDist = otherParticle.x - particle.x
|
||||||
|
val yDist = otherParticle.y - particle.y
|
||||||
|
// Prevent accidental overlap
|
||||||
|
if (xVelocityDiff * xDist + yVelocityDiff * yDist >= 0) {
|
||||||
|
val angle = -atan2(otherParticle.y - particle.y, otherParticle.x - particle.x)
|
||||||
|
val m1 = 1f
|
||||||
|
val m2 = 1f
|
||||||
|
val u1 = rotate(particle.velocity, angle)
|
||||||
|
val u2 = rotate(otherParticle.velocity, angle)
|
||||||
|
val v1 = Velocity(
|
||||||
|
x = (u1.x * (m1 - m2)) / (m1 + m2) + (u2.x * 2 * m2) / (m1 + m2),
|
||||||
|
y = u1.y,
|
||||||
|
)
|
||||||
|
val v2 = Velocity(
|
||||||
|
x = (u2.x * (m1 - m2)) / (m1 + m2) + (u1.x * 2 * m2) / (m1 + m2),
|
||||||
|
y = u2.y,
|
||||||
|
)
|
||||||
|
val vFinal1 = rotate(v1, -angle)
|
||||||
|
val vFinal2 = rotate(v2, -angle)
|
||||||
|
particle.velocity.x = vFinal1.x
|
||||||
|
particle.velocity.y = vFinal1.y
|
||||||
|
otherParticle.velocity.x = vFinal2.x
|
||||||
|
otherParticle.velocity.y = vFinal2.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rotate(velocity: Velocity, angle: Float): Velocity {
|
||||||
|
return Velocity(
|
||||||
|
x = velocity.x * cos(angle) - velocity.y * sin(angle),
|
||||||
|
y = velocity.x * sin(angle) + velocity.y * cos(angle),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildShellColors(shellColor: Int): IntArray {
|
||||||
|
shellColors[0] = colorWithAlpha(shellColor, 0f)
|
||||||
|
shellColors[1] = colorWithAlpha(shellColor, 0f)
|
||||||
|
shellColors[2] = colorWithAlpha(shellColor, 0.04f)
|
||||||
|
shellColors[3] = colorWithAlpha(shellColor, 0.12f)
|
||||||
|
shellColors[4] = colorWithAlpha(shellColor, 0.28f)
|
||||||
|
shellColors[5] = colorWithAlpha(shellColor, 0.44f)
|
||||||
|
shellColors[6] = colorWithAlpha(shellColor, 0.58f)
|
||||||
|
shellColors[7] = colorWithAlpha(shellColor, 0.64f)
|
||||||
|
return shellColors
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildInnerColors(innerColor: Int): IntArray {
|
||||||
|
innerColors[0] = colorWithAlpha(innerColor, 0.48f)
|
||||||
|
innerColors[1] = colorWithAlpha(innerColor, 0.36f)
|
||||||
|
innerColors[2] = colorWithAlpha(innerColor, 0.08f)
|
||||||
|
innerColors[3] = colorWithAlpha(innerColor, 0f)
|
||||||
|
return innerColors
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveBubbleRadius(style: BubblesStyle): Float {
|
||||||
|
val minDimension = min(width, height)
|
||||||
|
val baseRadius = minDimension * style.bubbleRadiusFraction
|
||||||
|
val minRadius = style.minBubbleRadiusPx.coerceAtLeast(1f)
|
||||||
|
val maxRadius = maxOf(minRadius, style.maxBubbleRadiusPx)
|
||||||
|
if (!style.adaptiveSizing || minDimension <= 0f) {
|
||||||
|
return baseRadius.coerceIn(minRadius, maxRadius)
|
||||||
|
}
|
||||||
|
|
||||||
|
val reference = style.referenceMinSizePx.coerceAtLeast(1f)
|
||||||
|
val dampening = sqrt((reference / minDimension).coerceAtMost(1f))
|
||||||
|
return (baseRadius * dampening).coerceIn(minRadius, maxRadius)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveTargetBubbleCount(style: BubblesStyle): Int {
|
||||||
|
val baseCount = style.bubbleCount.coerceAtLeast(1)
|
||||||
|
if (!style.adaptiveBubbleCount) return baseCount
|
||||||
|
|
||||||
|
val area = width * height
|
||||||
|
val referenceArea = style.referenceAreaPx.coerceAtLeast(1f)
|
||||||
|
val areaScale = sqrt((area / referenceArea).coerceAtLeast(0.25f))
|
||||||
|
val scaledCount = (baseCount * areaScale).toInt()
|
||||||
|
val minCount = style.minBubbleCount.coerceAtLeast(1)
|
||||||
|
val maxCount = maxOf(minCount, style.maxBubbleCount.coerceAtLeast(1))
|
||||||
|
return scaledCount.coerceIn(minCount, maxCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveVelocityRange(style: BubblesStyle): Pair<Float, Float> {
|
||||||
|
val referenceMaxVelocity = maxOf(height / 300f, 1f)
|
||||||
|
val velocityMin = (style.minVelocity * style.speedScale).coerceAtLeast(0.05f)
|
||||||
|
val velocityMax = maxOf(velocityMin, min(style.maxVelocity, referenceMaxVelocity) * style.speedScale)
|
||||||
|
return velocityMin to velocityMax
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun randomInRange(min: Float, max: Float): Float {
|
||||||
|
return random.nextFloat() * (max - min) + min
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hueToRgb(h: Float, l: Float): Int {
|
||||||
|
val hNorm = h / 360f
|
||||||
|
val lNorm = l / 100f
|
||||||
|
val sNorm = 1f
|
||||||
|
val c = (1f - kotlin.math.abs(2f * lNorm - 1f)) * sNorm
|
||||||
|
val hp = hNorm * 6f
|
||||||
|
val x = c * (1f - kotlin.math.abs((hp % 2f) - 1f))
|
||||||
|
val m = lNorm - c / 2f
|
||||||
|
val (r, g, b) = when {
|
||||||
|
hp < 1f -> Triple(c, x, 0f)
|
||||||
|
hp < 2f -> Triple(x, c, 0f)
|
||||||
|
hp < 3f -> Triple(0f, c, x)
|
||||||
|
hp < 4f -> Triple(0f, x, c)
|
||||||
|
hp < 5f -> Triple(x, 0f, c)
|
||||||
|
else -> Triple(c, 0f, x)
|
||||||
|
}
|
||||||
|
val r8 = ((r + m) * 255).toInt().coerceIn(0, 255)
|
||||||
|
val g8 = ((g + m) * 255).toInt().coerceIn(0, 255)
|
||||||
|
val b8 = ((b + m) * 255).toInt().coerceIn(0, 255)
|
||||||
|
return (0xFF shl 24) or (r8 shl 16) or (g8 shl 8) or b8
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun colorWithAlpha(color: Int, alpha: Float): Int {
|
||||||
|
val a = (alpha * 255).toInt().coerceIn(0, 255)
|
||||||
|
val r = (color shr 16) and 0xFF
|
||||||
|
val g = (color shr 8) and 0xFF
|
||||||
|
val b = color and 0xFF
|
||||||
|
return (a shl 24) or (r shl 16) or (g shl 8) or b
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mixWithWhite(color: Int, amount: Float): Int {
|
||||||
|
val t = amount.coerceIn(0f, 1f)
|
||||||
|
val r = (color shr 16) and 0xFF
|
||||||
|
val g = (color shr 8) and 0xFF
|
||||||
|
val b = color and 0xFF
|
||||||
|
val mixedR = (r + (255 - r) * t).toInt().coerceIn(0, 255)
|
||||||
|
val mixedG = (g + (255 - g) * t).toInt().coerceIn(0, 255)
|
||||||
|
val mixedB = (b + (255 - b) * t).toInt().coerceIn(0, 255)
|
||||||
|
return (0xFF shl 24) or (mixedR shl 16) or (mixedG shl 8) or mixedB
|
||||||
|
}
|
||||||
+259
-64
@@ -27,36 +27,63 @@ import androidx.compose.foundation.layout.PaddingValues
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ElevatedButton
|
import androidx.compose.material3.ElevatedButton
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.TextLayoutResult
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.ui.unit.Velocity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
|
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
@@ -123,7 +150,7 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
}
|
}
|
||||||
val sdfTime = remember(isUtc) {
|
val sdfTime = remember(isUtc) {
|
||||||
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
|
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
|
||||||
}
|
}
|
||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -151,16 +178,17 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
val elevColor = elevationColor(pass.maxElevation)
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(R.drawable.ic_elevation),
|
painter = painterResource(R.drawable.ic_elevation),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = elevColor,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "${pass.maxElevation}°",
|
text = "${pass.maxElevation}°",
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = elevColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
@@ -178,12 +206,6 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
|
||||||
painter = painterResource(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)
|
Text(text = "${pass.altitude} km", fontSize = 15.sp)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
@@ -197,6 +219,11 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun displayLocale(): Locale {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
@@ -212,14 +239,16 @@ fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier)
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier) {
|
fun IconCard(
|
||||||
ElevatedCard(modifier = Modifier.size(48.dp)) {
|
action: () -> Unit, resId: Int, modifier: Modifier = Modifier,
|
||||||
Box(
|
enabled: Boolean = true, containerColor: Color = Color.Unspecified
|
||||||
modifier = Modifier
|
) {
|
||||||
.clickable(onClick = action)
|
val colors = if (containerColor == Color.Unspecified) CardDefaults.elevatedCardColors()
|
||||||
.fillMaxSize(),
|
else CardDefaults.elevatedCardColors(containerColor = containerColor)
|
||||||
contentAlignment = Alignment.Center
|
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action, colors = colors) {
|
||||||
) { Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier) }
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,69 +301,140 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SharedDialog(
|
fun InfoDialog(
|
||||||
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
DialogShell(title = title, titleFontSize = 16, onDismissRequest = onCancel) {
|
|
||||||
content()
|
|
||||||
Row(modifier = Modifier.padding(start = it, bottom = it, end = it)) {
|
|
||||||
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun InfoDialog(title: String, text: String, onDismiss: () -> Unit) {
|
|
||||||
DialogShell(title = title, titleFontSize = 18, onDismissRequest = {}) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier.padding(horizontal = it)
|
|
||||||
)
|
|
||||||
Row(modifier = Modifier.padding(start = it, bottom = it, end = it)) {
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
CardButton(onClick = onDismiss, text = stringResource(R.string.btn_accept))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DialogShell(
|
|
||||||
title: String,
|
title: String,
|
||||||
titleFontSize: Int,
|
onDismiss: () -> Unit,
|
||||||
onDismissRequest: () -> Unit,
|
onAccept: () -> Unit,
|
||||||
content: @Composable (padding: androidx.compose.ui.unit.Dp) -> Unit
|
extraAction: (@Composable () -> Unit)? = null,
|
||||||
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
DialogShell(onDismissRequest = onDismiss) { padding ->
|
||||||
Dialog(onDismissRequest = onDismissRequest) {
|
Row(
|
||||||
ElevatedCard {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
Column(
|
modifier = Modifier
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
.fillMaxWidth()
|
||||||
verticalArrangement = Arrangement.spacedBy(padding)
|
.padding(start = padding, top = padding, end = padding)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
fontSize = titleFontSize.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.padding(start = padding, top = padding, end = padding)
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
content(padding)
|
extraAction?.let {
|
||||||
|
it()
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
}
|
}
|
||||||
|
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ConfirmDialog(
|
||||||
|
title: String,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onAccept: () -> Unit,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
DialogShell(onDismissRequest = onCancel) { padding ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = padding, top = padding, end = padding)
|
||||||
|
) {
|
||||||
|
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = padding)
|
||||||
|
)
|
||||||
|
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WhatsNewDialog(onDismiss: () -> Unit) {
|
||||||
|
InfoDialog(
|
||||||
|
title = stringResource(R.string.pass_whatsnew_title),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
onAccept = onDismiss
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.pass_whatsnew_message),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(0.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun DialogShell(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
content: @Composable (padding: Dp) -> Unit
|
||||||
|
) {
|
||||||
|
val padding = LocalSpacing.current.large
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||||
|
val containerHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
|
||||||
|
val maxSheetHeight = containerHeight - statusBarHeight
|
||||||
|
val stopSheetFling = remember {
|
||||||
|
object : NestedScrollConnection {
|
||||||
|
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||||
|
return available
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPostScroll(
|
||||||
|
consumed: Offset,
|
||||||
|
available: Offset,
|
||||||
|
source: NestedScrollSource
|
||||||
|
): Offset = Offset.Zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
sheetState = sheetState,
|
||||||
|
dragHandle = null,
|
||||||
|
shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
|
||||||
|
scrimColor = Color.Black.copy(alpha = 0.64f)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(padding),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = maxSheetHeight)
|
||||||
|
.nestedScroll(stopSheetFling)
|
||||||
|
) {
|
||||||
|
content(padding)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun hasEnoughHeight(): Boolean =
|
fun hasEnoughHeight(): Boolean =
|
||||||
currentWindowAdaptiveInfo().windowSizeClass.isHeightAtLeastBreakpoint(480)
|
currentWindowAdaptiveInfoV2().windowSizeClass.isHeightAtLeastBreakpoint(480)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun hasEnoughWidth(): Boolean =
|
fun hasEnoughWidth(): Boolean =
|
||||||
currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600)
|
currentWindowAdaptiveInfoV2().windowSizeClass.isWidthAtLeastBreakpoint(600)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun isVerticalLayout(): Boolean = !hasEnoughWidth()
|
fun isVerticalLayout(): Boolean = !hasEnoughWidth()
|
||||||
@@ -385,3 +485,98 @@ fun TopBar(
|
|||||||
TopBar { startAction(); topInfo(); bottomInfo(); endAction() }
|
TopBar { startAction(); topInfo(); bottomInfo(); endAction() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun elevationColor(elevation: Double): Color {
|
||||||
|
val thresholds = LocalElevationThresholds.current
|
||||||
|
return when {
|
||||||
|
elevation < thresholds.low -> ElevationLowColor // soft red for low elevation
|
||||||
|
elevation < thresholds.high -> MaterialTheme.colorScheme.primary // accent yellow for normal
|
||||||
|
else -> ElevationHighColor // soft green for high elevation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User-configurable elevation highlight thresholds (in degrees). */
|
||||||
|
data class ElevationThresholds(val low: Double = 15.0, val high: Double = 45.0)
|
||||||
|
|
||||||
|
/** Provided at the app root from settings; defaults keep the original 15°/45° behavior. */
|
||||||
|
val LocalElevationThresholds = compositionLocalOf { ElevationThresholds() }
|
||||||
|
|
||||||
|
/** Soft red used for elevations below the low threshold. */
|
||||||
|
val ElevationLowColor = Color(0xFFEF5350)
|
||||||
|
|
||||||
|
/** Soft green used for elevations above the high threshold. */
|
||||||
|
val ElevationHighColor = Color(0xFF66BB6A)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OutlinedText(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
fillColor: Color = Color.Unspecified,
|
||||||
|
outlineColor: Color,
|
||||||
|
fontSize: TextUnit = TextUnit.Unspecified,
|
||||||
|
fontStyle: FontStyle? = null,
|
||||||
|
fontWeight: FontWeight? = null,
|
||||||
|
fontFamily: FontFamily? = null,
|
||||||
|
letterSpacing: TextUnit = TextUnit.Unspecified,
|
||||||
|
textDecoration: TextDecoration? = null,
|
||||||
|
textAlign: TextAlign? = null,
|
||||||
|
lineHeight: TextUnit = TextUnit.Unspecified,
|
||||||
|
overflow: TextOverflow = TextOverflow.Clip,
|
||||||
|
softWrap: Boolean = true,
|
||||||
|
maxLines: Int = Int.MAX_VALUE,
|
||||||
|
minLines: Int = 1,
|
||||||
|
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||||
|
style: TextStyle = LocalTextStyle.current,
|
||||||
|
outlineDrawStyle: Stroke = Stroke(width = 8f),
|
||||||
|
) {
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
modifier = Modifier.semantics { hideFromAccessibility() },
|
||||||
|
color = outlineColor,
|
||||||
|
fontSize = fontSize,
|
||||||
|
fontStyle = fontStyle,
|
||||||
|
fontWeight = fontWeight,
|
||||||
|
fontFamily = fontFamily,
|
||||||
|
letterSpacing = letterSpacing,
|
||||||
|
textDecoration = null,
|
||||||
|
textAlign = textAlign,
|
||||||
|
lineHeight = lineHeight,
|
||||||
|
overflow = overflow,
|
||||||
|
softWrap = softWrap,
|
||||||
|
maxLines = maxLines,
|
||||||
|
minLines = minLines,
|
||||||
|
onTextLayout = onTextLayout,
|
||||||
|
style = style.copy(shadow = null, drawStyle = outlineDrawStyle),
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
color = fillColor,
|
||||||
|
fontSize = fontSize,
|
||||||
|
fontStyle = fontStyle,
|
||||||
|
fontWeight = fontWeight,
|
||||||
|
fontFamily = fontFamily,
|
||||||
|
letterSpacing = letterSpacing,
|
||||||
|
textDecoration = textDecoration,
|
||||||
|
textAlign = textAlign,
|
||||||
|
lineHeight = lineHeight,
|
||||||
|
overflow = overflow,
|
||||||
|
softWrap = softWrap,
|
||||||
|
maxLines = maxLines,
|
||||||
|
minLines = minLines,
|
||||||
|
onTextLayout = onTextLayout,
|
||||||
|
style = style,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formats a frequency in Hz as "MMM.KKK.HHH" (e.g. 145.825.000) or "---"
|
||||||
|
fun formatFrequency(frequencyHz: Long): String {
|
||||||
|
if (frequencyHz <= 0) return "---"
|
||||||
|
val mhz = frequencyHz / 1_000_000
|
||||||
|
val khz = (frequencyHz % 1_000_000) / 1_000
|
||||||
|
val hz = frequencyHz % 1_000
|
||||||
|
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
|
||||||
|
}
|
||||||
+272
@@ -0,0 +1,272 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2021 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.rtbishop.look4sat.core.presentation
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
|
import androidx.compose.foundation.gestures.scrollBy
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyItemScope
|
||||||
|
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SwipeToDismissBox
|
||||||
|
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||||
|
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.dropShadow
|
||||||
|
import androidx.compose.ui.draw.innerShadow
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
//region DragDrop Region
|
||||||
|
@Composable
|
||||||
|
fun rememberDragDropState(lazyListState: LazyListState, onMove: (Int, Int) -> Unit): DragDropState {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val state = remember(lazyListState) {
|
||||||
|
DragDropState(state = lazyListState, onMove = onMove, scope = scope)
|
||||||
|
}
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
while (true) {
|
||||||
|
val diff = state.scrollChannel.receive()
|
||||||
|
lazyListState.scrollBy(diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
class DragDropState internal constructor(
|
||||||
|
private val state: LazyListState,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val onMove: (Int, Int) -> Unit
|
||||||
|
) {
|
||||||
|
var draggingItemIndex by mutableStateOf<Int?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
internal val scrollChannel = Channel<Float>()
|
||||||
|
|
||||||
|
private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
|
||||||
|
private var draggingItemInitialOffset by mutableIntStateOf(0)
|
||||||
|
internal val draggingItemOffset: Float
|
||||||
|
get() = draggingItemLayoutInfo?.let { item ->
|
||||||
|
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
|
||||||
|
} ?: 0f
|
||||||
|
|
||||||
|
private val draggingItemLayoutInfo: LazyListItemInfo?
|
||||||
|
get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex }
|
||||||
|
|
||||||
|
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
|
||||||
|
private set
|
||||||
|
internal var previousItemOffset = Animatable(0f)
|
||||||
|
private set
|
||||||
|
|
||||||
|
internal fun onDragStart(offset: Offset) {
|
||||||
|
state.layoutInfo.visibleItemsInfo.firstOrNull { item ->
|
||||||
|
offset.y.toInt() in item.offset..(item.offset + item.size)
|
||||||
|
}?.also {
|
||||||
|
draggingItemIndex = it.index
|
||||||
|
draggingItemInitialOffset = it.offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun onDragInterrupted() {
|
||||||
|
if (draggingItemIndex != null) {
|
||||||
|
previousIndexOfDraggedItem = draggingItemIndex
|
||||||
|
val startOffset = draggingItemOffset
|
||||||
|
scope.launch {
|
||||||
|
previousItemOffset.snapTo(startOffset)
|
||||||
|
previousItemOffset.animateTo(
|
||||||
|
0f, spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f)
|
||||||
|
)
|
||||||
|
previousIndexOfDraggedItem = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draggingItemDraggedDelta = 0f
|
||||||
|
draggingItemIndex = null
|
||||||
|
draggingItemInitialOffset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun onDrag(offset: Offset) {
|
||||||
|
draggingItemDraggedDelta += offset.y
|
||||||
|
|
||||||
|
val draggingItem = draggingItemLayoutInfo ?: return
|
||||||
|
val startOffset = draggingItem.offset + draggingItemOffset
|
||||||
|
val endOffset = startOffset + draggingItem.size
|
||||||
|
val middleOffset = startOffset + (endOffset - startOffset) / 2f
|
||||||
|
|
||||||
|
val targetItem = state.layoutInfo.visibleItemsInfo.find { item ->
|
||||||
|
middleOffset.toInt() in item.offset..item.offsetEnd && draggingItem.index != item.index
|
||||||
|
}
|
||||||
|
if (targetItem != null) {
|
||||||
|
if (draggingItem.index == state.firstVisibleItemIndex || targetItem.index == state.firstVisibleItemIndex) {
|
||||||
|
state.requestScrollToItem(state.firstVisibleItemIndex, state.firstVisibleItemScrollOffset)
|
||||||
|
}
|
||||||
|
onMove.invoke(draggingItem.index, targetItem.index)
|
||||||
|
draggingItemIndex = targetItem.index
|
||||||
|
} else {
|
||||||
|
val overscroll = when {
|
||||||
|
draggingItemDraggedDelta > 0 -> (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
|
||||||
|
draggingItemDraggedDelta < 0 -> (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
if (overscroll != 0f) {
|
||||||
|
scrollChannel.trySend(overscroll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val LazyListItemInfo.offsetEnd: Int
|
||||||
|
get() = this.offset + this.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Modifier.dragContainer(dragDropState: DragDropState): Modifier {
|
||||||
|
return pointerInput(dragDropState) {
|
||||||
|
detectDragGesturesAfterLongPress(
|
||||||
|
onDrag = { change, offset ->
|
||||||
|
change.consume()
|
||||||
|
dragDropState.onDrag(offset = offset)
|
||||||
|
},
|
||||||
|
onDragStart = { offset -> dragDropState.onDragStart(offset) },
|
||||||
|
onDragEnd = { dragDropState.onDragInterrupted() },
|
||||||
|
onDragCancel = { dragDropState.onDragInterrupted() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LazyItemScope.DraggableItem(
|
||||||
|
dragDropState: DragDropState,
|
||||||
|
index: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable ColumnScope.(isDragging: Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
val zIndexMod = Modifier.zIndex(1f)
|
||||||
|
val dragging = index == dragDropState.draggingItemIndex
|
||||||
|
val draggingModifier = when {
|
||||||
|
dragging -> zIndexMod.graphicsLayer { translationY = dragDropState.draggingItemOffset }
|
||||||
|
index == dragDropState.previousIndexOfDraggedItem -> {
|
||||||
|
zIndexMod.graphicsLayer { translationY = dragDropState.previousItemOffset.value }
|
||||||
|
}
|
||||||
|
else -> Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
|
||||||
|
}
|
||||||
|
Column(modifier = modifier.then(draggingModifier)) { content(dragging) }
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region Swipe Region
|
||||||
|
@Composable
|
||||||
|
fun SwipeableItem(onRemove: () -> Unit, content: @Composable () -> Unit) {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val dismissThresholdPx = with(LocalDensity.current) { 120.dp.toPx() }
|
||||||
|
val dismissState = rememberSwipeToDismissBoxState { dismissThresholdPx }
|
||||||
|
val isVisible = remember { mutableStateOf(true) }
|
||||||
|
val willTrigger by remember { derivedStateOf { dismissState.targetValue != SwipeToDismissBoxValue.Settled } }
|
||||||
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
|
LaunchedEffect(willTrigger) {
|
||||||
|
val feedbackType = if (willTrigger) HapticFeedbackType.LongPress else HapticFeedbackType.SegmentTick
|
||||||
|
hapticFeedback.performHapticFeedback(feedbackType)
|
||||||
|
}
|
||||||
|
AnimatedVisibility(visible = isVisible.value, exit = fadeOut(spring())) {
|
||||||
|
SwipeToDismissBox(
|
||||||
|
state = dismissState,
|
||||||
|
enableDismissFromStartToEnd = true,
|
||||||
|
enableDismissFromEndToStart = false,
|
||||||
|
backgroundContent = { DismissBackground(willTrigger) },
|
||||||
|
content = { content() },
|
||||||
|
onDismiss = {
|
||||||
|
isVisible.value = false
|
||||||
|
coroutineScope.launch { dismissState.reset() }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LaunchedEffect(isVisible.value) {
|
||||||
|
if (!isVisible.value) { delay(250.milliseconds).also { onRemove() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DismissBackground(willTrigger: Boolean, bgColor: Color = Color(0xFFFF3C3A)) {
|
||||||
|
val iconScale by animateFloatAsState(targetValue = if (willTrigger) 1f else .8f)
|
||||||
|
val slide by animateDpAsState(targetValue = if (willTrigger) 32.dp else (12).dp)
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.border(width = 2.dp, shape = MaterialTheme.shapes.extraLarge, color = bgColor)
|
||||||
|
.dropShadow(shape = MaterialTheme.shapes.extraLarge) {
|
||||||
|
color = bgColor
|
||||||
|
radius = 40f
|
||||||
|
alpha = if (willTrigger) .2f else 0f
|
||||||
|
}
|
||||||
|
.innerShadow(shape = MaterialTheme.shapes.extraLarge) {
|
||||||
|
color = bgColor
|
||||||
|
radius = 40f
|
||||||
|
alpha = if (willTrigger) 1f else .2f
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_delete),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = iconScale
|
||||||
|
scaleY = iconScale
|
||||||
|
translationX = slide.toPx()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
+311
@@ -0,0 +1,311 @@
|
|||||||
|
/*
|
||||||
|
* 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.presentation
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
|
import androidx.compose.foundation.gestures.scrollBy
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableFloatState
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Long-press drag-to-reorder for rows inside a `LazyColumn`, with built-in edge auto-scroll and
|
||||||
|
* a settle-back animation when the finger is released.
|
||||||
|
*
|
||||||
|
* Reordering only ever swaps an item with its neighbor *inside the `items` list* passed to
|
||||||
|
* [rememberDragRowState] — so multiple independent groups of rows (e.g. two sections separated
|
||||||
|
* by header items) can share one [DragReorderState]/`LazyColumn` simply by giving each section
|
||||||
|
* its own backing list and calling [rememberDragRowState] with that section's list. Keys must be
|
||||||
|
* globally unique across the whole `LazyColumn` and match exactly what is passed as the
|
||||||
|
* `key = { ... }` lambda of `items`/`itemsIndexed`.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```
|
||||||
|
* val dragState = rememberDragReorderState(listState)
|
||||||
|
* itemsIndexed(entries, key = { _, e -> e.id }) { index, entry ->
|
||||||
|
* val row = rememberDragRowState(dragState, entries, entry, key = { it.id }) { from, to ->
|
||||||
|
* entries.add(to, entries.removeAt(from))
|
||||||
|
* }
|
||||||
|
* Row(
|
||||||
|
* modifier = Modifier
|
||||||
|
* .dragLift(row.isLifted, row.translationY)
|
||||||
|
* .animateItem(placementSpec = if (row.isDragging) tween(0) else spring())
|
||||||
|
* ) {
|
||||||
|
* Icon(modifier = Modifier.dragHandle(row), painter = ..., contentDescription = null)
|
||||||
|
* Text(entry.label)
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class DragReorderState internal constructor(internal val listState: LazyListState) {
|
||||||
|
internal var draggedKey by mutableStateOf<Any?>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberDragReorderState(listState: LazyListState): DragReorderState =
|
||||||
|
remember(listState) { DragReorderState(listState) }
|
||||||
|
|
||||||
|
/** Used for the settle-back animation once the finger is released: quick and snappy. */
|
||||||
|
private val reorderSettleSpec = tween<Float>(durationMillis = 150, easing = FastOutSlowInEasing)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-row drag bookkeeping, obtained via [rememberDragRowState].
|
||||||
|
*
|
||||||
|
* [dragStartOffset] is this row's viewport offset captured once at drag start, [fingerOffset] is
|
||||||
|
* the raw accumulated finger travel (used for edge auto-scroll and swap detection). [translationY]
|
||||||
|
* is derived every frame from those two plus the row's *current* live layout offset, so it
|
||||||
|
* automatically stays correct across neighbor swaps and list auto-scroll without any manual
|
||||||
|
* offset-compensation math.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class DragRowState<T> internal constructor(
|
||||||
|
private val dragState: DragReorderState,
|
||||||
|
private val itemsState: State<List<T>>,
|
||||||
|
private val itemKey: Any,
|
||||||
|
private val key: (T) -> Any,
|
||||||
|
private val onMoveState: State<(Int, Int) -> Unit>,
|
||||||
|
private val scope: CoroutineScope
|
||||||
|
) {
|
||||||
|
internal val fingerOffset: MutableFloatState = mutableFloatStateOf(0f)
|
||||||
|
internal val dragStartOffset: MutableFloatState = mutableFloatStateOf(0f)
|
||||||
|
internal val startCenterY: MutableFloatState = mutableFloatStateOf(0f)
|
||||||
|
internal val settleAnim = Animatable(0f)
|
||||||
|
|
||||||
|
var isSettling: Boolean by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
val isDragging: Boolean get() = dragState.draggedKey == itemKey
|
||||||
|
val isLifted: Boolean get() = isDragging || isSettling
|
||||||
|
|
||||||
|
val translationY: Float
|
||||||
|
get() = when {
|
||||||
|
isSettling -> settleAnim.value
|
||||||
|
isDragging -> currentTranslation()
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far this row must be pushed away from its *current* layout slot so it stays glued to
|
||||||
|
* the finger. Because [dragStartOffset] is fixed at drag start while the row's live layout
|
||||||
|
* offset moves as neighbors swap places or the list auto-scrolls, this difference naturally
|
||||||
|
* absorbs both effects with no extra bookkeeping.
|
||||||
|
*/
|
||||||
|
private fun currentTranslation(): Float {
|
||||||
|
val liveOffset = dragState.listState.layoutInfo.visibleItemsInfo
|
||||||
|
.firstOrNull { it.key == itemKey }?.offset?.toFloat()
|
||||||
|
?: dragStartOffset.floatValue
|
||||||
|
return dragStartOffset.floatValue + fingerOffset.floatValue - liveOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun onDragStart() {
|
||||||
|
isSettling = false
|
||||||
|
val layout = dragState.listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == itemKey }
|
||||||
|
dragStartOffset.floatValue = (layout?.offset ?: 0).toFloat()
|
||||||
|
startCenterY.floatValue = (layout?.offset ?: 0) + (layout?.size ?: 0) / 2f
|
||||||
|
fingerOffset.floatValue = 0f
|
||||||
|
dragState.draggedKey = itemKey
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun onDrag(deltaY: Float) {
|
||||||
|
if (dragState.draggedKey != itemKey) return
|
||||||
|
fingerOffset.floatValue += deltaY
|
||||||
|
reorderLive()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reorderLive() {
|
||||||
|
val items = itemsState.value
|
||||||
|
val myIndex = items.indexOfFirst { key(it) == itemKey }
|
||||||
|
if (myIndex !in items.indices) return
|
||||||
|
val myCenter = startCenterY.floatValue + fingerOffset.floatValue
|
||||||
|
val visible = dragState.listState.layoutInfo.visibleItemsInfo
|
||||||
|
val onMove = onMoveState.value
|
||||||
|
// Dragging down: swap when the dragged center passes the next row's midpoint.
|
||||||
|
if (myIndex < items.lastIndex) {
|
||||||
|
val nextKey = key(items[myIndex + 1])
|
||||||
|
val next = visible.firstOrNull { it.key == nextKey }
|
||||||
|
if (next != null && myCenter > next.offset + next.size / 2f) {
|
||||||
|
onMove(myIndex, myIndex + 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dragging up: swap when the dragged center passes the previous row's midpoint.
|
||||||
|
if (myIndex > 0) {
|
||||||
|
val prevKey = key(items[myIndex - 1])
|
||||||
|
val prev = visible.firstOrNull { it.key == prevKey }
|
||||||
|
if (prev != null && myCenter < prev.offset + prev.size / 2f) {
|
||||||
|
onMove(myIndex, myIndex - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the drag bookkeeping and fly the lifted row back into its slot.
|
||||||
|
// All state resets happen inside the launched block so the settle animation takes over from
|
||||||
|
// the current visual position without a one-frame jump: isSettling is flipped to true
|
||||||
|
// (switching rendering to settleAnim, already snapped to the last offset) before the drag
|
||||||
|
// flags are cleared.
|
||||||
|
internal fun onDragEnd() {
|
||||||
|
val lastOffset = currentTranslation()
|
||||||
|
if (kotlin.math.abs(lastOffset) < 1f) {
|
||||||
|
fingerOffset.floatValue = 0f
|
||||||
|
dragState.draggedKey = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
settleAnim.snapTo(lastOffset)
|
||||||
|
isSettling = true
|
||||||
|
fingerOffset.floatValue = 0f
|
||||||
|
dragState.draggedKey = null
|
||||||
|
settleAnim.animateTo(0f, reorderSettleSpec)
|
||||||
|
isSettling = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remembers a [DragRowState] for one row of a drag-reorderable list.
|
||||||
|
*
|
||||||
|
* [items] must be the exact (optionally section-scoped) list backing the enclosing
|
||||||
|
* `items`/`itemsIndexed` call, and [key] must return the same value used as that call's
|
||||||
|
* `key = { ... }` lambda. Reordering only ever swaps neighbors within [items], so passing a
|
||||||
|
* section-local list is what confines dragging to one section of a multi-section `LazyColumn`.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <T> rememberDragRowState(
|
||||||
|
dragState: DragReorderState,
|
||||||
|
items: List<T>,
|
||||||
|
item: T,
|
||||||
|
key: (T) -> Any,
|
||||||
|
onMove: (from: Int, to: Int) -> Unit
|
||||||
|
): DragRowState<T> {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val itemsState = rememberUpdatedState(items)
|
||||||
|
val onMoveState = rememberUpdatedState(onMove)
|
||||||
|
val itemKey = key(item)
|
||||||
|
val rowState = remember(dragState, itemKey) {
|
||||||
|
DragRowState(dragState, itemsState, itemKey, key, onMoveState, scope)
|
||||||
|
}
|
||||||
|
LaunchedEffect(rowState.isDragging) {
|
||||||
|
if (!rowState.isDragging) return@LaunchedEffect
|
||||||
|
autoScrollWhileDragging(dragState.listState, rowState.startCenterY, rowState.fingerOffset)
|
||||||
|
}
|
||||||
|
return rowState
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drag-handle gesture: attach to a small handle icon to start/drive/end reordering of [rowState]'s row. */
|
||||||
|
fun Modifier.dragHandle(rowState: DragRowState<*>): Modifier = pointerInput(rowState) {
|
||||||
|
detectDragGesturesAfterLongPress(
|
||||||
|
onDragStart = { rowState.onDragStart() },
|
||||||
|
onDragEnd = { rowState.onDragEnd() },
|
||||||
|
onDragCancel = { rowState.onDragEnd() }
|
||||||
|
) { change, dragAmount ->
|
||||||
|
change.consume()
|
||||||
|
rowState.onDrag(dragAmount.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual treatment for a draggable row: while [isLifted], translates the row by [translationY],
|
||||||
|
* scales it up slightly and raises it above its neighbors with a shadow and a solid background so
|
||||||
|
* it fully covers the row beneath instead of showing a translucent overlap of both rows.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Modifier.dragLift(isLifted: Boolean, translationY: Float): Modifier {
|
||||||
|
val shape = MaterialTheme.shapes.small
|
||||||
|
val scale by animateFloatAsState(
|
||||||
|
targetValue = if (isLifted) 1.02f else 1f,
|
||||||
|
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
|
||||||
|
label = "dragScale"
|
||||||
|
)
|
||||||
|
return this
|
||||||
|
.graphicsLayer {
|
||||||
|
if (isLifted) {
|
||||||
|
this.translationY = translationY
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.then(
|
||||||
|
if (isLifted) {
|
||||||
|
Modifier
|
||||||
|
.zIndex(1f)
|
||||||
|
.shadow(8.dp, shape, clip = false)
|
||||||
|
.background(MaterialTheme.colorScheme.surface, shape)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls the list while dragging so the entry follows the finger past the viewport edges.
|
||||||
|
* The visual center is tracked independently of the entry's layout slot (which can scroll out of
|
||||||
|
* `LazyListState.layoutInfo.visibleItemsInfo` during a long drag); [startCenterY] is the entry's
|
||||||
|
* viewport center captured at drag start and [fingerOffset] is the raw finger delta. The
|
||||||
|
* resulting scroll is picked up automatically by [DragRowState.translationY] on the next frame,
|
||||||
|
* so no separate scroll-compensation bookkeeping is required here.
|
||||||
|
*/
|
||||||
|
private suspend fun autoScrollWhileDragging(
|
||||||
|
listState: LazyListState,
|
||||||
|
startCenterY: MutableFloatState,
|
||||||
|
fingerOffset: MutableFloatState
|
||||||
|
) {
|
||||||
|
val threshold = 48f
|
||||||
|
val maxSpeed = 24f
|
||||||
|
while (true) {
|
||||||
|
val info = listState.layoutInfo
|
||||||
|
val center = startCenterY.floatValue + fingerOffset.floatValue
|
||||||
|
val top = info.viewportStartOffset + threshold
|
||||||
|
val bottom = info.viewportEndOffset - threshold
|
||||||
|
val delta = when {
|
||||||
|
center < top -> -(top - center).coerceAtMost(maxSpeed)
|
||||||
|
center > bottom -> (center - bottom).coerceAtMost(maxSpeed)
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
if (delta != 0f) listState.scrollBy(delta)
|
||||||
|
delay(16L.milliseconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-7
@@ -46,7 +46,7 @@ data object Spacing {
|
|||||||
val extraSmall = 6.dp
|
val extraSmall = 6.dp
|
||||||
val small = 8.dp
|
val small = 8.dp
|
||||||
val medium = 12.dp
|
val medium = 12.dp
|
||||||
val large = 16.dp
|
val large = 12.dp
|
||||||
val extraLarge = 24.dp
|
val extraLarge = 24.dp
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,8 +61,8 @@ fun MainTheme(isDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable
|
|||||||
SideEffect {
|
SideEffect {
|
||||||
val window = (view.context as ComponentActivity).window
|
val window = (view.context as ComponentActivity).window
|
||||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||||
insetsController.isAppearanceLightStatusBars = false
|
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||||
insetsController.isAppearanceLightNavigationBars = false
|
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
window.isNavigationBarContrastEnforced = false
|
window.isNavigationBarContrastEnforced = false
|
||||||
}
|
}
|
||||||
@@ -79,6 +79,10 @@ private val lightScheme = lightColorScheme(
|
|||||||
onSecondary = Color(0xFFFFFFFF),
|
onSecondary = Color(0xFFFFFFFF),
|
||||||
secondaryContainer = Color(0xFFF1E1BB),
|
secondaryContainer = Color(0xFFF1E1BB),
|
||||||
onSecondaryContainer = Color(0xFF221B04),
|
onSecondaryContainer = Color(0xFF221B04),
|
||||||
|
tertiary = Color(0xFF3C6FE0), // AMSAT Active (darker for light theme)
|
||||||
|
onTertiary = Color(0xFFFFFFFF),
|
||||||
|
tertiaryContainer = Color(0xFFE09800), // AMSAT Telemetry (darker)
|
||||||
|
onTertiaryContainer = Color(0xFF000000),
|
||||||
background = Color(0xFFFFF8F0),
|
background = Color(0xFFFFF8F0),
|
||||||
onBackground = Color(0xFF1E1B13),
|
onBackground = Color(0xFF1E1B13),
|
||||||
surface = Color(0xFFFFF8F0),
|
surface = Color(0xFFFFF8F0),
|
||||||
@@ -101,10 +105,10 @@ private val darkScheme = darkColorScheme(
|
|||||||
onSecondary = Color(0xFF000000),
|
onSecondary = Color(0xFF000000),
|
||||||
secondaryContainer = Color(0xFF404040), // navBar indicator,
|
secondaryContainer = Color(0xFF404040), // navBar indicator,
|
||||||
onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon
|
onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon
|
||||||
// tertiary = Color(0xFF121212),
|
tertiary = Color(0xFF648FFF), // AMSAT Active (from amsat.org/status)
|
||||||
// onTertiary = Color(0xFF121212),
|
onTertiary = Color(0xFF000000),
|
||||||
// tertiaryContainer = Color(0xFF121212),
|
tertiaryContainer = Color(0xFFFFB000), // AMSAT Telemetry
|
||||||
// onTertiaryContainer = Color(0xFF121212),
|
onTertiaryContainer = Color(0xFF000000),
|
||||||
background = Color(0xFF121212),
|
background = Color(0xFF121212),
|
||||||
onBackground = Color(0xFFE0E0E0),
|
onBackground = Color(0xFFE0E0E0),
|
||||||
surface = Color(0xFF202020), // card background
|
surface = Color(0xFF202020), // card background
|
||||||
|
|||||||
+260
@@ -0,0 +1,260 @@
|
|||||||
|
/*
|
||||||
|
* 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.presentation
|
||||||
|
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class MatrixStyle(
|
||||||
|
val bgColor: Color = Color.Black,
|
||||||
|
val bodyColor: Color = Color(0xFF29C94A),
|
||||||
|
val headColor: Color = Color(0xFFC2FFC6),
|
||||||
|
val tailAlphaFloor: Float = 0.14f, // 0.14f - 0.18f,
|
||||||
|
val fontSize: TextUnit = 14.sp, // 11.sp - 14.sp
|
||||||
|
val minStreamLength: Int = 8, // 6 - 8
|
||||||
|
val maxStreamLength: Int = 28, // 20 - 28
|
||||||
|
val minSpeedRps: Float = 10f, // 10f - 15f
|
||||||
|
val maxSpeedRps: Float = 38f, // 38f - 45f
|
||||||
|
val resetPauseSec: ClosedFloatingPointRange<Float> = 0.1f..1.0f,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MatrixEffect(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
isRunning: Boolean = true,
|
||||||
|
style: MatrixStyle = MatrixStyle(),
|
||||||
|
symbols: String = DEFAULT_SYMBOLS,
|
||||||
|
) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val renderer = remember { MatrixRenderer() }
|
||||||
|
var frameSignal by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
LaunchedEffect(isRunning, style, symbols) {
|
||||||
|
if (!isRunning) return@LaunchedEffect
|
||||||
|
|
||||||
|
var previousNanos = 0L
|
||||||
|
while (isActive) {
|
||||||
|
withFrameNanos { now ->
|
||||||
|
if (previousNanos == 0L) previousNanos = now
|
||||||
|
val deltaSec = ((now - previousNanos).coerceAtMost(MAX_STEP_NANOS)).toFloat() / NANOS_TO_SECONDS
|
||||||
|
previousNanos = now
|
||||||
|
renderer.update(deltaSec, style)
|
||||||
|
frameSignal++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Canvas(modifier = modifier) {
|
||||||
|
frameSignal
|
||||||
|
renderer.ensureLayout(size, density.density, style, symbols)
|
||||||
|
renderer.draw(this, style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAX_STEP_NANOS = 33_333_333L
|
||||||
|
private const val NANOS_TO_SECONDS = 1_000_000_000f
|
||||||
|
private const val DEFAULT_SYMBOLS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789<>=*+-~:;/[]{}()"
|
||||||
|
|
||||||
|
private fun Color.toArgb(): Int {
|
||||||
|
val a = (alpha.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||||
|
val r = (red.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||||
|
val g = (green.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||||
|
val b = (blue.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||||
|
return (a shl 24) or (r shl 16) or (g shl 8) or b
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MatrixRenderer {
|
||||||
|
private var columns = 0
|
||||||
|
private var rows = 0
|
||||||
|
private var width = 0f
|
||||||
|
private var height = 0f
|
||||||
|
private var fontSizePx = 0f
|
||||||
|
private var charWidth = 0f
|
||||||
|
private var charHeight = 0f
|
||||||
|
private var baselineOffset = 0f
|
||||||
|
private var symbolSet = ""
|
||||||
|
private var symbols = charArrayOf()
|
||||||
|
|
||||||
|
private var glyphs = charArrayOf()
|
||||||
|
private var streams = emptyArray<StreamState>()
|
||||||
|
|
||||||
|
private val bodyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE }
|
||||||
|
private val headPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE }
|
||||||
|
private val charBuffer = CharArray(1)
|
||||||
|
private val random = Random(System.currentTimeMillis())
|
||||||
|
|
||||||
|
fun ensureLayout(size: Size, density: Float, style: MatrixStyle, symbols: String) {
|
||||||
|
val targetFontPx = style.fontSize.value * density
|
||||||
|
val targetWidth = size.width
|
||||||
|
val targetHeight = size.height
|
||||||
|
val targetSymbols = symbols.ifBlank { DEFAULT_SYMBOLS }
|
||||||
|
|
||||||
|
if (targetWidth <= 0f || targetHeight <= 0f) return
|
||||||
|
|
||||||
|
val shouldRebuild = width != targetWidth ||
|
||||||
|
height != targetHeight ||
|
||||||
|
fontSizePx != targetFontPx ||
|
||||||
|
symbolSet != targetSymbols
|
||||||
|
|
||||||
|
if (!shouldRebuild) return
|
||||||
|
|
||||||
|
width = targetWidth
|
||||||
|
height = targetHeight
|
||||||
|
fontSizePx = targetFontPx
|
||||||
|
symbolSet = targetSymbols
|
||||||
|
this.symbols = targetSymbols.toCharArray()
|
||||||
|
|
||||||
|
bodyPaint.textSize = targetFontPx
|
||||||
|
headPaint.textSize = targetFontPx
|
||||||
|
charWidth = max(bodyPaint.measureText("W"), 1f)
|
||||||
|
val metrics = bodyPaint.fontMetrics
|
||||||
|
charHeight = max(metrics.descent - metrics.ascent, 1f)
|
||||||
|
baselineOffset = -metrics.ascent
|
||||||
|
|
||||||
|
columns = max((width / charWidth).toInt(), 1)
|
||||||
|
rows = max((height / charHeight).toInt() + 2, 1)
|
||||||
|
|
||||||
|
glyphs = CharArray(columns * rows) { randomGlyph() }
|
||||||
|
streams = Array(columns) { StreamState.random(rows, style, random) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(deltaSeconds: Float, style: MatrixStyle) {
|
||||||
|
if (columns == 0 || rows == 0 || deltaSeconds <= 0f) return
|
||||||
|
|
||||||
|
for (column in streams.indices) {
|
||||||
|
val stream = streams[column]
|
||||||
|
stream.pauseSec -= deltaSeconds
|
||||||
|
if (stream.pauseSec > 0f) continue
|
||||||
|
|
||||||
|
val previousHead = floor(stream.headRow).toInt()
|
||||||
|
stream.headRow += stream.speedRps * deltaSeconds
|
||||||
|
val newHead = floor(stream.headRow).toInt()
|
||||||
|
|
||||||
|
if (newHead > previousHead) {
|
||||||
|
for (row in (previousHead + 1)..newHead) {
|
||||||
|
if (row in 0 until rows) {
|
||||||
|
glyphs[row * columns + column] = randomGlyph()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newHead - stream.length > rows) {
|
||||||
|
stream.reset(rows, style, random)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun draw(scope: androidx.compose.ui.graphics.drawscope.DrawScope, style: MatrixStyle) {
|
||||||
|
if (columns == 0 || rows == 0) return
|
||||||
|
|
||||||
|
scope.drawRect(style.bgColor)
|
||||||
|
|
||||||
|
bodyPaint.color = style.bodyColor.toArgb()
|
||||||
|
headPaint.color = style.headColor.toArgb()
|
||||||
|
val tailAlphaFloor = style.tailAlphaFloor.coerceIn(0f, 1f)
|
||||||
|
|
||||||
|
val canvas = scope.drawContext.canvas.nativeCanvas
|
||||||
|
for (column in streams.indices) {
|
||||||
|
val stream = streams[column]
|
||||||
|
if (stream.pauseSec > 0f) continue
|
||||||
|
|
||||||
|
val head = floor(stream.headRow).toInt()
|
||||||
|
val startRow = max(0, head - stream.length + 1)
|
||||||
|
val endRow = min(rows - 1, head)
|
||||||
|
if (startRow > endRow) continue
|
||||||
|
|
||||||
|
for (row in endRow downTo startRow) {
|
||||||
|
val tailIndex = head - row
|
||||||
|
|
||||||
|
val paint = if (tailIndex == 0) headPaint else bodyPaint
|
||||||
|
if (tailIndex != 0) {
|
||||||
|
val normalized = ((stream.length - tailIndex).toFloat() / stream.length).coerceIn(0f, 1f)
|
||||||
|
val alpha = tailAlphaFloor + (1f - tailAlphaFloor) * normalized
|
||||||
|
paint.alpha = (alpha * 255).toInt()
|
||||||
|
} else {
|
||||||
|
paint.alpha = 255
|
||||||
|
}
|
||||||
|
|
||||||
|
val glyph = glyphs[row * columns + column]
|
||||||
|
charBuffer[0] = glyph
|
||||||
|
val x = column * charWidth
|
||||||
|
val y = row * charHeight + baselineOffset
|
||||||
|
canvas.drawText(charBuffer, 0, 1, x, y, paint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun randomGlyph(): Char = symbols[random.nextInt(symbols.size)]
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class StreamState(
|
||||||
|
var headRow: Float,
|
||||||
|
var length: Int,
|
||||||
|
var speedRps: Float,
|
||||||
|
var pauseSec: Float,
|
||||||
|
) {
|
||||||
|
fun reset(rows: Int, style: MatrixStyle, random: Random) {
|
||||||
|
val randomOffset = random.nextFloat() * rows
|
||||||
|
headRow = -randomOffset
|
||||||
|
length = random.nextInt(
|
||||||
|
from = style.minStreamLength.coerceAtLeast(2),
|
||||||
|
until = (style.maxStreamLength.coerceAtLeast(style.minStreamLength + 1) + 1),
|
||||||
|
)
|
||||||
|
speedRps = random.nextFloat() * (style.maxSpeedRps - style.minSpeedRps) + style.minSpeedRps
|
||||||
|
pauseSec = random.nextFloat() * (style.resetPauseSec.endInclusive - style.resetPauseSec.start) +
|
||||||
|
style.resetPauseSec.start
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun random(rows: Int, style: MatrixStyle, random: Random): StreamState {
|
||||||
|
val length = random.nextInt(
|
||||||
|
from = style.minStreamLength.coerceAtLeast(2),
|
||||||
|
until = (style.maxStreamLength.coerceAtLeast(style.minStreamLength + 1) + 1),
|
||||||
|
)
|
||||||
|
return StreamState(
|
||||||
|
headRow = -random.nextFloat() * rows,
|
||||||
|
length = length,
|
||||||
|
speedRps = random.nextFloat() * (style.maxSpeedRps - style.minSpeedRps) + style.minSpeedRps,
|
||||||
|
pauseSec = random.nextFloat() * (style.resetPauseSec.endInclusive - style.resetPauseSec.start) +
|
||||||
|
style.resetPauseSec.start,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-5
@@ -24,16 +24,13 @@ import kotlinx.serialization.Serializable
|
|||||||
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat)
|
data object Satellites : Screen(R.drawable.ic_sputnik, R.string.nav_sat)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Radar(val catNum: Int = 0, val aosTime: Long = 0L) : Screen(R.drawable.ic_radar, R.string.nav_radar)
|
data object Status : Screen(R.drawable.ic_satellite, R.string.nav_status)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class RadioControl(val catNum: Int = 0, val aosTime: Long = 0L) : Screen(0, 0)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
||||||
@@ -41,3 +38,33 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object RadarDestination : NavKey
|
||||||
|
|
||||||
|
interface IDeeplinkMatcher {
|
||||||
|
fun match(deeplink: String): NavKey?
|
||||||
|
}
|
||||||
|
|
||||||
|
object PassDetailsMatcher : IDeeplinkMatcher {
|
||||||
|
val passDetailsRegex = """https://github.com/rt-bishop/Look4Sat/passes/(.*)""".toRegex()
|
||||||
|
|
||||||
|
override fun match(deeplink: String): NavKey? {
|
||||||
|
val passMatch = passDetailsRegex.find(deeplink)
|
||||||
|
passMatch?.let { match ->
|
||||||
|
val passId = match.groupValues[1]
|
||||||
|
if (passId.isNotEmpty()) return RadarDestination
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeeplinkResolver(private val fallbackDestination: NavKey = Screen.Passes) {
|
||||||
|
|
||||||
|
private val matchers: List<IDeeplinkMatcher> = listOf(PassDetailsMatcher)
|
||||||
|
|
||||||
|
fun resolve(deeplink: String): NavKey {
|
||||||
|
matchers.forEach { it.match(deeplink)?.let { match -> return match } }
|
||||||
|
return fallbackDestination
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
package com.rtbishop.look4sat.core.presentation
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SwipeToDismissBox
|
||||||
|
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||||
|
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.dropShadow
|
||||||
|
import androidx.compose.ui.draw.innerShadow
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SwipeableItem(onSwipeRight: () -> Unit, onSwipeLeft: () -> Unit, content: @Composable () -> Unit) {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val dismissThresholdPx = with(LocalDensity.current) { 120.dp.toPx() }
|
||||||
|
val dismissState = rememberSwipeToDismissBoxState { dismissThresholdPx }
|
||||||
|
val willTrigger by remember { derivedStateOf { dismissState.targetValue != SwipeToDismissBoxValue.Settled } }
|
||||||
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
|
LaunchedEffect(willTrigger) {
|
||||||
|
val feedbackType = if (willTrigger) HapticFeedbackType.LongPress else HapticFeedbackType.SegmentTick
|
||||||
|
if (willTrigger) hapticFeedback.performHapticFeedback(feedbackType)
|
||||||
|
}
|
||||||
|
SwipeToDismissBox(
|
||||||
|
state = dismissState,
|
||||||
|
enableDismissFromStartToEnd = true,
|
||||||
|
enableDismissFromEndToStart = true,
|
||||||
|
backgroundContent = {
|
||||||
|
val isSwipeRight = dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
|
||||||
|
SwipeBackground(isSwipeRight, willTrigger, MaterialTheme.colorScheme.primary)
|
||||||
|
},
|
||||||
|
content = { content() },
|
||||||
|
onDismiss = {
|
||||||
|
val targetValue = dismissState.targetValue
|
||||||
|
when (targetValue) {
|
||||||
|
SwipeToDismissBoxValue.StartToEnd -> onSwipeRight()
|
||||||
|
SwipeToDismissBoxValue.EndToStart -> onSwipeLeft()
|
||||||
|
SwipeToDismissBoxValue.Settled -> {}
|
||||||
|
}
|
||||||
|
if (targetValue != SwipeToDismissBoxValue.Settled) {
|
||||||
|
coroutineScope.launch { dismissState.snapTo(SwipeToDismissBoxValue.Settled) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SwipeBackground(isSwipeRight: Boolean, willTrigger: Boolean, bgColor: Color = Color(0x00000000)) {
|
||||||
|
val shape = RectangleShape
|
||||||
|
Box(
|
||||||
|
contentAlignment = if (isSwipeRight) Alignment.CenterStart else Alignment.CenterEnd,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.border(width = 1.dp, shape = shape, color = bgColor)
|
||||||
|
.dropShadow(shape = shape) {
|
||||||
|
color = bgColor
|
||||||
|
radius = 40f
|
||||||
|
alpha = if (willTrigger) .2f else 0f
|
||||||
|
}
|
||||||
|
.innerShadow(shape = shape) {
|
||||||
|
color = bgColor
|
||||||
|
radius = 40f
|
||||||
|
alpha = if (willTrigger) 1f else .2f
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
val iconScale by animateFloatAsState(targetValue = if (willTrigger) 1f else .8f)
|
||||||
|
val slide by animateDpAsState(targetValue = if (willTrigger) 32.dp else (12).dp)
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_filter),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.rotate(if (isSwipeRight) 0f else 180f)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = iconScale
|
||||||
|
scaleY = iconScale
|
||||||
|
translationX = slide.toPx()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z" />
|
||||||
|
</vector>
|
||||||
+1
-1
@@ -5,5 +5,5 @@
|
|||||||
android:viewportHeight="24">
|
android:viewportHeight="24">
|
||||||
<path
|
<path
|
||||||
android:fillColor="@android:color/white"
|
android:fillColor="@android:color/white"
|
||||||
android:pathData="M4,20h16v2L4,22zM4,2h16v2L4,4zM13,9h3l-4,-4 -4,4h3v6L8,15l4,4 4,-4h-3z" />
|
android:pathData="M20,9H4v2h16V9zM4,15h16v-2H4V15z" />
|
||||||
</vector>
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="15"
|
||||||
|
android:viewportHeight="15">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M13.91,6.75c-1.17,2.25 -4.3,5.31 -6.07,6.94c-0.19,0.172 -0.48,0.172 -0.67,0C5.39,12.06 2.26,9 1.09,6.75C-1.48,1.8 5,-1.5 7.5,3.45C10,-1.5 16.48,1.8 13.91,6.75z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12.34,2.02C6.59,1.82 2,6.42 2,12c0,5.52 4.48,10 10,10c3.71,0 6.93,-2.02 8.66,-5.02C13.15,16.73 8.57,8.55 12.34,2.02z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M8,5v14l11,-7z" />
|
||||||
|
</vector>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="24dp"
|
android:width="24dp"
|
||||||
android:height="24dp"
|
android:height="24dp"
|
||||||
android:viewportWidth="24"
|
android:viewportWidth="960"
|
||||||
android:viewportHeight="24">
|
android:viewportHeight="960">
|
||||||
<path
|
<path
|
||||||
android:fillColor="@android:color/white"
|
android:fillColor="@android:color/white"
|
||||||
android:pathData="M14,21c1.93,0 3.62,-1.17 4,-3l-1.75,-0.88C16,18.21 15.33,19 14,19l-4.9,0c0.83,-1 1.5,-2.34 1.5,-4c0,-0.35 -0.03,-0.69 -0.08,-1L14,14v-2l-4.18,0C9,10.42 8,9.6 8,8c0,-1.93 1.57,-3.5 3.5,-3.5c1.5,0 2.79,0.95 3.28,2.28L16.63,6c-0.8,-2.05 -2.79,-3.5 -5.13,-3.5C8.46,2.5 6,4.96 6,8c0,1.78 0.79,2.9 1.49,4L6,12v2l2.47,0c0.08,0.31 0.13,0.64 0.13,1c0,2.7 -2.6,4 -2.6,4v2H14z" />
|
android:pathData="M240,840L240,760Q240,760 256.5,750Q273,740 292.5,720.5Q312,701 328,670.5Q344,640 344,600Q344,589 342.5,579Q341,569 339,560L240,560L240,480L300,480Q279,447 259.5,410.5Q240,374 240,320Q240,228 304,164Q368,100 460,100Q531,100 586,139Q641,178 665,240L591,271Q576,231 540.5,205.5Q505,180 460,180Q402,180 361,221Q320,262 320,320Q320,368 344,400Q368,432 393,480L560,480L560,560L421,560Q423,569 423.5,579Q424,589 424,600Q424,650 406.5,690Q389,730 364,760L560,760Q600,760 621,739Q642,718 650,685L720,720Q709,775 663.5,807.5Q618,840 560,840L240,840Z" />
|
||||||
</vector>
|
</vector>
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="960"
|
|
||||||
android:viewportHeight="960">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80L520,80L520,411Q538,422 549,439.5Q560,457 560,480Q560,513 536.5,536.5Q513,560 480,560Q447,560 423.5,536.5Q400,513 400,480Q400,457 411,439Q422,421 440,411L440,325Q388,339 354,381.5Q320,424 320,480Q320,546 367,593Q414,640 480,640Q546,640 593,593Q640,546 640,480Q640,444 625.5,413.5Q611,383 586,360L643,303Q678,336 699,381.5Q720,427 720,480Q720,580 650,650Q580,720 480,720Q380,720 310,650Q240,580 240,480Q240,390 297,323.5Q354,257 440,243L440,162Q321,177 240.5,267Q160,357 160,480Q160,614 253,707Q346,800 480,800Q614,800 707,707Q800,614 800,480Q800,411 773,351Q746,291 699,247L756,190Q813,245 846.5,319.5Q880,394 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Z" />
|
|
||||||
</vector>
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M13,3c-4.97,0 -9,4.03 -9,9L1,12l3.89,3.89 0.07,0.14L9,12L6,12c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,8v5l4.28,2.54 0.72,-1.21 -3.5,-2.08L13.5,8L12,8z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="128"
|
||||||
|
android:viewportHeight="128">
|
||||||
|
<path
|
||||||
|
android:pathData="m105.14,114.12a2.86,2.86 90,0 0,1.7 -0.85l19.33,-19.33a2.86,2.86 90,0 0,-0 -4.03l-26.58,-26.62a2.86,2.86 90,0 0,-1.84 -0.81l-7.92,-0.63 5.77,-5.77a2.86,2.86 90,0 0,-0 -4.03l-19.6,-19.64a2.86,2.86 90,0 0,-4.07 -0l-5.77,5.77 -0.63,-7.92a2.86,2.86 90,0 0,-0.81 -1.83l-26.63,-26.58a2.86,2.86 90,0 0,-4.07 -0l-19.29,19.29a2.86,2.86 90,0 0,-0 4.07l26.58,26.62a2.86,2.86 90,0 0,1.83 0.81l7.92,0.63 -5.77,5.77a2.86,2.86 90,0 0,-0 4.07l19.6,19.6a2.86,2.86 90,0 0,4.07 -0l5.77,-5.77 0.63,7.92a2.86,2.86 90,0 0,0.81 1.83l26.63,26.58a2.86,2.86 90,0 0,2.06 0.85,2.86 2.86,90 0,0 0.27,-0zM104.82,107.23 L80.97,83.38 80.16,72.69 85.58,67.27 96.28,68.12 120.13,91.93zM55.33,47.85 L44.63,47 20.78,23.19 36.08,7.89 59.89,31.74 60.74,42.44z"
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="m62.65,115.81a2.86,2.86 90,1 0,-0 -5.73c-11.46,-0 -22.88,-4.35 -31.63,-13.11 -8.74,-8.74 -13.1,-20.17 -13.1,-31.61a2.86,2.86 90,1 0,-5.73 -0c-0,12.9 4.94,25.83 14.77,35.66 9.85,9.85 22.77,14.78 35.68,14.78z"
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="m62.65,127a2.86,2.86 90,1 0,-0 -5.73c-14.32,-0 -28.61,-5.44 -39.55,-16.37 -10.93,-10.93 -16.38,-25.23 -16.38,-39.54a2.86,2.86 90,1 0,-5.73 -0c-0,15.76 6.03,31.56 18.06,43.59 12.03,12.03 27.82,18.05 43.6,18.05z"
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="m62.65,104.7a2.86,2.86 90,1 0,-0 -5.73c-8.62,-0 -17.2,-3.26 -23.78,-9.84 -6.58,-6.58 -9.86,-15.17 -9.86,-23.77a2.86,2.86 90,1 0,-5.73 -0c-0,10.06 3.86,20.15 11.53,27.82 7.67,7.67 17.76,11.52 27.83,11.52z"
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="m62.65,93.48a2.86,2.86 90,1 0,-0 -5.73c-5.74,-0 -11.45,-2.17 -15.84,-6.56 -4.38,-4.38 -6.56,-10.1 -6.56,-15.83a2.86,2.86 90,1 0,-5.73 -0c-0,7.19 2.76,14.41 8.24,19.88 5.48,5.48 12.69,8.23 19.89,8.23z"
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM12,19c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM15,9L5,9L5,5h10v4z" />
|
||||||
|
</vector>
|
||||||
File renamed without changes.
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M11,4V2c0,-0.55 0.45,-1 1,-1s1,0.45 1,1v2c0,0.55 -0.45,1 -1,1S11,4.55 11,4zM18.36,7.05l1.41,-1.42c0.39,-0.39 0.39,-1.02 0,-1.41c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.41,1.42c-0.39,0.39 -0.39,1.02 0,1.41C17.34,7.44 17.97,7.44 18.36,7.05zM22,11h-2c-0.55,0 -1,0.45 -1,1s0.45,1 1,1h2c0.55,0 1,-0.45 1,-1S22.55,11 22,11zM12,19c-0.55,0 -1,0.45 -1,1v2c0,0.55 0.45,1 1,1s1,-0.45 1,-1v-2C13,19.45 12.55,19 12,19zM5.64,7.05L4.22,5.64c-0.39,-0.39 -0.39,-1.03 0,-1.41s1.03,-0.39 1.41,0l1.41,1.41c0.39,0.39 0.39,1.03 0,1.41S6.02,7.44 5.64,7.05zM16.95,16.95c-0.39,0.39 -0.39,1.03 0,1.41l1.41,1.41c0.39,0.39 1.03,0.39 1.41,0c0.39,-0.39 0.39,-1.03 0,-1.41l-1.41,-1.41C17.98,16.56 17.34,16.56 16.95,16.95zM2,13h2c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1H2c-0.55,0 -1,0.45 -1,1S1.45,13 2,13zM5.64,19.78l1.41,-1.41c0.39,-0.39 0.39,-1.03 0,-1.41s-1.03,-0.39 -1.41,0l-1.41,1.41c-0.39,0.39 -0.39,1.03 0,1.41C4.61,20.17 5.25,20.17 5.64,19.78zM12,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6s6,-2.69 6,-6S15.31,6 12,6z" />
|
||||||
|
</vector>
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="960"
|
|
||||||
android:viewportHeight="960">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M240,800L280,640L120,640L140,560L300,560L340,400L180,400L200,320L360,320L400,160L480,160L440,320L600,320L640,160L720,160L680,320L840,320L820,400L660,400L620,560L780,560L760,640L600,640L560,800L480,800L520,640L360,640L320,800L240,800ZM380,560L540,560L580,400L420,400L380,560Z" />
|
|
||||||
</vector>
|
|
||||||
@@ -13,12 +13,16 @@
|
|||||||
<string name="nav_prefs">Ajustes</string>
|
<string name="nav_prefs">Ajustes</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Tipo: %s</string>
|
<string name="sat_type_hint">Modos: %s</string>
|
||||||
<string name="sat_type_title">Seleccionar tipo de satélite</string>
|
<string name="sat_type_title">Seleccionar modos</string>
|
||||||
<string name="sat_search_hint">Id - Nombre</string>
|
<string name="sat_search_hint">Id - Nombre</string>
|
||||||
<string name="sat_search_clear">Vaciar</string>
|
<string name="sat_search_clear">Vaciar</string>
|
||||||
<string name="sat_clear_all">Vaciar todo</string>
|
<string name="sat_clear_all">Vaciar todo</string>
|
||||||
<string name="sat_select_all">Seleccionar todo</string>
|
<string name="sat_select_all">Seleccionar todo</string>
|
||||||
|
<string name="sat_group_selected">Seleccionados</string>
|
||||||
|
<string name="sat_group_available">Disponibles</string>
|
||||||
|
<string name="sat_group_selected_count">Seleccionados (%1$d)</string>
|
||||||
|
<string name="sat_group_available_count">Disponibles (%1$d)</string>
|
||||||
<string name="sat_empty_list_message">
|
<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>
|
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_title">Alarma\!</string>
|
||||||
@@ -26,13 +30,16 @@
|
|||||||
Esta aplicación incluye más de 9000 satélites.
|
Esta aplicación incluye más de 9000 satélites.
|
||||||
No tiene sentido rastrearlos todos a la vez.
|
No tiene sentido rastrearlos todos a la vez.
|
||||||
\n\nIntenta siempre limitar la lista a los que te
|
\n\nIntenta siempre limitar la lista a los que te
|
||||||
interesen mediante la búsqueda y el selector de tipos.</string>
|
interesen mediante la búsqueda y el selector de modos.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Filtrar pases</string>
|
<string name="pass_filter_title">Filtrar pases</string>
|
||||||
<string name="pass_filter_elev">Elevación mínima</string>
|
<string name="pass_filter_elev">Elevación mínima</string>
|
||||||
<string name="pass_filter_hours">Horas por delante</string>
|
<string name="pass_filter_hours">Tiempo por delante</string>
|
||||||
<string name="pass_modes_title">Tipo de modulación</string>
|
<string name="pass_filter_aos_time">Ventana AOS</string>
|
||||||
|
<string name="pass_filter_invert_time">Invertir ventana AOS</string>
|
||||||
|
<string name="pass_filter_deep_space">DeepSpace (período >225min)</string>
|
||||||
|
<string name="pass_modes_title">Seleccionar modos</string>
|
||||||
<string name="pass_elevation">Elevación: %.1f°</string>
|
<string name="pass_elevation">Elevación: %.1f°</string>
|
||||||
<string name="pass_altitude">Altitud: %.0f km</string>
|
<string name="pass_altitude">Altitud: %.0f km</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
@@ -93,11 +100,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</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_qth_error">Ubicación QTH no válida</string>
|
||||||
<string name="prefs_loc_success">Ubicación actualizada con éxito</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_title">Ubicación de la estación</string>
|
||||||
<string name="prefs_station_lat_text">Latitud de tu estación terrestre</string>
|
<string name="prefs_station_lat_text">Latitud de estación</string>
|
||||||
<string name="prefs_station_lon_text">Longitud de tu estación terrestre</string>
|
<string name="prefs_station_lon_text">Longitud de estación</string>
|
||||||
<string name="prefs_locator_title">Ajustes ubicación QTH</string>
|
<string name="prefs_locator_title">Localizador QTH</string>
|
||||||
<string name="prefs_locator_text">Ajustar posición de la estación con tu ubicación</string>
|
<string name="prefs_locator_text">Posición por localizador</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Datos satelitales</string>
|
<string name="prefs_data_title">Datos satelitales</string>
|
||||||
<string name="prefs_data_entries">Satélites: %s</string>
|
<string name="prefs_data_entries">Satélites: %s</string>
|
||||||
@@ -107,37 +114,65 @@
|
|||||||
<string name="prefs_data_clear">Vaciar</string>
|
<string name="prefs_data_clear">Vaciar</string>
|
||||||
<string name="prefs_data_clear_success">Datos eliminados con éxito</string>
|
<string name="prefs_data_clear_success">Datos eliminados con éxito</string>
|
||||||
<string name="prefs_data_update_success">Actualización completada con éxito</string>
|
<string name="prefs_data_update_success">Actualización completada con éxito</string>
|
||||||
|
<string name="prefs_data_import_satellites_error">No se importaron satélites. Usa un TLE/3LE (.txt) u OMM (.csv) válido.</string>
|
||||||
|
<string name="prefs_data_import_transceivers_error">No se importaron transceptores. Usa un SatNOGS (.json) válido.</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
<string name="prefs_data_sources_title">Fuentes personalizadas</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
<string name="prefs_data_sources_restore">Restaurar fuentes predeterminadas</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
<string name="prefs_data_sources_sat_title">Datos de satélites</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_title">Datos de transceptores</string>
|
||||||
|
<string name="prefs_data_sources_sat_hint">Prioridad arriba: nombres de satélites</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_hint">Prioridad arriba: datos de transceptores</string>
|
||||||
|
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_switch">URL transceptores</string>
|
||||||
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
|
|
||||||
<string name="prefs_data_output_title">Data output</string>
|
<string name="prefs_data_output_title">Salida de datos</string>
|
||||||
<string name="prefs_net_output">Network</string>
|
<string name="prefs_net_output">Red</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
|
<string name="prefs_cat_output">CAT</string>
|
||||||
|
|
||||||
<string name="prefs_net_title">Salida de datos de red</string>
|
<string name="nav_radiocontrol">Control de radio</string>
|
||||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
<string name="rc_settings_title">Control CAT</string>
|
||||||
|
<string name="rc_radio_model">Modelo de radio</string>
|
||||||
|
<string name="rc_tx_device_hint">Dirección BT TX</string>
|
||||||
|
<string name="rc_rx_device_hint">Dirección BT RX</string>
|
||||||
|
<string name="rc_tx_name_hint">Nombre radio TX</string>
|
||||||
|
<string name="rc_rx_name_hint">Nombre radio RX</string>
|
||||||
|
<string name="rc_enable_switch">Activar CAT</string>
|
||||||
|
|
||||||
|
<string name="prefs_net_title">Salida de red</string>
|
||||||
|
<string name="prefs_net_rotator_switch">Activar salida de rotación</string>
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Puerto</string>
|
<string name="prefs_net_rotator_address_hint">IP:Puerto</string>
|
||||||
<string name="prefs_net_rotator_format_hint">Formato de datos</string>
|
<string name="prefs_net_rotator_format_hint">Formato de datos</string>
|
||||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
<string name="prefs_net_frequency_switch">Activar salida de frecuencia</string>
|
||||||
<string name="prefs_net_frequency_address_hint">IP:Puerto</string>
|
<string name="prefs_net_frequency_address_hint">IP:Puerto</string>
|
||||||
<string name="prefs_net_frequency_format_hint">Formato de datos</string>
|
<string name="prefs_net_frequency_format_hint">Formato de datos</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">Salida por Bluetooth</string>
|
<string name="prefs_bt_title">Salida Bluetooth</string>
|
||||||
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
<string name="prefs_bt_rotator_switch">Activar salida de rotación</string>
|
||||||
<string name="prefs_bt_rotator_device_hint">Id dispositivo</string>
|
<string name="prefs_bt_rotator_device_hint">ID dispositivo</string>
|
||||||
<string name="prefs_bt_rotator_output_hint">Formato de datos</string>
|
<string name="prefs_bt_rotator_output_hint">Formato</string>
|
||||||
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
<string name="prefs_bt_frequency_switch">Activar salida de frecuencia</string>
|
||||||
<string name="prefs_bt_frequency_device_hint">Id dispositivo</string>
|
<string name="prefs_bt_frequency_device_hint">ID dispositivo</string>
|
||||||
<string name="prefs_bt_frequency_output_hint">Formato de datos</string>
|
<string name="prefs_bt_frequency_output_hint">Formato</string>
|
||||||
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</string>
|
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</string>
|
||||||
|
<string name="prefs_net_perm_error">Revisa los permisos de red</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Otros ajustes</string>
|
<string name="prefs_other_title">Otros</string>
|
||||||
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
|
<string name="prefs_other_switch_utc">Hora UTC</string>
|
||||||
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
|
<string name="prefs_other_switch_update">Auto-actualizar</string>
|
||||||
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
|
<string name="prefs_other_switch_sweep">Animación radar</string>
|
||||||
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
|
<string name="prefs_other_switch_sensors">Sensores</string>
|
||||||
|
<string name="prefs_other_switch_night_mode">Filtro nocturno</string>
|
||||||
|
<string name="prefs_other_switch_light_theme">Tema claro</string>
|
||||||
|
<string name="prefs_other_compass_offset_az">Desfase de brújula (ácimut)</string>
|
||||||
|
<string name="prefs_other_compass_offset_elev">Desfase de brújula (elevación)</string>
|
||||||
|
|
||||||
|
<string name="prefs_highlight_title">Resaltado de elevación</string>
|
||||||
|
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">Media %1$d-%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">Alta > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">Me gustaría dar las gracias a:</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>
|
<string name="prefs_outro_license">La app viene sin garantías de ningún tipo.</string>
|
||||||
|
|||||||
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user