mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b6fb5eb8f | ||
|
|
f4aef4f4f0 | ||
|
|
9147323db2 | ||
|
|
81d397a09d | ||
|
|
ec1a55c93d | ||
|
|
e5033cb2e9 | ||
|
|
39b786ae82 | ||
|
|
79809aaec0 | ||
|
|
f7f5a73c4c | ||
|
|
18c8ac1822 | ||
|
|
22607e7710 | ||
|
|
ad0ff6859e | ||
|
|
a39b1716e7 | ||
|
|
8a2339cfbf | ||
|
|
821fa0dfe7 | ||
|
|
ce8c2a4834 | ||
|
|
8cc4ef5610 | ||
|
|
dde22faf46 | ||
|
|
7ad81e32e1 | ||
|
|
5af963f3c9 | ||
|
|
bfb9fb6ca8 | ||
|
|
df24c66ef3 | ||
|
|
b166e896c2 | ||
|
|
18f8ab517d | ||
|
|
d3b8e951dd | ||
|
|
6e5a3cb0bb | ||
|
|
31d329a19b | ||
|
|
3df358b88c | ||
|
|
7d5bca300d | ||
|
|
fb2cd85b11 | ||
|
|
99158e74fa | ||
|
|
8265b73d75 | ||
|
|
d2b3184084 | ||
|
|
0c5d3699c0 | ||
|
|
a94f95874b | ||
|
|
4a3a01729b | ||
|
|
aadc285d7f | ||
|
|
2df0b9ba3d | ||
|
|
868155c532 | ||
|
|
3f981c09d5 | ||
|
|
b79fec13f4 | ||
|
|
6588291997 | ||
|
|
be96602c76 | ||
|
|
b59f9a6c17 | ||
|
|
9afe2ab69e | ||
|
|
2edc892dc2 | ||
|
|
45eb6915c2 | ||
|
|
706c912b13 | ||
|
|
e523eb5517 | ||
|
|
0bef2ffddc | ||
|
|
279594c425 | ||
|
|
8c1df334d8 |
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
# GitHub Copilot Instructions
|
||||||
|
|
||||||
|
Read `AGENTS.md` first, then `CLAUDE.md`.
|
||||||
|
|
||||||
|
`AGENTS.md` contains the architecture, module boundaries, implementation details, conventions, and gotchas.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
@@ -25,50 +25,55 @@ jobs:
|
|||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
||||||
- 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.3'
|
||||||
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: |
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Look4Sat AI Agent Instructions
|
||||||
|
|
||||||
|
This is the canonical project guide for all AI assistants working on Look4Sat.
|
||||||
|
All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) point here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
||||||
|
satellites using TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays
|
||||||
|
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
|
||||||
|
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
|
||||||
|
data download.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**MVI (Model-View-Intent)** with unidirectional data flow:
|
||||||
|
- `State` data class → exposed via `StateFlow` from ViewModel
|
||||||
|
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
|
||||||
|
- Jetpack Compose UI observes state and recomposes reactively
|
||||||
|
|
||||||
|
**Clean Architecture layers:**
|
||||||
|
|
||||||
|
| Module | Responsibility |
|
||||||
|
|----------------------|---------------------------------------------------------------------|
|
||||||
|
| `app` | Entry point. Aggregates all modules |
|
||||||
|
| `core:data` | Android library. Room DB, OkHttp networking, repo implementations |
|
||||||
|
| `core:domain` | Pure Kotlin (JVM). Orbital math (SGP4/SDP4), models, repo contracts |
|
||||||
|
| `core:presentation` | Android library. Compose theme, shared UI components, NavKeys |
|
||||||
|
| `feature:map` | OSMDroid map with ground tracks |
|
||||||
|
| `feature:passes` | Pass predictions and upcoming events |
|
||||||
|
| `feature:radar` | Polar radar view of satellite positions, SSTV image decoding |
|
||||||
|
| `feature:satellites` | Satellite list, filtering, selection |
|
||||||
|
| `feature:settings` | User preferences |
|
||||||
|
|
||||||
|
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
|
||||||
|
|
||||||
|
## Build & Run
|
||||||
|
|
||||||
|
```shell
|
||||||
|
# Debug build
|
||||||
|
./gradlew assembleDebug
|
||||||
|
|
||||||
|
# Release build (minified, shrunk resources)
|
||||||
|
./gradlew assembleRelease
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
./gradlew test
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
||||||
|
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
|
||||||
|
|
||||||
|
## Key Libraries
|
||||||
|
|
||||||
|
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
||||||
|
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
|
||||||
|
- **Room** (KSP code generation) for local satellite/TLE storage
|
||||||
|
- **OkHttp** 5.x for TLE downloads
|
||||||
|
- **OSMDroid** for map rendering
|
||||||
|
- **Kotlin Serialization** for navigation args and data parsing
|
||||||
|
- **Coroutines** + `StateFlow` for async/reactive patterns
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Minimal dependencies**: Avoid adding libraries when a simple manual solution exists. Fewer deps = less maintenance.
|
||||||
|
- **DI**: Manual — ViewModels use companion `factory()` methods with `IMainContainer` interface.
|
||||||
|
- **Navigation**: Type-safe Compose Navigation3 with `@Serializable` data classes as nav keys.
|
||||||
|
- **State naming**: `<Feature>State` data class + `<Feature>Action` sealed interface per feature.
|
||||||
|
- **No feature-to-feature deps**: All cross-feature communication goes through core layers.
|
||||||
|
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh).
|
||||||
|
|
||||||
|
## Data Formats & Migration
|
||||||
|
|
||||||
|
**TLE vs. OMM/CSV format:**
|
||||||
|
|
||||||
|
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
|
||||||
|
|
||||||
|
- **TLE format**: Traditional 3-line element format (deprecated). NORAD catalog numbers are 5-digit integers, which
|
||||||
|
are running out of space. Celestrak has signaled that TLE format will eventually be phased out.
|
||||||
|
- **OMM/CSV format**: The future standard. CSV files contain the same orbital parameters as TLE but use ISO 8601
|
||||||
|
timestamps and support larger NORAD IDs. Celestrak and SatNOGS already provide OMM data in CSV format.
|
||||||
|
|
||||||
|
**Current implementation:**
|
||||||
|
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly
|
||||||
|
- TLE data is downloaded from configured sources and stored in Room database
|
||||||
|
- When downloading satellite data, the app automatically detects format and parses accordingly
|
||||||
|
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching
|
||||||
|
|
||||||
|
**Migration path:**
|
||||||
|
As NORAD catalog space becomes constrained, OMM/CSV will become the primary format. Look4Sat is already positioned
|
||||||
|
to handle this transition without code changes — existing users can continue using TLE files while new sources
|
||||||
|
transition to OMM/CSV automatically.
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- Prefer **short, focused functions** — single responsibility, easy to read.
|
||||||
|
- **Exceptions**: Composable functions and math-heavy algorithms (SGP4/SDP4) may be longer.
|
||||||
|
- Strict code style — no dead code, no unused imports, consistent formatting.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- **KMP migration**: `core:domain` is to become a fully shareable KMM module. Keep it pure Kotlin/JVM.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Orbital math lives in `core:domain/predict/` — it's dense vector math (SGP4/SDP4). Tread carefully.
|
||||||
|
- TLE/OMM data must be refreshed weekly for accurate predictions (satellite orbits decay). TLE format is legacy and
|
||||||
|
will eventually be deprecated in favor of OMM/CSV as NORAD catalog numbers approach the 5-digit limit.
|
||||||
|
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
||||||
|
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
|
||||||
|
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Read `AGENTS.md` first, then follow the instructions there.
|
||||||
|
|
||||||
|
`AGENTS.md` contains the architecture, module boundaries, implementation details, conventions, and gotchas.
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.convention.androidAppPlugin)
|
alias(libs.plugins.convention.applicationPlugin)
|
||||||
}
|
}
|
||||||
@@ -11,12 +11,15 @@
|
|||||||
<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:roundIcon="@mipmap/ic_launcher_round">
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
@@ -26,6 +29,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,21 @@ package com.rtbishop.look4sat
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.ColorMatrix
|
||||||
|
import android.graphics.ColorMatrixColorFilter
|
||||||
|
import android.graphics.Paint
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
|
import 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 +47,37 @@ class MainActivity : ComponentActivity() {
|
|||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
observeNightFilterState()
|
||||||
setContent {
|
setContent {
|
||||||
MainTheme(isDarkTheme = true) { MainScreen() }
|
MainTheme(isDarkTheme = true) { MainScreen() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun observeNightFilterState() {
|
||||||
|
val mainContainer = (applicationContext as IContainerProvider).getMainContainer()
|
||||||
|
lifecycleScope.launch {
|
||||||
|
mainContainer.settingsRepo.otherSettings
|
||||||
|
.map { it.stateOfNightMode }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { nightMode -> applyNightFilter(nightMode) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyNightFilter(enabled: Boolean) {
|
||||||
|
if (enabled) {
|
||||||
|
val nightMatrix = ColorMatrix(
|
||||||
|
floatArrayOf(
|
||||||
|
1f, 0f, 0f, 0f, 0f, // R → R
|
||||||
|
0f, 0f, 0f, 0f, 0f, // G → 0
|
||||||
|
0f, 0f, 0f, 0f, 0f, // B → 0
|
||||||
|
0f, 0f, 0f, 1f, 0f // A → A
|
||||||
|
)
|
||||||
|
)
|
||||||
|
window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, Paint().apply {
|
||||||
|
colorFilter = ColorMatrixColorFilter(nightMatrix)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
window.decorView.setLayerType(View.LAYER_TYPE_NONE, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -17,74 +17,232 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat
|
package com.rtbishop.look4sat
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.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.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
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.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.navigation.NavHostController
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
|
import androidx.navigation3.runtime.entryProvider
|
||||||
|
import androidx.navigation3.runtime.rememberNavBackStack
|
||||||
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
|
import androidx.navigation3.ui.NavDisplay
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
|
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
||||||
|
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.satellites.satellitesDestination
|
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||||
import com.rtbishop.look4sat.feature.settings.settingsDestination
|
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(navController: NavHostController = rememberNavController()) {
|
fun NavRoot(deeplink: String? = null) {
|
||||||
val items = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
|
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||||
val currentDestination = navController.currentBackStackEntryAsState().value?.destination?.route
|
val deeplinkResolver = DeeplinkResolver()
|
||||||
val startDestination = Screen.Passes.route
|
LaunchedEffect(deeplink) {
|
||||||
|
deeplink?.let {
|
||||||
|
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
|
||||||
|
rootBackStack.add(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||||
|
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
||||||
|
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
||||||
|
NavDisplay(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
backStack = rootBackStack,
|
||||||
|
onBack = navigateBack,
|
||||||
|
transitionSpec = { slideInTransition },
|
||||||
|
popTransitionSpec = { slideOutTransition },
|
||||||
|
predictivePopTransitionSpec = { slideOutTransition },
|
||||||
|
entryDecorators = listOf(
|
||||||
|
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
|
||||||
|
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
|
||||||
|
),
|
||||||
|
entryProvider = entryProvider {
|
||||||
|
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
|
||||||
|
entry<RadarDestination> {
|
||||||
|
Scaffold { innerPadding ->
|
||||||
|
RadarDestination(navigateUp = navigateBack)
|
||||||
|
innerPadding.calculateTopPadding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||||
|
val backStack = rememberNavBackStack(Screen.Passes)
|
||||||
|
val currentKey = backStack.lastOrNull()
|
||||||
|
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||||
|
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
||||||
|
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
|
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
NavigationSuiteScaffold(
|
NavigationSuiteScaffold(
|
||||||
navigationSuiteItems = {
|
navigationSuiteItems = {
|
||||||
items.forEach {
|
navItems.forEach { screen ->
|
||||||
|
val isSelected = when (currentKey) {
|
||||||
|
is Screen.Satellites -> screen is Screen.Satellites
|
||||||
|
is Screen.Passes -> screen is Screen.Passes
|
||||||
|
is Screen.Radar -> screen is Screen.Radar
|
||||||
|
is Screen.Map -> screen is Screen.Map
|
||||||
|
is Screen.Settings -> screen is Screen.Settings
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
item(
|
item(
|
||||||
icon = { Icon(painterResource(it.iconResId), stringResource(it.titleResId)) },
|
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
|
||||||
label = { Text(stringResource(it.titleResId)) },
|
label = { Text(stringResource(screen.titleResId)) },
|
||||||
selected = currentDestination?.contains(it.route) ?: false,
|
selected = isSelected,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (currentDestination?.contains(it.route) ?: false) return@item
|
if (isSelected) return@item
|
||||||
navController.navigate(it.route) {
|
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||||
popUpTo(startDestination) { saveState = false }
|
if (screen !is Screen.Passes) backStack.add(screen)
|
||||||
launchSingleTop = true
|
}
|
||||||
restoreState = false
|
)
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}, navigationSuiteColors = NavigationSuiteDefaults.colors(
|
},
|
||||||
|
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
||||||
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||||
), layoutType = when {
|
),
|
||||||
|
layoutType = when {
|
||||||
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
|
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
|
||||||
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
|
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
|
||||||
else -> NavigationSuiteType.ShortNavigationBarMedium
|
else -> NavigationSuiteType.ShortNavigationBarMedium
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
NavHost(
|
Column {
|
||||||
navController = navController,
|
NavDisplay(
|
||||||
startDestination = startDestination,
|
backStack = backStack,
|
||||||
enterTransition = { fadeIn(animationSpec = tween(350)) },
|
modifier = Modifier.weight(1f),
|
||||||
exitTransition = { fadeOut(animationSpec = tween(350)) }
|
onBack = navigateBack,
|
||||||
) {
|
transitionSpec = { fadeTransition },
|
||||||
satellitesDestination { navController.navigateUp() }
|
popTransitionSpec = { fadeTransition },
|
||||||
passesDestination { catNum: Int, aosTime: Long ->
|
predictivePopTransitionSpec = { fadeTransition },
|
||||||
val radarRoute = "${Screen.Radar.route}?catNum=${catNum}&aosTime=${aosTime}"
|
entryDecorators = listOf(
|
||||||
navController.navigate(radarRoute)
|
// Required for saving Compose state per entry
|
||||||
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
|
// Required for ViewModel scoping per entry
|
||||||
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
|
),
|
||||||
|
entryProvider = entryProvider {
|
||||||
|
entry<Screen.Satellites> {
|
||||||
|
SatellitesDestination(navigateUp = navigateBack)
|
||||||
|
}
|
||||||
|
entry<Screen.Passes> {
|
||||||
|
PassesDestination { catNum, aosTime ->
|
||||||
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
|
backStack.add(Screen.Radar)
|
||||||
|
// navigateToRadar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry<Screen.Radar> {
|
||||||
|
RadarDestination(navigateUp = navigateBack)
|
||||||
|
}
|
||||||
|
entry<Screen.Map> {
|
||||||
|
MapDestination()
|
||||||
|
}
|
||||||
|
entry<Screen.Settings> {
|
||||||
|
SettingsDestination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// Radio tracking status banner
|
||||||
|
if (trackingState.isActive) {
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
||||||
|
val alpha by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 1f, targetValue = 0.4f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(1000, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
), label = "pulseAlpha"
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
|
.clickable {
|
||||||
|
val pass = trackingState.currentPass
|
||||||
|
if (pass != null) {
|
||||||
|
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||||
|
backStack.add(Screen.Radar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
val txOk = if (trackingState.txConnected) "TX" else ""
|
||||||
|
val rxOk = if (trackingState.rxConnected) "RX" else ""
|
||||||
|
Text(
|
||||||
|
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
radarDestination { navController.navigateUp() }
|
|
||||||
mapDestination()
|
|
||||||
settingsDestination()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,25 +24,25 @@ group = "com.rtbishop.look4sat.build_logic.convention"
|
|||||||
|
|
||||||
gradlePlugin {
|
gradlePlugin {
|
||||||
plugins {
|
plugins {
|
||||||
register("androidAppPlugin") {
|
register("applicationPlugin") {
|
||||||
id = libs.plugins.convention.androidAppPlugin.get().pluginId
|
id = libs.plugins.convention.applicationPlugin.get().pluginId
|
||||||
implementationClass = "com.rtbishop.look4sat.convention.AndroidAppPlugin"
|
implementationClass = "com.rtbishop.look4sat.convention.ApplicationPlugin"
|
||||||
}
|
}
|
||||||
register("androidLibraryPlugin") {
|
register("coreDataPlugin") {
|
||||||
id = libs.plugins.convention.androidLibraryPlugin.get().pluginId
|
id = libs.plugins.convention.coreDataPlugin.get().pluginId
|
||||||
implementationClass = "com.rtbishop.look4sat.convention.AndroidLibraryPlugin"
|
implementationClass = "com.rtbishop.look4sat.convention.CoreDataPlugin"
|
||||||
}
|
}
|
||||||
register("composeFeaturePlugin") {
|
register("coreDomainPlugin") {
|
||||||
id = libs.plugins.convention.composeFeaturePlugin.get().pluginId
|
id = libs.plugins.convention.coreDomainPlugin.get().pluginId
|
||||||
implementationClass = "com.rtbishop.look4sat.convention.ComposeFeaturePlugin"
|
implementationClass = "com.rtbishop.look4sat.convention.CoreDomainPlugin"
|
||||||
}
|
}
|
||||||
register("composeLibraryPlugin") {
|
register("corePresentationPlugin") {
|
||||||
id = libs.plugins.convention.composeLibraryPlugin.get().pluginId
|
id = libs.plugins.convention.corePresentationPlugin.get().pluginId
|
||||||
implementationClass = "com.rtbishop.look4sat.convention.ComposeLibraryPlugin"
|
implementationClass = "com.rtbishop.look4sat.convention.CorePresentationPlugin"
|
||||||
}
|
}
|
||||||
register("kotlinLibraryPlugin") {
|
register("featurePlugin") {
|
||||||
id = libs.plugins.convention.kotlinLibraryPlugin.get().pluginId
|
id = libs.plugins.convention.featurePlugin.get().pluginId
|
||||||
implementationClass = "com.rtbishop.look4sat.convention.KotlinLibraryPlugin"
|
implementationClass = "com.rtbishop.look4sat.convention.FeaturePlugin"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+18
-19
@@ -22,25 +22,24 @@ import org.gradle.api.Project
|
|||||||
import org.gradle.kotlin.dsl.dependencies
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
|
||||||
@Suppress("Unused")
|
@Suppress("Unused")
|
||||||
internal class AndroidAppPlugin : Plugin<Project> {
|
internal class ApplicationPlugin : Plugin<Project> {
|
||||||
override fun apply(target: Project) {
|
override fun apply(target: Project) = with(target) {
|
||||||
with(target) {
|
setupAndroidApp()
|
||||||
setupAndroidApplication()
|
setupCompose()
|
||||||
setupComposeFeature()
|
setupKotlin()
|
||||||
setupKotlinToolchain()
|
dependencies {
|
||||||
setupAndroidTestDependencies()
|
implementation(project(":core:data"))
|
||||||
setupTestDependencies()
|
implementation(project(":core:domain"))
|
||||||
dependencies {
|
implementation(project(":core:presentation"))
|
||||||
IMPLEMENTATION(project(":core:data"))
|
implementation(project(":feature:map"))
|
||||||
IMPLEMENTATION(project(":core:domain"))
|
implementation(project(":feature:passes"))
|
||||||
IMPLEMENTATION(project(":core:presentation"))
|
implementation(project(":feature:radar"))
|
||||||
IMPLEMENTATION(project(":feature:map"))
|
implementation(project(":feature:satellites"))
|
||||||
IMPLEMENTATION(project(":feature:passes"))
|
implementation(project(":feature:settings"))
|
||||||
IMPLEMENTATION(project(":feature:radar"))
|
implementation(libs.androidx.core.splashscreen)
|
||||||
IMPLEMENTATION(project(":feature:satellites"))
|
implementation(libs.compose.material3.adaptive)
|
||||||
IMPLEMENTATION(project(":feature:settings"))
|
implementation(libs.compose.navigation3)
|
||||||
IMPLEMENTATION(libs.androidx.core.splashscreen)
|
androidTestImplementation(libs.bundles.androidTest)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+13
-19
@@ -22,25 +22,19 @@ import org.gradle.api.Project
|
|||||||
import org.gradle.kotlin.dsl.dependencies
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
|
||||||
@Suppress("Unused")
|
@Suppress("Unused")
|
||||||
internal class AndroidLibraryPlugin : Plugin<Project> {
|
internal class CoreDataPlugin : Plugin<Project> {
|
||||||
override fun apply(target: Project) {
|
override fun apply(target: Project) = with(target) {
|
||||||
with(target) {
|
applyPlugin(libs.plugins.google.ksp)
|
||||||
with(pluginManager) {
|
setupAndroidLib()
|
||||||
alias(libs.plugins.google.ksp)
|
setupKotlin()
|
||||||
}
|
dependencies {
|
||||||
setupCommonLibrary()
|
implementation(project(":core:domain"))
|
||||||
setupKotlinToolchain()
|
implementation(libs.androidx.core.ktx)
|
||||||
setupAndroidTestDependencies()
|
implementation(libs.androidx.room)
|
||||||
setupTestDependencies()
|
implementation(libs.androidx.room.runtime)
|
||||||
dependencies {
|
ksp(libs.androidx.room.compiler)
|
||||||
IMPLEMENTATION(project(":core:domain"))
|
implementation(libs.kotlin.coroutines)
|
||||||
IMPLEMENTATION(libs.androidx.core.ktx)
|
implementation(libs.other.okhttp)
|
||||||
IMPLEMENTATION(libs.androidx.room.asProvider())
|
|
||||||
IMPLEMENTATION(libs.androidx.room.runtime)
|
|
||||||
KSP(libs.androidx.room.compiler)
|
|
||||||
IMPLEMENTATION(libs.other.coroutines)
|
|
||||||
IMPLEMENTATION(libs.other.okhttp)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+8
-12
@@ -22,18 +22,14 @@ import org.gradle.api.Project
|
|||||||
import org.gradle.kotlin.dsl.dependencies
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
|
||||||
@Suppress("Unused")
|
@Suppress("Unused")
|
||||||
internal class KotlinLibraryPlugin : Plugin<Project> {
|
internal class CoreDomainPlugin : Plugin<Project> {
|
||||||
override fun apply(target: Project) {
|
override fun apply(target: Project) = with(target) {
|
||||||
with(target) {
|
applyPlugin(libs.plugins.kotlin.jvm)
|
||||||
with(pluginManager) {
|
applyPlugin(libs.plugins.kotlin.serialization)
|
||||||
alias(libs.plugins.kotlin.jvm)
|
setupKotlin()
|
||||||
}
|
dependencies {
|
||||||
setupKotlinToolchain()
|
implementation(libs.kotlin.coroutines)
|
||||||
setupTestDependencies()
|
implementation(libs.kotlin.serialization)
|
||||||
dependencies {
|
|
||||||
IMPLEMENTATION(libs.other.coroutines)
|
|
||||||
IMPLEMENTATION(libs.other.json)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+11
-12
@@ -22,18 +22,17 @@ import org.gradle.api.Project
|
|||||||
import org.gradle.kotlin.dsl.dependencies
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
|
||||||
@Suppress("Unused")
|
@Suppress("Unused")
|
||||||
internal class ComposeLibraryPlugin : Plugin<Project> {
|
internal class CorePresentationPlugin : Plugin<Project> {
|
||||||
override fun apply(target: Project) {
|
override fun apply(target: Project) = with(target) {
|
||||||
with(target) {
|
applyPlugin(libs.plugins.kotlin.serialization)
|
||||||
setupCommonLibrary()
|
setupAndroidLib()
|
||||||
setupComposeFeature()
|
setupCompose()
|
||||||
setupKotlinToolchain()
|
setupKotlin()
|
||||||
setupAndroidTestDependencies()
|
dependencies {
|
||||||
setupTestDependencies()
|
implementation(project(":core:domain"))
|
||||||
dependencies {
|
implementation(libs.androidx.core.splashscreen)
|
||||||
IMPLEMENTATION(project(":core:domain"))
|
implementation(libs.kotlin.serialization)
|
||||||
IMPLEMENTATION(libs.androidx.core.splashscreen)
|
implementation(libs.compose.material3.adaptive)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+8
-12
@@ -22,18 +22,14 @@ import org.gradle.api.Project
|
|||||||
import org.gradle.kotlin.dsl.dependencies
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
|
||||||
@Suppress("Unused")
|
@Suppress("Unused")
|
||||||
internal class ComposeFeaturePlugin : Plugin<Project> {
|
internal class FeaturePlugin : Plugin<Project> {
|
||||||
override fun apply(target: Project) {
|
override fun apply(target: Project) = with(target) {
|
||||||
with(target) {
|
setupAndroidLib()
|
||||||
setupCommonLibrary()
|
setupCompose()
|
||||||
setupComposeFeature()
|
setupKotlin()
|
||||||
setupKotlinToolchain()
|
dependencies {
|
||||||
setupAndroidTestDependencies()
|
implementation(project(":core:domain"))
|
||||||
setupTestDependencies()
|
implementation(project(":core:presentation"))
|
||||||
dependencies {
|
|
||||||
IMPLEMENTATION(project(":core:domain"))
|
|
||||||
IMPLEMENTATION(project(":core:presentation"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+33
-40
@@ -21,7 +21,7 @@ import com.android.build.api.dsl.ApplicationExtension
|
|||||||
import com.android.build.api.dsl.CommonExtension
|
import com.android.build.api.dsl.CommonExtension
|
||||||
import org.gradle.accessors.dm.LibrariesForLibs
|
import org.gradle.accessors.dm.LibrariesForLibs
|
||||||
import org.gradle.api.Project
|
import org.gradle.api.Project
|
||||||
import org.gradle.api.plugins.PluginManager
|
import org.gradle.api.artifacts.dsl.DependencyHandler
|
||||||
import org.gradle.api.provider.Provider
|
import org.gradle.api.provider.Provider
|
||||||
import org.gradle.kotlin.dsl.accessors.runtime.extensionOf
|
import org.gradle.kotlin.dsl.accessors.runtime.extensionOf
|
||||||
import org.gradle.kotlin.dsl.configure
|
import org.gradle.kotlin.dsl.configure
|
||||||
@@ -29,23 +29,30 @@ import org.gradle.kotlin.dsl.dependencies
|
|||||||
import org.gradle.plugin.use.PluginDependency
|
import org.gradle.plugin.use.PluginDependency
|
||||||
import org.jetbrains.kotlin.gradle.dsl.kotlinExtension
|
import org.jetbrains.kotlin.gradle.dsl.kotlinExtension
|
||||||
|
|
||||||
internal const val ANDROID_TEST_IMPLEMENTATION = "androidTestImplementation"
|
|
||||||
internal const val DEBUG_IMPLEMENTATION = "debugImplementation"
|
|
||||||
internal const val IMPLEMENTATION = "implementation"
|
|
||||||
internal const val KSP = "ksp"
|
|
||||||
internal const val TEST_IMPLEMENTATION = "testImplementation"
|
|
||||||
|
|
||||||
internal val Project.libs
|
internal val Project.libs
|
||||||
get(): LibrariesForLibs = extensionOf(this, "libs") as LibrariesForLibs
|
get(): LibrariesForLibs = extensionOf(this, "libs") as LibrariesForLibs
|
||||||
|
|
||||||
internal fun PluginManager.alias(notation: Provider<PluginDependency>) {
|
internal fun Project.applyPlugin(notation: Provider<PluginDependency>) {
|
||||||
apply(notation.get().pluginId)
|
pluginManager.apply(notation.get().pluginId)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun Project.setupAndroidApplication() {
|
internal fun DependencyHandler.implementation(dependencyNotation: Any) =
|
||||||
with(pluginManager) {
|
add("implementation", dependencyNotation)
|
||||||
alias(libs.plugins.android.application)
|
|
||||||
}
|
internal fun DependencyHandler.debugImplementation(dependencyNotation: Any) =
|
||||||
|
add("debugImplementation", dependencyNotation)
|
||||||
|
|
||||||
|
internal fun DependencyHandler.testImplementation(dependencyNotation: Any) =
|
||||||
|
add("testImplementation", dependencyNotation)
|
||||||
|
|
||||||
|
internal fun DependencyHandler.androidTestImplementation(dependencyNotation: Any) =
|
||||||
|
add("androidTestImplementation", dependencyNotation)
|
||||||
|
|
||||||
|
internal fun DependencyHandler.ksp(dependencyNotation: Any) =
|
||||||
|
add("ksp", dependencyNotation)
|
||||||
|
|
||||||
|
internal fun Project.setupAndroidApp() {
|
||||||
|
applyPlugin(libs.plugins.android.application)
|
||||||
extensions.configure<ApplicationExtension> {
|
extensions.configure<ApplicationExtension> {
|
||||||
namespace = libs.versions.packageName.get()
|
namespace = libs.versions.packageName.get()
|
||||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||||
@@ -55,9 +62,6 @@ internal fun Project.setupAndroidApplication() {
|
|||||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||||
versionName = libs.versions.appVersionName.get()
|
versionName = libs.versions.appVersionName.get()
|
||||||
}
|
}
|
||||||
buildFeatures {
|
|
||||||
compose = true
|
|
||||||
}
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
applicationIdSuffix = ".debug"
|
applicationIdSuffix = ".debug"
|
||||||
@@ -70,49 +74,38 @@ internal fun Project.setupAndroidApplication() {
|
|||||||
}
|
}
|
||||||
androidResources {
|
androidResources {
|
||||||
generateLocaleConfig = true
|
generateLocaleConfig = true
|
||||||
localeFilters.addAll(listOf("en", "es", "ru", "si", "uk", "zh"))
|
localeFilters.addAll(listOf("en", "es", "ru", "si", "tr", "uk", "zh"))
|
||||||
}
|
}
|
||||||
packaging { resources { excludes += listOf("META-INF/*") } }
|
packaging { resources { excludes += listOf("META-INF/*") } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun Project.setupCommonLibrary() {
|
internal fun Project.setupAndroidLib() {
|
||||||
with(pluginManager) {
|
applyPlugin(libs.plugins.android.library)
|
||||||
alias(libs.plugins.android.library)
|
|
||||||
}
|
|
||||||
extensions.configure<CommonExtension> {
|
extensions.configure<CommonExtension> {
|
||||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||||
defaultConfig.minSdk = libs.versions.minSdk.get().toInt()
|
defaultConfig.minSdk = libs.versions.minSdk.get().toInt()
|
||||||
}
|
}
|
||||||
|
dependencies {
|
||||||
|
androidTestImplementation(libs.bundles.androidTest)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun Project.setupComposeFeature() {
|
internal fun Project.setupCompose() {
|
||||||
with(pluginManager) {
|
applyPlugin(libs.plugins.compose.compiler)
|
||||||
alias(libs.plugins.compose.compiler)
|
|
||||||
}
|
|
||||||
extensions.configure<CommonExtension> {
|
extensions.configure<CommonExtension> {
|
||||||
buildFeatures.compose = true
|
buildFeatures.compose = true
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
IMPLEMENTATION(platform(libs.compose.bom))
|
implementation(platform(libs.compose.bom))
|
||||||
IMPLEMENTATION(libs.bundles.composeAll)
|
implementation(libs.bundles.composeAll)
|
||||||
DEBUG_IMPLEMENTATION(libs.bundles.composeDebug)
|
debugImplementation(libs.bundles.composeDebug)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun Project.setupKotlinToolchain() {
|
internal fun Project.setupKotlin() {
|
||||||
kotlinExtension.jvmToolchain(libs.versions.jdkVersion.get().toInt())
|
kotlinExtension.jvmToolchain(libs.versions.jdkVersion.get().toInt())
|
||||||
}
|
|
||||||
|
|
||||||
internal fun Project.setupAndroidTestDependencies() {
|
|
||||||
dependencies {
|
dependencies {
|
||||||
ANDROID_TEST_IMPLEMENTATION(libs.bundles.androidTest)
|
testImplementation(libs.bundles.unitTest)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun Project.setupTestDependencies() {
|
|
||||||
dependencies {
|
|
||||||
TEST_IMPLEMENTATION(libs.test.coroutines)
|
|
||||||
TEST_IMPLEMENTATION(libs.test.junit4)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,8 +4,10 @@ plugins {
|
|||||||
alias(libs.plugins.compose.compiler) apply false
|
alias(libs.plugins.compose.compiler) apply false
|
||||||
alias(libs.plugins.google.ksp) apply false
|
alias(libs.plugins.google.ksp) apply false
|
||||||
alias(libs.plugins.kotlin.jvm) apply false
|
alias(libs.plugins.kotlin.jvm) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register("clean", Delete::class.java) {
|
tasks.register("clean", Delete::class.java) {
|
||||||
|
description = "Cleans the build directory"
|
||||||
delete(rootProject.layout.buildDirectory)
|
delete(rootProject.layout.buildDirectory)
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.convention.androidLibraryPlugin)
|
alias(libs.plugins.convention.coreDataPlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|||||||
@@ -11,5 +11,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>
|
||||||
@@ -25,11 +25,11 @@ data class SatRadio(
|
|||||||
@PrimaryKey val uuid: String,
|
@PrimaryKey val uuid: String,
|
||||||
val info: String,
|
val info: String,
|
||||||
val isAlive: Boolean,
|
val isAlive: Boolean,
|
||||||
var downlinkLow: Long?,
|
val downlinkLow: Long?,
|
||||||
var downlinkHigh: Long?,
|
val downlinkHigh: Long?,
|
||||||
val downlinkMode: String?,
|
val downlinkMode: String?,
|
||||||
var uplinkLow: Long?,
|
val uplinkLow: Long?,
|
||||||
var uplinkHigh: Long?,
|
val uplinkHigh: Long?,
|
||||||
val uplinkMode: String?,
|
val uplinkMode: String?,
|
||||||
val isInverted: Boolean,
|
val isInverted: Boolean,
|
||||||
val catnum: Int?
|
val catnum: Int?
|
||||||
|
|||||||
+77
-86
@@ -20,121 +20,112 @@ package com.rtbishop.look4sat.core.data.framework
|
|||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
import android.bluetooth.BluetoothSocket
|
import android.bluetooth.BluetoothSocket
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.rtbishop.look4sat.core.domain.repository.BtService
|
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IReporterRepo
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.WithoutExtParams
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
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
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.math.abs
|
|
||||||
|
|
||||||
data class DeviceConnection(
|
|
||||||
var socket: BluetoothSocket? = null,
|
|
||||||
var outputStream: OutputStream? = null,
|
|
||||||
var connected: Boolean = false,
|
|
||||||
var connecting: Boolean = false,
|
|
||||||
var connectionJob: Job? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
class BluetoothReporter(
|
class BluetoothReporter(
|
||||||
private val bluetoothManager: BluetoothManager,
|
private val bluetoothManager: BluetoothManager,
|
||||||
private val reporterScope: CoroutineScope
|
private val reporterScope: CoroutineScope,
|
||||||
) : IReporterRepo<WithoutExtParams> {
|
private val rotatorDeviceId: String,
|
||||||
|
private val frequencyDeviceId: String
|
||||||
|
) : IReporter {
|
||||||
|
|
||||||
private val tag = "BTReporter"
|
private val tag = "BTReporter"
|
||||||
private val sppid: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
|
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
|
||||||
|
|
||||||
private val serviceToDevice = mutableMapOf<BtService, String>()
|
|
||||||
private val deviceConnections = mutableMapOf<String, DeviceConnection>()
|
|
||||||
private val writeMutex = Mutex()
|
private val writeMutex = Mutex()
|
||||||
|
|
||||||
override fun isConnected(service: BtService): Boolean {
|
private var rotatorSocket: BluetoothSocket? = null
|
||||||
val deviceId = serviceToDevice[service] ?: return false
|
private var rotatorStream: OutputStream? = null
|
||||||
return deviceConnections[deviceId]?.connected == true
|
private var rotatorConnected = false
|
||||||
}
|
private var rotatorConnecting = false
|
||||||
|
|
||||||
override fun isConnecting(service: BtService): Boolean {
|
private var frequencySocket: BluetoothSocket? = null
|
||||||
val deviceId = serviceToDevice[service] ?: return false
|
private var frequencyStream: OutputStream? = null
|
||||||
return deviceConnections[deviceId]?.connecting == true
|
private var frequencyConnected = false
|
||||||
}
|
private var frequencyConnecting = false
|
||||||
|
|
||||||
override fun connect(service: BtService, deviceId: String) {
|
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
|
||||||
serviceToDevice[service] = deviceId
|
reporterScope.launch {
|
||||||
val connection = deviceConnections.getOrPut(deviceId) {
|
ensureRotatorConnected()
|
||||||
DeviceConnection()
|
if (!rotatorConnected) return@launch
|
||||||
|
val el = if (elevation > 0.0) elevation else 0.0
|
||||||
|
val command = format
|
||||||
|
.replace($$"$AZ", azimuth.toString())
|
||||||
|
.replace($$"$EL", el.toString())
|
||||||
|
.unescapeControlChars()
|
||||||
|
write(rotatorStream, command) { rotatorConnected = false }
|
||||||
}
|
}
|
||||||
if (connection.connected || connection.connecting) return
|
}
|
||||||
connection.connectionJob = reporterScope.launch {
|
|
||||||
|
override fun reportFrequency(format: String, frequency: Long) {
|
||||||
|
reporterScope.launch {
|
||||||
|
ensureFrequencyConnected()
|
||||||
|
if (!frequencyConnected) return@launch
|
||||||
|
val command = format
|
||||||
|
.replace($$"$FREQ", frequency.toString())
|
||||||
|
.unescapeControlChars()
|
||||||
|
write(frequencyStream, command) { frequencyConnected = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureRotatorConnected() {
|
||||||
|
if (rotatorConnected || rotatorConnecting || rotatorDeviceId.isBlank()) return
|
||||||
|
reporterScope.launch {
|
||||||
try {
|
try {
|
||||||
connection.connecting = true
|
rotatorConnecting = true
|
||||||
val device = bluetoothManager.adapter.getRemoteDevice(deviceId)
|
val device = bluetoothManager.adapter.getRemoteDevice(rotatorDeviceId)
|
||||||
val socket = device.createInsecureRfcommSocketToServiceRecord(sppid)
|
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||||
socket.connect()
|
socket.connect()
|
||||||
connection.socket = socket
|
rotatorSocket = socket
|
||||||
connection.outputStream = socket.outputStream
|
rotatorStream = socket.outputStream
|
||||||
connection.connected = true
|
rotatorConnected = true
|
||||||
Log.i(tag, "$tag: Connected to $deviceId")
|
Log.i(tag, "Rotator connected to $rotatorDeviceId")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(tag, "$tag: ${e.message}")
|
Log.e(tag, "Rotator connect error: ${e.message}")
|
||||||
connection.connected = false
|
rotatorConnected = false
|
||||||
} finally {
|
} finally {
|
||||||
connection.connecting = false
|
rotatorConnecting = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun write(service: BtService, buffer: String) {
|
private fun ensureFrequencyConnected() {
|
||||||
val deviceId = serviceToDevice[service] ?: return
|
if (frequencyConnected || frequencyConnecting || frequencyDeviceId.isBlank()) return
|
||||||
val connection = deviceConnections[deviceId] ?: return
|
reporterScope.launch {
|
||||||
if (!connection.connected) return
|
try {
|
||||||
|
frequencyConnecting = true
|
||||||
|
val device = bluetoothManager.adapter.getRemoteDevice(frequencyDeviceId)
|
||||||
|
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||||
|
socket.connect()
|
||||||
|
frequencySocket = socket
|
||||||
|
frequencyStream = socket.outputStream
|
||||||
|
frequencyConnected = true
|
||||||
|
Log.i(tag, "Frequency connected to $frequencyDeviceId")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Frequency connect error: ${e.message}")
|
||||||
|
frequencyConnected = false
|
||||||
|
} finally {
|
||||||
|
frequencyConnecting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun write(stream: OutputStream?, data: String, onError: () -> Unit) {
|
||||||
try {
|
try {
|
||||||
writeMutex.withLock {
|
writeMutex.withLock {
|
||||||
connection.outputStream?.write(buffer.toByteArray())
|
stream?.write(data.toByteArray())
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(tag, "$tag: Write failed ${e.message}")
|
Log.e(tag, "Write error: ${e.message}")
|
||||||
connection.connected = false
|
onError()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun reportRotation(format: String, azimuth: Double, elevation: Double, params: WithoutExtParams) {
|
private fun String.unescapeControlChars(): String =
|
||||||
reporterScope.launch {
|
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
|
||||||
if (!isConnected(BtService.ROTATOR)) return@launch
|
|
||||||
val newElevation = if (elevation > 0.0) elevation else 0.0
|
|
||||||
val azimuthString = intToStringWithLeadingZeroes(azimuth.toInt())
|
|
||||||
val elevationString = intToStringWithLeadingZeroes(newElevation.toInt())
|
|
||||||
val buffer = format
|
|
||||||
.replace($$"$AZ", azimuthString)
|
|
||||||
.replace($$"$EL", elevationString)
|
|
||||||
.replace("\\r", "\r")
|
|
||||||
.replace("\\n", "\n")
|
|
||||||
.replace("\\t", "\t")
|
|
||||||
write(BtService.ROTATOR, buffer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun reportFrequency(format: String, frequency: Long, params: WithoutExtParams) {
|
|
||||||
reporterScope.launch {
|
|
||||||
if (!isConnected(BtService.FREQUENCY)) return@launch
|
|
||||||
val buffer = format
|
|
||||||
.replace($$"$FREQ", frequency.toString())
|
|
||||||
.replace("\\r", "\r")
|
|
||||||
.replace("\\n", "\n")
|
|
||||||
.replace("\\t", "\t")
|
|
||||||
write(BtService.FREQUENCY, buffer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun intToStringWithLeadingZeroes(value: Int): String {
|
|
||||||
return if (value > 0) {
|
|
||||||
if (value < 10) "00$value" else if (value < 100) "0$value" else "$value"
|
|
||||||
} else {
|
|
||||||
val absValue = abs(value)
|
|
||||||
if (value > -10) "-00$absValue" else if (value > -100) "-0$absValue" else "-$absValue"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
object Ft817CatProtocol {
|
||||||
|
|
||||||
|
const val CMD_SET_FREQ: Byte = 0x01
|
||||||
|
const val CMD_READ_FREQ_MODE: Byte = 0x03
|
||||||
|
const val CMD_SET_MODE: Byte = 0x07
|
||||||
|
const val CMD_PTT_ON: Byte = 0x08
|
||||||
|
const val CMD_PTT_OFF: Byte = 0x88.toByte()
|
||||||
|
const val CMD_CTCSS_MODE: Byte = 0x0A
|
||||||
|
const val CMD_CTCSS_TONE: Byte = 0x0B
|
||||||
|
|
||||||
|
const val CTCSS_ENC_ON: Byte = 0x2A
|
||||||
|
const val CTCSS_OFF: Byte = 0x8A.toByte()
|
||||||
|
|
||||||
|
val MODE_TO_BYTE: Map<String, Byte> = mapOf(
|
||||||
|
"LSB" to 0x00,
|
||||||
|
"USB" to 0x01,
|
||||||
|
"CW" to 0x02,
|
||||||
|
"CW-R" to 0x03,
|
||||||
|
"AM" to 0x04,
|
||||||
|
"FM" to 0x08,
|
||||||
|
"DIG" to 0x0A,
|
||||||
|
"PKT" to 0x0C
|
||||||
|
)
|
||||||
|
|
||||||
|
val BYTE_TO_MODE: Map<Byte, String> = MODE_TO_BYTE.entries.associate { it.value to it.key }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode frequency in Hz to 4-byte BCD with 10 Hz resolution.
|
||||||
|
* Example: 145500000 Hz → [0x14, 0x55, 0x00, 0x00]
|
||||||
|
*/
|
||||||
|
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
|
||||||
|
val freq10Hz = frequencyHz / 10
|
||||||
|
val bcd = ByteArray(4)
|
||||||
|
val digits = String.format("%08d", freq10Hz)
|
||||||
|
for (i in 0 until 4) {
|
||||||
|
val high = digits[i * 2] - '0'
|
||||||
|
val low = digits[i * 2 + 1] - '0'
|
||||||
|
bcd[i] = ((high shl 4) or low).toByte()
|
||||||
|
}
|
||||||
|
return bcd
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode 4-byte BCD frequency to Hz.
|
||||||
|
*/
|
||||||
|
fun decodeFrequencyBcd(bcd: ByteArray): Long {
|
||||||
|
var freq10Hz = 0L
|
||||||
|
for (i in 0 until 4) {
|
||||||
|
val b = bcd[i].toInt() and 0xFF
|
||||||
|
val high = b shr 4
|
||||||
|
val low = b and 0x0F
|
||||||
|
freq10Hz = freq10Hz * 100 + high * 10 + low
|
||||||
|
}
|
||||||
|
return freq10Hz * 10
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode CTCSS tone frequency (in Hz, e.g. 67.0) to 2-byte BCD.
|
||||||
|
* 67.0 Hz → 670 (in 0.1 Hz) → BCD [0x06, 0x70]
|
||||||
|
*/
|
||||||
|
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
|
||||||
|
val tone01Hz = (toneHz * 10).toLong()
|
||||||
|
val digits = String.format("%04d", tone01Hz)
|
||||||
|
val bcd = ByteArray(2)
|
||||||
|
for (i in 0 until 2) {
|
||||||
|
val high = digits[i * 2] - '0'
|
||||||
|
val low = digits[i * 2 + 1] - '0'
|
||||||
|
bcd[i] = ((high shl 4) or low).toByte()
|
||||||
|
}
|
||||||
|
return bcd
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildSetFreqCommand(frequencyHz: Long): ByteArray {
|
||||||
|
val bcd = encodeFrequencyBcd(frequencyHz)
|
||||||
|
return byteArrayOf(bcd[0], bcd[1], bcd[2], bcd[3], CMD_SET_FREQ)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildSetModeCommand(mode: String): ByteArray? {
|
||||||
|
val modeByte = MODE_TO_BYTE[mode.uppercase()] ?: return null
|
||||||
|
return byteArrayOf(modeByte, 0x00, 0x00, 0x00, CMD_SET_MODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildReadFreqModeCommand(): ByteArray {
|
||||||
|
return byteArrayOf(0x00, 0x00, 0x00, 0x00, CMD_READ_FREQ_MODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildPttOnCommand(): ByteArray {
|
||||||
|
return byteArrayOf(0x00, 0x00, 0x00, 0x00, CMD_PTT_ON)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildPttOffCommand(): ByteArray {
|
||||||
|
return byteArrayOf(0x00, 0x00, 0x00, 0x00, CMD_PTT_OFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildCtcssModeCommand(enabled: Boolean): ByteArray {
|
||||||
|
val sub = if (enabled) CTCSS_ENC_ON else CTCSS_OFF
|
||||||
|
return byteArrayOf(sub, 0x00, 0x00, 0x00, CMD_CTCSS_MODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildSetCtcssToneCommand(toneHz: Double): ByteArray {
|
||||||
|
val bcd = encodeCtcssToneBcd(toneHz)
|
||||||
|
return byteArrayOf(bcd[0], bcd[1], 0x00, 0x00, CMD_CTCSS_TONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the 5-byte response from a READ FREQ+MODE command.
|
||||||
|
* Returns (frequencyHz, modeString) or null if parsing fails.
|
||||||
|
*/
|
||||||
|
fun parseReadResponse(response: ByteArray): Pair<Long, String>? {
|
||||||
|
if (response.size < 5) return null
|
||||||
|
val freqBcd = response.copyOfRange(0, 4)
|
||||||
|
val frequencyHz = decodeFrequencyBcd(freqBcd)
|
||||||
|
val modeByte = response[4]
|
||||||
|
val mode = BYTE_TO_MODE[modeByte] ?: return null
|
||||||
|
return Pair(frequencyHz, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.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
|
||||||
|
|
||||||
|
class Ft817Controller(
|
||||||
|
private val bluetoothManager: BluetoothManager,
|
||||||
|
private val deviceAddress: String
|
||||||
|
) : IRadioController {
|
||||||
|
|
||||||
|
private val tag = "FT817"
|
||||||
|
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
|
||||||
|
private val ioMutex = Mutex()
|
||||||
|
private val commandDelayMs = 200L
|
||||||
|
|
||||||
|
private var socket: BluetoothSocket? = null
|
||||||
|
private var outputStream: OutputStream? = null
|
||||||
|
private var inputStream: InputStream? = null
|
||||||
|
|
||||||
|
override var isConnected: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
if (isConnected) return@withContext true
|
||||||
|
if (deviceAddress.isBlank()) return@withContext false
|
||||||
|
try {
|
||||||
|
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
|
||||||
|
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||||
|
btSocket.connect()
|
||||||
|
socket = btSocket
|
||||||
|
outputStream = btSocket.outputStream
|
||||||
|
inputStream = btSocket.inputStream
|
||||||
|
isConnected = true
|
||||||
|
Log.i(tag, "Connected to $deviceAddress")
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Connect error: ${e.message}")
|
||||||
|
isConnected = false
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun disconnect() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
inputStream?.close()
|
||||||
|
outputStream?.close()
|
||||||
|
socket?.close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Disconnect error: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
inputStream = null
|
||||||
|
outputStream = null
|
||||||
|
socket = null
|
||||||
|
isConnected = false
|
||||||
|
Log.i(tag, "Disconnected from $deviceAddress")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock {
|
||||||
|
sendCommandWithAck(Ft817CatProtocol.buildSetFreqCommand(frequencyHz))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setMode(mode: String): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val cmd = Ft817CatProtocol.buildSetModeCommand(mode) ?: return@withContext false
|
||||||
|
ioMutex.withLock { sendCommandWithAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setCtcssMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock {
|
||||||
|
sendCommandWithAck(Ft817CatProtocol.buildCtcssModeCommand(enabled))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setCtcssTone(toneHz: Double): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock {
|
||||||
|
sendCommandWithAck(Ft817CatProtocol.buildSetCtcssToneCommand(toneHz))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readFrequencyAndMode(): Pair<Long, String>? = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock {
|
||||||
|
val sent = sendCommand(Ft817CatProtocol.buildReadFreqModeCommand())
|
||||||
|
if (!sent) return@withContext null
|
||||||
|
delay(commandDelayMs)
|
||||||
|
val response = readResponse(5) ?: return@withContext null
|
||||||
|
Ft817CatProtocol.parseReadResponse(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun pttOn(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock { sendCommandWithAck(Ft817CatProtocol.buildPttOnCommand()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun pttOff(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock { sendCommandWithAck(Ft817CatProtocol.buildPttOffCommand()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendCommand(bytes: ByteArray): Boolean {
|
||||||
|
return try {
|
||||||
|
outputStream?.write(bytes) ?: return false
|
||||||
|
outputStream?.flush()
|
||||||
|
delay(commandDelayMs)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Send error: ${e.message}")
|
||||||
|
isConnected = false
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send command and read the 1-byte ACK response (0x00 = OK). */
|
||||||
|
private suspend fun sendCommandWithAck(bytes: ByteArray): Boolean {
|
||||||
|
if (!sendCommand(bytes)) return false
|
||||||
|
return try {
|
||||||
|
val ack = inputStream?.read() ?: return false
|
||||||
|
ack == 0x00
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "ACK read error: ${e.message}")
|
||||||
|
true // command was sent, ACK read failed - continue anyway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readResponse(length: Int): ByteArray? {
|
||||||
|
return try {
|
||||||
|
val buffer = ByteArray(length)
|
||||||
|
var read = 0
|
||||||
|
while (read < length) {
|
||||||
|
val count = inputStream?.read(buffer, read, length - read) ?: return null
|
||||||
|
if (count < 0) return null
|
||||||
|
read += count
|
||||||
|
}
|
||||||
|
buffer
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Read error: ${e.message}")
|
||||||
|
isConnected = false
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
-67
@@ -17,11 +17,8 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.data.framework
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.BtService
|
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ExtendedParams
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IReporterRepo
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
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
|
||||||
@@ -29,89 +26,94 @@ import java.net.InetSocketAddress
|
|||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.channels.SocketChannel
|
import java.nio.channels.SocketChannel
|
||||||
|
|
||||||
enum class NetworkService { ROTATOR, FREQUENCY }
|
class NetworkReporter(
|
||||||
|
private val reporterScope: CoroutineScope,
|
||||||
|
private val rotatorServer: String,
|
||||||
|
private val rotatorPort: Int,
|
||||||
|
private val frequencyServer: String,
|
||||||
|
private val frequencyPort: Int
|
||||||
|
) : IReporter {
|
||||||
|
|
||||||
data class SocketConnection(
|
|
||||||
var socket: SocketChannel? = null,
|
|
||||||
var connected: Boolean = false,
|
|
||||||
var connecting: Boolean = false,
|
|
||||||
var connectionJob: Job? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
class NetworkReporter(private val reporterScope: CoroutineScope) : IReporterRepo<ExtendedParams> {
|
|
||||||
private val serviceToAddress = mutableMapOf<NetworkService, String>()
|
|
||||||
private val connections = mutableMapOf<String, SocketConnection>()
|
|
||||||
private val writeMutex = Mutex()
|
private val writeMutex = Mutex()
|
||||||
|
|
||||||
fun isConnected(service: NetworkService): Boolean {
|
private var rotatorSocket: SocketChannel? = null
|
||||||
val addr = serviceToAddress[service] ?: return false
|
private var rotatorConnected = false
|
||||||
return connections[addr]?.connected == true
|
private var rotatorConnecting = false
|
||||||
|
|
||||||
|
private var frequencySocket: SocketChannel? = null
|
||||||
|
private var frequencyConnected = false
|
||||||
|
private var frequencyConnecting = false
|
||||||
|
|
||||||
|
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
|
||||||
|
reporterScope.launch {
|
||||||
|
ensureRotatorConnected()
|
||||||
|
if (!rotatorConnected) return@launch
|
||||||
|
val el = if (elevation > 0.0) elevation else 0.0
|
||||||
|
val command = format
|
||||||
|
.replace($$"$AZ", azimuth.toString())
|
||||||
|
.replace($$"$EL", el.toString())
|
||||||
|
.unescapeControlChars()
|
||||||
|
write(rotatorSocket, command) { rotatorConnected = false }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getOrCreateConnection(addr: String): SocketConnection {
|
override fun reportFrequency(format: String, frequency: Long) {
|
||||||
return connections.getOrPut(addr) { SocketConnection() }
|
reporterScope.launch {
|
||||||
|
ensureFrequencyConnected()
|
||||||
|
if (!frequencyConnected) return@launch
|
||||||
|
val command = format
|
||||||
|
.replace($$"$FREQ", frequency.toString())
|
||||||
|
.unescapeControlChars()
|
||||||
|
write(frequencySocket, command) { frequencyConnected = false }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun connect(service: NetworkService, server: String, port: Int) {
|
private fun ensureRotatorConnected() {
|
||||||
val addr = "$server:$port"
|
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
|
||||||
serviceToAddress[service] = addr
|
reporterScope.launch {
|
||||||
val connection = getOrCreateConnection(addr)
|
|
||||||
if (connection.connected || connection.connecting) return
|
|
||||||
connection.connectionJob = reporterScope.launch {
|
|
||||||
try {
|
try {
|
||||||
connection.connecting = true
|
rotatorConnecting = true
|
||||||
val socket = SocketChannel.open(InetSocketAddress(server, port))
|
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
||||||
connection.socket = socket
|
rotatorConnected = true
|
||||||
connection.connected = true
|
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
|
||||||
println("NetworkReporter: $service connected to $addr")
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter connect error: ${e.message}")
|
println("NetworkReporter rotator connect error: ${e.message}")
|
||||||
connection.connected = false
|
rotatorConnected = false
|
||||||
} finally {
|
} finally {
|
||||||
connection.connecting = false
|
rotatorConnecting = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun write(service: NetworkService, command: String) {
|
private fun ensureFrequencyConnected() {
|
||||||
val addr = serviceToAddress[service] ?: return
|
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
|
||||||
val connection = connections[addr] ?: return
|
reporterScope.launch {
|
||||||
if (!connection.connected) return
|
try {
|
||||||
|
frequencyConnecting = true
|
||||||
|
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
||||||
|
frequencyConnected = true
|
||||||
|
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("NetworkReporter frequency connect error: ${e.message}")
|
||||||
|
frequencyConnected = false
|
||||||
|
} finally {
|
||||||
|
frequencyConnecting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun write(socket: SocketChannel?, command: String, onError: () -> Unit) {
|
||||||
try {
|
try {
|
||||||
writeMutex.withLock {
|
writeMutex.withLock {
|
||||||
val buffer = ByteBuffer.wrap("\\$command\n".toByteArray())
|
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
|
||||||
connection.socket?.write(buffer)
|
socket?.write(buffer)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter write error: ${e.message}")
|
println("NetworkReporter write error: ${e.message}")
|
||||||
connection.connected = false
|
onError()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isConnected(service: BtService): Boolean = false
|
private fun String.unescapeControlChars(): String =
|
||||||
|
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
|
||||||
override fun isConnecting(service: BtService): Boolean = false
|
|
||||||
|
|
||||||
override fun connect(service: BtService, deviceId: String) {}
|
|
||||||
|
|
||||||
override fun reportRotation(format: String, azimuth: Double, elevation: Double, params: ExtendedParams) {
|
|
||||||
reporterScope.launch {
|
|
||||||
connect(NetworkService.ROTATOR, params.server, params.port)
|
|
||||||
if (!isConnected(NetworkService.ROTATOR)) return@launch
|
|
||||||
val el = if (elevation > 0.0) elevation else 0.0
|
|
||||||
val command = format
|
|
||||||
.replace("\$AZ", azimuth.toString())
|
|
||||||
.replace("\$EL", el.toString())
|
|
||||||
write(NetworkService.ROTATOR, command)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun reportFrequency(format: String, frequency: Long, params: ExtendedParams) {
|
|
||||||
reporterScope.launch {
|
|
||||||
connect(NetworkService.FREQUENCY, params.server, params.port)
|
|
||||||
if (!isConnected(NetworkService.FREQUENCY)) return@launch
|
|
||||||
val command = format.replace("\$FREQ", frequency.toString())
|
|
||||||
write(NetworkService.FREQUENCY, command)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+357
@@ -0,0 +1,357 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.util.Log
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class RadioTrackingService(
|
||||||
|
private val appScope: CoroutineScope,
|
||||||
|
private val bluetoothManager: BluetoothManager,
|
||||||
|
private val satelliteRepo: ISatelliteRepo,
|
||||||
|
private val settingsRepo: ISettingsRepo
|
||||||
|
) : IRadioTrackingService {
|
||||||
|
|
||||||
|
private val tag = "RadioTracking"
|
||||||
|
private val _state = MutableStateFlow(RadioTrackingState())
|
||||||
|
override val state: StateFlow<RadioTrackingState> = _state
|
||||||
|
|
||||||
|
private var txController: IRadioController? = null
|
||||||
|
private var rxController: IRadioController? = null
|
||||||
|
private var trackingJob: Job? = null
|
||||||
|
|
||||||
|
override suspend fun connectRadios() {
|
||||||
|
// Disconnect old controllers if any
|
||||||
|
txController?.disconnect()
|
||||||
|
rxController?.disconnect()
|
||||||
|
|
||||||
|
// Read current addresses from settings
|
||||||
|
val rcSettings = settingsRepo.radioControlSettings.value
|
||||||
|
val txAddr = rcSettings.txRadioAddress
|
||||||
|
val rxAddr = rcSettings.rxRadioAddress
|
||||||
|
|
||||||
|
Log.i(tag, "Connecting TX=$txAddr RX=$rxAddr")
|
||||||
|
|
||||||
|
if (txAddr.isBlank() && rxAddr.isBlank()) {
|
||||||
|
_state.update { it.copy(errorMessage = "No radio addresses configured in Settings") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val tx = Ft817Controller(bluetoothManager, txAddr)
|
||||||
|
val rx = Ft817Controller(bluetoothManager, rxAddr)
|
||||||
|
txController = tx
|
||||||
|
rxController = rx
|
||||||
|
|
||||||
|
_state.update { it.copy(errorMessage = null) }
|
||||||
|
val txOk = if (txAddr.isNotBlank()) tx.connect() else false
|
||||||
|
val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
txConnected = txOk,
|
||||||
|
rxConnected = rxOk,
|
||||||
|
errorMessage = when {
|
||||||
|
!txOk && !rxOk -> "Could not connect to TX and RX radios"
|
||||||
|
!txOk -> "Could not connect to TX radio ($txAddr)"
|
||||||
|
!rxOk -> "Could not connect to RX radio ($rxAddr)"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun disconnectRadios() {
|
||||||
|
stopTracking()
|
||||||
|
txController?.disconnect()
|
||||||
|
rxController?.disconnect()
|
||||||
|
txController = null
|
||||||
|
rxController = null
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
txConnected = false,
|
||||||
|
rxConnected = false,
|
||||||
|
isActive = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isActive = true,
|
||||||
|
currentPass = pass,
|
||||||
|
selectedTransponder = transponder,
|
||||||
|
txBaseFrequencyHz = txBaseFreqHz
|
||||||
|
)
|
||||||
|
}
|
||||||
|
trackingJob?.cancel()
|
||||||
|
trackingJob = appScope.launch {
|
||||||
|
// Set modes on both radios at tracking start
|
||||||
|
val tx = txController
|
||||||
|
val rx = rxController
|
||||||
|
val txMode = transponder.uplinkMode
|
||||||
|
val rxMode = transponder.downlinkMode
|
||||||
|
?: transponder.uplinkMode?.let {
|
||||||
|
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||||
|
}
|
||||||
|
if (tx != null && tx.isConnected && txMode != null) {
|
||||||
|
tx.setMode(txMode)
|
||||||
|
Log.i(tag, "TX mode set to $txMode")
|
||||||
|
}
|
||||||
|
if (rx != null && rx.isConnected && rxMode != null) {
|
||||||
|
rx.setMode(rxMode)
|
||||||
|
Log.i(tag, "RX mode set to $rxMode")
|
||||||
|
}
|
||||||
|
// Set CTCSS if FM
|
||||||
|
if (txMode?.uppercase() == "FM") {
|
||||||
|
_state.value.ctcssTone?.let { tone ->
|
||||||
|
tx?.setCtcssTone(tone)
|
||||||
|
tx?.setCtcssMode(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
||||||
|
|
||||||
|
var lastSetTxFreq = 0.0
|
||||||
|
var lastSetRxFreq = 0.0
|
||||||
|
var tuningRadio = "" // "", "tx", or "rx" - which radio the user is tuning
|
||||||
|
var lastReadFreq = 0L
|
||||||
|
var stableCount = 0
|
||||||
|
|
||||||
|
while (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 timeNow = System.currentTimeMillis()
|
||||||
|
|
||||||
|
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
|
||||||
|
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
|
||||||
|
|
||||||
|
if (tuningRadio.isNotEmpty()) {
|
||||||
|
// --- User is tuning: keep reading, wait for stabilization ---
|
||||||
|
val radio = if (tuningRadio == "tx") tx else rx
|
||||||
|
if (radio != null && radio.isConnected) {
|
||||||
|
val readResult = radio.readFrequencyAndMode()
|
||||||
|
if (readResult != null) {
|
||||||
|
val (freq, _) = readResult
|
||||||
|
if (kotlin.math.abs(freq - lastReadFreq) <= 20) {
|
||||||
|
stableCount++
|
||||||
|
} else {
|
||||||
|
stableCount = 0
|
||||||
|
lastReadFreq = freq
|
||||||
|
}
|
||||||
|
// Stable for 2 reads → user stopped turning
|
||||||
|
if (stableCount >= 2) {
|
||||||
|
if (tuningRadio == "tx" && txBaseFreq != null) {
|
||||||
|
val newBase = (freq.toDouble() * c / (c + v)).toLong()
|
||||||
|
if (newBase > 0) {
|
||||||
|
txBaseFreq = newBase
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = newBase) }
|
||||||
|
Log.i(tag, "TX tuning done → base=$newBase")
|
||||||
|
}
|
||||||
|
} else if (tuningRadio == "rx") {
|
||||||
|
val rxNominal = (freq.toDouble() * c / (c - v)).toLong()
|
||||||
|
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
|
||||||
|
if (newTxBase != null && newTxBase > 0) {
|
||||||
|
txBaseFreq = newTxBase
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
|
||||||
|
Log.i(tag, "RX tuning done → txBase=$newTxBase")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tuningRadio = ""
|
||||||
|
stableCount = 0
|
||||||
|
lastSetTxFreq = 0.0
|
||||||
|
lastSetRxFreq = 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// --- Normal tracking: read, detect changes, command ---
|
||||||
|
|
||||||
|
// TX dial feedback
|
||||||
|
if (hasUplink && tx != null && tx.isConnected && lastSetTxFreq > 0.0) {
|
||||||
|
val readResult = tx.readFrequencyAndMode()
|
||||||
|
if (readResult != null) {
|
||||||
|
val (actualTxFreq, _) = readResult
|
||||||
|
if (kotlin.math.abs(actualTxFreq - lastSetTxFreq) >= 20.0) {
|
||||||
|
tuningRadio = "tx"
|
||||||
|
lastReadFreq = actualTxFreq
|
||||||
|
stableCount = 0
|
||||||
|
Log.i(tag, "TX tuning detected (read=$actualTxFreq, lastSet=$lastSetTxFreq)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RX dial feedback (only if TX not tuning)
|
||||||
|
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"
|
||||||
|
lastReadFreq = actualRxFreq
|
||||||
|
stableCount = 0
|
||||||
|
Log.i(tag, "RX tuning detected (read=$actualRxFreq, lastSet=$lastSetRxFreq)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute Doppler-corrected frequencies
|
||||||
|
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
|
||||||
|
val rxBaseFreq = if (txBaseFreq != null) {
|
||||||
|
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
|
||||||
|
} else {
|
||||||
|
xpdr.downlinkLow
|
||||||
|
}
|
||||||
|
val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) }
|
||||||
|
|
||||||
|
// Command radios (only when not tuning)
|
||||||
|
if (tuningRadio.isEmpty()) {
|
||||||
|
if (tx != null && tx.isConnected && txRadioFreq != null) {
|
||||||
|
tx.setFrequency(txRadioFreq)
|
||||||
|
lastSetTxFreq = txRadioFreq.toDouble()
|
||||||
|
}
|
||||||
|
if (rx != null && rx.isConnected && rxRadioFreq != null) {
|
||||||
|
rx.setFrequency(rxRadioFreq)
|
||||||
|
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
txConnected = tx?.isConnected ?: false,
|
||||||
|
rxConnected = rx?.isConnected ?: false,
|
||||||
|
txFrequencyHz = txRadioFreq,
|
||||||
|
rxFrequencyHz = rxRadioFreq,
|
||||||
|
azimuth = Math.toDegrees(pos.azimuth),
|
||||||
|
elevation = Math.toDegrees(pos.elevation),
|
||||||
|
distance = pos.distance
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stopTracking() {
|
||||||
|
trackingJob?.cancel()
|
||||||
|
trackingJob = null
|
||||||
|
_state.update { it.copy(isActive = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setTransponder(transponder: SatRadio) {
|
||||||
|
appScope.launch {
|
||||||
|
val tx = txController
|
||||||
|
val rx = rxController
|
||||||
|
transponder.uplinkMode?.let { tx?.setMode(it) }
|
||||||
|
val rxMode = transponder.downlinkMode
|
||||||
|
?: transponder.uplinkMode?.let {
|
||||||
|
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||||
|
}
|
||||||
|
rxMode?.let { rx?.setMode(it) }
|
||||||
|
|
||||||
|
if (transponder.uplinkMode?.uppercase() == "FM") {
|
||||||
|
_state.value.ctcssTone?.let { tone ->
|
||||||
|
tx?.setCtcssTone(tone)
|
||||||
|
tx?.setCtcssMode(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val txCenter = when {
|
||||||
|
transponder.uplinkLow != null && transponder.uplinkHigh != null ->
|
||||||
|
(transponder.uplinkLow!! + transponder.uplinkHigh!!) / 2
|
||||||
|
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
// Show nominal frequencies immediately
|
||||||
|
val rxNominal = if (txCenter != null) {
|
||||||
|
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
||||||
|
} else {
|
||||||
|
// Downlink-only transponder (beacon etc.) - use downlink directly
|
||||||
|
transponder.downlinkLow
|
||||||
|
}
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
selectedTransponder = transponder,
|
||||||
|
txBaseFrequencyHz = txCenter,
|
||||||
|
txFrequencyHz = txCenter,
|
||||||
|
rxFrequencyHz = rxNominal,
|
||||||
|
txMode = transponder.uplinkMode,
|
||||||
|
rxMode = transponder.downlinkMode
|
||||||
|
?: transponder.uplinkMode?.let { m ->
|
||||||
|
TransponderMapper.mapUplinkModeToDownlinkMode(m, transponder.isInverted)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setTxBaseFrequency(frequencyHz: Long) {
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = frequencyHz) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun adjustTxBaseFrequency(deltaHz: Long) {
|
||||||
|
val current = _state.value.txBaseFrequencyHz ?: return
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = current + deltaHz) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setCtcssTone(toneHz: Double?) {
|
||||||
|
_state.update { it.copy(ctcssTone = toneHz) }
|
||||||
|
appScope.launch {
|
||||||
|
val tx = txController
|
||||||
|
if (toneHz != null) {
|
||||||
|
tx?.setCtcssTone(toneHz)
|
||||||
|
tx?.setCtcssMode(true)
|
||||||
|
} else {
|
||||||
|
tx?.setCtcssMode(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setMode(txMode: String, rxMode: String) {
|
||||||
|
appScope.launch {
|
||||||
|
txController?.setMode(txMode)
|
||||||
|
rxController?.setMode(rxMode)
|
||||||
|
}
|
||||||
|
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+66
-12
@@ -1,15 +1,33 @@
|
|||||||
|
/*
|
||||||
|
* 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.room.Room
|
import androidx.room.Room
|
||||||
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.NetworkReporter
|
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
||||||
|
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
||||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
import com.rtbishop.look4sat.core.data.repository.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
|
||||||
@@ -18,19 +36,23 @@ 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.repository.ExtendedParams
|
|
||||||
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.IReporterRepo
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.WithoutExtParams
|
|
||||||
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
|
||||||
@@ -48,25 +70,57 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
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 radioTrackingService: IRadioTrackingService by lazy {
|
||||||
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||||
|
}
|
||||||
|
|
||||||
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
||||||
|
|
||||||
override fun provideShowToast(): IShowToast = ShowToast(context)
|
override fun provideShowToast(): IShowToast = ShowToast(context)
|
||||||
|
|
||||||
override fun provideBluetoothReporter(): IReporterRepo<WithoutExtParams> {
|
override fun provideAudioCapture(): IAudioCapture = AudioCapture()
|
||||||
|
|
||||||
|
override fun provideSaveImage(): ISaveImage = SaveImage(context)
|
||||||
|
|
||||||
|
override fun provideBluetoothReporter(): IReporter {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
return BluetoothReporter(manager, CoroutineScope(Dispatchers.IO))
|
val rc = settingsRepo.rcSettings.value
|
||||||
|
return BluetoothReporter(
|
||||||
|
manager,
|
||||||
|
CoroutineScope(Dispatchers.IO),
|
||||||
|
rc.bluetoothRotatorAddress,
|
||||||
|
rc.bluetoothFrequencyAddress
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun provideNetworkReporter(): IReporterRepo<ExtendedParams> {
|
override fun provideNetworkReporter(): IReporter {
|
||||||
return NetworkReporter(CoroutineScope(Dispatchers.IO))
|
val rc = settingsRepo.rcSettings.value
|
||||||
|
return NetworkReporter(
|
||||||
|
CoroutineScope(Dispatchers.IO),
|
||||||
|
rc.rotatorAddress,
|
||||||
|
rc.rotatorPort.toIntOrNull() ?: 0,
|
||||||
|
rc.frequencyAddress,
|
||||||
|
rc.frequencyPort.toIntOrNull() ?: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun provideTxRadioController(): IRadioController {
|
||||||
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
val address = settingsRepo.radioControlSettings.value.txRadioAddress
|
||||||
|
return Ft817Controller(manager, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun provideRxRadioController(): IRadioController {
|
||||||
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
val address = settingsRepo.radioControlSettings.value.rxRadioAddress
|
||||||
|
return 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideDatabaseRepo(): IDatabaseRepo {
|
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||||
|
|||||||
+44
-64
@@ -18,7 +18,6 @@
|
|||||||
package com.rtbishop.look4sat.core.data.repository
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
@@ -28,7 +27,9 @@ 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
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.InputStream
|
||||||
import java.util.zip.ZipInputStream
|
import java.util.zip.ZipInputStream
|
||||||
|
|
||||||
class DatabaseRepo(
|
class DatabaseRepo(
|
||||||
@@ -39,78 +40,49 @@ class DatabaseRepo(
|
|||||||
private val settingsRepo: ISettingsRepo
|
private val settingsRepo: ISettingsRepo
|
||||||
) : IDatabaseRepo {
|
) : IDatabaseRepo {
|
||||||
|
|
||||||
|
private val customSourceType = "Other"
|
||||||
|
|
||||||
override suspend fun updateTLEFromFile(uri: String) = withContext(dispatcher) {
|
override suspend fun updateTLEFromFile(uri: String) = withContext(dispatcher) {
|
||||||
val importedSatellites = remoteSource.getFileStream(uri)?.let { dataParser.parseTLEStream(it) }
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
importedSatellites?.let { localSource.insertEntries(it) }
|
val entries = dataParser.parseTLEStream(unwrapIfZipped(uri, stream))
|
||||||
|
localSource.insertEntries(entries)
|
||||||
|
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
|
||||||
|
}
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateTransceiversFromFile(uri: String) = withContext(dispatcher) {
|
override suspend fun updateTransceiversFromFile(uri: String) = withContext(dispatcher) {
|
||||||
val radios = remoteSource.getFileStream(uri)?.let { dataParser.parseJSONStream(it) }
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
radios?.let {
|
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
||||||
if(it.isNotEmpty()) {
|
localSource.insertRadios(transceivers)
|
||||||
localSource.deleteRadios()
|
|
||||||
localSource.insertRadios(it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||||
val importedEntries = mutableListOf<OrbitalData>()
|
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||||
val importedRadios = mutableListOf<SatRadio>()
|
val tleUrls = buildMap {
|
||||||
val tleUrls = Sources.satelliteDataUrls.toMutableMap().apply {
|
putAll(Sources.satelliteDataUrls)
|
||||||
if (settingsRepo.dataSourcesSettings.value.useCustomTLE) {
|
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
|
||||||
this["Other"] = settingsRepo.dataSourcesSettings.value.tleUrl
|
}.filterValues { it.isNotBlank() }
|
||||||
|
val radioUrls = buildMap {
|
||||||
|
putAll(Sources.transceiversDataUrls)
|
||||||
|
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
|
||||||
|
}.filterValues { it.isNotBlank() }
|
||||||
|
// launch all network requests concurrently
|
||||||
|
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||||
|
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||||
|
// parse fetched data concurrently and associate with types
|
||||||
|
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
||||||
|
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
||||||
|
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
||||||
|
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val jobsMap = tleUrls
|
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
||||||
.filter { (_, url) -> url.isNotEmpty() }
|
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
||||||
.mapValues { (_, url) -> async { remoteSource.getNetworkStream(url) } }
|
|
||||||
val radioUrls = buildList {
|
|
||||||
add(Sources.RADIO_DATA_URL)
|
|
||||||
if (settingsRepo.dataSourcesSettings.value.useCustomTransceivers) {
|
|
||||||
add(settingsRepo.dataSourcesSettings.value.transceiversUrl)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val jobRadios = radioUrls.associateWith { url -> async { remoteSource.getNetworkStream(url) } }
|
// insert parsed data into the database
|
||||||
// parse
|
|
||||||
jobsMap.mapValues { job -> job.value.await() }.forEach { entry ->
|
|
||||||
entry.value?.let { stream ->
|
|
||||||
when (val type = entry.key) {
|
|
||||||
"Amsat", "R4UAB", "Other" -> {
|
|
||||||
// parse tle stream
|
|
||||||
val satellites = dataParser.parseTLEStream(stream)
|
|
||||||
val catnums = satellites.map { it.catnum }
|
|
||||||
settingsRepo.setSatelliteTypeIds(type, catnums)
|
|
||||||
importedEntries.addAll(satellites)
|
|
||||||
}
|
|
||||||
|
|
||||||
"McCants", "Classified" -> {
|
|
||||||
// unzip and parse tle stream
|
|
||||||
val unzipped = ZipInputStream(stream).apply { nextEntry }
|
|
||||||
val satellites = dataParser.parseTLEStream(unzipped)
|
|
||||||
val catnums = satellites.map { it.catnum }
|
|
||||||
settingsRepo.setSatelliteTypeIds(type, catnums)
|
|
||||||
importedEntries.addAll(satellites)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
// parse csv stream
|
|
||||||
val satellites = dataParser.parseCSVStream(stream)
|
|
||||||
val catnums = satellites.map { it.catnum }
|
|
||||||
settingsRepo.setSatelliteTypeIds(type, catnums)
|
|
||||||
importedEntries.addAll(satellites)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
jobRadios.values.forEach { job ->
|
|
||||||
job.await()?.let {
|
|
||||||
importedRadios.addAll(dataParser.parseJSONStream(it))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// insert
|
|
||||||
localSource.insertEntries(importedEntries)
|
localSource.insertEntries(importedEntries)
|
||||||
localSource.insertRadios(importedRadios)
|
localSource.insertRadios(importedRadios)
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
@@ -122,9 +94,17 @@ class DatabaseRepo(
|
|||||||
setUpdateSuccessful(0L)
|
setUpdateSuccessful(0L)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun setUpdateSuccessful(timestamp: Long) = withContext(dispatcher) {
|
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> = when {
|
||||||
val numberOfRadios = localSource.getRadiosTotal()
|
url.contains("FORMAT=csv", ignoreCase = true) -> dataParser.parseCSVStream(stream)
|
||||||
val numberOfSatellites = localSource.getEntriesTotal()
|
else -> dataParser.parseTLEStream(stream)
|
||||||
settingsRepo.updateDatabaseState(DatabaseState(numberOfRadios, numberOfSatellites, timestamp))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
||||||
|
settingsRepo.updateDatabaseState(
|
||||||
|
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unwrapIfZipped(url: String, stream: InputStream): InputStream =
|
||||||
|
if (url.endsWith(".zip", ignoreCase = true)) ZipInputStream(stream).apply { nextEntry } else stream
|
||||||
}
|
}
|
||||||
+100
-95
@@ -28,6 +28,10 @@ import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
|||||||
import com.rtbishop.look4sat.core.domain.utility.round
|
import com.rtbishop.look4sat.core.domain.utility.round
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
@@ -42,17 +46,26 @@ class SatelliteRepo(
|
|||||||
private val _passes = MutableStateFlow<List<OrbitalPass>>(emptyList())
|
private val _passes = MutableStateFlow<List<OrbitalPass>>(emptyList())
|
||||||
override val passes: StateFlow<List<OrbitalPass>> = _passes
|
override val passes: StateFlow<List<OrbitalPass>> = _passes
|
||||||
|
|
||||||
|
private val _isCalculating = MutableStateFlow(false)
|
||||||
|
override val isCalculating: StateFlow<Boolean> = _isCalculating
|
||||||
|
|
||||||
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
|
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 ->
|
settingsRepo.selectedIds.collect { selectedIds ->
|
||||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||||
val (hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
|
val (_, hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
|
||||||
val timeNow = 1000 * ((System.currentTimeMillis() + 500) / 1000)
|
calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
|
||||||
calculatePasses(timeNow, hoursAhead, minElevation, modes)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +75,8 @@ class SatelliteRepo(
|
|||||||
|
|
||||||
override suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos> {
|
override suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos> {
|
||||||
return withContext(dispatcher) {
|
return withContext(dispatcher) {
|
||||||
val positions = mutableListOf<OrbitalPos>()
|
val estimatedSize = ((end - start) / 15000).toInt() + 1
|
||||||
|
val positions = ArrayList<OrbitalPos>(estimatedSize)
|
||||||
var currentTime = start
|
var currentTime = start
|
||||||
while (currentTime < end) {
|
while (currentTime < end) {
|
||||||
positions.add(sat.getPosition(pos, currentTime))
|
positions.add(sat.getPosition(pos, currentTime))
|
||||||
@@ -80,65 +94,70 @@ class SatelliteRepo(
|
|||||||
): List<SatRadio> {
|
): List<SatRadio> {
|
||||||
return withContext(dispatcher) {
|
return withContext(dispatcher) {
|
||||||
val satPos = sat.getPosition(pos, time)
|
val satPos = sat.getPosition(pos, time)
|
||||||
val copiedList = radios.map { it.copy() }
|
radios.map { transmitter ->
|
||||||
copiedList.forEach { transmitter ->
|
transmitter.copy(
|
||||||
transmitter.downlinkLow?.let { transmitter.downlinkLow = satPos.getDownlinkFreq(it) }
|
downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) },
|
||||||
transmitter.downlinkHigh?.let { transmitter.downlinkHigh = satPos.getDownlinkFreq(it) }
|
downlinkHigh = transmitter.downlinkHigh?.let { satPos.getDownlinkFreq(it) },
|
||||||
transmitter.uplinkLow?.let { transmitter.uplinkLow = satPos.getUplinkFreq(it) }
|
uplinkLow = transmitter.uplinkLow?.let { satPos.getUplinkFreq(it) },
|
||||||
transmitter.uplinkHigh?.let { transmitter.uplinkHigh = satPos.getUplinkFreq(it) }
|
uplinkHigh = transmitter.uplinkHigh?.let { satPos.getUplinkFreq(it) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
copiedList.map { it.copy() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun processPasses(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
|
|
||||||
return withContext(dispatcher) {
|
|
||||||
passList.forEach { pass ->
|
|
||||||
if (!pass.isDeepSpace) {
|
|
||||||
val timeStart = pass.aosTime
|
|
||||||
if (time > timeStart) {
|
|
||||||
val deltaNow = time.minus(timeStart).toFloat()
|
|
||||||
val deltaTotal = pass.losTime.minus(timeStart).toFloat()
|
|
||||||
pass.progress = (deltaNow / deltaTotal).round(2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
passList.filter { pass -> pass.progress < 1.0 }.map { it.copy() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>) {
|
override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>) {
|
||||||
if (_satellites.value.isNotEmpty()) {
|
_isCalculating.value = true
|
||||||
withContext(dispatcher) {
|
// Normalize to the start of the current minute so that coarse 60-second stepping
|
||||||
val newPasses = mutableListOf<OrbitalPass>()
|
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
|
||||||
val idsWithModes = localStorage.getIdsWithModes(modes)
|
val normalizedTime = time / 60_000L * 60_000L
|
||||||
_satellites.value.forEach { satellite ->
|
val currentSatellites = _satellites.value
|
||||||
if (idsWithModes.isEmpty() || satellite.data.catnum in idsWithModes) {
|
withContext(dispatcher) {
|
||||||
newPasses.addAll(satellite.getPasses(settingsRepo.stationPosition.value, time, hoursAhead))
|
val idsWithModes = localStorage.getIdsWithModes(modes)
|
||||||
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
|
val filteredSatellites = if (idsWithModes.isEmpty()) {
|
||||||
|
currentSatellites
|
||||||
|
} else {
|
||||||
|
currentSatellites.filter { it.data.catnum in idsWithModes }
|
||||||
|
}
|
||||||
|
// Compute passes for each satellite in parallel
|
||||||
|
val passLists = coroutineScope {
|
||||||
|
filteredSatellites.map { satellite ->
|
||||||
|
async { satellite.getPasses(stationPos, normalizedTime, hoursAhead) }
|
||||||
|
}.awaitAll()
|
||||||
|
}
|
||||||
|
// Flatten and filter in a single pass
|
||||||
|
val timeFuture = normalizedTime + (hoursAhead * 60L * 60L * 1000L)
|
||||||
|
val newPasses = ArrayList<OrbitalPass>()
|
||||||
|
for (list in passLists) {
|
||||||
|
for (pass in list) {
|
||||||
|
if (pass.losTime > time && pass.aosTime < timeFuture && pass.maxElevation > minElevation) {
|
||||||
|
newPasses.add(pass)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_passes.update { newPasses.filter(time, hoursAhead, minElevation) }
|
|
||||||
}
|
}
|
||||||
} else {
|
newPasses.sortBy { it.aosTime }
|
||||||
_passes.update { emptyList() }
|
delay(1000) // Simulate loading time for better UX
|
||||||
|
_passes.update { newPasses }
|
||||||
}
|
}
|
||||||
|
_isCalculating.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -149,94 +168,80 @@ class SatelliteRepo(
|
|||||||
return passes
|
return passes
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<OrbitalPass>.filter(time: Long, hoursAhead: Int, minElev: Double): List<OrbitalPass> {
|
private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, decayed: Boolean): OrbitalPass {
|
||||||
val timeFuture = time + (hoursAhead * 60L * 60L * 1000L)
|
|
||||||
return this.filter { it.losTime > time }.filter { it.aosTime < timeFuture }
|
|
||||||
.filter { it.maxElevation > minElev }.sortedBy { it.aosTime }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long): 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
|
||||||
var maxElevation = 0.0
|
var maxElevation = 0.0
|
||||||
var alt = 0.0 // var tcaAz = 0.0
|
|
||||||
// rewind 1/4 of an orbit
|
// rewind 1/4 of an orbit
|
||||||
if (rewind) calendarTimeMillis += -quarterOrbitMin * 60L * 1000L
|
if (rewind) calendarTimeMillis -= quarterOrbitMin * 60L * 1000L
|
||||||
|
|
||||||
var satPos = sat.getPosition(pos, calendarTimeMillis)
|
// Use lightweight elevation check for coarse searching
|
||||||
if (satPos.elevation > 0.0) {
|
if (sat.getElevation(pos, calendarTimeMillis) > 0.0) {
|
||||||
// move forward in 30 second intervals until the sat goes below the horizon
|
// move forward in 30 second intervals until the sat goes below the horizon
|
||||||
do {
|
do {
|
||||||
calendarTimeMillis += 30 * 1000L
|
calendarTimeMillis += 30 * 1000L
|
||||||
satPos = sat.getPosition(pos, calendarTimeMillis)
|
} while (sat.getElevation(pos, calendarTimeMillis) > 0.0)
|
||||||
} while (satPos.elevation > 0.0)
|
|
||||||
// move forward 3/4 of an orbit
|
// move forward 3/4 of an orbit
|
||||||
calendarTimeMillis += quarterOrbitMin * 3 * 60L * 1000L
|
calendarTimeMillis += quarterOrbitMin * 3 * 60L * 1000L
|
||||||
}
|
}
|
||||||
|
|
||||||
// find the next time sat comes above the horizon
|
// find the next time sat comes above the horizon (coarse: 60s steps)
|
||||||
do {
|
do {
|
||||||
calendarTimeMillis += 60L * 1000L
|
calendarTimeMillis += 60L * 1000L
|
||||||
satPos = sat.getPosition(pos, calendarTimeMillis)
|
elevation = sat.getElevation(pos, calendarTimeMillis)
|
||||||
elevation = satPos.elevation
|
if (elevation > maxElevation) maxElevation = elevation
|
||||||
if (elevation > maxElevation) {
|
} while (elevation < 0.0)
|
||||||
maxElevation = elevation
|
|
||||||
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
|
|
||||||
}
|
|
||||||
} while (satPos.elevation < 0.0)
|
|
||||||
|
|
||||||
// refine to 1 second
|
// refine AOS to ~500ms precision
|
||||||
calendarTimeMillis += -60L * 1000L
|
calendarTimeMillis -= 60L * 1000L
|
||||||
do {
|
do {
|
||||||
calendarTimeMillis += 1L * 500L
|
calendarTimeMillis += 500L
|
||||||
satPos = sat.getPosition(pos, calendarTimeMillis)
|
elevation = sat.getElevation(pos, calendarTimeMillis)
|
||||||
elevation = satPos.elevation
|
if (elevation > maxElevation) maxElevation = elevation
|
||||||
if (elevation > maxElevation) {
|
} while (elevation < 0.0)
|
||||||
maxElevation = elevation
|
|
||||||
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
|
|
||||||
}
|
|
||||||
} while (satPos.elevation < 0.0)
|
|
||||||
|
|
||||||
val aos = 1000 * ((satPos.time + 500) / 1000)
|
// Get full position for AOS data (azimuth, altitude)
|
||||||
val aosAz = satPos.azimuth.toDegrees().round(1)
|
val aosPos = sat.getFullPosition(pos, calendarTimeMillis)
|
||||||
|
val aos = 1000 * ((aosPos.time + 500) / 1000)
|
||||||
|
val aosAz = aosPos.azimuth.toDegrees().round(1)
|
||||||
|
|
||||||
// find when sat goes below
|
// find when sat goes below (coarse: 30s steps)
|
||||||
do {
|
do {
|
||||||
calendarTimeMillis += 30L * 1000L
|
calendarTimeMillis += 30L * 1000L
|
||||||
satPos = sat.getPosition(pos, calendarTimeMillis)
|
elevation = sat.getElevation(pos, calendarTimeMillis)
|
||||||
elevation = satPos.elevation
|
if (elevation > maxElevation) maxElevation = elevation
|
||||||
if (elevation > maxElevation) {
|
} while (elevation > 0.0)
|
||||||
maxElevation = elevation
|
|
||||||
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
|
|
||||||
}
|
|
||||||
} while (satPos.elevation > 0.0)
|
|
||||||
|
|
||||||
// refine to 1 second
|
// refine LOS to ~500ms precision
|
||||||
calendarTimeMillis += -30L * 1000L
|
calendarTimeMillis -= 30L * 1000L
|
||||||
do {
|
do {
|
||||||
calendarTimeMillis += 1L * 500L
|
calendarTimeMillis += 500L
|
||||||
satPos = sat.getPosition(pos, calendarTimeMillis)
|
elevation = sat.getElevation(pos, calendarTimeMillis)
|
||||||
elevation = satPos.elevation
|
if (elevation > maxElevation) maxElevation = elevation
|
||||||
if (elevation > maxElevation) {
|
} while (elevation > 0.0)
|
||||||
maxElevation = elevation
|
|
||||||
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
|
// Get full position for LOS data (azimuth, altitude)
|
||||||
}
|
val losPos = sat.getFullPosition(pos, calendarTimeMillis)
|
||||||
} while (satPos.elevation > 0.0)
|
val los = 1000 * ((losPos.time + 500) / 1000)
|
||||||
|
val losAz = losPos.azimuth.toDegrees().round(1)
|
||||||
|
|
||||||
|
// Get altitude at approximate TCA (max elevation)
|
||||||
|
val tcaTime = (aos + los) / 2
|
||||||
|
val tcaPos = sat.getFullPosition(pos, tcaTime)
|
||||||
|
val alt = tcaPos.altitude
|
||||||
|
|
||||||
val los = 1000 * ((satPos.time + 500) / 1000) // val tca = (aos + los) / 2
|
|
||||||
val losAz = satPos.azimuth.toDegrees().round(1)
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+38
-19
@@ -40,11 +40,23 @@ class SelectionRepo(
|
|||||||
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
||||||
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
|
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.
|
||||||
|
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
||||||
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
||||||
currentItems.map { items -> items.filterByTypes(types) }
|
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
||||||
|
null // null = no filtering
|
||||||
|
} else {
|
||||||
|
val ids = settingsRepo.getSatelliteTypesIds(types)
|
||||||
|
if (ids.isEmpty()) null else ids.toHashSet()
|
||||||
|
}
|
||||||
|
currentItems.map { items ->
|
||||||
|
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
||||||
itemsWithTypes.map { items -> items.filterByQuery(query) }
|
itemsWithTypes.map { items -> filterByQuery(items, query) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getCurrentTypes() = currentTypes.value
|
override fun getCurrentTypes() = currentTypes.value
|
||||||
@@ -54,7 +66,7 @@ class SelectionRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
||||||
val selectedIds = settingsRepo.selectedIds.value
|
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
|
||||||
currentItems.value = localSource.getEntriesList().map { item ->
|
currentItems.value = localSource.getEntriesList().map { item ->
|
||||||
item.copy(isSelected = item.catnum in selectedIds)
|
item.copy(isSelected = item.catnum in selectedIds)
|
||||||
}
|
}
|
||||||
@@ -66,17 +78,19 @@ class SelectionRepo(
|
|||||||
settingsRepo.setSelectedTypes(types)
|
settingsRepo.setSelectedTypes(types)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setQuery(query: String) = withContext(dispatcher) {
|
override suspend fun setQuery(query: String) {
|
||||||
currentQuery.value = query
|
currentQuery.value = query
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setSelection(selectAll: Boolean) = withContext(dispatcher) {
|
override suspend fun setSelection(selectAll: Boolean) = withContext(dispatcher) {
|
||||||
setSelection(itemsWithQuery.first().map { item -> item.catnum }, selectAll)
|
val visibleIds = itemsWithQuery.first().mapTo(HashSet()) { it.catnum }
|
||||||
|
setSelection(visibleIds, selectAll)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setSelection(ids: List<Int>, isTicked: Boolean) = withContext(dispatcher) {
|
override suspend fun setSelection(ids: List<Int>, isTicked: Boolean) = withContext(dispatcher) {
|
||||||
|
val idSet = ids.toHashSet()
|
||||||
currentItems.value = currentItems.value.map { item ->
|
currentItems.value = currentItems.value.map { item ->
|
||||||
if (item.catnum in ids) item.copy(isSelected = isTicked) else item
|
if (item.catnum in idSet) item.copy(isSelected = isTicked) else item
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,19 +99,24 @@ class SelectionRepo(
|
|||||||
settingsRepo.setSelectedIds(currentSelection)
|
settingsRepo.setSelectedIds(currentSelection)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun List<SatItem>.filterByTypes(types: List<String>) = withContext(dispatcher) {
|
/**
|
||||||
if (types.isEmpty()) return@withContext this@filterByTypes
|
* Bulk selection using a pre-built Set for O(1) lookups.
|
||||||
val catnums = settingsRepo.getSatelliteTypesIds(types)
|
*/
|
||||||
if (catnums.isEmpty()) return@withContext this@filterByTypes
|
private suspend fun setSelection(idSet: Set<Int>, isTicked: Boolean) = withContext(dispatcher) {
|
||||||
return@withContext this@filterByTypes.filter { item -> item.catnum in catnums }
|
currentItems.value = currentItems.value.map { item ->
|
||||||
}
|
if (item.catnum in idSet) item.copy(isSelected = isTicked) else item
|
||||||
|
|
||||||
private suspend fun List<SatItem>.filterByQuery(query: String) = withContext(dispatcher) {
|
|
||||||
if (query.isBlank()) return@withContext this@filterByQuery
|
|
||||||
return@withContext try {
|
|
||||||
this@filterByQuery.filter { it.catnum == query.toInt() }
|
|
||||||
} catch (_: Exception) {
|
|
||||||
this@filterByQuery.filter { item -> item.name.lowercase().contains(query.lowercase()) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
|
||||||
|
* and lowercases the query once up front instead of per-item.
|
||||||
|
*/
|
||||||
|
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
|
||||||
|
if (query.isBlank()) return items
|
||||||
|
val catnum = query.toIntOrNull()
|
||||||
|
if (catnum != null) return items.filter { it.catnum == catnum }
|
||||||
|
val lowerQuery = query.lowercase()
|
||||||
|
return items.filter { it.name.lowercase().contains(lowerQuery) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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,82 +32,101 @@ 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 orientationValues = FloatArray(3)
|
private val orientationValues = FloatArray(3)
|
||||||
private var sensorAccuracy = SensorManager.SENSOR_STATUS_UNRELIABLE
|
private var smoothAzimuth = 0f
|
||||||
|
private var smoothPitch = 0f
|
||||||
|
private var hasInitialReading = false
|
||||||
|
|
||||||
override val orientation: StateFlow<Pair<Float, Float>> = _orientation
|
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
|
||||||
|
|
||||||
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
||||||
val latitude = geoPos.latitude.toFloat()
|
return GeomagneticField(
|
||||||
val longitude = geoPos.longitude.toFloat()
|
geoPos.latitude.toFloat(),
|
||||||
return GeomagneticField(latitude, longitude, geoPos.altitude.toFloat(), time).declination
|
geoPos.longitude.toFloat(),
|
||||||
|
geoPos.altitude.toFloat(),
|
||||||
|
time
|
||||||
|
).declination
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun enableSensor() {
|
override fun enableSensor() {
|
||||||
sensor?.let { sensorManager.registerListener(this, it, 8000) }
|
hasInitialReading = false
|
||||||
|
sensor?.let { sensorManager.registerListener(this, it, SENSOR_RATE_US) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun disableSensor() = sensorManager.unregisterListener(this)
|
override fun disableSensor() = sensorManager.unregisterListener(this)
|
||||||
|
|
||||||
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
|
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
|
||||||
sensorAccuracy = accuracy
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSensorChanged(event: SensorEvent) = when {
|
override fun onSensorChanged(event: SensorEvent) {
|
||||||
sensorAccuracy == SensorManager.SENSOR_STATUS_UNRELIABLE -> {}
|
if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) handleSensorEvent(event)
|
||||||
event.sensor == sensor -> updateOrientation(event.values)
|
|
||||||
else -> {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (e: Exception) {
|
|
||||||
Surface.ROTATION_0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun transformRotationMatrix(rotationMatrix: FloatArray, rotation: Int) {
|
private fun remapForRotation(rotation: Int) {
|
||||||
val tempMatrix = FloatArray(9)
|
val remapped = when (rotation) {
|
||||||
when (rotation) {
|
Surface.ROTATION_90 -> SensorManager.remapCoordinateSystem(
|
||||||
Surface.ROTATION_0 -> {
|
rotationMatrix, SensorManager.AXIS_Y, SensorManager.AXIS_MINUS_X, tempMatrix
|
||||||
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_X, SensorManager.AXIS_Y, tempMatrix)
|
)
|
||||||
System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
|
|
||||||
}
|
Surface.ROTATION_180 -> SensorManager.remapCoordinateSystem(
|
||||||
Surface.ROTATION_90 -> {
|
rotationMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_MINUS_Y, tempMatrix
|
||||||
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_Y, SensorManager.AXIS_MINUS_X, tempMatrix)
|
)
|
||||||
SensorManager.remapCoordinateSystem(tempMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_Y, rotationMatrix)
|
|
||||||
}
|
Surface.ROTATION_270 -> SensorManager.remapCoordinateSystem(
|
||||||
Surface.ROTATION_180 -> {
|
rotationMatrix, SensorManager.AXIS_MINUS_Y, SensorManager.AXIS_X, tempMatrix
|
||||||
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_MINUS_Y, tempMatrix)
|
)
|
||||||
System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
|
|
||||||
}
|
else -> false
|
||||||
Surface.ROTATION_270 -> {
|
|
||||||
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_MINUS_Y, SensorManager.AXIS_X, tempMatrix)
|
|
||||||
SensorManager.remapCoordinateSystem(tempMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_Y, rotationMatrix)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
transformRotationMatrix(rotationMatrix, 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() // roll [2]
|
val pitch = (orientationValues[1] * RAD2DEG).toFloat()
|
||||||
val magneticAzimuth = (azimuth + 360f) % 360f
|
if (!hasInitialReading) {
|
||||||
val roundedAzimuth = round(magneticAzimuth * 10) / 10
|
smoothAzimuth = azimuth
|
||||||
val roundedPitch = round(pitch * 10) / 10
|
smoothPitch = pitch
|
||||||
_orientation.value = Pair(roundedAzimuth, roundedPitch)
|
hasInitialReading = true
|
||||||
|
} else {
|
||||||
|
smoothAzimuth = lowPassAngle(smoothAzimuth, azimuth)
|
||||||
|
smoothPitch = lowPass(smoothPitch, pitch)
|
||||||
|
}
|
||||||
|
_sensorData.value = Pair(round(smoothAzimuth * 10) / 10, round(smoothPitch * 10) / 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun lowPass(previous: Float, current: Float): Float {
|
||||||
|
return previous + SMOOTHING_FACTOR * (current - previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Low-pass filter that accounts for the 0°/360° wraparound.
|
||||||
|
* Always takes the shortest angular path between the two values.
|
||||||
|
*/
|
||||||
|
private fun lowPassAngle(previous: Float, current: Float): Float {
|
||||||
|
var delta = current - previous
|
||||||
|
while (delta > 180f) delta -= 360f
|
||||||
|
while (delta <= -180f) delta += 360f
|
||||||
|
return normalizeAzimuth(previous + SMOOTHING_FACTOR * delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeAzimuth(value: Float): Float = (value + 360f) % 360f
|
||||||
}
|
}
|
||||||
+117
-131
@@ -28,6 +28,7 @@ import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
|||||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
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.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.utility.positionToQth
|
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||||
@@ -50,6 +51,7 @@ class SettingsRepo(
|
|||||||
private val keyBluetoothFrequencyState = "bluetoothFrequencyState"
|
private val keyBluetoothFrequencyState = "bluetoothFrequencyState"
|
||||||
private val keyBluetoothFrequencyAddress = "bluetoothFrequencyAddress"
|
private val keyBluetoothFrequencyAddress = "bluetoothFrequencyAddress"
|
||||||
private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat"
|
private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat"
|
||||||
|
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
|
||||||
private val keyFilterHoursAhead = "filterHoursAhead"
|
private val keyFilterHoursAhead = "filterHoursAhead"
|
||||||
private val keyFilterMinElevation = "filterMinElevation"
|
private val keyFilterMinElevation = "filterMinElevation"
|
||||||
private val keyNumberOfRadios = "numberOfRadios"
|
private val keyNumberOfRadios = "numberOfRadios"
|
||||||
@@ -70,6 +72,7 @@ class SettingsRepo(
|
|||||||
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"
|
||||||
@@ -78,6 +81,7 @@ class SettingsRepo(
|
|||||||
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_v$appVersionName"
|
||||||
|
private val keySstvMode = "sstvMode"
|
||||||
private val keyUseCustomTle = "useCustomTle"
|
private val keyUseCustomTle = "useCustomTle"
|
||||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||||
private val keyTleUrl = "tleUrl"
|
private val keyTleUrl = "tleUrl"
|
||||||
@@ -109,8 +113,8 @@ class SettingsRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun getSelectedTypes(): List<String> {
|
private fun getSelectedTypes(): List<String> {
|
||||||
val typesString = preferences.getString(keySelectedTypes, null)
|
val typesString = preferences.getString(keySelectedTypes, "Amateur")
|
||||||
if (typesString.isNullOrEmpty()) return listOf("Amateur")
|
if (typesString.isNullOrEmpty()) return emptyList()
|
||||||
return typesString.split(separatorComma)
|
return typesString.split(separatorComma)
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
@@ -120,6 +124,7 @@ class SettingsRepo(
|
|||||||
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
|
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
|
||||||
|
|
||||||
override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
|
override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
|
||||||
|
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))
|
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
|
||||||
@@ -127,11 +132,12 @@ class SettingsRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun getPassesSettings(): PassesSettings {
|
private fun getPassesSettings(): PassesSettings {
|
||||||
|
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 selectedModesString = preferences.getString(keySelectedModes, null)
|
||||||
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
|
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
|
||||||
return PassesSettings(hoursAhead, minElevation, selectedModes)
|
return PassesSettings(showDeepSpace, hoursAhead, minElevation, selectedModes)
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -246,82 +252,51 @@ class SettingsRepo(
|
|||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # RC settings
|
//region # RC settings
|
||||||
|
init {
|
||||||
|
migrateRCFormats()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Remove after a few releases (added in v4.2.0)
|
||||||
|
private val keyRCFormatsMigrated = "rcFormatsMigrated"
|
||||||
|
|
||||||
|
private fun migrateRCFormats() {
|
||||||
|
if (preferences.getBoolean(keyRCFormatsMigrated, false)) return
|
||||||
|
val formatKeys = listOf(
|
||||||
|
keyRotatorFormat, keyFrequencyFormat, keyBluetoothRotatorFormat, keyBluetoothFrequencyFormat
|
||||||
|
)
|
||||||
|
preferences.edit {
|
||||||
|
for (key in formatKeys) {
|
||||||
|
val value = preferences.getString(key, null) ?: continue
|
||||||
|
if (value.contains("_") && !value.startsWith("\\")) {
|
||||||
|
putString(key, "\\$value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
putBoolean(keyRCFormatsMigrated, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val _rcSettings = MutableStateFlow(getRCSettings())
|
private val _rcSettings = MutableStateFlow(getRCSettings())
|
||||||
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
||||||
|
|
||||||
override fun setBluetoothRotatorAddress(value: String) {
|
override fun updateRCSettings(settings: RCSettings) {
|
||||||
preferences.edit { putString(keyBluetoothRotatorAddress, value) }
|
preferences.edit {
|
||||||
_rcSettings.update { it.copy(bluetoothRotatorAddress = value) }
|
putBoolean(keyRotatorState, settings.rotatorState)
|
||||||
}
|
putString(keyRotatorAddress, settings.rotatorAddress)
|
||||||
|
putString(keyRotatorPort, settings.rotatorPort)
|
||||||
override fun setBluetoothRotatorFormat(value: String) {
|
putString(keyRotatorFormat, settings.rotatorFormat)
|
||||||
preferences.edit { putString(keyBluetoothRotatorFormat, value) }
|
putBoolean(keyFrequencyState, settings.frequencyState)
|
||||||
_rcSettings.update { it.copy(bluetoothRotatorFormat = value) }
|
putString(keyFrequencyAddress, settings.frequencyAddress)
|
||||||
}
|
putString(keyFrequencyPort, settings.frequencyPort)
|
||||||
|
putString(keyFrequencyFormat, settings.frequencyFormat)
|
||||||
override fun setBluetoothRotatorName(value: String) {
|
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
||||||
preferences.edit { putString(keyBluetoothRotatorName, value) }
|
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
||||||
_rcSettings.update { it.copy(bluetoothRotatorName = value) }
|
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
||||||
}
|
putString(keyBluetoothRotatorAddress, settings.bluetoothRotatorAddress)
|
||||||
|
putBoolean(keyBluetoothFrequencyState, settings.bluetoothFrequencyState)
|
||||||
override fun setBluetoothRotatorState(value: Boolean) {
|
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
||||||
preferences.edit { putBoolean(keyBluetoothRotatorState, value) }
|
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
||||||
_rcSettings.update { it.copy(bluetoothRotatorState = value) }
|
}
|
||||||
}
|
_rcSettings.value = settings
|
||||||
|
|
||||||
override fun setBluetoothFrequencyState(value: Boolean) {
|
|
||||||
preferences.edit { putBoolean(keyBluetoothFrequencyState, value) }
|
|
||||||
_rcSettings.update { it.copy(bluetoothFrequencyState = value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setBluetoothFrequencyAddress(value: String) {
|
|
||||||
preferences.edit { putString(keyBluetoothFrequencyAddress, value) }
|
|
||||||
_rcSettings.update { it.copy(bluetoothFrequencyAddress = value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setBluetoothFrequencyFormat(value: String) {
|
|
||||||
preferences.edit { putString(keyBluetoothFrequencyFormat, value) }
|
|
||||||
_rcSettings.update { it.copy(bluetoothFrequencyFormat = value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setRotatorAddress(value: String) {
|
|
||||||
preferences.edit { putString(keyRotatorAddress, value) }
|
|
||||||
_rcSettings.update { it.copy(rotatorAddress = value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setRotatorPort(value: String) {
|
|
||||||
preferences.edit { putString(keyRotatorPort, value) }
|
|
||||||
_rcSettings.update { it.copy(rotatorPort = value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setRotatorState(value: Boolean) {
|
|
||||||
preferences.edit { putBoolean(keyRotatorState, value) }
|
|
||||||
_rcSettings.update { it.copy(rotatorState = value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setRotatorFormat(value: String) {
|
|
||||||
preferences.edit { putString(keyRotatorFormat, value) }
|
|
||||||
_rcSettings.update { it.copy(rotatorFormat = value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setFrequencyState(value: Boolean) {
|
|
||||||
preferences.edit { putBoolean(keyFrequencyState, value) }
|
|
||||||
_rcSettings.update { it.copy(frequencyState = value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setFrequencyAddress(value: String) {
|
|
||||||
preferences.edit { putString(keyFrequencyAddress, value) }
|
|
||||||
_rcSettings.update { it.copy(frequencyAddress = value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setFrequencyPort(value: String) {
|
|
||||||
preferences.edit { putString(keyFrequencyPort, value) }
|
|
||||||
_rcSettings.update { it.copy(frequencyPort = value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setFrequencyFormat(value: String) {
|
|
||||||
preferences.edit { putString(keyFrequencyFormat, value) }
|
|
||||||
_rcSettings.update { it.copy(frequencyFormat = value) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRCSettings(): RCSettings = RCSettings(
|
private fun getRCSettings(): RCSettings = RCSettings(
|
||||||
@@ -332,14 +307,14 @@ class SettingsRepo(
|
|||||||
frequencyState = preferences.getBoolean(keyFrequencyState, false),
|
frequencyState = preferences.getBoolean(keyFrequencyState, false),
|
||||||
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) ?: $$"set_freq $FREQ",
|
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
|
||||||
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
|
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
|
||||||
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"W$AZ $EL",
|
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
||||||
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
||||||
bluetoothRotatorAddress = preferences.getString(keyBluetoothRotatorAddress, null) ?: "00:0C:BF:13:80:5D",
|
bluetoothRotatorAddress = preferences.getString(keyBluetoothRotatorAddress, null) ?: "00:0C:BF:13:80:5D",
|
||||||
bluetoothFrequencyState = preferences.getBoolean(keyBluetoothFrequencyState, false),
|
bluetoothFrequencyState = preferences.getBoolean(keyBluetoothFrequencyState, false),
|
||||||
bluetoothFrequencyAddress = preferences.getString(keyBluetoothFrequencyAddress, null) ?: "00:0C:BF:13:80:5D",
|
bluetoothFrequencyAddress = preferences.getString(keyBluetoothFrequencyAddress, null) ?: "00:0C:BF:13:80:5D",
|
||||||
bluetoothFrequencyFormat = preferences.getString(keyBluetoothFrequencyFormat, null) ?: $$"FA$FREQ"
|
bluetoothFrequencyFormat = preferences.getString(keyBluetoothFrequencyFormat, null) ?: $$"F $FREQ"
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -347,39 +322,22 @@ class SettingsRepo(
|
|||||||
private val _otherSettings = MutableStateFlow(getOtherSettings())
|
private val _otherSettings = MutableStateFlow(getOtherSettings())
|
||||||
override val otherSettings: StateFlow<OtherSettings> = _otherSettings
|
override val otherSettings: StateFlow<OtherSettings> = _otherSettings
|
||||||
|
|
||||||
override fun setStateOfAutoUpdate(value: Boolean) {
|
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) {
|
||||||
preferences.edit { putBoolean(keyStateOfAutoUpdate, value) }
|
_otherSettings.update { current ->
|
||||||
_otherSettings.update { it.copy(stateOfAutoUpdate = value) }
|
val new = transform(current)
|
||||||
}
|
preferences.edit {
|
||||||
|
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
|
||||||
override fun setStateOfSensors(value: Boolean) {
|
putBoolean(keyStateOfSensors, new.stateOfSensors)
|
||||||
preferences.edit { putBoolean(keyStateOfSensors, value) }
|
putBoolean(keyStateOfSweep, new.stateOfSweep)
|
||||||
_otherSettings.update { it.copy(stateOfSensors = value) }
|
putBoolean(keyStateOfUtc, new.stateOfUtc)
|
||||||
}
|
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
|
||||||
|
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
|
||||||
override fun setStateOfSweep(value: Boolean) {
|
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
||||||
preferences.edit { putBoolean(keyStateOfSweep, value) }
|
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
||||||
_otherSettings.update { it.copy(stateOfSweep = value) }
|
putString(keySstvMode, new.sstvMode)
|
||||||
}
|
}
|
||||||
|
new
|
||||||
override fun setStateOfUtc(value: Boolean) {
|
}
|
||||||
preferences.edit { putBoolean(keyStateOfUtc, value) }
|
|
||||||
_otherSettings.update { it.copy(stateOfUtc = value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setStateOfLightTheme(value: Boolean){
|
|
||||||
preferences.edit { putBoolean(keyStateOfLightTheme, value) }
|
|
||||||
_otherSettings.update { it.copy(stateOfLightTheme = value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setWarningDismissed() {
|
|
||||||
preferences.edit { putBoolean(keyShouldSeeWarning, false) }
|
|
||||||
_otherSettings.update { it.copy(shouldSeeWarning = false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setWhatsNewDismissed() {
|
|
||||||
preferences.edit { putBoolean(keyShouldSeeWhatsNew, false) }
|
|
||||||
_otherSettings.update { it.copy(shouldSeeWhatsNew = false) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getOtherSettings(): OtherSettings = OtherSettings(
|
private fun getOtherSettings(): OtherSettings = OtherSettings(
|
||||||
@@ -388,8 +346,10 @@ 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"
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -397,24 +357,14 @@ class SettingsRepo(
|
|||||||
private val _dataSourcesSettings = MutableStateFlow(getDataSourcesSettings())
|
private val _dataSourcesSettings = MutableStateFlow(getDataSourcesSettings())
|
||||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
|
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
|
||||||
|
|
||||||
override fun setUseCustomTle(value: Boolean) {
|
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
|
||||||
preferences.edit { putBoolean(keyUseCustomTle, value) }
|
preferences.edit {
|
||||||
_dataSourcesSettings.update { it.copy(useCustomTLE = value) }
|
putBoolean(keyUseCustomTle, settings.useCustomTLE)
|
||||||
}
|
putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers)
|
||||||
|
putString(keyTleUrl, settings.tleUrl)
|
||||||
override fun setUseCustomTransceivers(value: Boolean) {
|
putString(keyTransceiversUrl, settings.transceiversUrl)
|
||||||
preferences.edit { putBoolean(keyUseCustomTransceivers, value) }
|
}
|
||||||
_dataSourcesSettings.update { it.copy(useCustomTransceivers = value) }
|
_dataSourcesSettings.value = settings
|
||||||
}
|
|
||||||
|
|
||||||
override fun setTleUrl(value: String) {
|
|
||||||
preferences.edit { putString(keyTleUrl, value) }
|
|
||||||
_dataSourcesSettings.update { it.copy(tleUrl = value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setTransceiversUrl(value: String) {
|
|
||||||
preferences.edit { putString(keyTransceiversUrl, value) }
|
|
||||||
_dataSourcesSettings.update { it.copy(transceiversUrl = value) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
||||||
@@ -424,4 +374,40 @@ class SettingsRepo(
|
|||||||
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
|
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
//region # Radio control settings
|
||||||
|
private val keyRadioControlEnabled = "radioControlEnabled"
|
||||||
|
private val keyRadioModel = "radioModel"
|
||||||
|
private val keyTxRadioAddress = "txRadioAddress"
|
||||||
|
private val keyRxRadioAddress = "rxRadioAddress"
|
||||||
|
private val keyTxRadioName = "txRadioName"
|
||||||
|
private val keyRxRadioName = "rxRadioName"
|
||||||
|
private val keyRadioBaudRate = "radioBaudRate"
|
||||||
|
|
||||||
|
private val _radioControlSettings = MutableStateFlow(getRadioControlSettings())
|
||||||
|
override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings
|
||||||
|
|
||||||
|
override fun updateRadioControlSettings(settings: RadioControlSettings) {
|
||||||
|
preferences.edit {
|
||||||
|
putBoolean(keyRadioControlEnabled, settings.enabled)
|
||||||
|
putString(keyRadioModel, settings.radioModel)
|
||||||
|
putString(keyTxRadioAddress, settings.txRadioAddress)
|
||||||
|
putString(keyRxRadioAddress, settings.rxRadioAddress)
|
||||||
|
putString(keyTxRadioName, settings.txRadioName)
|
||||||
|
putString(keyRxRadioName, settings.rxRadioName)
|
||||||
|
putInt(keyRadioBaudRate, settings.baudRate)
|
||||||
|
}
|
||||||
|
_radioControlSettings.value = settings
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings(
|
||||||
|
enabled = preferences.getBoolean(keyRadioControlEnabled, false),
|
||||||
|
radioModel = preferences.getString(keyRadioModel, null) ?: "Yaesu FT-817/818",
|
||||||
|
txRadioAddress = preferences.getString(keyTxRadioAddress, null) ?: "",
|
||||||
|
rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "",
|
||||||
|
txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio",
|
||||||
|
rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio",
|
||||||
|
baudRate = preferences.getInt(keyRadioBaudRate, 4800)
|
||||||
|
)
|
||||||
|
//endregion
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
override suspend fun insertRadios(radios: List<SatRadio>) {
|
||||||
look4SatDao.deleteRadios()
|
|
||||||
look4SatDao.insertRadios(radios.toFrameworkRadios())
|
look4SatDao.insertRadios(radios.toFrameworkRadios())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import kotlinx.coroutines.CoroutineDispatcher
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
class RemoteSource(
|
class RemoteSource(
|
||||||
@@ -45,7 +46,10 @@ class RemoteSource(
|
|||||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
||||||
try {
|
try {
|
||||||
val networkRequest = Request.Builder().url(url).build()
|
val networkRequest = Request.Builder().url(url).build()
|
||||||
httpClient.newCall(networkRequest).execute().body.byteStream()
|
httpClient.newCall(networkRequest).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@withContext null
|
||||||
|
ByteArrayInputStream(response.body.bytes())
|
||||||
|
}
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
println("RemoteSource network stream exception: $exception")
|
println("RemoteSource network stream 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.data.framework.Ft817CatProtocol
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.test.assertContentEquals
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
class Ft817CatProtocolTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeFrequencyBcd_145500000() {
|
||||||
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(145500000L)
|
||||||
|
assertContentEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00), bcd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeFrequencyBcd_435100000() {
|
||||||
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(435100000L)
|
||||||
|
assertContentEquals(byteArrayOf(0x43, 0x51, 0x00, 0x00), bcd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeFrequencyBcd_7074000() {
|
||||||
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(7074000L)
|
||||||
|
assertContentEquals(byteArrayOf(0x00, 0x70, 0x74, 0x00), bcd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decodeFrequencyBcd_roundTrips() {
|
||||||
|
val testFreqs = listOf(145500000L, 435100000L, 7074000L, 14200000L, 28500000L)
|
||||||
|
for (freq in testFreqs) {
|
||||||
|
val rounded = (freq / 10) * 10
|
||||||
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(freq)
|
||||||
|
assertEquals(rounded, Ft817CatProtocol.decodeFrequencyBcd(bcd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildSetFreqCommand_correctFormat() {
|
||||||
|
val cmd = Ft817CatProtocol.buildSetFreqCommand(145500000L)
|
||||||
|
assertEquals(5, cmd.size)
|
||||||
|
assertEquals(0x01.toByte(), cmd[4])
|
||||||
|
assertContentEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01), cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildSetModeCommand_usb() {
|
||||||
|
val cmd = Ft817CatProtocol.buildSetModeCommand("USB")
|
||||||
|
assertNotNull(cmd)
|
||||||
|
assertContentEquals(byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x07), cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildSetModeCommand_fm() {
|
||||||
|
val cmd = Ft817CatProtocol.buildSetModeCommand("FM")
|
||||||
|
assertNotNull(cmd)
|
||||||
|
assertContentEquals(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x07), cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildSetModeCommand_unknownReturnsNull() {
|
||||||
|
assertNull(Ft817CatProtocol.buildSetModeCommand("INVALID"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeCtcssTone_67_0() {
|
||||||
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(67.0)
|
||||||
|
assertContentEquals(byteArrayOf(0x06, 0x70), bcd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeCtcssTone_74_4() {
|
||||||
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(74.4)
|
||||||
|
assertContentEquals(byteArrayOf(0x07, 0x44), bcd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeCtcssTone_141_3() {
|
||||||
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(141.3)
|
||||||
|
assertContentEquals(byteArrayOf(0x14, 0x13), bcd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildSetCtcssToneCommand_correctFormat() {
|
||||||
|
val cmd = Ft817CatProtocol.buildSetCtcssToneCommand(67.0)
|
||||||
|
assertEquals(5, cmd.size)
|
||||||
|
assertEquals(0x0B.toByte(), cmd[4])
|
||||||
|
assertContentEquals(byteArrayOf(0x06, 0x70, 0x00, 0x00, 0x0B), cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildCtcssModeCommand_enable() {
|
||||||
|
val cmd = Ft817CatProtocol.buildCtcssModeCommand(true)
|
||||||
|
assertContentEquals(byteArrayOf(0x2A, 0x00, 0x00, 0x00, 0x0A), cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildCtcssModeCommand_disable() {
|
||||||
|
val cmd = Ft817CatProtocol.buildCtcssModeCommand(false)
|
||||||
|
assertEquals(0x8A.toByte(), cmd[0])
|
||||||
|
assertEquals(0x0A.toByte(), cmd[4])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseReadResponse_validResponse() {
|
||||||
|
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01)
|
||||||
|
val result = Ft817CatProtocol.parseReadResponse(response)
|
||||||
|
assertNotNull(result)
|
||||||
|
assertEquals(145500000L, result.first)
|
||||||
|
assertEquals("USB", result.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseReadResponse_fmMode() {
|
||||||
|
val response = byteArrayOf(0x14, 0x60, 0x00, 0x00, 0x08)
|
||||||
|
val result = Ft817CatProtocol.parseReadResponse(response)
|
||||||
|
assertNotNull(result)
|
||||||
|
assertEquals(146000000L, result.first)
|
||||||
|
assertEquals("FM", result.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseReadResponse_tooShort() {
|
||||||
|
assertNull(Ft817CatProtocol.parseReadResponse(byteArrayOf(0x14, 0x55, 0x00)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseReadResponse_unknownMode() {
|
||||||
|
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x0F)
|
||||||
|
assertNull(Ft817CatProtocol.parseReadResponse(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildPttCommands() {
|
||||||
|
val on = Ft817CatProtocol.buildPttOnCommand()
|
||||||
|
assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x08), on)
|
||||||
|
|
||||||
|
val off = Ft817CatProtocol.buildPttOffCommand()
|
||||||
|
assertEquals(0x88.toByte(), off[4])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildReadCommand() {
|
||||||
|
val cmd = Ft817CatProtocol.buildReadFreqModeCommand()
|
||||||
|
assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x03), cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.convention.kotlinLibraryPlugin)
|
alias(libs.plugins.convention.coreDomainPlugin)
|
||||||
}
|
}
|
||||||
@@ -17,16 +17,20 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.domain.model
|
package com.rtbishop.look4sat.core.domain.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class SatRadio(
|
data class SatRadio(
|
||||||
val uuid: String,
|
@SerialName("uuid") val uuid: String,
|
||||||
val info: String,
|
@SerialName("description") val info: String,
|
||||||
val isAlive: Boolean,
|
@SerialName("alive") val isAlive: Boolean,
|
||||||
var downlinkLow: Long?,
|
@SerialName("downlink_low") val downlinkLow: Long?,
|
||||||
var downlinkHigh: Long?,
|
@SerialName("downlink_high") val downlinkHigh: Long?,
|
||||||
val downlinkMode: String?,
|
@SerialName("mode") val downlinkMode: String?,
|
||||||
var uplinkLow: Long?,
|
@SerialName("uplink_low") val uplinkLow: Long?,
|
||||||
var uplinkHigh: Long?,
|
@SerialName("uplink_high") val uplinkHigh: Long?,
|
||||||
val uplinkMode: String?,
|
@SerialName("uplink_mode") val uplinkMode: String?,
|
||||||
val isInverted: Boolean,
|
@SerialName("invert") val isInverted: Boolean,
|
||||||
val catnum: Int?
|
@SerialName("norad_cat_id") val catnum: Int?
|
||||||
)
|
)
|
||||||
@@ -24,6 +24,7 @@ data class DatabaseState(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class PassesSettings(
|
data class PassesSettings(
|
||||||
|
val showDeepSpace: Boolean = true,
|
||||||
val hoursAhead: Int,
|
val hoursAhead: Int,
|
||||||
val minElevation: Double,
|
val minElevation: Double,
|
||||||
val selectedModes: List<String>
|
val selectedModes: List<String>
|
||||||
@@ -53,8 +54,10 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DataSourcesSettings(
|
data class DataSourcesSettings(
|
||||||
@@ -63,3 +66,20 @@ data class DataSourcesSettings(
|
|||||||
val tleUrl: String,
|
val tleUrl: String,
|
||||||
val transceiversUrl: String
|
val transceiversUrl: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class RadioControlSettings(
|
||||||
|
val enabled: Boolean,
|
||||||
|
val radioModel: String,
|
||||||
|
val txRadioAddress: String,
|
||||||
|
val rxRadioAddress: String,
|
||||||
|
val txRadioName: String,
|
||||||
|
val rxRadioName: String,
|
||||||
|
val baudRate: Int
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val SUPPORTED_RADIOS = listOf(
|
||||||
|
"Yaesu FT-817/818",
|
||||||
|
"Yaesu FT-857/897"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+667
@@ -0,0 +1,667 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.predict
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.toRadians
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.acos
|
||||||
|
import kotlin.math.asin
|
||||||
|
import kotlin.math.atan
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.log10
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
import kotlin.math.tan
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone celestial computations extracted from PREDICT v2.2.5.
|
||||||
|
* Provides Sun position, Moon position, satellite visibility classification,
|
||||||
|
* orbital metadata, RA/Dec conversion, and rise/set finding for Sun and Moon.
|
||||||
|
*
|
||||||
|
* All angles are in degrees unless noted. Time is Unix epoch milliseconds.
|
||||||
|
*
|
||||||
|
* Shared math utilities (thetaGJD, modulus, mod2PI, deltaET, millisToDaynum,
|
||||||
|
* solarPositionECI, eciToGeodetic) live in OrbitalMath.kt in the same package.
|
||||||
|
*/
|
||||||
|
object CelestialComputer {
|
||||||
|
|
||||||
|
// ── Result types ──
|
||||||
|
|
||||||
|
/** Sun position as seen from a ground observer. */
|
||||||
|
data class SunPosition(
|
||||||
|
val azimuth: Double, // degrees, 0=N, 90=E
|
||||||
|
val elevation: Double, // degrees, >0 above horizon
|
||||||
|
val distance: Double, // normalized: 1.0 + ((range - AU) / AU)
|
||||||
|
val rangeRate: Double, // km/s
|
||||||
|
val latitude: Double, // sub-solar point latitude, degrees
|
||||||
|
val longitude: Double, // sub-solar point longitude, degrees
|
||||||
|
val rightAscension: Double, // degrees
|
||||||
|
val declination: Double // degrees
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Moon position as seen from a ground observer. */
|
||||||
|
data class MoonPosition(
|
||||||
|
val azimuth: Double, // degrees, 0=N, 90=E
|
||||||
|
val elevation: Double, // degrees
|
||||||
|
val rightAscension: Double, // degrees
|
||||||
|
val declination: Double, // degrees
|
||||||
|
val gha: Double, // Greenwich Hour Angle, degrees
|
||||||
|
val angularDiameter: Double, // apparent diameter relative to Earth's diameter
|
||||||
|
val radialVelocity: Double // m/s, Doppler radial velocity for EME
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3-state satellite visibility classification.
|
||||||
|
* - [VISIBLE]: satellite is sunlit, observer is in darkness (sun below -12°) — optically visible
|
||||||
|
* - [DAYLIGHT]: satellite is sunlit, observer is in daylight
|
||||||
|
* - [ECLIPSED]: satellite is in Earth's shadow
|
||||||
|
*/
|
||||||
|
enum class SatVisibility { VISIBLE, DAYLIGHT, ECLIPSED }
|
||||||
|
|
||||||
|
/** Orbital metadata not typically included in pass data. */
|
||||||
|
data class OrbitalMetadata(
|
||||||
|
val footprintDiameter: Double, // km, ground coverage circle diameter
|
||||||
|
val orbitNumber: Long, // current orbit/revolution number
|
||||||
|
val betaAngle: Double, // degrees, angle between orbital plane and Sun
|
||||||
|
val orbitalPhase: Double // 0-256 phase within current orbit
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Sun position ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the Sun's full position as seen from [observer] at [timeMillis].
|
||||||
|
* Includes az/el, RA/Dec, sub-solar lat/lon, range, and range rate.
|
||||||
|
* Based on FindSun() from PREDICT v2.2.5.
|
||||||
|
*/
|
||||||
|
fun getSunPosition(observer: GeoPos, timeMillis: Long): SunPosition {
|
||||||
|
val daynum = millisToDaynum(timeMillis)
|
||||||
|
val julUtc = daynum + 2444238.5
|
||||||
|
val sunVec = solarPositionECI(julUtc)
|
||||||
|
val zeroVel = doubleArrayOf(0.0, 0.0, 0.0)
|
||||||
|
val obsGeo = observerGeodetic(observer)
|
||||||
|
|
||||||
|
// Az, El, Range, RangeRate
|
||||||
|
val obsSet = computeObsAngles(julUtc, sunVec, zeroVel, obsGeo)
|
||||||
|
|
||||||
|
// Lat/Lon of sub-solar point
|
||||||
|
val latLon = eciToGeodetic(julUtc, sunVec)
|
||||||
|
|
||||||
|
// RA/Dec
|
||||||
|
val raDec = calculateRADec(julUtc, sunVec, zeroVel, obsGeo)
|
||||||
|
|
||||||
|
return SunPosition(
|
||||||
|
azimuth = obsSet[0].toDegrees(),
|
||||||
|
elevation = obsSet[1].toDegrees(),
|
||||||
|
distance = 1.0 + ((obsSet[2] - ASTRONOMICAL_UNIT) / ASTRONOMICAL_UNIT),
|
||||||
|
rangeRate = 1000.0 * obsSet[3],
|
||||||
|
latitude = latLon[0].toDegrees(),
|
||||||
|
longitude = latLon[1].toDegrees().let { if (it > 180.0) it - 360.0 else it },
|
||||||
|
rightAscension = raDec[0].toDegrees(),
|
||||||
|
declination = raDec[1].toDegrees()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Moon position ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the Moon's position as seen from [observer] at [timeMillis].
|
||||||
|
* Full Meeus lunar ephemeris from PREDICT v2.2.5 with expanded terms
|
||||||
|
* and radial velocity approximation for EME Doppler.
|
||||||
|
*/
|
||||||
|
fun getMoonPosition(observer: GeoPos, timeMillis: Long): MoonPosition {
|
||||||
|
val daynum = millisToDaynum(timeMillis)
|
||||||
|
val jd = daynum + 2444238.5
|
||||||
|
var t = (jd - 2415020.0) / 36525.0
|
||||||
|
val t2 = t * t
|
||||||
|
val t3 = t2 * t
|
||||||
|
|
||||||
|
var l1 = 270.434164 + 481267.8831 * t - 0.001133 * t2 + 0.0000019 * t3
|
||||||
|
var mSun = 358.475833 + 35999.0498 * t - 0.00015 * t2 - 0.0000033 * t3
|
||||||
|
var m1 = 296.104608 + 477198.8491 * t + 0.009192 * t2 + 0.0000144 * t3
|
||||||
|
var d = 350.737486 + 445267.1142 * t - 0.001436 * t2 + 0.0000019 * t3
|
||||||
|
var ff = 11.250889 + 483202.0251 * t - 0.003211 * t2 - 0.0000003 * t3
|
||||||
|
val om = (259.183275 - 1934.142 * t + 0.002078 * t2 + 0.0000022 * t3) * DEG2RAD
|
||||||
|
|
||||||
|
val correction512 = sin((51.2 + 20.2 * t) * DEG2RAD)
|
||||||
|
val ss = 0.003964 * sin((346.56 + 132.87 * t - 0.0091731 * t2) * DEG2RAD)
|
||||||
|
l1 += 0.000233 * correction512 + ss + 0.001964 * sin(om)
|
||||||
|
mSun -= 0.001778 * correction512
|
||||||
|
m1 += 0.000817 * correction512 + ss + 0.002541 * sin(om)
|
||||||
|
d += 0.002011 * correction512 + ss + 0.001964 * sin(om)
|
||||||
|
ff += ss - 0.024691 * sin(om) - 0.004328 * sin(om + (275.05 - 2.3 * t) * DEG2RAD)
|
||||||
|
|
||||||
|
val ex = 1.0 - 0.002495 * t - 0.00000752 * t2
|
||||||
|
l1 = primeAngle(l1); mSun = primeAngle(mSun); m1 = primeAngle(m1)
|
||||||
|
d = primeAngle(d); ff = primeAngle(ff)
|
||||||
|
|
||||||
|
val mR = mSun * DEG2RAD
|
||||||
|
val m1R = m1 * DEG2RAD
|
||||||
|
val dR = d * DEG2RAD
|
||||||
|
val ffR = ff * DEG2RAD
|
||||||
|
|
||||||
|
// Ecliptic longitude — expanded v225 terms
|
||||||
|
var l = l1 + 6.28875 * sin(m1R) + 1.274018 * sin(2 * dR - m1R) + 0.658309 * sin(2 * dR)
|
||||||
|
l += 0.213616 * sin(2 * m1R) - ex * 0.185596 * sin(mR) - 0.114336 * sin(2 * ffR)
|
||||||
|
l += 0.058793 * sin(2 * dR - 2 * m1R) + ex * 0.057212 * sin(2 * dR - mR - m1R) + 0.05332 * sin(2 * dR + m1R)
|
||||||
|
l += ex * 0.045874 * sin(2 * dR - mR) + ex * 0.041024 * sin(m1R - mR) - 0.034718 * sin(dR)
|
||||||
|
l -= ex * 0.030465 * sin(mR + m1R) + 0.015326 * sin(2 * dR - 2 * ffR) - 0.012528 * sin(2 * ffR + m1R)
|
||||||
|
l -= 0.01098 * sin(2 * ffR - m1R) + 0.010674 * sin(4 * dR - m1R) + 0.010034 * sin(3 * m1R)
|
||||||
|
l += 0.008548 * sin(4 * dR - 2 * m1R) - ex * 0.00791 * sin(mR - m1R + 2 * dR)
|
||||||
|
l -= ex * 0.006783 * sin(2 * dR + mR)
|
||||||
|
l += 0.005162 * sin(m1R - dR) + ex * 0.005 * sin(mR + dR) + ex * 0.004049 * sin(m1R - mR + 2 * dR)
|
||||||
|
l += 0.003996 * sin(2 * m1R + 2 * dR) + 0.003862 * sin(4 * dR) + 0.003665 * sin(2 * dR - 3 * m1R)
|
||||||
|
l += ex * 0.002695 * sin(2 * m1R - mR) + 0.002602 * sin(m1R - 2 * ffR - 2 * dR)
|
||||||
|
l += ex * 0.002396 * sin(2 * dR - mR - 2 * m1R)
|
||||||
|
l -= 0.002349 * sin(m1R + dR) + ex * ex * 0.002249 * sin(2 * dR - 2 * mR)
|
||||||
|
l -= ex * 0.002125 * sin(2 * m1R + mR)
|
||||||
|
l -= ex * ex * 0.002079 * sin(2 * mR) + ex * ex * 0.002059 * sin(2 * dR - m1R - 2 * mR)
|
||||||
|
l -= 0.001773 * sin(m1R + 2 * dR - 2 * ffR)
|
||||||
|
l += ex * 0.00122 * sin(4 * dR - mR - m1R) - 0.00111 * sin(2 * m1R + 2 * ffR) + 0.000892 * sin(m1R - 3 * dR)
|
||||||
|
l -= ex * 0.000811 * sin(mR + m1R + 2 * dR) + ex * 0.000761 * sin(4 * dR - mR - 2 * m1R)
|
||||||
|
l += ex * ex * 0.000717 * sin(m1R - 2 * mR)
|
||||||
|
l += ex * ex * 0.000704 * sin(m1R - 2 * mR - 2 * dR) + ex * 0.000693 * sin(mR - 2 * m1R + 2 * dR)
|
||||||
|
l += ex * 0.000598 * sin(2 * dR - mR - 2 * ffR) + 0.00055 * sin(m1R + 4 * dR)
|
||||||
|
l += 0.000538 * sin(4 * m1R) + ex * 0.000521 * sin(4 * dR - mR) + 0.000486 * sin(2 * m1R - dR)
|
||||||
|
l -= 0.001595 * sin(2 * ffR + 2 * dR)
|
||||||
|
|
||||||
|
// Ecliptic latitude — expanded v225 terms
|
||||||
|
var b =
|
||||||
|
5.128189 * sin(ffR) + 0.280606 * sin(m1R + ffR) + 0.277693 * sin(m1R - ffR) + 0.173238 * sin(2 * dR - ffR)
|
||||||
|
b += 0.055413 * sin(2 * dR + ffR - m1R) + 0.046272 * sin(2 * dR - ffR - m1R) + 0.032573 * sin(2 * dR + ffR)
|
||||||
|
b += 0.017198 * sin(2 * m1R + ffR) + 9.266999e-03 * sin(2 * dR + m1R - ffR) + 0.008823 * sin(2 * m1R - ffR)
|
||||||
|
b += ex * 0.008247 * sin(2 * dR - mR - ffR) + 0.004323 * sin(2 * dR - ffR - 2 * m1R)
|
||||||
|
b += 0.0042 * sin(2 * dR + ffR + m1R)
|
||||||
|
b += ex * 0.003372 * sin(ffR - mR - 2 * dR) + ex * 0.002472 * sin(2 * dR + ffR - mR - m1R)
|
||||||
|
b += ex * 0.002222 * sin(2 * dR + ffR - mR)
|
||||||
|
b += 0.002072 * sin(2 * dR - ffR - mR - m1R) + ex * 0.001877 * sin(ffR - mR + m1R)
|
||||||
|
b += 0.001828 * sin(4 * dR - ffR - m1R)
|
||||||
|
b -= ex * 0.001803 * sin(ffR + mR) - 0.00175 * sin(3 * ffR)
|
||||||
|
b += ex * 0.00157 * sin(m1R - mR - ffR) - 0.001487 * sin(ffR + dR)
|
||||||
|
b -= ex * 0.001481 * sin(ffR + mR + m1R) + ex * 0.001417 * sin(ffR - mR - m1R)
|
||||||
|
b += ex * 0.00135 * sin(ffR - mR) + 0.00133 * sin(ffR - dR)
|
||||||
|
b += 0.001106 * sin(ffR + 3 * m1R) + 0.00102 * sin(4 * dR - ffR) + 0.000833 * sin(ffR + 4 * dR - m1R)
|
||||||
|
b += 0.000781 * sin(m1R - 3 * ffR) + 0.00067 * sin(ffR + 4 * dR - 2 * m1R)
|
||||||
|
b += 0.000606 * sin(2 * dR - 3 * ffR)
|
||||||
|
b += 0.000597 * sin(2 * dR + 2 * m1R - ffR) + ex * 0.000492 * sin(2 * dR + m1R - mR - ffR)
|
||||||
|
b += 0.00045 * sin(2 * m1R - ffR - 2 * dR)
|
||||||
|
b += 0.000439 * sin(3 * m1R - ffR) + 0.000423 * sin(ffR + 2 * dR + 2 * m1R)
|
||||||
|
b += 0.000422 * sin(2 * dR - ffR - 3 * m1R)
|
||||||
|
b -= ex * 0.000367 * sin(mR + ffR + 2 * dR - m1R) - ex * 0.000353 * sin(mR + ffR + 2 * dR)
|
||||||
|
b += 0.000331 * sin(ffR + 4 * dR)
|
||||||
|
b += ex * 0.000317 * sin(2 * dR + ffR - mR + m1R) + ex * ex * 0.000306 * sin(2 * dR - 2 * mR - ffR)
|
||||||
|
b -= 0.000283 * sin(m1R + 3 * ffR)
|
||||||
|
|
||||||
|
val w1 = 0.0004664 * cos(om)
|
||||||
|
val w2 = 0.0000754 * cos(om + (275.05 - 2.3 * t) * DEG2RAD)
|
||||||
|
val bt = b * (1.0 - w1 - w2)
|
||||||
|
|
||||||
|
// Parallax — expanded v225 terms
|
||||||
|
var p =
|
||||||
|
0.950724 + 0.051818 * cos(m1R) + 0.009531 * cos(2 * dR - m1R) + 0.007843 * cos(2 * dR) + 0.002824 * cos(2 * m1R)
|
||||||
|
p += 0.000857 * cos(2 * dR + m1R) + ex * 0.000533 * cos(2 * dR - mR) + ex * 0.000401 * cos(2 * dR - mR - m1R)
|
||||||
|
p += 0.000173 * cos(3 * m1R) + 0.000167 * cos(4 * dR - m1R) - ex * 0.000111 * cos(mR)
|
||||||
|
p += 0.000103 * cos(4 * dR - 2 * m1R) - 0.000084 * cos(2 * m1R - 2 * dR) - ex * 0.000083 * cos(2 * dR + mR)
|
||||||
|
p += 0.000079 * cos(2 * dR + 2 * m1R)
|
||||||
|
p += 0.000072 * cos(4 * dR) + ex * 0.000064 * cos(2 * dR - mR + m1R) - ex * 0.000063 * cos(2 * dR + mR - m1R)
|
||||||
|
p += ex * 0.000041 * cos(mR + dR) + ex * 0.000035 * cos(2 * m1R - mR) - 0.000033 * cos(3 * m1R - 2 * dR)
|
||||||
|
p -= 0.00003 * cos(m1R + dR) - 0.000029 * cos(2 * ffR - 2 * dR) - ex * 0.000029 * cos(2 * m1R + mR)
|
||||||
|
p += ex * ex * 0.000026 * cos(2 * dR - 2 * mR) - 0.000023 * cos(2 * ffR - 2 * dR + m1R)
|
||||||
|
p += ex * 0.000019 * cos(4 * dR - mR - m1R)
|
||||||
|
|
||||||
|
val bRad = bt * DEG2RAD
|
||||||
|
val lm = l * DEG2RAD
|
||||||
|
val moonDx = 3.0 / (PI * p)
|
||||||
|
|
||||||
|
// Ecliptic → equatorial
|
||||||
|
val z = (jd - 2415020.5) / 365.2422
|
||||||
|
val ob = (23.452294 - (0.46845 * z + 5.9e-07 * z * z) / 3600.0).toRadians()
|
||||||
|
val dec = asin(sin(bRad) * cos(ob) + cos(bRad) * sin(ob) * sin(lm))
|
||||||
|
var ra = acos(cos(bRad) * cos(lm) / cos(dec)); if (lm > PI) ra = TWO_PI - ra
|
||||||
|
|
||||||
|
val n = observer.latitude * DEG2RAD
|
||||||
|
t = (jd - 2451545.0) / 36525.0
|
||||||
|
var teg = 280.46061837 + 360.98564736629 * (jd - 2451545.0) + (0.000387933 * t - t * t / 38710000.0) * t
|
||||||
|
while (teg > 360.0) teg -= 360.0
|
||||||
|
// LST = GMST + east longitude (positive east convention)
|
||||||
|
val th = mod2PI((teg + observer.longitude) * DEG2RAD)
|
||||||
|
val h = th - ra
|
||||||
|
val azVal = atan2(sin(h), cos(h) * sin(n) - tan(dec) * cos(n)) + PI
|
||||||
|
val el = asin(sin(n) * sin(dec) + cos(n) * cos(dec) * cos(h))
|
||||||
|
|
||||||
|
// Moon radial velocity approximation (from "Amateur Radio Software", GM4ANB, RSGB 1985)
|
||||||
|
val mm = fixAngle(1.319238 + daynum * 0.228027135)
|
||||||
|
val radT2 = 0.10976
|
||||||
|
val radT1 = mm + radT2 * sin(mm)
|
||||||
|
var dv = 0.01255 * moonDx * moonDx * sin(radT1) * (1.0 + radT2 * cos(mm))
|
||||||
|
dv *= 4449.0
|
||||||
|
val earthR = 6378.0
|
||||||
|
val moonDist = 384401.0
|
||||||
|
val radT3 = earthR * moonDist * (cos(dec) * cos(n) * sin(h)) /
|
||||||
|
sqrt(moonDist * moonDist - moonDist * earthR * sin(el))
|
||||||
|
val moonDv = dv + radT3 * 0.0753125
|
||||||
|
|
||||||
|
val moonRa = ra / DEG2RAD
|
||||||
|
var moonGha = teg - moonRa
|
||||||
|
if (moonGha < 0.0) moonGha += 360.0
|
||||||
|
|
||||||
|
return MoonPosition(
|
||||||
|
azimuth = azVal / DEG2RAD,
|
||||||
|
elevation = el / DEG2RAD,
|
||||||
|
rightAscension = moonRa,
|
||||||
|
declination = dec / DEG2RAD,
|
||||||
|
gha = moonGha,
|
||||||
|
angularDiameter = moonDx,
|
||||||
|
radialVelocity = moonDv
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Satellite visibility ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify satellite visibility given its eclipse state and the Sun's elevation
|
||||||
|
* at the observer's location.
|
||||||
|
*
|
||||||
|
* @param isEclipsed whether the satellite is in Earth's shadow
|
||||||
|
* @param sunElevationDeg Sun elevation at observer in degrees
|
||||||
|
* @param satElevationDeg satellite elevation at observer in degrees (must be >= 0)
|
||||||
|
*/
|
||||||
|
fun classifyVisibility(
|
||||||
|
isEclipsed: Boolean,
|
||||||
|
sunElevationDeg: Double,
|
||||||
|
satElevationDeg: Double
|
||||||
|
): SatVisibility {
|
||||||
|
if (isEclipsed) return SatVisibility.ECLIPSED
|
||||||
|
return if (sunElevationDeg <= -12.0 && satElevationDeg >= 0.0) SatVisibility.VISIBLE
|
||||||
|
else SatVisibility.DAYLIGHT
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Orbital metadata ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute orbital metadata for a satellite at its current position.
|
||||||
|
*
|
||||||
|
* @param altitudeKm satellite altitude in km
|
||||||
|
* @param meanMotion revolutions per day from TLE
|
||||||
|
* @param bstar drag term from TLE
|
||||||
|
* @param meanAnomaly mean anomaly at epoch (radians)
|
||||||
|
* @param revNumAtEpoch revolution number at TLE epoch
|
||||||
|
* @param ageDays days since TLE epoch (julUTC - julEpoch)
|
||||||
|
* @param phase orbital phase in radians (from SGP4/SDP4 output)
|
||||||
|
* @param satPosECI satellite ECI position [x, y, z]
|
||||||
|
* @param satVelECI satellite ECI velocity [vx, vy, vz]
|
||||||
|
* @param sunPosECI sun ECI position [x, y, z]
|
||||||
|
*/
|
||||||
|
fun computeOrbitalMetadata(
|
||||||
|
altitudeKm: Double,
|
||||||
|
meanMotion: Double,
|
||||||
|
bstar: Double,
|
||||||
|
meanAnomaly: Double,
|
||||||
|
revNumAtEpoch: Int,
|
||||||
|
ageDays: Double,
|
||||||
|
phase: Double,
|
||||||
|
satPosECI: DoubleArray,
|
||||||
|
satVelECI: DoubleArray,
|
||||||
|
sunPosECI: DoubleArray
|
||||||
|
): OrbitalMetadata {
|
||||||
|
// Footprint diameter (km)
|
||||||
|
val footprint = 12756.33 * acos(EARTH_RADIUS / (EARTH_RADIUS + altitudeKm))
|
||||||
|
|
||||||
|
// Orbit number
|
||||||
|
val xmnpda = 1.44E3
|
||||||
|
val orbitNum = floor(
|
||||||
|
(meanMotion * xmnpda / TWO_PI + ageDays * bstar) * ageDays + meanAnomaly / TWO_PI
|
||||||
|
).toLong() + revNumAtEpoch
|
||||||
|
|
||||||
|
// Beta angle: angle between orbital plane and Sun direction
|
||||||
|
// Orbital plane normal = cross(pos, vel)
|
||||||
|
val nx = satPosECI[1] * satVelECI[2] - satPosECI[2] * satVelECI[1]
|
||||||
|
val ny = satPosECI[2] * satVelECI[0] - satPosECI[0] * satVelECI[2]
|
||||||
|
val nz = satPosECI[0] * satVelECI[1] - satPosECI[1] * satVelECI[0]
|
||||||
|
val nMag = sqrt(nx * nx + ny * ny + nz * nz)
|
||||||
|
val sMag = sqrt(sunPosECI[0] * sunPosECI[0] + sunPosECI[1] * sunPosECI[1] + sunPosECI[2] * sunPosECI[2])
|
||||||
|
val dotNS = nx * sunPosECI[0] + ny * sunPosECI[1] + nz * sunPosECI[2]
|
||||||
|
val betaAngle = if (nMag > 0 && sMag > 0) {
|
||||||
|
(PI / 2.0 - acos(dotNS / (nMag * sMag))).toDegrees()
|
||||||
|
} else 0.0
|
||||||
|
|
||||||
|
// Phase (0-256 scale, matching PREDICT convention)
|
||||||
|
val orbitalPhase = 256.0 * (phase / TWO_PI)
|
||||||
|
|
||||||
|
return OrbitalMetadata(footprint, orbitNum, betaAngle, orbitalPhase)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Satellite status checks ──
|
||||||
|
|
||||||
|
/** Check if a satellite is geostationary (mean motion ≈ 1.0027 rev/day). */
|
||||||
|
fun isGeostationary(meanMotion: Double): Boolean = abs(meanMotion - 1.0027) < 0.0002
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a satellite has likely decayed based on drag and time since epoch.
|
||||||
|
*
|
||||||
|
* @param meanMotion revolutions per day
|
||||||
|
* @param drag first derivative of mean motion / 2 (from TLE line 1)
|
||||||
|
* @param epochDaynum TLE epoch as daynum (days since 31Dec79)
|
||||||
|
* @param currentDaynum current time as daynum
|
||||||
|
*/
|
||||||
|
fun hasDecayed(meanMotion: Double, drag: Double, epochDaynum: Double, currentDaynum: Double): Boolean {
|
||||||
|
return epochDaynum + ((16.666666 - meanMotion) / (10.0 * abs(drag))) < currentDaynum
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rise/Set finding ──
|
||||||
|
|
||||||
|
/** Rise and set times for a celestial body. */
|
||||||
|
data class RiseSetTimes(
|
||||||
|
val riseTimeMillis: Long, // 0 if not found
|
||||||
|
val setTimeMillis: Long // 0 if not found
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the next sunrise and sunset times from [startMillis] for [observer].
|
||||||
|
* Uses elevation threshold of -0.8333° to match the standard civil definition:
|
||||||
|
* upper limb on geometric horizon with standard atmospheric refraction (~0.57°)
|
||||||
|
* and solar semidiameter (~0.27°) corrections applied, matching USNO/timeanddate.com.
|
||||||
|
*/
|
||||||
|
fun findSunRiseSet(observer: GeoPos, startMillis: Long): RiseSetTimes {
|
||||||
|
// Standard civil threshold: center elevation when upper limb meets geometric horizon
|
||||||
|
// -0.8333° = standard refraction (~0.5667°) + solar semidiameter (~0.2667°)
|
||||||
|
val threshold = 0.8333
|
||||||
|
var daynum = millisToDaynum(startMillis)
|
||||||
|
var sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
|
||||||
|
// Phase 1: if sun is above threshold, fast-forward to well past sunset into night
|
||||||
|
if (sunPos.elevation > -threshold) {
|
||||||
|
var guard = 0
|
||||||
|
while (sunPos.elevation > -threshold && guard++ < 500) {
|
||||||
|
daynum += 0.008 // fixed ~11.5 min steps past the setting sun
|
||||||
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
// Now advance until sun is clearly below minimum (deep night)
|
||||||
|
guard = 0
|
||||||
|
while (sunPos.elevation > -12.0 && guard++ < 500) {
|
||||||
|
daynum += 0.02
|
||||||
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: advance until sun starts rising toward threshold (elevation increasing)
|
||||||
|
var guard = 0
|
||||||
|
while (sunPos.elevation < -threshold && guard++ < 500) {
|
||||||
|
daynum += 0.008
|
||||||
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: converge symmetrically on elevation = -threshold (sunrise)
|
||||||
|
var sunrise = 0.0
|
||||||
|
guard = 0
|
||||||
|
while (sunrise == 0.0 && guard++ < 200) {
|
||||||
|
val delta = sunPos.elevation + threshold
|
||||||
|
if (abs(delta) < 0.01) {
|
||||||
|
sunrise = daynum
|
||||||
|
} else {
|
||||||
|
daynum -= 0.004 * delta
|
||||||
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sunrise == 0.0) sunrise = daynum
|
||||||
|
|
||||||
|
// Phase 4: fast-forward through the day until sun drops back below threshold
|
||||||
|
daynum = sunrise
|
||||||
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
guard = 0
|
||||||
|
while (sunPos.elevation > -threshold && guard++ < 500) {
|
||||||
|
daynum += 0.008
|
||||||
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 5: converge symmetrically on elevation = -threshold (sunset)
|
||||||
|
var sunset = 0.0
|
||||||
|
guard = 0
|
||||||
|
while (sunset == 0.0 && guard++ < 200) {
|
||||||
|
val delta = sunPos.elevation + threshold
|
||||||
|
if (abs(delta) < 0.01) {
|
||||||
|
sunset = daynum
|
||||||
|
} else {
|
||||||
|
daynum += 0.004 * delta
|
||||||
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sunset == 0.0) sunset = daynum
|
||||||
|
|
||||||
|
return RiseSetTimes(daynumToMillis(sunrise), daynumToMillis(sunset))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the next moonrise and moonset times from [startMillis] for [observer].
|
||||||
|
* Uses the adaptive iteration from PREDICT v2.2.5's PredictMoon().
|
||||||
|
*/
|
||||||
|
fun findMoonRiseSet(observer: GeoPos, startMillis: Long): RiseSetTimes {
|
||||||
|
var daynum = millisToDaynum(startMillis)
|
||||||
|
var moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||||
|
|
||||||
|
// If moon is already up, move forward until it sets
|
||||||
|
var guard = 0
|
||||||
|
if (moonPos.elevation > 0) {
|
||||||
|
while (moonPos.elevation > 0 && guard++ < 1000) {
|
||||||
|
daynum += 0.004 * sin(DEG2RAD * (moonPos.elevation + 0.5))
|
||||||
|
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
daynum += 0.4
|
||||||
|
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
// Find moonrise
|
||||||
|
var moonrise = 0.0
|
||||||
|
guard = 0
|
||||||
|
while (moonrise == 0.0 && guard++ < 1000) {
|
||||||
|
if (abs(moonPos.elevation) < 0.03) {
|
||||||
|
moonrise = daynum
|
||||||
|
} else {
|
||||||
|
daynum -= 0.004 * moonPos.elevation
|
||||||
|
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (moonrise == 0.0) moonrise = daynum
|
||||||
|
|
||||||
|
// Find moonset from moonrise
|
||||||
|
daynum = moonrise
|
||||||
|
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||||
|
guard = 0
|
||||||
|
while (moonPos.elevation > -1 && guard++ < 1000) {
|
||||||
|
daynum += 0.04 * cos(DEG2RAD * (moonPos.elevation + 0.5))
|
||||||
|
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
var moonset = 0.0
|
||||||
|
guard = 0
|
||||||
|
while (moonset == 0.0 && guard++ < 1000) {
|
||||||
|
if (abs(moonPos.elevation) < 0.03) {
|
||||||
|
moonset = daynum
|
||||||
|
} else {
|
||||||
|
daynum += 0.004 * moonPos.elevation
|
||||||
|
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (moonset == 0.0) moonset = daynum
|
||||||
|
|
||||||
|
return RiseSetTimes(daynumToMillis(moonrise), daynumToMillis(moonset))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Visual magnitude estimation ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate the apparent visual magnitude of a satellite.
|
||||||
|
*
|
||||||
|
* Uses the standard formula from McCants/Heavens-Above:
|
||||||
|
* apparentMag = stdMag + 5 * log10(range / 1000) - 15 * log10(cos(phaseAngle / 2))
|
||||||
|
*
|
||||||
|
* @param rangeKm slant range from observer to satellite in km
|
||||||
|
* @param phaseAngleDeg Sun-satellite-observer angle in degrees
|
||||||
|
* @param stdMag intrinsic/standard magnitude (default 4.0)
|
||||||
|
* @return estimated apparent visual magnitude
|
||||||
|
*/
|
||||||
|
fun estimateVisualMagnitude(rangeKm: Double, phaseAngleDeg: Double, stdMag: Double = 4.0): Double {
|
||||||
|
if (rangeKm <= 0) return stdMag
|
||||||
|
val halfPhaseRad = phaseAngleDeg.toRadians() / 2.0
|
||||||
|
val cosHalfPhase = cos(halfPhaseRad)
|
||||||
|
val phaseTerm = if (cosHalfPhase > 1e-6) -15.0 * log10(cosHalfPhase) else 99.0
|
||||||
|
return stdMag + 5.0 * log10(rangeKm / 1000.0) + phaseTerm
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the phase angle (Sun-satellite-observer) in degrees.
|
||||||
|
*
|
||||||
|
* @param satPosECI satellite ECI position [x, y, z] in km
|
||||||
|
* @param sunPosECI sun ECI position [x, y, z] in km
|
||||||
|
* @param obsPosECI observer ECI position [x, y, z] in km
|
||||||
|
* @return phase angle in degrees (0 = fully illuminated face toward observer)
|
||||||
|
*/
|
||||||
|
fun computePhaseAngle(satPosECI: DoubleArray, sunPosECI: DoubleArray, obsPosECI: DoubleArray): Double {
|
||||||
|
val toSunX = sunPosECI[0] - satPosECI[0]
|
||||||
|
val toSunY = sunPosECI[1] - satPosECI[1]
|
||||||
|
val toSunZ = sunPosECI[2] - satPosECI[2]
|
||||||
|
val toObsX = obsPosECI[0] - satPosECI[0]
|
||||||
|
val toObsY = obsPosECI[1] - satPosECI[1]
|
||||||
|
val toObsZ = obsPosECI[2] - satPosECI[2]
|
||||||
|
val dot = toSunX * toObsX + toSunY * toObsY + toSunZ * toObsZ
|
||||||
|
val magSun = sqrt(toSunX * toSunX + toSunY * toSunY + toSunZ * toSunZ)
|
||||||
|
val magObs = sqrt(toObsX * toObsX + toObsY * toObsY + toObsZ * toObsZ)
|
||||||
|
if (magSun == 0.0 || magObs == 0.0) return 90.0
|
||||||
|
val cosAngle = (dot / (magSun * magObs)).coerceIn(-1.0, 1.0)
|
||||||
|
return acos(cosAngle).toDegrees()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Doppler ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute Doppler shift for a given base frequency and range rate.
|
||||||
|
*
|
||||||
|
* @param frequencyHz base frequency in Hz
|
||||||
|
* @param rangeRateKmS range rate in km/s (negative = approaching)
|
||||||
|
* @return shifted frequency in Hz
|
||||||
|
*/
|
||||||
|
fun dopplerShift(frequencyHz: Double, rangeRateKmS: Double): Double {
|
||||||
|
return frequencyHz * (299792.458 - rangeRateKmS) / 299792.458
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal helpers ──
|
||||||
|
|
||||||
|
private fun observerGeodetic(pos: GeoPos): DoubleArray {
|
||||||
|
// [lat_rad, lon_rad, alt_km] — longitude positive east, matching OrbitalObject convention.
|
||||||
|
// LST = thetaGJD(julUtc) + obsGeo[1] = GMST + lon_rad (correct).
|
||||||
|
return doubleArrayOf(pos.latitude * DEG2RAD, pos.longitude * DEG2RAD, pos.altitude / 1000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert az/el observation to Right Ascension / Declination.
|
||||||
|
* Returns [ra_rad, dec_rad].
|
||||||
|
* Based on Calculate_RADec() from PREDICT v2.2.5 (Escobal method).
|
||||||
|
*/
|
||||||
|
private fun calculateRADec(
|
||||||
|
julUtc: Double,
|
||||||
|
targetPos: DoubleArray,
|
||||||
|
targetVel: DoubleArray,
|
||||||
|
obsGeo: DoubleArray
|
||||||
|
): DoubleArray {
|
||||||
|
val obsSet = computeObsAngles(julUtc, targetPos, targetVel, obsGeo)
|
||||||
|
val az = obsSet[0]
|
||||||
|
val el = obsSet[1]
|
||||||
|
val phi = obsGeo[0]
|
||||||
|
val theta = mod2PI(thetaGJD(julUtc) + obsGeo[1])
|
||||||
|
val sinTheta = sin(theta)
|
||||||
|
val cosTheta = cos(theta)
|
||||||
|
val sinPhi = sin(phi)
|
||||||
|
val cosPhi = cos(phi)
|
||||||
|
val lxh = -cos(az) * cos(el)
|
||||||
|
val lyh = sin(az) * cos(el)
|
||||||
|
val lzh = sin(el)
|
||||||
|
val sx = sinPhi * cosTheta
|
||||||
|
val ex2 = -sinTheta
|
||||||
|
val zx = cosTheta * cosPhi
|
||||||
|
val sy = sinPhi * sinTheta
|
||||||
|
val zy = sinTheta * cosPhi
|
||||||
|
val sz = -cosPhi
|
||||||
|
val lx = sx * lxh + ex2 * lyh + zx * lzh
|
||||||
|
val ly = sy * lxh + cosTheta * lyh + zy * lzh
|
||||||
|
val lz = sz * lxh + 0.0 * lyh + sinPhi * lzh
|
||||||
|
val dec = asin(lz)
|
||||||
|
val cosDelta = sqrt(1.0 - lz * lz)
|
||||||
|
val sinAlpha = ly / cosDelta
|
||||||
|
val cosAlpha = lx / cosDelta
|
||||||
|
val ra = mod2PI(atan2(sinAlpha, cosAlpha))
|
||||||
|
return doubleArrayOf(ra, dec)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute observer look-angles (az, el, range, rangeRate) to a target at ECI position.
|
||||||
|
* Returns [azimuth_rad, elevation_rad, range_km, rangeRate_km/s].
|
||||||
|
* Azimuth is north-referenced (0=N, π/2=E), matching OrbitalObject's convention.
|
||||||
|
*/
|
||||||
|
private fun computeObsAngles(
|
||||||
|
julUtc: Double,
|
||||||
|
targetPos: DoubleArray,
|
||||||
|
targetVel: DoubleArray,
|
||||||
|
obsGeo: DoubleArray // [lat_rad, lon_rad, alt_km]
|
||||||
|
): DoubleArray {
|
||||||
|
val theta = mod2PI(thetaGJD(julUtc) + obsGeo[1])
|
||||||
|
val c = 1.0 / sqrt(1 + FLAT_FACT * (FLAT_FACT - 2) * sin(obsGeo[0]).pow(2))
|
||||||
|
val sq = (1 - FLAT_FACT).pow(2) * c
|
||||||
|
val achcp = (EARTH_RADIUS * c + obsGeo[2]) * cos(obsGeo[0])
|
||||||
|
val ox = achcp * cos(theta)
|
||||||
|
val oy = achcp * sin(theta)
|
||||||
|
val oz = (EARTH_RADIUS * sq + obsGeo[2]) * sin(obsGeo[0])
|
||||||
|
val ovx = -MFACTOR * oy
|
||||||
|
val ovy = MFACTOR * ox
|
||||||
|
|
||||||
|
val rx = targetPos[0] - ox
|
||||||
|
val ry = targetPos[1] - oy
|
||||||
|
val rz = targetPos[2] - oz
|
||||||
|
val rMag = sqrt(rx * rx + ry * ry + rz * rz)
|
||||||
|
val rvx = targetVel[0] - ovx
|
||||||
|
val rvy = targetVel[1] - ovy
|
||||||
|
val rvz = targetVel[2]
|
||||||
|
|
||||||
|
val sinLat = sin(obsGeo[0])
|
||||||
|
val cosLat = cos(obsGeo[0])
|
||||||
|
val sinTheta = sin(theta)
|
||||||
|
val cosTheta = cos(theta)
|
||||||
|
val topS = sinLat * cosTheta * rx + sinLat * sinTheta * ry - cosLat * rz
|
||||||
|
val topE = -sinTheta * rx + cosTheta * ry
|
||||||
|
val topZ = cosLat * cosTheta * rx + cosLat * sinTheta * ry + sinLat * rz
|
||||||
|
|
||||||
|
// Match north-based convention (0=N, 90=E) used by OrbitalObject.calculateObs
|
||||||
|
// Must use atan(-topE / topS) not atan2(-topE, topS) — they differ in quadrant handling
|
||||||
|
var azim = atan(-topE / topS)
|
||||||
|
if (topS > 0.0) azim += PI
|
||||||
|
if (azim < 0.0) azim += TWO_PI
|
||||||
|
val el = asin(topZ / rMag)
|
||||||
|
val rangeRate = (rx * rvx + ry * rvy + rz * rvz) / rMag
|
||||||
|
|
||||||
|
return doubleArrayOf(azim, el, rMag, rangeRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MFACTOR = 7.292115E-5
|
||||||
|
|
||||||
|
private fun primeAngle(x: Double) = x - 360.0 * floor(x / 360.0)
|
||||||
|
|
||||||
|
private fun fixAngle(x: Double): Double {
|
||||||
|
var a = x; while (a > TWO_PI) a -= TWO_PI; return a
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
+32
-34
@@ -97,40 +97,38 @@ class DeepSpaceObject(data: OrbitalData) : OrbitalObject(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal fun calculateSDP4(tSince: Double) {
|
internal fun calculateSDP4(tSince: Double) {
|
||||||
synchronized(this) {
|
val temp = DoubleArray(12)
|
||||||
val temp = DoubleArray(12)
|
val xmdf = data.xmo + dsv.xmdot * tSince
|
||||||
val xmdf = data.xmo + dsv.xmdot * tSince
|
val tsq = tSince * tSince
|
||||||
val tsq = tSince * tSince
|
val templ = t2cof * tsq
|
||||||
val templ = t2cof * tsq
|
dsv.xll = xmdf + dsv.xnodp * templ
|
||||||
dsv.xll = xmdf + dsv.xnodp * templ
|
dsv.omgadf = data.omegao + dsv.omgdot * tSince
|
||||||
dsv.omgadf = data.omegao + dsv.omgdot * tSince
|
val xnoddf = data.xnodeo + dsv.xnodot * tSince
|
||||||
val xnoddf = data.xnodeo + dsv.xnodot * tSince
|
dsv.xnode = xnoddf + xnodcf * tsq
|
||||||
dsv.xnode = xnoddf + xnodcf * tsq
|
val tempa = 1.0 - c1 * tSince
|
||||||
val tempa = 1.0 - c1 * tSince
|
val tempe = data.bstar * c4 * tSince
|
||||||
val tempe = data.bstar * c4 * tSince
|
dsv.xn = dsv.xnodp
|
||||||
dsv.xn = dsv.xnodp
|
dsv.t = tSince
|
||||||
dsv.t = tSince
|
deep.dpsec(data)
|
||||||
deep.dpsec(data)
|
val a = (XKE / dsv.xn).pow(TWO_THIRDS) * tempa * tempa
|
||||||
val a = (XKE / dsv.xn).pow(TWO_THIRDS) * tempa * tempa
|
dsv.em -= tempe
|
||||||
dsv.em -= tempe
|
deep.dpper()
|
||||||
deep.dpper()
|
val xl = dsv.xll + dsv.omgadf + dsv.xnode
|
||||||
val xl = dsv.xll + dsv.omgadf + dsv.xnode
|
val beta = sqrt(1.0 - dsv.em * dsv.em)
|
||||||
val beta = sqrt(1.0 - dsv.em * dsv.em)
|
dsv.xn = XKE / a.pow(1.5)
|
||||||
dsv.xn = XKE / a.pow(1.5)
|
// Long period periodics
|
||||||
// Long period periodics
|
val axn = dsv.em * cos(dsv.omgadf)
|
||||||
val axn = dsv.em * cos(dsv.omgadf)
|
temp[0] = invert(a * beta * beta)
|
||||||
temp[0] = invert(a * beta * beta)
|
val xll = temp[0] * xlcof * axn
|
||||||
val xll = temp[0] * xlcof * axn
|
val aynl = temp[0] * aycof
|
||||||
val aynl = temp[0] * aycof
|
val xlt = xl + xll
|
||||||
val xlt = xl + xll
|
val ayn = dsv.em * sin(dsv.omgadf) + aynl
|
||||||
val ayn = dsv.em * sin(dsv.omgadf) + aynl
|
// Solve Kepler's equation
|
||||||
// Solve Kepler's equation
|
val capu = mod2PI(xlt - dsv.xnode)
|
||||||
val capu = mod2PI(xlt - dsv.xnode)
|
temp[2] = capu
|
||||||
temp[2] = capu
|
converge(temp, axn, ayn, capu)
|
||||||
converge(temp, axn, ayn, capu)
|
calculatePosAndVel(temp, a, axn, ayn)
|
||||||
calculatePosAndVel(temp, a, axn, ayn)
|
calculatePhase(xlt, dsv.xnode, dsv.omgadf)
|
||||||
calculatePhase(xlt, dsv.xnode, dsv.omgadf)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculatePosAndVel(temp: DoubleArray, a: Double, axn: Double, ayn: Double) {
|
private fun calculatePosAndVel(temp: DoubleArray, a: Double, axn: Double, ayn: Double) {
|
||||||
|
|||||||
+44
-46
@@ -141,53 +141,51 @@ class NearEarthObject(data: OrbitalData) : OrbitalObject(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal fun calculateSGP4(tSince: Double) {
|
internal fun calculateSGP4(tSince: Double) {
|
||||||
synchronized(this) {
|
val temp = DoubleArray(9)
|
||||||
val temp = DoubleArray(9)
|
val xmdf = data.xmo + xmdot * tSince
|
||||||
val xmdf = data.xmo + xmdot * tSince
|
val omgadf = data.omegao + omgdot * tSince
|
||||||
val omgadf = data.omegao + omgdot * tSince
|
val xnoddf = data.xnodeo + xnodot * tSince
|
||||||
val xnoddf = data.xnodeo + xnodot * tSince
|
var omega = omgadf
|
||||||
var omega = omgadf
|
var xmp = xmdf
|
||||||
var xmp = xmdf
|
val tsq = tSince * tSince
|
||||||
val tsq = sqr(tSince)
|
val xnode = xnoddf + xnodcf * tsq
|
||||||
val xnode = xnoddf + xnodcf * tsq
|
val bstar = data.bstar
|
||||||
val bstar = data.bstar
|
var tempa = 1.0 - c1 * tSince
|
||||||
var tempa = 1.0 - c1 * tSince
|
var tempe = bstar * c4 * tSince
|
||||||
var tempe = bstar * c4 * tSince
|
var templ = t2cof * tsq
|
||||||
var templ = t2cof * tsq
|
if (!sgp4Simple) {
|
||||||
if (!sgp4Simple) {
|
val delomg = omgcof * tSince
|
||||||
val delomg = omgcof * tSince
|
val delm = xmcof * ((1.0 + eta * cos(xmdf)).pow(3.0) - delmo)
|
||||||
val delm = xmcof * ((1.0 + eta * cos(xmdf)).pow(3.0) - delmo)
|
temp[0] = delomg + delm
|
||||||
temp[0] = delomg + delm
|
xmp = xmdf + temp[0]
|
||||||
xmp = xmdf + temp[0]
|
omega = omgadf - temp[0]
|
||||||
omega = omgadf - temp[0]
|
val tcube = tsq * tSince
|
||||||
val tcube = tsq * tSince
|
val tfour = tSince * tcube
|
||||||
val tfour = tSince * tcube
|
tempa = tempa - d2 * tsq - d3 * tcube - d4 * tfour
|
||||||
tempa = tempa - d2 * tsq - d3 * tcube - d4 * tfour
|
tempe += bstar * c5 * (sin(xmp) - sinmo)
|
||||||
tempe += bstar * c5 * (sin(xmp) - sinmo)
|
templ += t3cof * tcube + tfour * (t4cof + tSince * t5cof)
|
||||||
templ += t3cof * tcube + tfour * (t4cof + tSince * t5cof)
|
|
||||||
}
|
|
||||||
val a = aodp * tempa.pow(2.0)
|
|
||||||
val eo = data.eccn
|
|
||||||
val e = eo - tempe
|
|
||||||
val xl = xmp + omega + xnode + xnodp * templ
|
|
||||||
val beta = sqrt(1.0 - e * e)
|
|
||||||
val xn = XKE / a.pow(1.5)
|
|
||||||
|
|
||||||
// Long period periodics
|
|
||||||
val axn = e * cos(omega)
|
|
||||||
temp[0] = invert(a * sqr(beta))
|
|
||||||
val xll = temp[0] * xlcof * axn
|
|
||||||
val aynl = temp[0] * aycof
|
|
||||||
val xlt = xl + xll
|
|
||||||
val ayn = e * sin(omega) + aynl
|
|
||||||
|
|
||||||
// Solve Kepler's equation
|
|
||||||
val capu = mod2PI(xlt - xnode)
|
|
||||||
temp[2] = capu
|
|
||||||
converge(temp, axn, ayn, capu)
|
|
||||||
calculatePosAndVel(temp, xnode, a, xn, axn, ayn)
|
|
||||||
calculatePhase(xlt, xnode, omgadf)
|
|
||||||
}
|
}
|
||||||
|
val a = aodp * tempa * tempa
|
||||||
|
val eo = data.eccn
|
||||||
|
val e = eo - tempe
|
||||||
|
val xl = xmp + omega + xnode + xnodp * templ
|
||||||
|
val beta = sqrt(1.0 - e * e)
|
||||||
|
val xn = XKE / a.pow(1.5)
|
||||||
|
|
||||||
|
// Long period periodics
|
||||||
|
val axn = e * cos(omega)
|
||||||
|
temp[0] = invert(a * sqr(beta))
|
||||||
|
val xll = temp[0] * xlcof * axn
|
||||||
|
val aynl = temp[0] * aycof
|
||||||
|
val xlt = xl + xll
|
||||||
|
val ayn = e * sin(omega) + aynl
|
||||||
|
|
||||||
|
// Solve Kepler's equation
|
||||||
|
val capu = mod2PI(xlt - xnode)
|
||||||
|
temp[2] = capu
|
||||||
|
converge(temp, axn, ayn, capu)
|
||||||
|
calculatePosAndVel(temp, xnode, a, xn, axn, ayn)
|
||||||
|
calculatePhase(xlt, xnode, omgadf)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculatePosAndVel(
|
private fun calculatePosAndVel(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -37,4 +38,29 @@ data class OrbitalData(
|
|||||||
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)
|
||||||
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
|
||||||
|
val currentDaynum = (currentTimeMillis - 315446400000L) / 86400000.0
|
||||||
|
val epochDaynum = epochToDaynum(epoch)
|
||||||
|
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, currentDaynum)
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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
|
||||||
|
}
|
||||||
+91
-66
@@ -42,6 +42,27 @@ abstract class OrbitalObject(val data: OrbitalData) {
|
|||||||
var qoms24 = 0.0
|
var qoms24 = 0.0
|
||||||
var s4 = 0.0
|
var s4 = 0.0
|
||||||
|
|
||||||
|
// Pre-allocated reusable vectors to avoid GC pressure in hot loops
|
||||||
|
private val obsPos = Vector4()
|
||||||
|
private val obsVel = Vector4()
|
||||||
|
private val rangeVector = Vector4()
|
||||||
|
private val rgvelVector = Vector4()
|
||||||
|
private val squintVector = Vector4()
|
||||||
|
|
||||||
|
// Cached observer position data to avoid recalculation when observer hasn't moved
|
||||||
|
private var cachedGsLat = Double.NaN
|
||||||
|
private var cachedGsLon = Double.NaN
|
||||||
|
private var cachedGsAlt = Double.NaN
|
||||||
|
private var cachedSinLat = 0.0
|
||||||
|
private var cachedCosLat = 0.0
|
||||||
|
private var cachedObsC = 0.0
|
||||||
|
private var cachedObsSq = 0.0
|
||||||
|
private var cachedObsAchFactor = 0.0
|
||||||
|
private var cachedObsZFactor = 0.0
|
||||||
|
|
||||||
|
// Cache for julian epoch to avoid recomputing every call
|
||||||
|
private val julEpoch: Double = juliandDateOfEpoch(data.epoch)
|
||||||
|
|
||||||
fun willBeSeen(pos: GeoPos): Boolean {
|
fun willBeSeen(pos: GeoPos): Boolean {
|
||||||
return if (data.meanmo < 1e-8) false
|
return if (data.meanmo < 1e-8) false
|
||||||
else {
|
else {
|
||||||
@@ -57,15 +78,13 @@ abstract class OrbitalObject(val data: OrbitalData) {
|
|||||||
orbitalPos = OrbitalPos()
|
orbitalPos = OrbitalPos()
|
||||||
// Date/time at which the position and velocity were calculated
|
// Date/time at which the position and velocity were calculated
|
||||||
julUTC = calcCurrentDaynum(time) + 2444238.5
|
julUTC = calcCurrentDaynum(time) + 2444238.5
|
||||||
// Convert satellite's epoch time to Julian and calculate time since epoch in minutes
|
// Calculate time since epoch in minutes
|
||||||
val julEpoch = juliandDateOfEpoch(data.epoch)
|
|
||||||
val tsince = (julUTC - julEpoch) * MIN_PER_DAY
|
val tsince = (julUTC - julEpoch) * MIN_PER_DAY
|
||||||
calculateSDP4orSGP4(tsince)
|
calculateSDP4orSGP4(tsince)
|
||||||
// Scale position and velocity vectors to km and km/sec
|
// Scale position and velocity vectors to km and km/sec
|
||||||
convertSatState(position, velocity)
|
convertSatState(position, velocity)
|
||||||
// Calculate velocity of satellite
|
// Calculate velocity of satellite
|
||||||
magnitude(velocity)
|
magnitude(velocity)
|
||||||
val squintVector = Vector4()
|
|
||||||
// Angles in rads, dist in km, vel in km/S. Calculate sat Az, El, Range and Range-rate.
|
// Angles in rads, dist in km, vel in km/S. Calculate sat Az, El, Range and Range-rate.
|
||||||
calculateObs(julUTC, position, velocity, pos, squintVector)
|
calculateObs(julUTC, position, velocity, pos, squintVector)
|
||||||
calculateLatLonAlt(julUTC)
|
calculateLatLonAlt(julUTC)
|
||||||
@@ -75,6 +94,38 @@ abstract class OrbitalObject(val data: OrbitalData) {
|
|||||||
return orbitalPos
|
return orbitalPos
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight elevation-only check for pass finding. Avoids full lat/lon/alt,
|
||||||
|
* eclipse, and squint calculations that aren't needed when just searching for
|
||||||
|
* horizon crossings.
|
||||||
|
*/
|
||||||
|
fun getElevation(pos: GeoPos, time: Long): Double {
|
||||||
|
julUTC = calcCurrentDaynum(time) + 2444238.5
|
||||||
|
val tsince = (julUTC - julEpoch) * MIN_PER_DAY
|
||||||
|
calculateSDP4orSGP4(tsince)
|
||||||
|
convertSatState(position, velocity)
|
||||||
|
magnitude(velocity)
|
||||||
|
calculateObs(julUTC, position, velocity, pos, squintVector)
|
||||||
|
return orbitalPos.elevation
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full position calculation that also populates azimuth, altitude, etc.
|
||||||
|
* Used when we need all fields (AOS/LOS refinement, track computation).
|
||||||
|
*/
|
||||||
|
fun getFullPosition(pos: GeoPos, time: Long): OrbitalPos {
|
||||||
|
orbitalPos = OrbitalPos()
|
||||||
|
julUTC = calcCurrentDaynum(time) + 2444238.5
|
||||||
|
val tsince = (julUTC - julEpoch) * MIN_PER_DAY
|
||||||
|
calculateSDP4orSGP4(tsince)
|
||||||
|
convertSatState(position, velocity)
|
||||||
|
magnitude(velocity)
|
||||||
|
calculateObs(julUTC, position, velocity, pos, squintVector)
|
||||||
|
calculateLatLonAlt(julUTC)
|
||||||
|
orbitalPos.time = time
|
||||||
|
return orbitalPos
|
||||||
|
}
|
||||||
|
|
||||||
private fun calcCurrentDaynum(now: Long): Double {
|
private fun calcCurrentDaynum(now: Long): Double {
|
||||||
val then = 315446400000 // time in millis on 31Dec79 00:00:00 UTC (daynum 0)
|
val then = 315446400000 // time in millis on 31Dec79 00:00:00 UTC (daynum 0)
|
||||||
return (now - then) / 1000.0 / 60.0 / 60.0 / 24.0
|
return (now - then) / 1000.0 / 60.0 / 60.0 / 24.0
|
||||||
@@ -117,38 +168,34 @@ abstract class OrbitalObject(val data: OrbitalData) {
|
|||||||
gsPos: GeoPos,
|
gsPos: GeoPos,
|
||||||
squintVector: Vector4
|
squintVector: Vector4
|
||||||
) {
|
) {
|
||||||
val obsPos = Vector4()
|
|
||||||
val obsVel = Vector4()
|
|
||||||
val range = Vector4()
|
|
||||||
val rgvel = Vector4()
|
|
||||||
calculateUserPosVel(julianUTC, gsPos, obsPos, obsVel)
|
calculateUserPosVel(julianUTC, gsPos, obsPos, obsVel)
|
||||||
range.setXYZ(
|
rangeVector.setXYZ(
|
||||||
positionVector.x - obsPos.x,
|
positionVector.x - obsPos.x,
|
||||||
positionVector.y - obsPos.y,
|
positionVector.y - obsPos.y,
|
||||||
positionVector.z - obsPos.z
|
positionVector.z - obsPos.z
|
||||||
)
|
)
|
||||||
// Save these values globally for calculating squint angles later
|
// Save these values globally for calculating squint angles later
|
||||||
squintVector.setXYZ(range.x, range.y, range.z)
|
squintVector.setXYZ(rangeVector.x, rangeVector.y, rangeVector.z)
|
||||||
rgvel.setXYZ(
|
rgvelVector.setXYZ(
|
||||||
velocityVector.x - obsVel.x,
|
velocityVector.x - obsVel.x,
|
||||||
velocityVector.y - obsVel.y,
|
velocityVector.y - obsVel.y,
|
||||||
velocityVector.z - obsVel.z
|
velocityVector.z - obsVel.z
|
||||||
)
|
)
|
||||||
magnitude(range)
|
magnitude(rangeVector)
|
||||||
val sinLat = sin(DEG2RAD * gsPos.latitude)
|
val sinLat = cachedSinLat
|
||||||
val cosLat = cos(DEG2RAD * gsPos.latitude)
|
val cosLat = cachedCosLat
|
||||||
val sinTheta = sin(gsPosTheta)
|
val sinTheta = sin(gsPosTheta)
|
||||||
val cosTheta = cos(gsPosTheta)
|
val cosTheta = cos(gsPosTheta)
|
||||||
val topS = sinLat * cosTheta * range.x + sinLat * sinTheta * range.y - cosLat * range.z
|
val topS = sinLat * cosTheta * rangeVector.x + sinLat * sinTheta * rangeVector.y - cosLat * rangeVector.z
|
||||||
val topE = -sinTheta * range.x + cosTheta * range.y
|
val topE = -sinTheta * rangeVector.x + cosTheta * rangeVector.y
|
||||||
val topZ = cosLat * cosTheta * range.x + cosLat * sinTheta * range.y + sinLat * range.z
|
val topZ = cosLat * cosTheta * rangeVector.x + cosLat * sinTheta * rangeVector.y + sinLat * rangeVector.z
|
||||||
var azim = atan(-topE / topS)
|
var azim = atan(-topE / topS)
|
||||||
if (topS > 0.0) azim += PI
|
if (topS > 0.0) azim += PI
|
||||||
if (azim < 0.0) azim += TWO_PI
|
if (azim < 0.0) azim += TWO_PI
|
||||||
orbitalPos.azimuth = azim
|
orbitalPos.azimuth = azim
|
||||||
orbitalPos.elevation = asin(topZ / range.w)
|
orbitalPos.elevation = asin(topZ / rangeVector.w)
|
||||||
orbitalPos.distance = range.w
|
orbitalPos.distance = rangeVector.w
|
||||||
orbitalPos.distanceRate = dot(range, rgvel) / range.w
|
orbitalPos.distanceRate = dot(rangeVector, rgvelVector) / rangeVector.w
|
||||||
var elevation = orbitalPos.elevation / TWO_PI * 360.0
|
var elevation = orbitalPos.elevation / TWO_PI * 360.0
|
||||||
if (elevation > 90) elevation = 180 - elevation
|
if (elevation > 90) elevation = 180 - elevation
|
||||||
orbitalPos.aboveHorizon = elevation - 0 > EPSILON
|
orbitalPos.aboveHorizon = elevation - 0 > EPSILON
|
||||||
@@ -163,12 +210,24 @@ abstract class OrbitalObject(val data: OrbitalData) {
|
|||||||
) {
|
) {
|
||||||
val mFactor = 7.292115E-5
|
val mFactor = 7.292115E-5
|
||||||
gsPosTheta = mod2PI(thetaGJD(time) + DEG2RAD * gsPos.longitude)
|
gsPosTheta = mod2PI(thetaGJD(time) + DEG2RAD * gsPos.longitude)
|
||||||
val c = invert(sqrt(1.0 + FLAT_FACT * (FLAT_FACT - 2) * sqr(sin(DEG2RAD * gsPos.latitude))))
|
|
||||||
val sq = sqr(1.0 - FLAT_FACT) * c
|
// Cache trig and position factors when observer position changes
|
||||||
val achcp = (EARTH_RADIUS * c + gsPos.altitude / 1000.0) * cos(DEG2RAD * gsPos.latitude)
|
if (gsPos.latitude != cachedGsLat || gsPos.longitude != cachedGsLon || gsPos.altitude != cachedGsAlt) {
|
||||||
|
cachedGsLat = gsPos.latitude
|
||||||
|
cachedGsLon = gsPos.longitude
|
||||||
|
cachedGsAlt = gsPos.altitude
|
||||||
|
cachedSinLat = sin(DEG2RAD * gsPos.latitude)
|
||||||
|
cachedCosLat = cos(DEG2RAD * gsPos.latitude)
|
||||||
|
cachedObsC = invert(sqrt(1.0 + FLAT_FACT * (FLAT_FACT - 2) * sqr(cachedSinLat)))
|
||||||
|
cachedObsSq = sqr(1.0 - FLAT_FACT) * cachedObsC
|
||||||
|
cachedObsAchFactor = (EARTH_RADIUS * cachedObsC + gsPos.altitude / 1000.0) * cachedCosLat
|
||||||
|
cachedObsZFactor = (EARTH_RADIUS * cachedObsSq + gsPos.altitude / 1000.0) * cachedSinLat
|
||||||
|
}
|
||||||
|
|
||||||
obsPos.setXYZ(
|
obsPos.setXYZ(
|
||||||
achcp * cos(gsPosTheta), achcp * sin(gsPosTheta),
|
cachedObsAchFactor * cos(gsPosTheta),
|
||||||
(EARTH_RADIUS * sq + gsPos.altitude / 1000.0) * sin(DEG2RAD * gsPos.latitude)
|
cachedObsAchFactor * sin(gsPosTheta),
|
||||||
|
cachedObsZFactor
|
||||||
)
|
)
|
||||||
obsVel.setXYZ(-mFactor * obsPos.y, mFactor * obsPos.x, 0.0)
|
obsVel.setXYZ(-mFactor * obsPos.y, mFactor * obsPos.x, 0.0)
|
||||||
magnitude(obsPos)
|
magnitude(obsPos)
|
||||||
@@ -255,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) {
|
||||||
@@ -364,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 a 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
|
||||||
@@ -387,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) {
|
||||||
@@ -411,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,
|
||||||
var 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
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,6 @@ import kotlin.math.acos
|
|||||||
import kotlin.math.asin
|
import kotlin.math.asin
|
||||||
import kotlin.math.atan2
|
import kotlin.math.atan2
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
import kotlin.math.pow
|
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
@@ -50,23 +49,30 @@ data class OrbitalPos(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getOrbitalVelocity(): Double {
|
fun getOrbitalVelocity(): Double {
|
||||||
val earthG = 6.674 * 10.0.pow(-11)
|
val radius = EARTH_RADIUS_M + altitude * 1000.0
|
||||||
val earthM = 5.98 * 10.0.pow(24)
|
return sqrt(GM_EARTH / radius) / 1000.0
|
||||||
val radius = 6.37 * 10.0.pow(6) + altitude * 10.0.pow(3)
|
|
||||||
return sqrt(earthG * earthM / radius) / 1000
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getRangeCircle(): List<GeoPos> {
|
fun getRangeCircle(): List<GeoPos> {
|
||||||
val rangeCirclePoints = mutableListOf<GeoPos>()
|
val pointCount = 721
|
||||||
val beta = acos(EARTH_RADIUS / (EARTH_RADIUS + altitude)) // * EARTH_RADIUS = radiusKm
|
val rangeCirclePoints = ArrayList<GeoPos>(pointCount)
|
||||||
|
val beta = acos(EARTH_RADIUS / (EARTH_RADIUS + altitude))
|
||||||
|
val sinLat = sin(latitude)
|
||||||
|
val cosLat = cos(latitude)
|
||||||
|
val cosBeta = cos(beta)
|
||||||
|
val sinBeta = sin(beta)
|
||||||
for (azimuth in 0..720) {
|
for (azimuth in 0..720) {
|
||||||
val rads = azimuth * DEG2RAD
|
val rads = azimuth * DEG2RAD
|
||||||
val lat = asin(sin(latitude) * cos(beta) + (cos(latitude) * sin(beta) * cos(rads)))
|
val lat = asin(sinLat * cosBeta + cosLat * sinBeta * cos(rads))
|
||||||
val lon = (longitude + atan2(
|
val lon = longitude + atan2(sin(rads) * sinBeta * cosLat, cosBeta - sinLat * sin(lat))
|
||||||
sin(rads) * sin(beta) * cos(latitude), cos(beta) - sin(latitude) * sin(lat)
|
|
||||||
))
|
|
||||||
rangeCirclePoints.add(GeoPos(lat * RAD2DEG, lon * RAD2DEG))
|
rangeCirclePoints.add(GeoPos(lat * RAD2DEG, lon * RAD2DEG))
|
||||||
}
|
}
|
||||||
return rangeCirclePoints
|
return rangeCirclePoints
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Pre-computed constants for orbital velocity calculation
|
||||||
|
private const val GM_EARTH = 3.986004418E14 // m^3/s^2
|
||||||
|
private const val EARTH_RADIUS_M = 6.37E6 // meters
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+26
-2
@@ -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,11 +29,16 @@ interface IMainContainer {
|
|||||||
val selectionRepo: ISelectionRepo
|
val selectionRepo: ISelectionRepo
|
||||||
val satelliteRepo: ISatelliteRepo
|
val satelliteRepo: ISatelliteRepo
|
||||||
val databaseRepo: IDatabaseRepo
|
val databaseRepo: IDatabaseRepo
|
||||||
|
val radioTrackingService: IRadioTrackingService
|
||||||
fun provideAddToCalendar(): IAddToCalendar
|
fun provideAddToCalendar(): IAddToCalendar
|
||||||
fun provideShowToast(): IShowToast
|
fun provideShowToast(): IShowToast
|
||||||
fun provideBluetoothReporter(): IReporterRepo<WithoutExtParams>
|
fun provideBluetoothReporter(): IReporter
|
||||||
fun provideNetworkReporter(): IReporterRepo<ExtendedParams>
|
fun provideNetworkReporter(): IReporter
|
||||||
fun provideSensorsRepo(): ISensorsRepo
|
fun provideSensorsRepo(): ISensorsRepo
|
||||||
|
fun provideTxRadioController(): IRadioController
|
||||||
|
fun provideRxRadioController(): IRadioController
|
||||||
|
fun provideAudioCapture(): IAudioCapture
|
||||||
|
fun provideSaveImage(): ISaveImage
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IContainerProvider {
|
interface IContainerProvider {
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
|
interface IRadioController {
|
||||||
|
|
||||||
|
val isConnected: Boolean
|
||||||
|
|
||||||
|
suspend fun connect(): Boolean
|
||||||
|
|
||||||
|
suspend fun disconnect()
|
||||||
|
|
||||||
|
suspend fun setFrequency(frequencyHz: Long): Boolean
|
||||||
|
|
||||||
|
suspend fun setMode(mode: String): Boolean
|
||||||
|
|
||||||
|
suspend fun setCtcssMode(enabled: Boolean): Boolean
|
||||||
|
|
||||||
|
suspend fun setCtcssTone(toneHz: Double): Boolean
|
||||||
|
|
||||||
|
suspend fun readFrequencyAndMode(): Pair<Long, String>?
|
||||||
|
|
||||||
|
suspend fun pttOn(): Boolean
|
||||||
|
|
||||||
|
suspend fun pttOff(): Boolean
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
data class RadioTrackingState(
|
||||||
|
val isActive: Boolean = false,
|
||||||
|
val txConnected: Boolean = false,
|
||||||
|
val rxConnected: Boolean = false,
|
||||||
|
val txFrequencyHz: Long? = null,
|
||||||
|
val rxFrequencyHz: Long? = null,
|
||||||
|
val txMode: String? = null,
|
||||||
|
val rxMode: String? = null,
|
||||||
|
val ctcssTone: Double? = null,
|
||||||
|
val txBaseFrequencyHz: Long? = null,
|
||||||
|
val selectedTransponder: SatRadio? = null,
|
||||||
|
val currentPass: OrbitalPass? = null,
|
||||||
|
val azimuth: Double = 0.0,
|
||||||
|
val elevation: Double = 0.0,
|
||||||
|
val distance: Double = 0.0,
|
||||||
|
val errorMessage: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
interface IRadioTrackingService {
|
||||||
|
val state: StateFlow<RadioTrackingState>
|
||||||
|
|
||||||
|
suspend fun connectRadios()
|
||||||
|
suspend fun disconnectRadios()
|
||||||
|
fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?)
|
||||||
|
fun stopTracking()
|
||||||
|
fun setTransponder(transponder: SatRadio)
|
||||||
|
fun setTxBaseFrequency(frequencyHz: Long)
|
||||||
|
fun adjustTxBaseFrequency(deltaHz: Long)
|
||||||
|
fun setCtcssTone(toneHz: Double?)
|
||||||
|
fun setMode(txMode: String, rxMode: String)
|
||||||
|
}
|
||||||
+3
-14
@@ -17,18 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.domain.repository
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
interface IReporterParams
|
interface IReporter {
|
||||||
|
fun reportRotation(format: String, azimuth: Double, elevation: Double)
|
||||||
interface IReporterRepo<T : IReporterParams> {
|
fun reportFrequency(format: String, frequency: Long)
|
||||||
fun isConnected(service: BtService): Boolean
|
|
||||||
fun isConnecting(service: BtService): Boolean
|
|
||||||
fun connect(service: BtService, deviceId: String)
|
|
||||||
fun reportRotation(format: String, azimuth: Double, elevation: Double, params: T)
|
|
||||||
fun reportFrequency(format: String, frequency: Long, params: T)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ExtendedParams(val server: String, val port: Int) : IReporterParams
|
|
||||||
|
|
||||||
data class WithoutExtParams(val dummy: Int) : IReporterParams
|
|
||||||
|
|
||||||
enum class BtService { ROTATOR, FREQUENCY }
|
|
||||||
+29
-6
@@ -25,13 +25,36 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
interface ISatelliteRepo {
|
interface ISatelliteRepo {
|
||||||
val passes: StateFlow<List<OrbitalPass>>
|
/** All selected OrbitalObjects, updated when the selection changes. */
|
||||||
val satellites: StateFlow<List<OrbitalObject>>
|
val satellites: StateFlow<List<OrbitalObject>>
|
||||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
|
||||||
|
/** Raw calculated passes (without live progress). Updated on selection/filter change. */
|
||||||
|
val passes: StateFlow<List<OrbitalPass>>
|
||||||
|
|
||||||
|
/** Whether the repo is currently calculating passes. */
|
||||||
|
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. */
|
||||||
suspend fun initRepository()
|
suspend fun initRepository()
|
||||||
suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos
|
|
||||||
suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos>
|
/** Recalculate passes with the given filter parameters. */
|
||||||
suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio>
|
|
||||||
suspend fun processPasses(passList: List<OrbitalPass>, time: Long): List<OrbitalPass>
|
|
||||||
suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>)
|
suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>)
|
||||||
|
|
||||||
|
/** Get the current position of a single satellite. */
|
||||||
|
suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos
|
||||||
|
|
||||||
|
/** Get the ground track for a satellite over a time range. */
|
||||||
|
suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos>
|
||||||
|
|
||||||
|
/** Get Doppler-shifted radio frequencies for a satellite at the given time. */
|
||||||
|
suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio>
|
||||||
|
|
||||||
|
/** Fetch radio transceivers for a satellite by its catalog number. */
|
||||||
|
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||||
}
|
}
|
||||||
+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()
|
||||||
|
|||||||
+11
-26
@@ -22,6 +22,7 @@ import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
|||||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
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.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
@@ -57,39 +58,23 @@ interface ISettingsRepo {
|
|||||||
|
|
||||||
//region # RC settings
|
//region # RC settings
|
||||||
val rcSettings: StateFlow<RCSettings>
|
val rcSettings: StateFlow<RCSettings>
|
||||||
fun setBluetoothRotatorAddress(value: String)
|
fun updateRCSettings(settings: RCSettings)
|
||||||
fun setBluetoothRotatorFormat(value: String)
|
|
||||||
fun setBluetoothRotatorName(value: String)
|
|
||||||
fun setBluetoothRotatorState(value: Boolean)
|
|
||||||
fun setBluetoothFrequencyAddress(value: String)
|
|
||||||
fun setBluetoothFrequencyFormat(value: String)
|
|
||||||
fun setBluetoothFrequencyState(value: Boolean)
|
|
||||||
fun setRotatorAddress(value: String)
|
|
||||||
fun setRotatorPort(value: String)
|
|
||||||
fun setRotatorState(value: Boolean)
|
|
||||||
fun setRotatorFormat(value: String)
|
|
||||||
fun setFrequencyAddress(value: String)
|
|
||||||
fun setFrequencyPort(value: String)
|
|
||||||
fun setFrequencyState(value: Boolean)
|
|
||||||
fun setFrequencyFormat(value: String)
|
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Other settings
|
//region # Other settings
|
||||||
val otherSettings: StateFlow<OtherSettings>
|
val otherSettings: StateFlow<OtherSettings>
|
||||||
fun setStateOfAutoUpdate(value: Boolean)
|
fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings)
|
||||||
fun setStateOfSensors(value: Boolean)
|
fun setWarningDismissed() = updateOtherSettings { it.copy(shouldSeeWarning = false) }
|
||||||
fun setStateOfSweep(value: Boolean)
|
fun setWhatsNewDismissed() = updateOtherSettings { it.copy(shouldSeeWhatsNew = false) }
|
||||||
fun setStateOfUtc(value: Boolean)
|
|
||||||
fun setStateOfLightTheme(value: Boolean)
|
|
||||||
fun setWarningDismissed()
|
|
||||||
fun setWhatsNewDismissed()
|
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Transceivers settings
|
//region # Transceivers settings
|
||||||
val dataSourcesSettings: StateFlow<DataSourcesSettings>
|
val dataSourcesSettings: StateFlow<DataSourcesSettings>
|
||||||
fun setUseCustomTle(value: Boolean)
|
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
||||||
fun setUseCustomTransceivers(value: Boolean)
|
//endregion
|
||||||
fun setTleUrl(value: String)
|
|
||||||
fun setTransceiversUrl(value: String)
|
//region # Radio control settings
|
||||||
|
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||||
|
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||||
//endregion
|
//endregion
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
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"
|
|
||||||
val satelliteDataUrls = mapOf(
|
val satelliteDataUrls = mapOf(
|
||||||
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||||
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
||||||
@@ -49,4 +48,7 @@ object Sources {
|
|||||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||||
"Other" to "" // key for sats filter
|
"Other" to "" // key for sats filter
|
||||||
)
|
)
|
||||||
|
val transceiversDataUrls = mapOf(
|
||||||
|
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,611 @@
|
|||||||
|
/*
|
||||||
|
* 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.SharedFlow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.round
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
class SstvFrame(
|
||||||
|
val imagePixels: IntArray?,
|
||||||
|
val imageWidth: Int,
|
||||||
|
val imageHeight: Int,
|
||||||
|
val modeName: String
|
||||||
|
)
|
||||||
|
|
||||||
|
class SstvDecoder(
|
||||||
|
sampleRate: Int = 44100,
|
||||||
|
scopeWidth: Int = 320,
|
||||||
|
scopeHeight: Int = 256,
|
||||||
|
private val channelSelect: Int = 0
|
||||||
|
) {
|
||||||
|
private val scopeBuffer = PixelBuffer(scopeWidth, scopeHeight * 2)
|
||||||
|
private val imageBuffer = PixelBuffer(scopeWidth, scopeHeight)
|
||||||
|
private val decoder = DecoderEngine(scopeBuffer, imageBuffer, "Raw", sampleRate)
|
||||||
|
private val _frames = MutableSharedFlow<SstvFrame>(
|
||||||
|
replay = 1,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||||
|
)
|
||||||
|
val frames: SharedFlow<SstvFrame> = _frames
|
||||||
|
val supportedModes: List<String> = decoder.allModes.map { it.name }
|
||||||
|
|
||||||
|
suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) {
|
||||||
|
// Normalize to a fixed RMS before processing so that both direct audio
|
||||||
|
// coupling and air-coupled microphone input decode with equal reliability.
|
||||||
|
normalise(samples)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitFrame() {
|
||||||
|
val imageWidth = imageBuffer.width
|
||||||
|
val imageHeight = imageBuffer.height
|
||||||
|
// 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
|
||||||
|
_frames.tryEmit(SstvFrame(imagePixels, imageWidth, imageHeight, modeName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target RMS level for the normalizer. 0.25 leaves headroom while keeping the
|
||||||
|
// FM demodulator well above its noise floor regardless of input gain.
|
||||||
|
private val targetRms = 0.25f
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
|
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()) {
|
||||||
|
newLines = true
|
||||||
|
} else if (sample > lastSync + (curLineSamples * 5) / 4) {
|
||||||
|
copyLines(
|
||||||
|
currentMode.decodeScanLine(
|
||||||
|
pixelBuffer,
|
||||||
|
scratch,
|
||||||
|
scanLineBuffer,
|
||||||
|
scopeBuffer.width,
|
||||||
|
lastSync,
|
||||||
|
curLineSamples,
|
||||||
|
lastOffset
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lastSync += curLineSamples; newLines = true
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
+69
-109
@@ -21,134 +21,94 @@ import com.rtbishop.look4sat.core.domain.model.SatRadio
|
|||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONArray
|
import kotlinx.serialization.json.Json
|
||||||
import org.json.JSONObject
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
|
|
||||||
class DataParser(private val dispatcher: CoroutineDispatcher) {
|
class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
coerceInputValues = true
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
|
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
|
||||||
val parsedItems = mutableListOf<OrbitalData>()
|
|
||||||
stream.bufferedReader().useLines { lines ->
|
stream.bufferedReader().useLines { lines ->
|
||||||
lines.forEachIndexed { index, line ->
|
lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList()
|
||||||
if (index != 0) {
|
|
||||||
val values = line.split(",")
|
|
||||||
parseCSV(values)?.let { tle -> parsedItems.add(tle) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return@withContext parsedItems
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun parseTLEStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
|
suspend fun parseTLEStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
|
||||||
val tleStrings = mutableListOf(String(), String(), String())
|
stream.bufferedReader().readLines()
|
||||||
val parsedItems = mutableListOf<OrbitalData>()
|
.chunked(3)
|
||||||
var lineIndex = 0
|
.filter { it.size == 3 && it[1].startsWith("1") && it[2].startsWith("2") }
|
||||||
stream.bufferedReader().forEachLine { line ->
|
.mapNotNull { parseTLE(it) }
|
||||||
tleStrings[lineIndex] = line
|
|
||||||
if (lineIndex < 2) {
|
|
||||||
lineIndex++
|
|
||||||
} else {
|
|
||||||
val isLineOneValid = tleStrings[1].substring(0, 1) == "1"
|
|
||||||
val isLineTwoValid = tleStrings[2].substring(0, 1) == "2"
|
|
||||||
if (!isLineOneValid && !isLineTwoValid) return@forEachLine
|
|
||||||
parseTLE(tleStrings)?.let { tle -> parsedItems.add(tle) }
|
|
||||||
lineIndex = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return@withContext parsedItems
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun parseJSONStream(stream: InputStream): List<SatRadio> = withContext(dispatcher) {
|
suspend fun parseJSONStream(stream: InputStream): List<SatRadio> = withContext(dispatcher) {
|
||||||
val parsedItems = mutableListOf<SatRadio>()
|
runCatching {
|
||||||
try {
|
val root = json.parseToJsonElement(stream.bufferedReader().readText())
|
||||||
val jsonArray = JSONArray(stream.bufferedReader().readText())
|
(root as? JsonArray)?.mapNotNull { element ->
|
||||||
for (index in 0 until jsonArray.length()) {
|
runCatching { json.decodeFromJsonElement<SatRadio>(element) }
|
||||||
val jsonObject = jsonArray.getJSONObject(index)
|
.onFailure { println("JSON parsing exception: $it") }
|
||||||
parseJSON(jsonObject)?.let { parsedItems.add(it) }
|
.getOrNull()
|
||||||
}
|
} ?: emptyList()
|
||||||
return@withContext parsedItems
|
}.getOrDefault(emptyList())
|
||||||
} catch (_: Exception) {
|
|
||||||
return@withContext parsedItems
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isLeapYear(year: Int): Boolean {
|
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
|
||||||
return ((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseCSV(values: List<String>): OrbitalData? = try {
|
|
||||||
val name = values[0]
|
val name = values[0]
|
||||||
val year = values[2].substring(0, 4)
|
val timestamp = values[2]
|
||||||
val month = values[2].substring(5, 7)
|
val year = timestamp.substring(0, 4)
|
||||||
val dayOfMonth = values[2].substring(8, 10)
|
val month = timestamp.substring(5, 7).toInt()
|
||||||
val dayInt = getDayOfYear(year.toInt(), month.toInt(), dayOfMonth.toInt())
|
val dayOfMonth = timestamp.substring(8, 10).toInt()
|
||||||
val day = if (dayInt < 10) "00$dayInt" else if (dayInt < 100) "0$dayInt" else "$dayInt"
|
val dayInt = getDayOfYear(year.toInt(), month, dayOfMonth)
|
||||||
val hour = values[2].substring(11, 13).toInt() * 3600000 // ms in one hour
|
val day = dayInt.toString().padStart(3, '0')
|
||||||
val min = values[2].substring(14, 16).toInt() * 60000 // ms in one minute
|
val hour = timestamp.substring(11, 13).toInt() * 3600000
|
||||||
val sec = values[2].substring(17, 19).toInt() * 1000 // ms in one second
|
val min = timestamp.substring(14, 16).toInt() * 60000
|
||||||
val ms = values[2].substring(20, 26).toInt() / 1000.0 // microseconds to ms
|
val sec = timestamp.substring(17, 19).toInt() * 1000
|
||||||
val frac = ((hour + min + sec + ms) / 86400000.0).toString()
|
val ms = timestamp.substring(20, 26).toInt() / 1000.0
|
||||||
val epoch = "${year.substring(2)}$day${frac.substring(1)}".toDouble()
|
val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1)
|
||||||
val meanmo = values[3].toDouble()
|
val epoch = "${year.substring(2)}$day$frac".toDouble()
|
||||||
val eccn = values[4].toDouble()
|
OrbitalData(
|
||||||
val incl = values[5].toDouble()
|
name = name,
|
||||||
val raan = values[6].toDouble()
|
epoch = epoch,
|
||||||
val argper = values[7].toDouble()
|
meanmo = values[3].toDouble(),
|
||||||
val meanan = values[8].toDouble()
|
eccn = values[4].toDouble(),
|
||||||
val catnum = values[11].toInt()
|
incl = values[5].toDouble(),
|
||||||
val bstar = values[14].toDouble()
|
raan = values[6].toDouble(),
|
||||||
OrbitalData(name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar)
|
argper = values[7].toDouble(),
|
||||||
} catch (exception: Exception) {
|
meanan = values[8].toDouble(),
|
||||||
println("CSV parsing exception: $exception")
|
catnum = values[11].toInt(),
|
||||||
null
|
bstar = values[14].toDouble(),
|
||||||
}
|
ndot = values[15].toDouble()
|
||||||
|
)
|
||||||
|
}.onFailure { println("CSV parsing exception: $it") }.getOrNull()
|
||||||
|
|
||||||
private fun parseTLE(tle: List<String>): OrbitalData? = try {
|
private fun parseTLE(tle: List<String>): OrbitalData? = runCatching {
|
||||||
val name: String = tle[0].trim()
|
val line1 = tle[1]
|
||||||
val epoch: Double = tle[1].substring(18, 32).toDouble()
|
val line2 = tle[2]
|
||||||
val meanmo: Double = tle[2].substring(52, 63).toDouble()
|
OrbitalData(
|
||||||
val eccn: Double = tle[2].substring(26, 33).toDouble() / 10000000.0
|
name = tle[0].trim(),
|
||||||
val incl: Double = tle[2].substring(8, 16).toDouble()
|
epoch = line1.substring(18, 32).toDouble(),
|
||||||
val raan: Double = tle[2].substring(17, 25).toDouble()
|
meanmo = line2.substring(52, 63).toDouble(),
|
||||||
val argper: Double = tle[2].substring(34, 42).toDouble()
|
eccn = line2.substring(26, 33).toDouble() / 1e7,
|
||||||
val meanan: Double = tle[2].substring(43, 51).toDouble()
|
incl = line2.substring(8, 16).toDouble(),
|
||||||
val catnum: Int = tle[1].substring(2, 7).trim().toInt()
|
raan = line2.substring(17, 25).toDouble(),
|
||||||
val bstar: Double = 1.0e-5 * tle[1].substring(53, 59).toDouble() / 10.0.pow(tle[1].substring(60, 61).toDouble())
|
argper = line2.substring(34, 42).toDouble(),
|
||||||
OrbitalData(name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar)
|
meanan = line2.substring(43, 51).toDouble(),
|
||||||
} catch (exception: Exception) {
|
catnum = line1.substring(2, 7).trim().toInt(),
|
||||||
println("TLE parsing exception: $exception")
|
bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble()),
|
||||||
null
|
ndot = line1.substring(33, 43).trim().toDouble()
|
||||||
}
|
)
|
||||||
|
}.onFailure { println("TLE parsing exception: $it") }.getOrNull()
|
||||||
|
|
||||||
private fun parseJSON(json: JSONObject): SatRadio? = try {
|
fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||||
val uuid = json.getString("uuid")
|
|
||||||
val info = json.getString("description")
|
|
||||||
val alive = json.getBoolean("alive")
|
|
||||||
val dlinkLow = if (json.isNull("downlink_low")) null else json.getLong("downlink_low")
|
|
||||||
val dlinkHigh = if (json.isNull("downlink_high")) null else json.getLong("downlink_high")
|
|
||||||
val dlinkMode = if (json.isNull("mode")) null else json.getString("mode")
|
|
||||||
val ulinkLow = if (json.isNull("uplink_low")) null else json.getLong("uplink_low")
|
|
||||||
val ulinkHigh = if (json.isNull("uplink_high")) null else json.getLong("uplink_high")
|
|
||||||
val ulinkMode = if (json.isNull("uplink_mode")) null else json.getString("uplink_mode")
|
|
||||||
val inverted = json.getBoolean("invert")
|
|
||||||
val catnum = if (json.isNull("norad_cat_id")) null else json.getInt("norad_cat_id")
|
|
||||||
SatRadio(uuid, info, alive, dlinkLow, dlinkHigh, dlinkMode, ulinkLow, ulinkHigh, ulinkMode, inverted, catnum)
|
|
||||||
} catch (exception: Exception) {
|
|
||||||
println("JSON parsing exception: $exception")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
|
fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
|
||||||
if (month == 1) return dayOfMonth
|
val daysInMonth = intArrayOf(31, if (isLeapYear(year)) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
||||||
val daysArray = arrayOf(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
return daysInMonth.take(month - 1).sum() + dayOfMonth
|
||||||
var dayOfYear = dayOfMonth
|
|
||||||
// If leap year increment Feb days
|
|
||||||
if (isLeapYear(year)) daysArray[1]++
|
|
||||||
for (i in 0 until month - 1) {
|
|
||||||
dayOfYear += daysArray[i]
|
|
||||||
}
|
|
||||||
return dayOfYear
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,10 +21,11 @@ import java.util.Locale
|
|||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
fun Long.toTimerString(): String {
|
fun Long.toTimerString(): String {
|
||||||
|
val millis = coerceAtLeast(0L)
|
||||||
val format = "%02d:%02d:%02d"
|
val format = "%02d:%02d:%02d"
|
||||||
val hours = TimeUnit.MILLISECONDS.toHours(this)
|
val hours = TimeUnit.MILLISECONDS.toHours(millis)
|
||||||
val minutes = TimeUnit.MILLISECONDS.toMinutes(this) % 60
|
val minutes = TimeUnit.MILLISECONDS.toMinutes(millis) % 60
|
||||||
val seconds = TimeUnit.MILLISECONDS.toSeconds(this) % 60
|
val seconds = TimeUnit.MILLISECONDS.toSeconds(millis) % 60
|
||||||
return String.format(Locale.ENGLISH, format, hours, minutes, seconds)
|
return String.format(Locale.ENGLISH, format, hours, minutes, seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,14 @@ package com.rtbishop.look4sat.core.domain.utility
|
|||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.DEG2RAD
|
import com.rtbishop.look4sat.core.domain.predict.DEG2RAD
|
||||||
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 +53,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)
|
||||||
}
|
}
|
||||||
|
|||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
* 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.utility
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
|
||||||
|
object TransponderMapper {
|
||||||
|
|
||||||
|
fun mapUplinkToDownlink(txFreqHz: Long, transponder: SatRadio): Long? {
|
||||||
|
val uplinkLow = transponder.uplinkLow ?: return null
|
||||||
|
val downlinkLow = transponder.downlinkLow ?: return null
|
||||||
|
|
||||||
|
// Single-frequency transponder (FM repeater) - just return the downlink freq
|
||||||
|
val uplinkHigh = transponder.uplinkHigh
|
||||||
|
val downlinkHigh = transponder.downlinkHigh
|
||||||
|
if (uplinkHigh == null || downlinkHigh == null
|
||||||
|
|| uplinkLow == uplinkHigh || downlinkLow == downlinkHigh
|
||||||
|
) {
|
||||||
|
return downlinkLow
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passband transponder (linear) - map within the band
|
||||||
|
val offset = txFreqHz - uplinkLow
|
||||||
|
return if (transponder.isInverted) {
|
||||||
|
downlinkHigh - offset
|
||||||
|
} else {
|
||||||
|
downlinkLow + offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mapDownlinkToUplink(rxFreqHz: Long, transponder: SatRadio): Long? {
|
||||||
|
val uplinkLow = transponder.uplinkLow ?: return null
|
||||||
|
val downlinkLow = transponder.downlinkLow ?: return null
|
||||||
|
|
||||||
|
val uplinkHigh = transponder.uplinkHigh
|
||||||
|
val downlinkHigh = transponder.downlinkHigh
|
||||||
|
if (uplinkHigh == null || downlinkHigh == null
|
||||||
|
|| uplinkLow == uplinkHigh || downlinkLow == downlinkHigh
|
||||||
|
) {
|
||||||
|
return uplinkLow
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (transponder.isInverted) {
|
||||||
|
uplinkLow + (downlinkHigh - rxFreqHz)
|
||||||
|
} else {
|
||||||
|
uplinkLow + (rxFreqHz - downlinkLow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mapUplinkModeToDownlinkMode(txMode: String, isInverted: Boolean): String {
|
||||||
|
if (!isInverted) return txMode
|
||||||
|
return when (txMode.uppercase()) {
|
||||||
|
"USB" -> "LSB"
|
||||||
|
"LSB" -> "USB"
|
||||||
|
else -> txMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,10 +59,53 @@ 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.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 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
|
||||||
fun `Given invalid CSV stream returns empty list`() = runTest(testDispatcher) {
|
fun `Given invalid CSV stream returns empty list`() = runTest(testDispatcher) {
|
||||||
assert(dataParser.parseCSVStream(invalidCSVStream).isEmpty())
|
assert(dataParser.parseCSVStream(invalidCSVStream).isEmpty())
|
||||||
@@ -71,10 +114,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 +159,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 +207,36 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class TransponderMapperTest {
|
||||||
|
|
||||||
|
private fun makeRadio(
|
||||||
|
uplinkLow: Long? = 435000000L,
|
||||||
|
uplinkHigh: Long? = 435100000L,
|
||||||
|
downlinkLow: Long? = 145800000L,
|
||||||
|
downlinkHigh: Long? = 145900000L,
|
||||||
|
isInverted: Boolean = true
|
||||||
|
) = SatRadio(
|
||||||
|
uuid = "test",
|
||||||
|
info = "Test",
|
||||||
|
isAlive = true,
|
||||||
|
downlinkLow = downlinkLow,
|
||||||
|
downlinkHigh = downlinkHigh,
|
||||||
|
downlinkMode = "USB",
|
||||||
|
uplinkLow = uplinkLow,
|
||||||
|
uplinkHigh = uplinkHigh,
|
||||||
|
uplinkMode = "USB",
|
||||||
|
isInverted = isInverted,
|
||||||
|
catnum = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun invertedTransponder_mapsFrequencyCorrectly() {
|
||||||
|
val radio = makeRadio(isInverted = true)
|
||||||
|
// TX at uplinkLow → RX should be downlinkHigh
|
||||||
|
assertEquals(145900000L, TransponderMapper.mapUplinkToDownlink(435000000L, radio))
|
||||||
|
// TX at uplinkHigh → RX should be downlinkLow
|
||||||
|
assertEquals(145800000L, TransponderMapper.mapUplinkToDownlink(435100000L, radio))
|
||||||
|
// TX at center → RX at center
|
||||||
|
assertEquals(145850000L, TransponderMapper.mapUplinkToDownlink(435050000L, radio))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nonInvertedTransponder_mapsFrequencyCorrectly() {
|
||||||
|
val radio = makeRadio(isInverted = false)
|
||||||
|
// TX at uplinkLow → RX should be downlinkLow
|
||||||
|
assertEquals(145800000L, TransponderMapper.mapUplinkToDownlink(435000000L, radio))
|
||||||
|
// TX at uplinkHigh → RX should be downlinkHigh
|
||||||
|
assertEquals(145900000L, TransponderMapper.mapUplinkToDownlink(435100000L, radio))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nullFrequencies_returnsNull() {
|
||||||
|
assertNull(TransponderMapper.mapUplinkToDownlink(435000000L, makeRadio(uplinkLow = null)))
|
||||||
|
assertNull(TransponderMapper.mapUplinkToDownlink(435000000L, makeRadio(downlinkLow = null)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun singleFrequencyTransponder_returnsDownlinkLow() {
|
||||||
|
// FM repeater style: no passband, just single frequencies
|
||||||
|
val radio = makeRadio(
|
||||||
|
uplinkLow = 145990000L, uplinkHigh = null,
|
||||||
|
downlinkLow = 436300000L, downlinkHigh = null,
|
||||||
|
isInverted = false
|
||||||
|
)
|
||||||
|
assertEquals(436300000L, TransponderMapper.mapUplinkToDownlink(145990000L, radio))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun equalLowHighTransponder_returnsDownlinkLow() {
|
||||||
|
val radio = makeRadio(
|
||||||
|
uplinkLow = 145990000L, uplinkHigh = 145990000L,
|
||||||
|
downlinkLow = 436300000L, downlinkHigh = 436300000L,
|
||||||
|
isInverted = false
|
||||||
|
)
|
||||||
|
assertEquals(436300000L, TransponderMapper.mapUplinkToDownlink(145990000L, radio))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun invertedMode_swapsUsbLsb() {
|
||||||
|
assertEquals("LSB", TransponderMapper.mapUplinkModeToDownlinkMode("USB", true))
|
||||||
|
assertEquals("USB", TransponderMapper.mapUplinkModeToDownlinkMode("LSB", true))
|
||||||
|
assertEquals("FM", TransponderMapper.mapUplinkModeToDownlinkMode("FM", true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nonInvertedMode_keepsSame() {
|
||||||
|
assertEquals("USB", TransponderMapper.mapUplinkModeToDownlinkMode("USB", false))
|
||||||
|
assertEquals("LSB", TransponderMapper.mapUplinkModeToDownlinkMode("LSB", false))
|
||||||
|
assertEquals("FM", TransponderMapper.mapUplinkModeToDownlinkMode("FM", false))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.convention.composeLibraryPlugin)
|
alias(libs.plugins.convention.corePresentationPlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|||||||
+199
-141
@@ -40,19 +40,31 @@ 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.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.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.adaptive.currentWindowAdaptiveInfo
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
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.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
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.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
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 androidx.compose.ui.window.Dialog
|
||||||
@@ -62,8 +74,7 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
|||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
private val sdfTime = SimpleDateFormat("HH:mm:ss", Locale.ENGLISH)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@@ -76,19 +87,23 @@ private fun TopBarPreview() = MainTheme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TopBar(content: @Composable (RowScope.() -> Unit)) {
|
fun TopBar(content: @Composable RowScope.() -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.height(48.dp),
|
modifier = Modifier.height(48.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) { content() }
|
content = content
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RowScope.TimerRow(timeString: String, isTimeAos: Boolean) {
|
fun RowScope.TimerRow(timeString: String, isTimeAos: Boolean) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
val aosColor = if (isTimeAos) colorScheme.primary else colorScheme.onSurface
|
val (aosColor, losColor) = if (isTimeAos) {
|
||||||
val losColor = if (!isTimeAos) colorScheme.primary else colorScheme.onSurface
|
colorScheme.primary to colorScheme.onSurface
|
||||||
|
} else {
|
||||||
|
colorScheme.onSurface to colorScheme.primary
|
||||||
|
}
|
||||||
ElevatedCard(modifier = Modifier.weight(1f)) {
|
ElevatedCard(modifier = Modifier.weight(1f)) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -100,7 +115,7 @@ fun RowScope.TimerRow(timeString: String, isTimeAos: Boolean) {
|
|||||||
text = timeString,
|
text = timeString,
|
||||||
fontSize = 32.sp,
|
fontSize = 32.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = colorScheme.primary
|
||||||
)
|
)
|
||||||
Text(text = "LOS", fontSize = 16.sp, fontWeight = FontWeight.Medium, color = losColor)
|
Text(text = "LOS", fontSize = 16.sp, fontWeight = FontWeight.Medium, color = losColor)
|
||||||
}
|
}
|
||||||
@@ -114,7 +129,13 @@ private fun NextPassRowPreview() = MainTheme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier) {
|
fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc: Boolean = false) {
|
||||||
|
val timeZone = remember(isUtc) {
|
||||||
|
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
|
}
|
||||||
|
val sdfTime = remember(isUtc) {
|
||||||
|
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
|
||||||
|
}
|
||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.height(48.dp)
|
.height(48.dp)
|
||||||
@@ -124,13 +145,11 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier) {
|
|||||||
verticalArrangement = Arrangement.spacedBy((-2).dp),
|
verticalArrangement = Arrangement.spacedBy((-2).dp),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
// .background(color = MaterialTheme.colorScheme.background)
|
|
||||||
.padding(start = 6.dp, top = 1.dp, end = 6.dp, bottom = 0.dp)
|
.padding(start = 6.dp, top = 1.dp, end = 6.dp, bottom = 0.dp)
|
||||||
) {
|
) {
|
||||||
val passSatId = stringResource(id = R.string.pass_satId, pass.catNum)
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
text = "$passSatId - ",
|
text = "${stringResource(R.string.pass_satId, pass.catNum)} - ",
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
@@ -143,16 +162,17 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier) {
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
val elevColor = elevationColor(pass.maxElevation)
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = 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(
|
||||||
@@ -160,47 +180,24 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier) {
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Row(
|
Text(
|
||||||
modifier = Modifier.weight(1f),
|
text = sdfTime.format(Date(pass.aosTime)),
|
||||||
horizontalArrangement = Arrangement.Start,
|
fontSize = 15.sp,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
modifier = Modifier.weight(1f)
|
||||||
) {
|
)
|
||||||
Text(
|
|
||||||
text = sdfTime.format(Date(pass.aosTime)),
|
|
||||||
fontSize = 15.sp,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
Text(text = "${pass.altitude} km", fontSize = 15.sp)
|
||||||
painter = painterResource(id = R.drawable.ic_altitude),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "${pass.altitude} km",
|
|
||||||
fontSize = 15.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
horizontalArrangement = Arrangement.End,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(
|
|
||||||
id = R.string.pass_aosLos,
|
|
||||||
pass.aosAzimuth.toInt(),
|
|
||||||
pass.losAzimuth.toInt()
|
|
||||||
),
|
|
||||||
fontSize = 15.sp
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.pass_aosLos, pass.aosAzimuth.toInt(), pass.losAzimuth.toInt()),
|
||||||
|
fontSize = 15.sp,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,19 +206,21 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onClick = { onClick() }, colors = ButtonDefaults.buttonColors(
|
onClick = onClick,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
), shape = MaterialTheme.shapes.small, modifier = modifier,
|
),
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
modifier = modifier,
|
||||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)
|
||||||
) { Text(text = text, fontSize = 16.sp, textAlign = TextAlign.Center) }
|
) { Text(text = text, fontSize = 16.sp, textAlign = TextAlign.Center) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier) {
|
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier, enabled: Boolean = true) {
|
||||||
val clickableMod = Modifier.clickable { action() }
|
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action) {
|
||||||
ElevatedCard(modifier = Modifier.size(48.dp)) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Box(modifier = clickableMod.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
|
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,14 +228,16 @@ fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PrimaryIconCard(modifier: Modifier = Modifier, resId: Int, onClick: () -> Unit) {
|
fun PrimaryIconCard(modifier: Modifier = Modifier, resId: Int, onClick: () -> Unit) {
|
||||||
val clickableMod = modifier.clickable { onClick() }
|
|
||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = Modifier.size(102.dp, 48.dp),
|
modifier = Modifier.size(102.dp, 48.dp),
|
||||||
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.primary)
|
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.primary)
|
||||||
) {
|
) {
|
||||||
Box(modifier = clickableMod.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(
|
||||||
Icon(painter = painterResource(id = resId), contentDescription = null)
|
modifier = modifier
|
||||||
}
|
.clickable(onClick = onClick)
|
||||||
|
.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) { Icon(painter = painterResource(resId), contentDescription = null) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,56 +263,56 @@ fun EmptyListCard(message: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDefaultPass(): OrbitalPass {
|
private val defaultOrbitalData = OrbitalData(
|
||||||
val orbitalData = OrbitalData(
|
name = """ ¯\_(ツ)_/¯ ⚠️""",
|
||||||
""" ¯\_(ツ)_/¯ ⚠️""",
|
epoch = 0.0, meanmo = 0.0, eccn = 0.0, incl = 0.0,
|
||||||
0.0,
|
raan = 0.0, argper = 0.0, meanan = 0.0, catnum = 0, bstar = 0.0
|
||||||
0.0,
|
)
|
||||||
0.0,
|
|
||||||
0.0,
|
fun getDefaultPass(): OrbitalPass = OrbitalPass(
|
||||||
0.0,
|
aosTime = 0L, aosAzimuth = 0.0, losTime = Long.MAX_VALUE, losAzimuth = 0.0,
|
||||||
0.0,
|
altitude = 0, maxElevation = 0.0, orbitalObject = NearEarthObject(defaultOrbitalData), progress = 0f
|
||||||
0.0,
|
)
|
||||||
0,
|
|
||||||
0.0
|
|
||||||
)
|
|
||||||
val satellite = NearEarthObject(orbitalData)
|
|
||||||
return OrbitalPass(0L, 0.0, Long.MAX_VALUE, 0.0, 0, 0.0, satellite, 0f)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SharedDialog(
|
fun SharedDialog(
|
||||||
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
DialogShell(title = title, titleFontSize = 16, onDismissRequest = onCancel) {
|
||||||
Dialog(onDismissRequest = { onCancel() }) {
|
content()
|
||||||
ElevatedCard {
|
Row(modifier = Modifier.padding(start = it, bottom = it, end = it)) {
|
||||||
Column(
|
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
verticalArrangement = Arrangement.spacedBy(padding)
|
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(start = padding, top = padding, end = padding)
|
|
||||||
)
|
|
||||||
content()
|
|
||||||
Row(modifier = Modifier.padding(start = padding, bottom = padding, end = padding)) {
|
|
||||||
CardButton(onClick = onCancel, text = stringResource(id = R.string.btn_cancel))
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
CardButton(onClick = onAccept, text = stringResource(id = R.string.btn_accept))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InfoDialog(title: String, text: String, onDismiss: () -> Unit) {
|
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,
|
||||||
|
titleFontSize: Int,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
content: @Composable (padding: androidx.compose.ui.unit.Dp) -> Unit
|
||||||
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
val padding = LocalSpacing.current.large
|
||||||
Dialog(onDismissRequest = {}) {
|
Dialog(onDismissRequest = onDismissRequest) {
|
||||||
ElevatedCard {
|
ElevatedCard {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
@@ -319,54 +320,36 @@ fun InfoDialog(title: String, text: String, onDismiss: () -> Unit) {
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
fontSize = 18.sp,
|
fontSize = titleFontSize.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.padding(start = padding, top = padding, end = padding)
|
modifier = Modifier.padding(start = padding, top = padding, end = padding)
|
||||||
)
|
)
|
||||||
Text(
|
content(padding)
|
||||||
text = text,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier.padding(horizontal = padding)
|
|
||||||
)
|
|
||||||
Row(modifier = Modifier.padding(start = padding, bottom = padding, end = padding)) {
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
CardButton(
|
|
||||||
onClick = onDismiss,
|
|
||||||
text = stringResource(id = R.string.btn_accept)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun hasEnoughHeight(): Boolean {
|
fun hasEnoughHeight(): Boolean =
|
||||||
return currentWindowAdaptiveInfo().windowSizeClass.isHeightAtLeastBreakpoint(480)
|
currentWindowAdaptiveInfo().windowSizeClass.isHeightAtLeastBreakpoint(480)
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun hasEnoughWidth(): Boolean {
|
fun hasEnoughWidth(): Boolean =
|
||||||
return currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600)
|
currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600)
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun isVerticalLayout(): Boolean {
|
fun isVerticalLayout(): Boolean = !hasEnoughWidth()
|
||||||
return currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600).not()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Modifier.infiniteMarquee(): Modifier {
|
fun Modifier.infiniteMarquee(): Modifier =
|
||||||
return basicMarquee(iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(16.dp))
|
basicMarquee(iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(16.dp))
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Modifier.layoutPadding(): Modifier {
|
fun Modifier.layoutPadding(): Modifier {
|
||||||
val statusBarMod = this.statusBarsPadding()
|
|
||||||
val spacing = LocalSpacing.current.extraSmall
|
val spacing = LocalSpacing.current.extraSmall
|
||||||
return statusBarMod.padding(start = spacing, top = 0.dp, end = spacing, bottom = spacing)
|
return statusBarsPadding().padding(start = spacing, top = 0.dp, end = spacing, bottom = spacing)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -375,15 +358,15 @@ fun ScreenColumn(
|
|||||||
floatingBar: @Composable () -> Unit = {},
|
floatingBar: @Composable () -> Unit = {},
|
||||||
content: @Composable (Boolean) -> Unit = {}
|
content: @Composable (Boolean) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val isVerticalLayout = isVerticalLayout()
|
val isVertical = isVerticalLayout()
|
||||||
Surface(color = MaterialTheme.colorScheme.background) {
|
Surface(color = MaterialTheme.colorScheme.background) {
|
||||||
Box(modifier = Modifier.layoutPadding(), contentAlignment = Alignment.BottomCenter) {
|
Box(modifier = Modifier.layoutPadding(), contentAlignment = Alignment.BottomCenter) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(LocalSpacing.current.extraSmall)) {
|
Column(verticalArrangement = Arrangement.spacedBy(LocalSpacing.current.extraSmall)) {
|
||||||
topBar(isVerticalLayout)
|
topBar(isVertical)
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainer
|
color = MaterialTheme.colorScheme.surfaceContainer
|
||||||
) { content(isVerticalLayout) }
|
) { content(isVertical) }
|
||||||
}
|
}
|
||||||
floatingBar()
|
floatingBar()
|
||||||
}
|
}
|
||||||
@@ -394,21 +377,96 @@ fun ScreenColumn(
|
|||||||
fun TopBar(
|
fun TopBar(
|
||||||
isVerticalLayout: Boolean,
|
isVerticalLayout: Boolean,
|
||||||
startAction: @Composable () -> Unit,
|
startAction: @Composable () -> Unit,
|
||||||
topInfo: @Composable (RowScope.() -> Unit),
|
topInfo: @Composable RowScope.() -> Unit,
|
||||||
bottomInfo: @Composable (RowScope.() -> Unit),
|
bottomInfo: @Composable RowScope.() -> Unit,
|
||||||
endAction: @Composable () -> Unit
|
endAction: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val topBarRow = @Composable { content: @Composable RowScope.() -> Unit ->
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.height(48.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) { content() }
|
|
||||||
}
|
|
||||||
if (isVerticalLayout) {
|
if (isVerticalLayout) {
|
||||||
topBarRow { startAction().also { topInfo() }.also { endAction() } }
|
TopBar { startAction(); topInfo(); endAction() }
|
||||||
topBarRow { bottomInfo() }
|
TopBar { bottomInfo() }
|
||||||
} else {
|
} else {
|
||||||
topBarRow { startAction().also { topInfo() }.also { bottomInfo() }.also { endAction() } }
|
TopBar { startAction(); topInfo(); bottomInfo(); endAction() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun elevationColor(elevation: Double): Color {
|
||||||
|
return when {
|
||||||
|
elevation < 15.0 -> Color(0xFFEF5350) // soft red for low elevation
|
||||||
|
elevation < 45.0 -> MaterialTheme.colorScheme.primary // accent yellow for normal
|
||||||
|
else -> Color(0xFF66BB6A) // soft green for high elevation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
+67
-6
@@ -1,9 +1,70 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
package com.rtbishop.look4sat.core.presentation
|
||||||
|
|
||||||
sealed class Screen(val route: String, val iconResId: Int, val titleResId: Int) {
|
import androidx.navigation3.runtime.NavKey
|
||||||
data object Satellites : Screen("satellites", R.drawable.ic_satellites, R.string.nav_sat)
|
import kotlinx.serialization.Serializable
|
||||||
data object Passes : Screen("passes", R.drawable.ic_passes, R.string.nav_pass)
|
|
||||||
data object Radar : Screen("radar", R.drawable.ic_radar, R.string.nav_radar)
|
@Serializable
|
||||||
data object Map : Screen("map", R.drawable.ic_map, R.string.nav_map)
|
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
||||||
data object Settings : Screen("settings", R.drawable.ic_settings, R.string.nav_prefs)
|
|
||||||
|
@Serializable
|
||||||
|
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
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="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>
|
||||||
@@ -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>
|
||||||
+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="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z" />
|
||||||
</vector>
|
</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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -118,12 +118,10 @@
|
|||||||
|
|
||||||
<string name="prefs_net_title">Salida de datos de red</string>
|
<string name="prefs_net_title">Salida de datos de red</string>
|
||||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
||||||
<string name="prefs_net_rotator_ip_hint">Dirección IP</string>
|
<string name="prefs_net_rotator_address_hint">IP:Puerto</string>
|
||||||
<string name="prefs_net_rotator_port_hint">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">Enable frequency output</string>
|
||||||
<string name="prefs_net_frequency_ip_hint">Dirección IP</string>
|
<string name="prefs_net_frequency_address_hint">IP:Puerto</string>
|
||||||
<string name="prefs_net_frequency_port_hint">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 por Bluetooth</string>
|
||||||
|
|||||||
@@ -117,12 +117,10 @@
|
|||||||
|
|
||||||
<string name="prefs_net_title">Вывод сетевых данных</string>
|
<string name="prefs_net_title">Вывод сетевых данных</string>
|
||||||
<string name="prefs_net_rotator_switch">Включить вывод ротатора</string>
|
<string name="prefs_net_rotator_switch">Включить вывод ротатора</string>
|
||||||
<string name="prefs_net_rotator_ip_hint">IP адрес</string>
|
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
|
||||||
<string name="prefs_net_rotator_port_hint">Порт</string>
|
|
||||||
<string name="prefs_net_rotator_format_hint">Формат</string>
|
<string name="prefs_net_rotator_format_hint">Формат</string>
|
||||||
<string name="prefs_net_frequency_switch">Включить вывод частоты</string>
|
<string name="prefs_net_frequency_switch">Включить вывод частоты</string>
|
||||||
<string name="prefs_net_frequency_ip_hint">IP адрес</string>
|
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
||||||
<string name="prefs_net_frequency_port_hint">Порт</string>
|
|
||||||
<string name="prefs_net_frequency_format_hint">Формат</string>
|
<string name="prefs_net_frequency_format_hint">Формат</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">Вывод данных Bluetooth</string>
|
<string name="prefs_bt_title">Вывод данных Bluetooth</string>
|
||||||
|
|||||||
@@ -118,12 +118,10 @@
|
|||||||
|
|
||||||
<string name="prefs_net_title">ජාල ප්රතිදානය</string>
|
<string name="prefs_net_title">ජාල ප්රතිදානය</string>
|
||||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
||||||
<string name="prefs_net_rotator_ip_hint">IP ලිපිනය</string>
|
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_rotator_port_hint">Port</string>
|
|
||||||
<string name="prefs_net_rotator_format_hint">දත්ත අකාරය</string>
|
<string name="prefs_net_rotator_format_hint">දත්ත අකාරය</string>
|
||||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||||
<string name="prefs_net_frequency_ip_hint">IP ලිපිනය</string>
|
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_frequency_port_hint">Port</string>
|
|
||||||
<string name="prefs_net_frequency_format_hint">දත්ත අකාරය</string>
|
<string name="prefs_net_frequency_format_hint">දත්ත අකාරය</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">Bluetooth දත්ත ප්රතිදානය</string>
|
<string name="prefs_bt_title">Bluetooth දත්ත ප්රතිදානය</string>
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Commmon -->
|
||||||
|
<string name="app_name" translatable="false">Look4Sat</string>
|
||||||
|
<string name="btn_accept">Kabul et</string>
|
||||||
|
<string name="btn_cancel">İptal</string>
|
||||||
|
<string name="empty_list_message">Gösterilecek veri bulunamadı</string>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<string name="nav_sat">Uydular</string>
|
||||||
|
<string name="nav_pass">Geçişler</string>
|
||||||
|
<string name="nav_radar">Radar</string>
|
||||||
|
<string name="nav_map">Harita</string>
|
||||||
|
<string name="nav_prefs">Ayarlar</string>
|
||||||
|
|
||||||
|
<!-- Satellites screen -->
|
||||||
|
<string name="sat_type_hint">Uydu türü: %s</string>
|
||||||
|
<string name="sat_type_title">Uydu türü seçin</string>
|
||||||
|
<string name="sat_search_hint">Id - Ad</string>
|
||||||
|
<string name="sat_search_clear">Temizle</string>
|
||||||
|
<string name="sat_clear_all">Tümünü temizle</string>
|
||||||
|
<string name="sat_select_all">Tümünü seç</string>
|
||||||
|
<string name="sat_empty_list_message">"Arama sorgunuzun doğru olduğundan ve veritabanının güncellendiğinden emin olun"</string>
|
||||||
|
<string name="sat_warning_title">Uyarı\!</string>
|
||||||
|
<string name="sat_warning_message">
|
||||||
|
Bu uygulamada 9000\'den fazla uydu listelenmiştir.
|
||||||
|
Hepsini aynı anda takip etmek anlamsızdır.
|
||||||
|
\n\nListeyi yalnızca ilgilendiğiniz uydularla
|
||||||
|
daraltmak için arama ve tür seçiciyi kullanın.</string>
|
||||||
|
|
||||||
|
<!-- Passes screen -->
|
||||||
|
<string name="pass_filter_title">Geçişleri filtrele</string>
|
||||||
|
<string name="pass_filter_elev">Minimum yükseklik açısı</string>
|
||||||
|
<string name="pass_filter_hours">Gösterilecek saat aralığı</string>
|
||||||
|
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||||
|
<string name="pass_modes_title">Modulasyon türü seçin</string>
|
||||||
|
<string name="pass_satId" translatable="false">%05d</string>
|
||||||
|
<string name="pass_elevation">Yükseklik açısı: %.1f°</string>
|
||||||
|
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
||||||
|
<string name="pass_altitude">İrtifa: %d km</string>
|
||||||
|
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
|
||||||
|
<string name="pass_empty_list_message">
|
||||||
|
Filtrelerinizin doğru olduğundan ve uydu seçtiğinizden emin olun</string>
|
||||||
|
<string name="pass_error_message">Gösterilecek yaklaşan uydu geçişi bulunmuyor</string>
|
||||||
|
<string name="pass_welcome_title">Look4Sat\'a hoş geldiniz\!</string>
|
||||||
|
<string name="pass_welcome_message">
|
||||||
|
Ayarlar\'dan GPS, Enlem/Boylam veya QTH kullanarak konumunuzu belirlediğinizden emin olun.
|
||||||
|
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
|
||||||
|
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||||
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
|
* Switched to short-form commands in NetworkReporter, by theojalba
|
||||||
|
\n* Added CAT radio control for full-duplex SAT operation, by jcmerg
|
||||||
|
\n* Added normalized time for passes AOS/LOS, fixed refresh state bug
|
||||||
|
\n* Added multiple performance tweaks, improved passes calculation
|
||||||
|
\n* Fixed UTC pass time problem mentioned in issue #201
|
||||||
|
</string>
|
||||||
|
|
||||||
|
<!-- Radar screen -->
|
||||||
|
<string name="radar_back">Geri</string>
|
||||||
|
<string name="radar_notify">Bildir</string>
|
||||||
|
<string name="radar_az_text">Azimut</string>
|
||||||
|
<string name="radar_az_value" translatable="false">%.1f°</string>
|
||||||
|
<string name="radar_el_text">Yükseklik açısı</string>
|
||||||
|
<string name="radar_el_value" translatable="false">%.1f°</string>
|
||||||
|
<string name="radar_alt_text">İrtifa</string>
|
||||||
|
<string name="radar_alt_value" translatable="false">%.0f km</string>
|
||||||
|
<string name="radar_dist_text">Mesafe</string>
|
||||||
|
<string name="radar_dist_value" translatable="false">%.0f km</string>
|
||||||
|
<string name="radar_eclipsed">Gölgede</string>
|
||||||
|
<string name="radar_downlink">Downlink</string>
|
||||||
|
<string name="radar_uplink">Uplink</string>
|
||||||
|
<string name="radar_link_low" translatable="false">%.4f</string>
|
||||||
|
<string name="radar_link_lowHigh" translatable="false">%.4f - %.4f</string>
|
||||||
|
<string name="radar_no_link" translatable="false">- - . - -</string>
|
||||||
|
<string name="radar_no_data">Bu uyduda transceiver bulunmuyor</string>
|
||||||
|
<string name="radar_add">Ekle</string>
|
||||||
|
<string name="radar_data">Uydu transceiver\'ları:</string>
|
||||||
|
<string name="radar_inverted">Ters çevrilmiş: %s</string>
|
||||||
|
<string name="radar_mode">Modülasyon: %s</string>
|
||||||
|
<string name="radar_string_no">Hayır</string>
|
||||||
|
<string name="radar_string_yes">Evet</string>
|
||||||
|
<string name="radar_visible">Görünür</string>
|
||||||
|
|
||||||
|
<!-- Map screen -->
|
||||||
|
<string name="map_prev">Önceki</string>
|
||||||
|
<string name="map_next">Sonraki</string>
|
||||||
|
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
|
||||||
|
<string name="map_azimuth">Azimut: %.1f°</string>
|
||||||
|
<string name="map_elevation">Yükseklik açısı: %.1f°</string>
|
||||||
|
<string name="map_altitude">İrtifa: %.0f km</string>
|
||||||
|
<string name="map_distance">Mesafe: %.0f km</string>
|
||||||
|
<string name="map_latitude">Enlem: %.1f°</string>
|
||||||
|
<string name="map_longitude">Boylam: %.1f°</string>
|
||||||
|
<string name="map_qth" translatable="false">QTH: %s</string>
|
||||||
|
<string name="map_phase">Faz: %.1f°</string>
|
||||||
|
<string name="map_eclipsed">Gölgede</string>
|
||||||
|
<string name="map_period">Periyot: %.0f dk</string>
|
||||||
|
<string name="map_velocity">Hız: %.2f km/s</string>
|
||||||
|
<string name="map_visibility">Görünürlük: %s</string>
|
||||||
|
<string name="map_visible">Görünür</string>
|
||||||
|
|
||||||
|
<!-- Settings screen -->
|
||||||
|
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
|
||||||
|
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
|
||||||
|
<string name="prefs_donate_title">Bağış yap</string>
|
||||||
|
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
||||||
|
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
|
||||||
|
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
|
||||||
|
<string name="prefs_github_title" translatable="false">GitHub</string>
|
||||||
|
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
||||||
|
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
||||||
|
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
|
||||||
|
<string name="prefs_privacy_title">Gizlilik Politikası</string>
|
||||||
|
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
|
||||||
|
|
||||||
|
<string name="prefs_updated_title">Güncellendi: %s</string>
|
||||||
|
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
||||||
|
|
||||||
|
<string name="prefs_loc_title">İstasyon konumu</string>
|
||||||
|
<string name="prefs_loc_gps_title">GPS</string>
|
||||||
|
<string name="prefs_loc_gps_error">Konum izinlerinizi kontrol edin</string>
|
||||||
|
<string name="prefs_loc_input_title">Manuel giriş</string>
|
||||||
|
<string name="prefs_loc_input_error">Geçersiz konum girildi</string>
|
||||||
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
|
<string name="prefs_loc_qth_error">Geçersiz QTH konumlandırıcısı</string>
|
||||||
|
<string name="prefs_loc_success">Konum başarıyla güncellendi</string>
|
||||||
|
<string name="prefs_station_title">İstasyon konumu ayarları</string>
|
||||||
|
<string name="prefs_station_lat_text">Yer istasyonunuzun enlemini girin</string>
|
||||||
|
<string name="prefs_station_lon_text">Yer istasyonunuzun boylamını girin</string>
|
||||||
|
<string name="prefs_locator_title">QTH konumlandırıcı ayarları</string>
|
||||||
|
<string name="prefs_locator_text">İstasyon konumunu konumlandırıcı ile ayarlayın</string>
|
||||||
|
|
||||||
|
<string name="prefs_data_title">Uydu verisi</string>
|
||||||
|
<string name="prefs_data_entries">Uydular: %s</string>
|
||||||
|
<string name="prefs_data_radios">Transceiver\'lar: %s</string>
|
||||||
|
<string name="prefs_data_update">Güncelle</string>
|
||||||
|
<string name="prefs_data_import">İçe aktar</string>
|
||||||
|
<string name="prefs_data_clear">Temizle</string>
|
||||||
|
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
|
||||||
|
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
|
||||||
|
|
||||||
|
<string name="prefs_data_sources_title">Özel veri kaynakları</string>
|
||||||
|
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_switch">Özel transceiver URL</string>
|
||||||
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
|
|
||||||
|
<string name="prefs_data_output_title">Veri aktarımı</string>
|
||||||
|
<string name="prefs_net_output">Ağ</string>
|
||||||
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
|
<string name="prefs_cat_output">CAT</string>
|
||||||
|
|
||||||
|
<!-- Radio Control -->
|
||||||
|
<string name="nav_radiocontrol">Radyo Kontrolü</string>
|
||||||
|
<string name="rc_settings_title">CAT Radyo Kontrolü</string>
|
||||||
|
<string name="rc_radio_model">Radyo Modeli</string>
|
||||||
|
<string name="rc_tx_device_hint">TX Radyo BT Adresi</string>
|
||||||
|
<string name="rc_rx_device_hint">RX Radyo BT Adresi</string>
|
||||||
|
<string name="rc_tx_name_hint">TX Radyo Adı</string>
|
||||||
|
<string name="rc_rx_name_hint">RX Radyo Adı</string>
|
||||||
|
<string name="rc_enable_switch">CAT Kontrolünü Etkinleştir</string>
|
||||||
|
|
||||||
|
<string name="prefs_net_title">Ağ veri çıkışı</string>
|
||||||
|
<string name="prefs_net_rotator_switch">Döndürme çıkışını etkinleştir</string>
|
||||||
|
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||||
|
<string name="prefs_net_rotator_format_hint">Biçim</string>
|
||||||
|
<string name="prefs_net_frequency_switch">Frekans çıkışını etkinleştir</string>
|
||||||
|
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||||
|
<string name="prefs_net_frequency_format_hint">Biçim</string>
|
||||||
|
|
||||||
|
<string name="prefs_bt_title">Bluetooth veri çıkışı</string>
|
||||||
|
<string name="prefs_bt_rotator_switch">Döndürme çıkışını etkinleştir</string>
|
||||||
|
<string name="prefs_bt_rotator_device_hint">Cihaz kimliği</string>
|
||||||
|
<string name="prefs_bt_rotator_output_hint">Veri biçimi</string>
|
||||||
|
<string name="prefs_bt_frequency_switch">Frekans çıkışını etkinleştir</string>
|
||||||
|
<string name="prefs_bt_frequency_device_hint">Cihaz kimliği</string>
|
||||||
|
<string name="prefs_bt_frequency_output_hint">Veri biçimi</string>
|
||||||
|
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
|
||||||
|
|
||||||
|
<string name="prefs_other_title">Diğer ayarlar</string>
|
||||||
|
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
|
||||||
|
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
|
||||||
|
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
|
||||||
|
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
|
||||||
|
|
||||||
|
<string name="prefs_outro_title">Teşekkürler</string>
|
||||||
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
|
• Look4Sat users and contributors!
|
||||||
|
\n• David A. B. Johnson (predict4java)
|
||||||
|
\n• Dave Moten (predict4java)
|
||||||
|
\n• Alexandru Csete (Gpredict)
|
||||||
|
\n• Dr T.S. Kelso (Celestrak)
|
||||||
|
\n• Libre Space Foundation (SatNOGS)</string>
|
||||||
|
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
|
||||||
|
|
||||||
|
</resources>
|
||||||
@@ -118,12 +118,10 @@
|
|||||||
|
|
||||||
<string name="prefs_net_title">Вивід мережевих даних</string>
|
<string name="prefs_net_title">Вивід мережевих даних</string>
|
||||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
||||||
<string name="prefs_net_rotator_ip_hint">IP адреса</string>
|
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
|
||||||
<string name="prefs_net_rotator_port_hint">Порт</string>
|
|
||||||
<string name="prefs_net_rotator_format_hint">Формат даних</string>
|
<string name="prefs_net_rotator_format_hint">Формат даних</string>
|
||||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||||
<string name="prefs_net_frequency_ip_hint">IP адреса</string>
|
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
||||||
<string name="prefs_net_frequency_port_hint">Порт</string>
|
|
||||||
<string name="prefs_net_frequency_format_hint">Формат даних</string>
|
<string name="prefs_net_frequency_format_hint">Формат даних</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">Вивід даних Bluetooth</string>
|
<string name="prefs_bt_title">Вивід даних Bluetooth</string>
|
||||||
|
|||||||
@@ -110,12 +110,10 @@
|
|||||||
|
|
||||||
<string name="prefs_net_title">网络数据输出</string>
|
<string name="prefs_net_title">网络数据输出</string>
|
||||||
<string name="prefs_net_rotator_switch">启用方位俯仰角输出</string>
|
<string name="prefs_net_rotator_switch">启用方位俯仰角输出</string>
|
||||||
<string name="prefs_net_rotator_ip_hint">IP地址</string>
|
<string name="prefs_net_rotator_address_hint">IP:端口</string>
|
||||||
<string name="prefs_net_rotator_port_hint">端口</string>
|
|
||||||
<string name="prefs_net_rotator_format_hint">数据格式</string>
|
<string name="prefs_net_rotator_format_hint">数据格式</string>
|
||||||
<string name="prefs_net_frequency_switch">启用多普勒频率输出</string>
|
<string name="prefs_net_frequency_switch">启用多普勒频率输出</string>
|
||||||
<string name="prefs_net_frequency_ip_hint">IP地址</string>
|
<string name="prefs_net_frequency_address_hint">IP:端口</string>
|
||||||
<string name="prefs_net_frequency_port_hint">端口</string>
|
|
||||||
<string name="prefs_net_frequency_format_hint">数据格式</string>
|
<string name="prefs_net_frequency_format_hint">数据格式</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">蓝牙数据输出</string>
|
<string name="prefs_bt_title">蓝牙数据输出</string>
|
||||||
@@ -132,8 +130,16 @@
|
|||||||
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
||||||
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
|
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
|
||||||
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
|
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
|
||||||
|
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">我要感谢:</string>
|
<string name="prefs_outro_title">我要感谢:</string>
|
||||||
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
|
• Look4Sat 所有用户及贡献者!
|
||||||
|
\n• David A. B. Johnson (predict4java)
|
||||||
|
\n• Dave Moten (predict4java)
|
||||||
|
\n• Alexandru Csete (Gpredict)
|
||||||
|
\n• Dr T.S. Kelso (Celestrak)
|
||||||
|
\n• Libre Space Foundation (SatNOGS)</string>
|
||||||
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
<string name="pass_filter_title">Filter passes</string>
|
<string name="pass_filter_title">Filter passes</string>
|
||||||
<string name="pass_filter_elev">Minimal elevation</string>
|
<string name="pass_filter_elev">Minimal elevation</string>
|
||||||
<string name="pass_filter_hours">Hours ahead</string>
|
<string name="pass_filter_hours">Hours ahead</string>
|
||||||
|
<string name="pass_filter_deep_space">DeepSpace (period >225min)</string>
|
||||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||||
<string name="pass_modes_title">Select modulation type</string>
|
<string name="pass_modes_title">Select modulation type</string>
|
||||||
<string name="pass_satId" translatable="false">%05d</string>
|
<string name="pass_satId" translatable="false">%05d</string>
|
||||||
@@ -48,8 +49,9 @@
|
|||||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
||||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* Londre7 fixed the track path creation issue for split screen
|
* Fixed transceivers retention on a failed fetch from server
|
||||||
as well as enabled the compass data usage for landscape orientation
|
\n\n* Fixed SSTV frequency display, no transceiver selected hint
|
||||||
|
\n\n* Fixed All satellite category retention on Satellites screen
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
@@ -144,15 +146,24 @@
|
|||||||
<string name="prefs_data_output_title">Data output</string>
|
<string name="prefs_data_output_title">Data output</string>
|
||||||
<string name="prefs_net_output">Network</string>
|
<string name="prefs_net_output">Network</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
|
<string name="prefs_cat_output">CAT</string>
|
||||||
|
|
||||||
|
<!-- Radio Control -->
|
||||||
|
<string name="nav_radiocontrol">Radio Control</string>
|
||||||
|
<string name="rc_settings_title">CAT Radio Control</string>
|
||||||
|
<string name="rc_radio_model">Radio Model</string>
|
||||||
|
<string name="rc_tx_device_hint">TX Radio BT Address</string>
|
||||||
|
<string name="rc_rx_device_hint">RX Radio BT Address</string>
|
||||||
|
<string name="rc_tx_name_hint">TX Radio Name</string>
|
||||||
|
<string name="rc_rx_name_hint">RX Radio Name</string>
|
||||||
|
<string name="rc_enable_switch">Enable CAT Control</string>
|
||||||
|
|
||||||
<string name="prefs_net_title">Network data output</string>
|
<string name="prefs_net_title">Network data output</string>
|
||||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
||||||
<string name="prefs_net_rotator_ip_hint">IP address</string>
|
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_rotator_port_hint">Port</string>
|
|
||||||
<string name="prefs_net_rotator_format_hint">Format</string>
|
<string name="prefs_net_rotator_format_hint">Format</string>
|
||||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||||
<string name="prefs_net_frequency_ip_hint">IP address</string>
|
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_frequency_port_hint">Port</string>
|
|
||||||
<string name="prefs_net_frequency_format_hint">Format</string>
|
<string name="prefs_net_frequency_format_hint">Format</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">Bluetooth data output</string>
|
<string name="prefs_bt_title">Bluetooth data output</string>
|
||||||
@@ -169,15 +180,18 @@
|
|||||||
<string name="prefs_other_switch_update">Enable automatic data update</string>
|
<string name="prefs_other_switch_update">Enable automatic data update</string>
|
||||||
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
|
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
|
||||||
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
|
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
|
||||||
|
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">I would like to say thanks to</string>
|
<string name="prefs_outro_title">I would like to say thanks to</string>
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
• Look4Sat users and contributors!
|
* Look4Sat users and contributors!
|
||||||
\n• David A. B. Johnson (predict4java)
|
\n* David A. B. Johnson (predict4java)
|
||||||
\n• Dave Moten (predict4java)
|
\n* Dave Moten (predict4java)
|
||||||
\n• Alexandru Csete (Gpredict)
|
\n* Alexandru Csete (Gpredict)
|
||||||
\n• Dr T.S. Kelso (Celestrak)
|
\n* Dr T.S. Kelso (Celestrak)
|
||||||
\n• Libre Space Foundation (SatNOGS)</string>
|
\n* Libre Space Foundation (SatNOGS)
|
||||||
|
\n* xdsopl and Robot36 contributors!
|
||||||
|
</string>
|
||||||
<string name="prefs_outro_license">The app comes with no warranty</string>
|
<string name="prefs_outro_license">The app comes with no warranty</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
/*
|
||||||
|
* 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 org.junit.Test
|
||||||
|
|
||||||
|
class DeeplinkResolverTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun returnsDefaultDestination() {
|
||||||
|
val deeplinkResolver = DeeplinkResolver()
|
||||||
|
val result = deeplinkResolver.resolve("/deeplink")
|
||||||
|
assert(result == Screen.Passes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun returnsRadarDestination() {
|
||||||
|
val passId = "some-pass-id"
|
||||||
|
val passDeeplink = "https://github.com/rt-bishop/Look4Sat/passes/$passId"
|
||||||
|
val deeplinkResolver = DeeplinkResolver()
|
||||||
|
val result = deeplinkResolver.resolve(passDeeplink)
|
||||||
|
assert(result == RadarDestination)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
* Fixed track path creation issue for split screens (#199) by londre7
|
* Fixed transceivers retention on a failed fetch from server
|
||||||
* Enabled using compass data for landscape orientation (#202) by londre7
|
* Fixed SSTV frequency display, no transceiver selected hint
|
||||||
|
* Fixed All satellite category retention on Satellites screen
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
* Fixed track path creation issue for split screens (#199) by londre7
|
|
||||||
* Enabled using compass data for landscape orientation (#202) by londre7
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.convention.composeFeaturePlugin)
|
alias(libs.plugins.convention.featurePlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/*
|
||||||
|
* 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.feature.map
|
||||||
|
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.RectF
|
||||||
|
import org.osmdroid.views.MapView
|
||||||
|
import org.osmdroid.views.overlay.Overlay
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom osmdroid overlay that shades the night side of the globe.
|
||||||
|
*
|
||||||
|
* Works entirely in screen-pixel space: for each vertical strip on screen it
|
||||||
|
* asks osmdroid for the geographic coordinate, then tests whether that point is
|
||||||
|
* in the night half-sphere relative to the sub-solar point. Because the
|
||||||
|
* computation happens during draw() the result is always correct regardless
|
||||||
|
* of zoom level or map scroll position — no polygon winding issues possible.
|
||||||
|
*
|
||||||
|
* A point (latRad, lonRad) is in night when the angle to the sub-solar point
|
||||||
|
* exceeds 90°, i.e. the dot product of the two unit vectors is negative:
|
||||||
|
* dot = sin(lat)*sin(sunLat) + cos(lat)*cos(sunLat)*cos(lon - sunLon) < 0
|
||||||
|
*
|
||||||
|
* Performance: we sample one column per [stepPx] pixels (default 4) and draw
|
||||||
|
* filled vertical rectangles. On a 1080-wide screen this means ~270 trig
|
||||||
|
* evaluations per row, which is imperceptible.
|
||||||
|
*/
|
||||||
|
class MapNightOverlay : Overlay() {
|
||||||
|
|
||||||
|
/** Sub-solar latitude in degrees. */
|
||||||
|
var sunLatDeg: Double = 0.0
|
||||||
|
|
||||||
|
/** Sub-solar longitude in degrees. */
|
||||||
|
var sunLonDeg: Double = 0.0
|
||||||
|
|
||||||
|
private val nightPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
style = Paint.Style.FILL
|
||||||
|
color = Color.argb(75, 0, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val rect = RectF()
|
||||||
|
|
||||||
|
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
|
||||||
|
if (shadow) return
|
||||||
|
|
||||||
|
val proj = mapView.projection
|
||||||
|
val sunLatRad = Math.toRadians(sunLatDeg)
|
||||||
|
val sunLonRad = Math.toRadians(sunLonDeg)
|
||||||
|
val sinSunLat = sin(sunLatRad)
|
||||||
|
val cosSunLat = cos(sunLatRad)
|
||||||
|
|
||||||
|
val w = mapView.width
|
||||||
|
val h = mapView.height
|
||||||
|
val stepPx = 4 // sample every N pixels — balance quality vs CPU
|
||||||
|
|
||||||
|
// We scan column by column. For each column we determine the longitude,
|
||||||
|
// then find the latitude range that is in night and shade it.
|
||||||
|
// Since longitude is constant along a vertical strip and the day/night
|
||||||
|
// boundary at a given longitude is at most two latitudes, we can do a
|
||||||
|
// scan-line fill efficiently.
|
||||||
|
|
||||||
|
var x = 0
|
||||||
|
while (x < w) {
|
||||||
|
// Get the geographic coordinate at the top and bottom of this column.
|
||||||
|
val geoTop = proj.fromPixels(x, 0) ?: run { x += stepPx; continue }
|
||||||
|
val geoBot = proj.fromPixels(x, h - 1) ?: run { x += stepPx; continue }
|
||||||
|
|
||||||
|
val lonRad = Math.toRadians(geoTop.longitude)
|
||||||
|
val cosLonDiff = cos(lonRad - sunLonRad)
|
||||||
|
|
||||||
|
// Top pixel geographic lat
|
||||||
|
val latTopRad = Math.toRadians(geoTop.latitude)
|
||||||
|
// Bottom pixel geographic lat (osmdroid: y=0 is top of screen, higher y = lower lat)
|
||||||
|
val latBotRad = Math.toRadians(geoBot.latitude)
|
||||||
|
|
||||||
|
// dot(sunVec, pointVec) < 0 → night
|
||||||
|
// dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff
|
||||||
|
val dotTop = sin(latTopRad) * sinSunLat + cos(latTopRad) * cosSunLat * cosLonDiff
|
||||||
|
val dotBot = sin(latBotRad) * sinSunLat + cos(latBotRad) * cosSunLat * cosLonDiff
|
||||||
|
|
||||||
|
when {
|
||||||
|
dotTop < 0 && dotBot < 0 -> {
|
||||||
|
// Entire column is night — shade from top to bottom
|
||||||
|
rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), h.toFloat())
|
||||||
|
canvas.drawRect(rect, nightPaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
dotTop >= 0 && dotBot >= 0 -> {
|
||||||
|
// Entire column is day — nothing to draw
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
// Terminator crosses this column — find the crossing pixel by binary search
|
||||||
|
val crossY = findCrossingY(proj, x, 0, h - 1, sinSunLat, cosSunLat, cosLonDiff)
|
||||||
|
if (dotTop < 0) {
|
||||||
|
// Night at top, day at bottom
|
||||||
|
rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), crossY.toFloat())
|
||||||
|
canvas.drawRect(rect, nightPaint)
|
||||||
|
} else {
|
||||||
|
// Day at top, night at bottom
|
||||||
|
rect.set(x.toFloat(), crossY.toFloat(), (x + stepPx).toFloat(), h.toFloat())
|
||||||
|
canvas.drawRect(rect, nightPaint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
x += stepPx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binary-search for the pixel row where the day/night boundary crosses column [x].
|
||||||
|
* [yTop] is in day, [yBot] is in night (or vice versa).
|
||||||
|
*/
|
||||||
|
private fun findCrossingY(
|
||||||
|
proj: org.osmdroid.views.Projection,
|
||||||
|
x: Int,
|
||||||
|
yTop: Int,
|
||||||
|
yBot: Int,
|
||||||
|
sinSunLat: Double,
|
||||||
|
cosSunLat: Double,
|
||||||
|
cosLonDiff: Double
|
||||||
|
): Int {
|
||||||
|
var lo = yTop
|
||||||
|
var hi = yBot
|
||||||
|
while (hi - lo > 1) {
|
||||||
|
val mid = (lo + hi) / 2
|
||||||
|
val geo = proj.fromPixels(x, mid) ?: return mid
|
||||||
|
val latRad = Math.toRadians(geo.latitude)
|
||||||
|
val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
|
||||||
|
if (dot < 0) hi = mid else lo = mid
|
||||||
|
}
|
||||||
|
return (lo + hi) / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import android.graphics.ColorMatrixColorFilter
|
|||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
|
import androidx.collection.LruCache
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -40,7 +41,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.getValue
|
||||||
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
|
||||||
@@ -59,15 +60,13 @@ import androidx.lifecycle.LifecycleEventObserver
|
|||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavGraphBuilder
|
|
||||||
import androidx.navigation.compose.composable
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.Screen
|
|
||||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
@@ -80,6 +79,16 @@ import org.osmdroid.views.overlay.FolderOverlay
|
|||||||
import org.osmdroid.views.overlay.Marker
|
import org.osmdroid.views.overlay.Marker
|
||||||
import org.osmdroid.views.overlay.Polyline
|
import org.osmdroid.views.overlay.Polyline
|
||||||
|
|
||||||
|
// Overlay indices
|
||||||
|
private const val OVERLAY_STATION = 0
|
||||||
|
private const val OVERLAY_TRACK = 1
|
||||||
|
private const val OVERLAY_FOOTPRINT = 2
|
||||||
|
private const val OVERLAY_POSITIONS = 3
|
||||||
|
private const val OVERLAY_TERMINATOR = 4
|
||||||
|
private const val OVERLAY_SUN = 5
|
||||||
|
private const val OVERLAY_MOON = 6
|
||||||
|
private const val OVERLAY_COUNT = 7
|
||||||
|
|
||||||
private val minLat = MapView.getTileSystem().minLatitude
|
private val minLat = MapView.getTileSystem().minLatitude
|
||||||
private val maxLat = MapView.getTileSystem().maxLatitude
|
private val maxLat = MapView.getTileSystem().maxLatitude
|
||||||
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
@@ -100,98 +109,122 @@ private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|||||||
color = "#FFE082".toColorInt()
|
color = "#FFE082".toColorInt()
|
||||||
setShadowLayer(3f, 3f, 3f, Color.BLACK)
|
setShadowLayer(3f, 3f, 3f, Color.BLACK)
|
||||||
}
|
}
|
||||||
private val labelRect = Rect()
|
private val iconCache = LruCache<String, Drawable>(128)
|
||||||
|
private val sunIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
fun NavGraphBuilder.mapDestination() {
|
colorFilter =
|
||||||
composable(Screen.Map.route) {
|
android.graphics.PorterDuffColorFilter("#FFE082".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
|
||||||
val viewModel = viewModel(MapViewModel::class.java, factory = MapViewModel.Factory)
|
}
|
||||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle()
|
private val moonIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
val mapView = rememberMapViewWithLifecycle()
|
colorFilter =
|
||||||
MapScreen(uiState, mapView)
|
android.graphics.PorterDuffColorFilter("#E0E0E0".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MapScreen(uiState: State<MapState>, mapView: MapView) {
|
fun MapDestination() {
|
||||||
val onItemClick = { item: OrbitalObject -> uiState.value.sendAction(MapAction.SelectItem(item)) }
|
val context = LocalContext.current
|
||||||
val selectPrev = { uiState.value.sendAction(MapAction.SelectPrev) }
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val selectNext = { uiState.value.sendAction(MapAction.SelectNext) }
|
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val mapView = rememberMapViewWithLifecycle()
|
||||||
|
MapScreen(uiState, viewModel::onAction, mapView)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView: MapView) {
|
||||||
val rotateMod = Modifier.rotate(180f)
|
val rotateMod = Modifier.rotate(180f)
|
||||||
val timeString = uiState.value.mapData?.aosTime ?: "00:00:00"
|
val timeString = uiState.mapData?.aosTime ?: "00:00:00"
|
||||||
val isTimeAos = uiState.value.mapData?.isTimeAos ?: true
|
val isTimeAos = uiState.mapData?.isTimeAos ?: true
|
||||||
LaunchedEffect(uiState.value.track) {
|
|
||||||
val latitude = uiState.value.track?.get(0)?.get(0)?.latitude ?: 0.0
|
LaunchedEffect(uiState.track) {
|
||||||
val longitude = uiState.value.track?.get(0)?.get(0)?.longitude ?: 0.0
|
val firstPos = uiState.track?.firstOrNull()?.firstOrNull() ?: return@LaunchedEffect
|
||||||
mapView.controller.animateTo(GeoPoint(latitude, longitude))
|
mapView.controller.animateTo(GeoPoint(firstPos.latitude, firstPos.longitude))
|
||||||
}
|
}
|
||||||
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
if (isVerticalLayout()) {
|
val isVertical = isVerticalLayout()
|
||||||
|
if (isVertical) {
|
||||||
TopBar {
|
TopBar {
|
||||||
IconCard(action = selectPrev, resId = R.drawable.ic_arrow, modifier = rotateMod)
|
IconCard(action = { onAction(MapAction.SelectPrev) }, resId = R.drawable.ic_arrow, modifier = rotateMod)
|
||||||
TimerRow(timeString = timeString, isTimeAos = isTimeAos)
|
TimerRow(timeString = timeString, isTimeAos = isTimeAos)
|
||||||
IconCard(action = selectNext, resId = R.drawable.ic_arrow)
|
IconCard(action = { onAction(MapAction.SelectNext) }, resId = R.drawable.ic_arrow)
|
||||||
}
|
}
|
||||||
TopBar { NextPassRow(pass = uiState.value.orbitalPass) }
|
TopBar { NextPassRow(pass = uiState.orbitalPass, isUtc = uiState.isUtc) }
|
||||||
} else {
|
} else {
|
||||||
TopBar {
|
TopBar {
|
||||||
IconCard(action = selectPrev, resId = R.drawable.ic_arrow, modifier = rotateMod)
|
IconCard(action = { onAction(MapAction.SelectPrev) }, resId = R.drawable.ic_arrow, modifier = rotateMod)
|
||||||
TimerRow(timeString = timeString, isTimeAos = isTimeAos)
|
TimerRow(timeString = timeString, isTimeAos = isTimeAos)
|
||||||
NextPassRow(pass = uiState.value.orbitalPass, modifier = Modifier.weight(1f))
|
NextPassRow(pass = uiState.orbitalPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
|
||||||
IconCard(action = selectNext, resId = R.drawable.ic_arrow)
|
IconCard(action = { onAction(MapAction.SelectNext) }, resId = R.drawable.ic_arrow)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ElevatedCard(modifier = Modifier.weight(1f)) {
|
ElevatedCard(modifier = Modifier.weight(1f)) {
|
||||||
Box(contentAlignment = Alignment.BottomCenter) {
|
Box(contentAlignment = Alignment.BottomCenter) {
|
||||||
AndroidView({ mapView }) { mapView ->
|
AndroidView({ mapView }) { view ->
|
||||||
uiState.value.stationPosition?.let { setStationPosition(it, mapView) }
|
uiState.stationPosition?.let { setStationPosition(it, view) }
|
||||||
uiState.value.positions?.let { setPositions(it, mapView, onItemClick) }
|
uiState.track?.let { setSatelliteTrack(it, view) }
|
||||||
uiState.value.track?.let { setSatelliteTrack(it, mapView) }
|
uiState.footprint?.let { setFootprint(it, view) }
|
||||||
uiState.value.footprint?.let { setFootprint(it, mapView) }
|
uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } }
|
||||||
|
setTerminator(uiState.sunLatDeg, uiState.sunLonDeg, view)
|
||||||
|
setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view)
|
||||||
|
setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view)
|
||||||
|
view.invalidate()
|
||||||
}
|
}
|
||||||
uiState.value.mapData?.let { mapData ->
|
uiState.mapData?.let { mapData ->
|
||||||
if (isVerticalLayout()) MapDataCard(mapData) else MapDataCards(mapData)
|
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// region Map data composables
|
||||||
@Composable
|
@Composable
|
||||||
private fun MapDataCard(data: MapData) {
|
private fun MapDataCard(data: MapData) {
|
||||||
val textColor = MaterialTheme.colorScheme.primary
|
val textColor = MaterialTheme.colorScheme.primary
|
||||||
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
|
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(text = stringResource(R.string.map_copyright), fontSize = 14.sp)
|
Text(text = stringResource(R.string.map_copyright), fontSize = 14.sp)
|
||||||
Card(colors = cardColors) {
|
Card(colors = cardColors) {
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
MapDataRow(
|
||||||
Text(text = stringResource(R.string.map_azimuth, data.azimuth), color = textColor)
|
stringResource(R.string.map_azimuth, data.azimuth) to textColor,
|
||||||
Text(text = stringResource(R.string.map_elevation, data.elevation), color = textColor)
|
stringResource(R.string.map_elevation, data.elevation) to textColor
|
||||||
}
|
)
|
||||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
MapDataRow(
|
||||||
Text(text = stringResource(R.string.map_altitude, data.altitude))
|
stringResource(R.string.map_altitude, data.altitude) to null,
|
||||||
Text(text = stringResource(R.string.map_distance, data.range))
|
stringResource(R.string.map_distance, data.range) to null
|
||||||
}
|
)
|
||||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
MapDataRow(
|
||||||
Text(text = stringResource(R.string.map_latitude, data.osmPos.latitude), color = textColor)
|
stringResource(R.string.map_latitude, data.osmPos.latitude) to textColor,
|
||||||
Text(text = stringResource(R.string.map_longitude, data.osmPos.longitude), color = textColor)
|
stringResource(R.string.map_longitude, data.osmPos.longitude) to textColor
|
||||||
}
|
)
|
||||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
MapDataRow(
|
||||||
Text(text = stringResource(R.string.map_qth, data.qthLoc))
|
stringResource(R.string.map_qth, data.qthLoc) to null,
|
||||||
Text(text = stringResource(R.string.map_phase, data.phase))
|
stringResource(R.string.map_phase, data.phase) to null
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MapDataRow(
|
||||||
|
left: Pair<String, androidx.compose.ui.graphics.Color?>,
|
||||||
|
right: Pair<String, androidx.compose.ui.graphics.Color?>
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
if (left.second != null) Text(text = left.first, color = left.second!!)
|
||||||
|
else Text(text = left.first)
|
||||||
|
if (right.second != null) Text(text = right.first, color = right.second!!)
|
||||||
|
else Text(text = right.first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MapDataCards(data: MapData) {
|
private fun MapDataCards(data: MapData) {
|
||||||
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
|
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
|
||||||
val paddingMod = Modifier
|
val paddingMod = Modifier
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
.width(160.dp)
|
.width(160.dp)
|
||||||
val osmText = stringResource(R.string.map_copyright)
|
|
||||||
val textColor = MaterialTheme.colorScheme.primary
|
val textColor = MaterialTheme.colorScheme.primary
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
Card(colors = cardColors, modifier = Modifier.align(Alignment.TopStart)) {
|
Card(colors = cardColors, modifier = Modifier.align(Alignment.TopStart)) {
|
||||||
@@ -212,7 +245,11 @@ private fun MapDataCards(data: MapData) {
|
|||||||
Text(text = stringResource(R.string.map_qth, data.qthLoc))
|
Text(text = stringResource(R.string.map_qth, data.qthLoc))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(text = osmText, fontSize = 14.sp, modifier = Modifier.align(Alignment.BottomCenter))
|
Text(
|
||||||
|
text = stringResource(R.string.map_copyright),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter)
|
||||||
|
)
|
||||||
Card(colors = cardColors, modifier = Modifier.align(Alignment.BottomEnd)) {
|
Card(colors = cardColors, modifier = Modifier.align(Alignment.BottomEnd)) {
|
||||||
Column(horizontalAlignment = Alignment.End, modifier = paddingMod) {
|
Column(horizontalAlignment = Alignment.End, modifier = paddingMod) {
|
||||||
Text(text = stringResource(R.string.map_latitude, data.osmPos.latitude), color = textColor)
|
Text(text = stringResource(R.string.map_latitude, data.osmPos.latitude), color = textColor)
|
||||||
@@ -221,113 +258,250 @@ private fun MapDataCards(data: MapData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Map overlay helpers
|
||||||
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
||||||
try {
|
try {
|
||||||
Marker(mapView).apply {
|
val overlay = mapView.overlays[OVERLAY_STATION]
|
||||||
setInfoWindow(null)
|
if (overlay is Marker) {
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
overlay.position = GeoPoint(stationPos.latitude, stationPos.longitude)
|
||||||
icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position)
|
} else {
|
||||||
position = GeoPoint(stationPos.latitude, stationPos.longitude)
|
mapView.overlays[OVERLAY_STATION] = Marker(mapView).apply {
|
||||||
mapView.overlays[0] = this
|
|
||||||
mapView.invalidate()
|
|
||||||
}
|
|
||||||
} catch (exception: Exception) {
|
|
||||||
println(exception)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setPositions(posMap: Map<OrbitalObject, GeoPos>, mapView: MapView, action: (OrbitalObject) -> Unit) {
|
|
||||||
val markers = FolderOverlay()
|
|
||||||
try {
|
|
||||||
posMap.entries.forEach {
|
|
||||||
Marker(mapView).apply {
|
|
||||||
setInfoWindow(null)
|
setInfoWindow(null)
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||||
icon = getCustomTextIcon(it.key.data.name, mapView)
|
icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position)
|
||||||
try {
|
position = GeoPoint(stationPos.latitude, stationPos.longitude)
|
||||||
position = GeoPoint(it.value.latitude, it.value.longitude)
|
|
||||||
} catch (exception: IllegalArgumentException) {
|
|
||||||
println(exception.stackTraceToString())
|
|
||||||
}
|
|
||||||
setOnMarkerClickListener { _, _ ->
|
|
||||||
action(it.key)
|
|
||||||
return@setOnMarkerClickListener true
|
|
||||||
}
|
|
||||||
markers.add(this)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mapView.overlays[3] = markers
|
} catch (e: Exception) {
|
||||||
mapView.invalidate()
|
println(e)
|
||||||
} catch (exception: Exception) {
|
|
||||||
println(exception)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getCustomTextIcon(textLabel: String, mapView: MapView): Drawable {
|
/** Pool of reusable Marker objects keyed by satellite name, to avoid re-creation every frame */
|
||||||
textPaint.getTextBounds(textLabel, 0, textLabel.length, labelRect)
|
private val markerPool = HashMap<String, Marker>()
|
||||||
|
private var lastMapView: MapView? = null
|
||||||
|
|
||||||
|
private fun setPositions(
|
||||||
|
posMap: Map<OrbitalObject, GeoPos>,
|
||||||
|
mapView: MapView,
|
||||||
|
action: (OrbitalObject) -> Unit
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Clear caches when the MapView instance changes (e.g. config change)
|
||||||
|
if (lastMapView !== mapView) {
|
||||||
|
lastMapView = mapView
|
||||||
|
markerPool.clear()
|
||||||
|
iconCache.evictAll()
|
||||||
|
footprintPolyline = null
|
||||||
|
footprintPoints = null
|
||||||
|
}
|
||||||
|
// Reuse the existing FolderOverlay — creating a new one and replacing it
|
||||||
|
// causes osmdroid to detach shared Marker objects, making them invisible.
|
||||||
|
val folder = mapView.overlays[OVERLAY_POSITIONS] as? FolderOverlay ?: FolderOverlay().also {
|
||||||
|
mapView.overlays[OVERLAY_POSITIONS] = it
|
||||||
|
}
|
||||||
|
folder.items.clear()
|
||||||
|
|
||||||
|
val activeNames = HashSet<String>(posMap.size)
|
||||||
|
posMap.forEach { (satellite, geoPos) ->
|
||||||
|
val name = satellite.data.name
|
||||||
|
activeNames.add(name)
|
||||||
|
val marker = markerPool.getOrPut(name) {
|
||||||
|
Marker(mapView).apply {
|
||||||
|
setInfoWindow(null)
|
||||||
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
|
icon = getCachedTextIcon(name, mapView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Update position in-place — reuse existing GeoPoint if available
|
||||||
|
val pos = marker.position
|
||||||
|
if (pos != null) {
|
||||||
|
pos.latitude = geoPos.latitude
|
||||||
|
pos.longitude = geoPos.longitude
|
||||||
|
} else {
|
||||||
|
marker.position = GeoPoint(geoPos.latitude, geoPos.longitude)
|
||||||
|
}
|
||||||
|
marker.setOnMarkerClickListener { _, _ ->
|
||||||
|
action(satellite)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
folder.add(marker)
|
||||||
|
}
|
||||||
|
// Evict markers for satellites no longer tracked
|
||||||
|
val iter = markerPool.keys.iterator()
|
||||||
|
while (iter.hasNext()) {
|
||||||
|
if (iter.next() !in activeNames) iter.remove()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCachedTextIcon(name: String, mapView: MapView): Drawable {
|
||||||
|
iconCache[name]?.let { return it }
|
||||||
|
val labelRect = Rect()
|
||||||
|
textPaint.getTextBounds(name, 0, name.length, labelRect)
|
||||||
val iconSize = 10f
|
val iconSize = 10f
|
||||||
val width = labelRect.width() + iconSize * 2f
|
val width = labelRect.width() + iconSize * 2f
|
||||||
val height = textPaint.textSize * 3f + iconSize * 2f
|
val height = textPaint.textSize * 3f + iconSize * 2f
|
||||||
val bitmap = createBitmap(width.toInt(), height.toInt())
|
val bitmap = createBitmap(width.toInt(), height.toInt())
|
||||||
Canvas(bitmap).run {
|
Canvas(bitmap).run {
|
||||||
drawCircle(width / 2f, height / 2f, iconSize, textPaint)
|
drawCircle(width / 2f, height / 2f, iconSize, textPaint)
|
||||||
drawText(textLabel, iconSize / 2f, height - iconSize, textPaint)
|
drawText(name, iconSize / 2f, height - iconSize, textPaint)
|
||||||
}
|
}
|
||||||
return bitmap.toDrawable(mapView.context.resources)
|
val drawable = bitmap.toDrawable(mapView.context.resources)
|
||||||
|
iconCache.put(name, drawable)
|
||||||
|
return drawable
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
|
private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
|
||||||
val trackOverlay = FolderOverlay()
|
val trackOverlay = FolderOverlay()
|
||||||
try {
|
try {
|
||||||
satTrack.forEach { track ->
|
satTrack.forEach { track ->
|
||||||
val trackPoints = track.map { GeoPoint(it.latitude, it.longitude) }
|
|
||||||
Polyline().apply {
|
Polyline().apply {
|
||||||
setPoints(trackPoints)
|
setPoints(track.map { GeoPoint(it.latitude, it.longitude) })
|
||||||
outlinePaint.set(trackPaint)
|
outlinePaint.set(trackPaint)
|
||||||
trackOverlay.add(this)
|
trackOverlay.add(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mapView.overlays[1] = trackOverlay
|
mapView.overlays[OVERLAY_TRACK] = trackOverlay
|
||||||
} catch (exception: Exception) {
|
} catch (e: Exception) {
|
||||||
println(exception)
|
println(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reusable footprint Polyline — created once, points updated in-place each frame */
|
||||||
|
private var footprintPolyline: Polyline? = null
|
||||||
|
private var footprintPoints: ArrayList<GeoPoint>? = null
|
||||||
|
|
||||||
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
|
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
|
||||||
val footprintPoints = orbitalPos.getRangeCircle().map { GeoPoint(it.latitude, it.longitude) }
|
|
||||||
try {
|
try {
|
||||||
val footprintOverlay = Polyline().apply {
|
val rangeCircle = orbitalPos.getRangeCircle()
|
||||||
outlinePaint.set(footprintPaint)
|
var pts = footprintPoints
|
||||||
setPoints(footprintPoints)
|
if (pts == null || pts.size != rangeCircle.size) {
|
||||||
|
pts = ArrayList(rangeCircle.size)
|
||||||
|
for (gp in rangeCircle) pts.add(GeoPoint(gp.latitude, gp.longitude))
|
||||||
|
footprintPoints = pts
|
||||||
|
} else {
|
||||||
|
for (i in rangeCircle.indices) {
|
||||||
|
pts[i].latitude = rangeCircle[i].latitude
|
||||||
|
pts[i].longitude = rangeCircle[i].longitude
|
||||||
|
}
|
||||||
}
|
}
|
||||||
mapView.overlays[2] = footprintOverlay
|
val polyline = footprintPolyline ?: Polyline().apply {
|
||||||
} catch (exception: Exception) {
|
outlinePaint.set(footprintPaint)
|
||||||
println(exception)
|
footprintPolyline = this
|
||||||
|
}
|
||||||
|
polyline.setPoints(pts)
|
||||||
|
mapView.overlays[OVERLAY_FOOTPRINT] = polyline
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the NightOverlay with the current sub-solar position.
|
||||||
|
* The overlay is created once and kept in OVERLAY_TERMINATOR; only its
|
||||||
|
* sunLatDeg/sunLonDeg fields are updated each tick so osmdroid redraws it.
|
||||||
|
*/
|
||||||
|
private fun setTerminator(sunLatDeg: Double, sunLonDeg: Double, mapView: MapView) {
|
||||||
|
try {
|
||||||
|
val overlay = mapView.overlays[OVERLAY_TERMINATOR]
|
||||||
|
if (overlay is MapNightOverlay) {
|
||||||
|
overlay.sunLatDeg = sunLatDeg
|
||||||
|
overlay.sunLonDeg = sunLonDeg
|
||||||
|
} else {
|
||||||
|
mapView.overlays[OVERLAY_TERMINATOR] = MapNightOverlay().apply {
|
||||||
|
this.sunLatDeg = sunLatDeg
|
||||||
|
this.sunLonDeg = sunLonDeg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Place an ic_sun icon marker at the sub-solar point. */
|
||||||
|
private fun setSubSolarPoint(sunLatDeg: Double, sunLonDeg: Double, mapView: MapView) {
|
||||||
|
try {
|
||||||
|
val overlay = mapView.overlays[OVERLAY_SUN]
|
||||||
|
val sunPos = GeoPoint(sunLatDeg, sunLonDeg)
|
||||||
|
if (overlay is Marker) {
|
||||||
|
overlay.position = sunPos
|
||||||
|
} else {
|
||||||
|
val iconSize = 48
|
||||||
|
val bmp = createBitmap(iconSize, iconSize)
|
||||||
|
ContextCompat.getDrawable(mapView.context, R.drawable.ic_sun)?.apply {
|
||||||
|
setBounds(0, 0, iconSize, iconSize)
|
||||||
|
colorFilter = sunIconPaint.colorFilter
|
||||||
|
draw(Canvas(bmp))
|
||||||
|
}
|
||||||
|
mapView.overlays[OVERLAY_SUN] = Marker(mapView).apply {
|
||||||
|
setInfoWindow(null)
|
||||||
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
|
icon = bmp.toDrawable(mapView.context.resources)
|
||||||
|
position = sunPos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Place an ic_moon icon marker at the sub-lunar point. */
|
||||||
|
private fun setMoonPosition(moonLatDeg: Double, moonLonDeg: Double, mapView: MapView) {
|
||||||
|
try {
|
||||||
|
val overlay = mapView.overlays[OVERLAY_MOON]
|
||||||
|
val moonPos = GeoPoint(moonLatDeg, moonLonDeg)
|
||||||
|
if (overlay is Marker) {
|
||||||
|
overlay.position = moonPos
|
||||||
|
} else {
|
||||||
|
val iconSize = 48
|
||||||
|
val bmp = createBitmap(iconSize, iconSize)
|
||||||
|
val c = Canvas(bmp)
|
||||||
|
ContextCompat.getDrawable(mapView.context, R.drawable.ic_moon)?.apply {
|
||||||
|
setBounds(0, 0, iconSize, iconSize)
|
||||||
|
colorFilter = moonIconPaint.colorFilter
|
||||||
|
draw(c)
|
||||||
|
}
|
||||||
|
mapView.overlays[OVERLAY_MOON] = Marker(mapView).apply {
|
||||||
|
setInfoWindow(null)
|
||||||
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
|
icon = bmp.toDrawable(mapView.context.resources)
|
||||||
|
position = moonPos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region MapView lifecycle
|
||||||
@Composable
|
@Composable
|
||||||
private fun rememberMapViewWithLifecycle(): MapView {
|
private fun rememberMapViewWithLifecycle(): MapView {
|
||||||
val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
|
val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val mapView = remember { MapView(context) }.apply {
|
val isVertical = isVerticalLayout()
|
||||||
setMultiTouchControls(true)
|
val mapView = remember {
|
||||||
setUseDataConnection(false)
|
MapView(context).apply {
|
||||||
setTileSource(tileSource)
|
setMultiTouchControls(true)
|
||||||
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels)
|
setUseDataConnection(false)
|
||||||
maxZoomLevel = 7.0
|
setTileSource(tileSource)
|
||||||
controller.setCenter(GeoPoint(48.8575, 6.3514))
|
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels, isVertical)
|
||||||
controller.setZoom(minZoomLevel + 2)
|
maxZoomLevel = 7.0
|
||||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
controller.setCenter(GeoPoint(48.8575, 6.3514))
|
||||||
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
|
controller.setZoom(minZoomLevel + 2)
|
||||||
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
|
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
||||||
overlayManager.tilesOverlay.setColorFilter(getColorFilter())
|
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
|
||||||
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
|
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
|
||||||
// add overlays: 0 - GSP, 1 - SatTrack, 2 - SatFootprint, 3 - SatIcons
|
overlayManager.tilesOverlay.setColorFilter(createColorFilter())
|
||||||
overlays.addAll(Array(4) { FolderOverlay() })
|
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
|
||||||
|
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Makes MapView follow the lifecycle of this composable
|
|
||||||
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
|
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
|
||||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||||
DisposableEffect(lifecycle) {
|
DisposableEffect(lifecycle) {
|
||||||
@@ -348,8 +522,7 @@ private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapVie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
private fun createColorFilter(): ColorMatrixColorFilter {
|
||||||
private fun getColorFilter(): ColorMatrixColorFilter {
|
|
||||||
val grayScale = ColorMatrix().apply { setSaturation(0f) }
|
val grayScale = ColorMatrix().apply { setSaturation(0f) }
|
||||||
val negative = ColorMatrix(
|
val negative = ColorMatrix(
|
||||||
floatArrayOf(-1f, 0f, 0f, 0f, 260f, 0f, -1f, 0f, 0f, 260f, 0f, 0f, -1f, 0f, 260f, 0f, 0f, 0f, 1f, 0f)
|
floatArrayOf(-1f, 0f, 0f, 0f, 260f, 0f, -1f, 0f, 0f, 260f, 0f, 0f, -1f, 0f, 260f, 0f, 0f, 0f, 1f, 0f)
|
||||||
@@ -358,8 +531,8 @@ private fun getColorFilter(): ColorMatrixColorFilter {
|
|||||||
return ColorMatrixColorFilter(negative)
|
return ColorMatrixColorFilter(negative)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
private fun getMinZoom(screenHeight: Int, isVertical: Boolean): Double {
|
||||||
private fun getMinZoom(screenHeight: Int): Double {
|
if (!isVertical) return 3.5
|
||||||
if (!isVerticalLayout()) return 3.5
|
|
||||||
return MapView.getTileSystem().getLatitudeZoom(maxLat, minLat, screenHeight)
|
return MapView.getTileSystem().getLatitudeZoom(maxLat, minLat, screenHeight)
|
||||||
}
|
}
|
||||||
|
// endregion
|
||||||
@@ -23,21 +23,25 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
|||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
|
||||||
data class MapState(
|
data class MapState(
|
||||||
val mapData: MapData?,
|
val mapData: MapData? = null,
|
||||||
val isLightUi: Boolean,
|
val isLightUi: Boolean = false,
|
||||||
val stationPosition: GeoPos?,
|
val isUtc: Boolean = false,
|
||||||
|
val stationPosition: GeoPos? = null,
|
||||||
val orbitalPass: OrbitalPass,
|
val orbitalPass: OrbitalPass,
|
||||||
val track: List<List<GeoPos>>?,
|
val track: List<List<GeoPos>>? = null,
|
||||||
val footprint: OrbitalPos?,
|
val footprint: OrbitalPos? = null,
|
||||||
val positions: Map<OrbitalObject, GeoPos>?,
|
val positions: Map<OrbitalObject, GeoPos>? = null,
|
||||||
val sendAction: (MapAction) -> Unit
|
val sunLatDeg: Double = 0.0,
|
||||||
|
val sunLonDeg: Double = 0.0,
|
||||||
|
val moonLatDeg: Double = 0.0,
|
||||||
|
val moonLonDeg: Double = 0.0
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed class MapAction {
|
sealed interface MapAction {
|
||||||
data object SelectPrev: MapAction()
|
data object SelectPrev : MapAction
|
||||||
data object SelectNext: MapAction()
|
data object SelectNext : MapAction
|
||||||
data class SelectItem(val item: OrbitalObject): MapAction()
|
data class SelectItem(val item: OrbitalObject) : MapAction
|
||||||
data class SelectDefaultItem(val catnum: Int): MapAction()
|
data class SelectDefaultItem(val catnum: Int) : MapAction
|
||||||
}
|
}
|
||||||
|
|
||||||
data class MapData(
|
data class MapData(
|
||||||
|
|||||||
@@ -18,13 +18,15 @@
|
|||||||
package com.rtbishop.look4sat.feature.map
|
package com.rtbishop.look4sat.feature.map
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.utility.clipLat
|
import com.rtbishop.look4sat.core.domain.utility.clipLat
|
||||||
@@ -34,30 +36,31 @@ import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
|||||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||||
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
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.collectLatest
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
class MapViewModel(private val satelliteRepo: ISatelliteRepo, settingsRepo: ISettingsRepo) :
|
class MapViewModel(
|
||||||
ViewModel() {
|
private val satelliteRepo: ISatelliteRepo,
|
||||||
|
private val settingsRepo: ISettingsRepo
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
private val stationPos = settingsRepo.stationPosition.value
|
private val stationPos = settingsRepo.stationPosition.value
|
||||||
private val defaultPass = getDefaultPass()
|
private val defaultPass = getDefaultPass()
|
||||||
private val _uiState = MutableStateFlow(
|
private val _uiState = MutableStateFlow(
|
||||||
MapState(
|
MapState(
|
||||||
mapData = null,
|
|
||||||
isLightUi = settingsRepo.otherSettings.value.stateOfLightTheme,
|
isLightUi = settingsRepo.otherSettings.value.stateOfLightTheme,
|
||||||
stationPosition = null,
|
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
||||||
orbitalPass = defaultPass,
|
orbitalPass = defaultPass
|
||||||
track = null,
|
|
||||||
footprint = null,
|
|
||||||
positions = null,
|
|
||||||
sendAction = ::handleAction
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
private var allPasses = satelliteRepo.passes.value
|
private var allPasses = satelliteRepo.passes.value
|
||||||
@@ -68,10 +71,16 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, settingsRepo: ISet
|
|||||||
val uiState: StateFlow<MapState> = _uiState
|
val uiState: StateFlow<MapState> = _uiState
|
||||||
|
|
||||||
init {
|
init {
|
||||||
selectDefaultSatellite(-1)
|
viewModelScope.launch {
|
||||||
|
settingsRepo.otherSettings.collectLatest { settings ->
|
||||||
|
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
|
||||||
|
selectDefaultSatellite(if (selectedCatNum != 0) selectedCatNum else -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleAction(action: MapAction) {
|
fun onAction(action: MapAction) {
|
||||||
when (action) {
|
when (action) {
|
||||||
MapAction.SelectPrev -> scrollSelection(true)
|
MapAction.SelectPrev -> scrollSelection(true)
|
||||||
MapAction.SelectNext -> scrollSelection(false)
|
MapAction.SelectNext -> scrollSelection(false)
|
||||||
@@ -118,17 +127,147 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, settingsRepo: ISet
|
|||||||
val dateNow = Date()
|
val dateNow = Date()
|
||||||
getStationPosition()
|
getStationPosition()
|
||||||
getSatTrack(orbitalObject, stationPos, dateNow)
|
getSatTrack(orbitalObject, stationPos, dateNow)
|
||||||
|
// Scale update interval based on satellite count to avoid excessive CPU usage
|
||||||
|
val effectiveRate = when {
|
||||||
|
allSatellites.size > 5000 -> maxOf(updateFreq, 3000L)
|
||||||
|
allSatellites.size > 1000 -> maxOf(updateFreq, 2000L)
|
||||||
|
else -> updateFreq
|
||||||
|
}
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
dateNow.time = System.currentTimeMillis()
|
dateNow.time = System.currentTimeMillis()
|
||||||
getPositions(allSatellites, stationPos, dateNow)
|
updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
|
||||||
getSatFootprint(orbitalObject, stationPos, dateNow)
|
delay(effectiveRate)
|
||||||
getSatData(orbitalObject, stationPos, dateNow)
|
|
||||||
delay(updateFreq)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined update: computes all satellite positions in parallel, then derives
|
||||||
|
* the selected satellite's footprint and info panel data from the same batch,
|
||||||
|
* and emits a single state update to avoid redundant recompositions.
|
||||||
|
*/
|
||||||
|
private suspend fun updateMapState(
|
||||||
|
selected: OrbitalObject,
|
||||||
|
orbitalObjects: List<OrbitalObject>,
|
||||||
|
pos: GeoPos,
|
||||||
|
date: Date
|
||||||
|
) {
|
||||||
|
// 1. Compute all positions in parallel, collecting full OrbitalPos for the selected sat
|
||||||
|
val positionsMap = HashMap<OrbitalObject, GeoPos>(orbitalObjects.size)
|
||||||
|
var selectedSatPos: OrbitalPos? = null
|
||||||
|
|
||||||
|
coroutineScope {
|
||||||
|
// Chunk satellites to balance parallelism vs coroutine overhead
|
||||||
|
val chunkSize = maxOf(1, orbitalObjects.size / PARALLEL_CHUNKS)
|
||||||
|
val deferreds = orbitalObjects.chunked(chunkSize).map { chunk ->
|
||||||
|
async {
|
||||||
|
val localPositions = ArrayList<Pair<OrbitalObject, GeoPos>>(chunk.size)
|
||||||
|
var localSelectedPos: OrbitalPos? = null
|
||||||
|
for (satellite in chunk) {
|
||||||
|
val satPos = satelliteRepo.getPosition(satellite, pos, date.time)
|
||||||
|
val osmLat = clipLat(satPos.latitude.toDegrees())
|
||||||
|
val osmLon = clipLon(satPos.longitude.toDegrees())
|
||||||
|
localPositions.add(satellite to GeoPos(osmLat, osmLon))
|
||||||
|
if (satellite === selected) {
|
||||||
|
localSelectedPos = satPos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Pair(localPositions, localSelectedPos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ((localPositions, localSelectedPos) in deferreds.awaitAll()) {
|
||||||
|
for (pair in localPositions) {
|
||||||
|
positionsMap[pair.first] = pair.second
|
||||||
|
}
|
||||||
|
if (localSelectedPos != null) {
|
||||||
|
selectedSatPos = localSelectedPos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Derive footprint, info data, sun and moon position from already-computed state
|
||||||
|
val satPos = selectedSatPos ?: satelliteRepo.getPosition(selected, pos, date.time)
|
||||||
|
val footprint = satPos
|
||||||
|
val mapData = buildMapData(selected, satPos, date)
|
||||||
|
val sunPos = CelestialComputer.getSunPosition(stationPos, date.time)
|
||||||
|
val moonPos = CelestialComputer.getMoonPosition(stationPos, date.time)
|
||||||
|
|
||||||
|
// 3. Single atomic state update — one recomposition per cycle
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
positions = positionsMap,
|
||||||
|
footprint = footprint,
|
||||||
|
mapData = mapData.first,
|
||||||
|
orbitalPass = mapData.second,
|
||||||
|
sunLatDeg = sunPos.latitude,
|
||||||
|
sunLonDeg = sunPos.longitude,
|
||||||
|
moonLatDeg = moonPos.declination, // sub-lunar latitude = declination
|
||||||
|
moonLonDeg = if (moonPos.gha <= 180.0) -moonPos.gha else 360.0 - moonPos.gha
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the map info panel data from an already-computed OrbitalPos.
|
||||||
|
* No additional getPosition() call needed.
|
||||||
|
*/
|
||||||
|
private fun buildMapData(
|
||||||
|
sat: OrbitalObject,
|
||||||
|
satPos: OrbitalPos,
|
||||||
|
date: Date
|
||||||
|
): Pair<MapData, OrbitalPass> {
|
||||||
|
var orbitalPass = defaultPass
|
||||||
|
var aosTime = 0L.toTimerString()
|
||||||
|
var isTimeAos = true
|
||||||
|
allPasses.find { pass -> pass.catNum == sat.data.catnum && pass.progress < 1 }
|
||||||
|
?.let { satPass ->
|
||||||
|
orbitalPass = satPass
|
||||||
|
if (!satPass.isDeepSpace) {
|
||||||
|
when {
|
||||||
|
date.time < satPass.aosTime -> {
|
||||||
|
// Pass hasn't started yet — count down to AOS
|
||||||
|
isTimeAos = true
|
||||||
|
aosTime = (satPass.aosTime - date.time).toTimerString()
|
||||||
|
}
|
||||||
|
|
||||||
|
date.time < satPass.losTime -> {
|
||||||
|
// Pass is in progress — count down to LOS
|
||||||
|
isTimeAos = false
|
||||||
|
aosTime = (satPass.losTime - date.time).toTimerString()
|
||||||
|
}
|
||||||
|
// else: pass has ended (losTime <= date.time) — keep default "00:00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val azimuth = satPos.azimuth.toDegrees()
|
||||||
|
val elevation = satPos.elevation.toDegrees()
|
||||||
|
val osmLat = clipLat(satPos.latitude.toDegrees())
|
||||||
|
val osmLon = clipLon(satPos.longitude.toDegrees())
|
||||||
|
val osmPos = GeoPos(osmLat, osmLon)
|
||||||
|
val qthLoc = positionToQth(osmPos.latitude, osmPos.longitude) ?: "-- --"
|
||||||
|
val velocity = satPos.getOrbitalVelocity()
|
||||||
|
val phase = satPos.phase.toDegrees()
|
||||||
|
val visibility = satPos.eclipsed
|
||||||
|
val satData = MapData(
|
||||||
|
sat.data.catnum,
|
||||||
|
sat.data.name,
|
||||||
|
aosTime,
|
||||||
|
isTimeAos,
|
||||||
|
azimuth,
|
||||||
|
elevation,
|
||||||
|
satPos.distance,
|
||||||
|
satPos.altitude,
|
||||||
|
velocity,
|
||||||
|
qthLoc,
|
||||||
|
osmPos,
|
||||||
|
sat.data.orbitalPeriod,
|
||||||
|
phase,
|
||||||
|
visibility
|
||||||
|
)
|
||||||
|
return satData to orbitalPass
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun getSatTrack(orbitalObject: OrbitalObject, pos: GeoPos, date: Date) {
|
private suspend fun getSatTrack(orbitalObject: OrbitalObject, pos: GeoPos, date: Date) {
|
||||||
val satTracks = mutableListOf<List<GeoPos>>()
|
val satTracks = mutableListOf<List<GeoPos>>()
|
||||||
val currentTrack = mutableListOf<GeoPos>()
|
val currentTrack = mutableListOf<GeoPos>()
|
||||||
@@ -158,76 +297,16 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, settingsRepo: ISet
|
|||||||
_uiState.update { it.copy(track = satTracks) }
|
_uiState.update { it.copy(track = satTracks) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getPositions(orbitalObjects: List<OrbitalObject>, pos: GeoPos, date: Date) {
|
|
||||||
val positions = mutableMapOf<OrbitalObject, GeoPos>()
|
|
||||||
orbitalObjects.forEach { satellite ->
|
|
||||||
val satPos = satelliteRepo.getPosition(satellite, pos, date.time)
|
|
||||||
val osmLat = clipLat(satPos.latitude.toDegrees())
|
|
||||||
val osmLon = clipLon(satPos.longitude.toDegrees())
|
|
||||||
positions[satellite] = GeoPos(osmLat, osmLon)
|
|
||||||
}
|
|
||||||
_uiState.update { it.copy(positions = positions) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getSatFootprint(orbitalObject: OrbitalObject, pos: GeoPos, date: Date) {
|
|
||||||
val satPos = satelliteRepo.getPosition(orbitalObject, pos, date.time)
|
|
||||||
_uiState.update { it.copy(footprint = satPos) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getSatData(sat: OrbitalObject, pos: GeoPos, date: Date) {
|
|
||||||
var orbitalPass = defaultPass
|
|
||||||
var aosTime = 0L.toTimerString()
|
|
||||||
var isTimeAos = true
|
|
||||||
allPasses.find { pass -> pass.catNum == sat.data.catnum && pass.progress < 1 }
|
|
||||||
?.let { satPass ->
|
|
||||||
orbitalPass = satPass
|
|
||||||
if (!satPass.isDeepSpace) {
|
|
||||||
aosTime = if (date.time < satPass.aosTime) {
|
|
||||||
val millisBeforeStart = satPass.aosTime.minus(date.time)
|
|
||||||
isTimeAos = true
|
|
||||||
millisBeforeStart.toTimerString()
|
|
||||||
} else {
|
|
||||||
val millisBeforeEnd = satPass.losTime.minus(date.time)
|
|
||||||
isTimeAos = false
|
|
||||||
millisBeforeEnd.toTimerString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val satPos = satelliteRepo.getPosition(sat, pos, date.time)
|
|
||||||
val azimuth = satPos.azimuth.toDegrees()
|
|
||||||
val elevation = satPos.elevation.toDegrees()
|
|
||||||
val osmLat = clipLat(satPos.latitude.toDegrees())
|
|
||||||
val osmLon = clipLon(satPos.longitude.toDegrees())
|
|
||||||
val osmPos = GeoPos(osmLat, osmLon)
|
|
||||||
val qthLoc = positionToQth(osmPos.latitude, osmPos.longitude) ?: "-- --"
|
|
||||||
val velocity = satPos.getOrbitalVelocity()
|
|
||||||
val phase = satPos.phase.toDegrees()
|
|
||||||
val visibility = satPos.eclipsed
|
|
||||||
val satData = MapData(
|
|
||||||
sat.data.catnum,
|
|
||||||
sat.data.name,
|
|
||||||
aosTime,
|
|
||||||
isTimeAos,
|
|
||||||
azimuth,
|
|
||||||
elevation,
|
|
||||||
satPos.distance,
|
|
||||||
satPos.altitude,
|
|
||||||
velocity,
|
|
||||||
qthLoc,
|
|
||||||
osmPos,
|
|
||||||
sat.data.orbitalPeriod,
|
|
||||||
phase,
|
|
||||||
visibility
|
|
||||||
)
|
|
||||||
_uiState.update { it.copy(mapData = satData, orbitalPass = orbitalPass) }
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
/** Number of parallel chunks for satellite position computation */
|
||||||
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
|
private const val PARALLEL_CHUNKS = 4
|
||||||
|
|
||||||
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
val container = (this[applicationKey] as IContainerProvider).getMainContainer()
|
MapViewModel(
|
||||||
MapViewModel(container.satelliteRepo, container.settingsRepo)
|
satelliteRepo = container.satelliteRepo,
|
||||||
|
settingsRepo = container.settingsRepo
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.convention.composeFeaturePlugin)
|
alias(libs.plugins.convention.featurePlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|||||||
@@ -32,15 +32,18 @@ import androidx.compose.material3.Checkbox
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableDoubleStateOf
|
import androidx.compose.runtime.mutableDoubleStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
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.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -52,6 +55,7 @@ import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
|||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||||
|
import com.rtbishop.look4sat.core.presentation.elevationColor
|
||||||
|
|
||||||
private val allModes = listOf(
|
private val allModes = listOf(
|
||||||
"AFSK", "AFSK S-Net", "AFSK SALSAT", "AHRPT", "AM", "APT", "BPSK", "BPSK PMT-A3",
|
"AFSK", "AFSK S-Net", "AFSK SALSAT", "AHRPT", "AM", "APT", "BPSK", "BPSK PMT-A3",
|
||||||
@@ -61,34 +65,50 @@ private val allModes = listOf(
|
|||||||
"PSK", "PSK31", "PSK63", "QPSK", "QPSK31", "QPSK63", "SSTV", "USB", "WSJT"
|
"PSK", "PSK31", "PSK63", "QPSK", "QPSK31", "QPSK63", "SSTV", "USB", "WSJT"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 192, 216, 240)
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun PassesDialogPreview() {
|
private fun PassesDialogPreview() {
|
||||||
MainTheme { PassesDialog(24, 16.0, {}) { _, _ -> } }
|
MainTheme { PassesDialog(24, 16.0, true, {}) { _, _, _ -> } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PassesDialog(hours: Int, elevation: Double, cancel: () -> Unit, accept: (Int, Double) -> Unit) {
|
internal fun PassesDialog(
|
||||||
val hoursValue = remember { mutableIntStateOf(hours) }
|
hours: Int,
|
||||||
|
elevation: Double,
|
||||||
|
showDeepSpace: Boolean,
|
||||||
|
cancel: () -> Unit,
|
||||||
|
accept: (Int, Double, Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
val hoursIndex = remember { mutableIntStateOf(hourSteps.indexOfFirst { it >= hours }.coerceAtLeast(0)) }
|
||||||
val elevationValueNew = remember { mutableDoubleStateOf(elevation) }
|
val elevationValueNew = remember { mutableDoubleStateOf(elevation) }
|
||||||
|
var deepSpaceEnabled by remember { mutableStateOf(showDeepSpace) }
|
||||||
val onAccept = {
|
val onAccept = {
|
||||||
accept(hoursValue.intValue, elevationValueNew.doubleValue).also { cancel() }
|
accept(hourSteps[hoursIndex.intValue], elevationValueNew.doubleValue, deepSpaceEnabled).also { cancel() }
|
||||||
}
|
}
|
||||||
SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
|
SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
|
||||||
|
ToggleRow(
|
||||||
|
title = stringResource(R.string.pass_filter_deep_space),
|
||||||
|
checked = deepSpaceEnabled,
|
||||||
|
onCheckedChange = { deepSpaceEnabled = it }
|
||||||
|
)
|
||||||
SliderRow(
|
SliderRow(
|
||||||
title = stringResource(R.string.pass_filter_elev),
|
title = stringResource(R.string.pass_filter_elev),
|
||||||
value = elevationValueNew.doubleValue,
|
value = elevationValueNew.doubleValue,
|
||||||
valuePostfix = "°",
|
displayValue = "${elevationValueNew.doubleValue.toInt()}°",
|
||||||
valueResId = R.drawable.ic_elevation,
|
valueResId = R.drawable.ic_elevation,
|
||||||
valueRange = 0f..60f
|
valueRange = 0f..60f,
|
||||||
|
accentColor = elevationColor(elevationValueNew.doubleValue)
|
||||||
) { elevationValueNew.doubleValue = it.toDouble() }
|
) { elevationValueNew.doubleValue = it.toDouble() }
|
||||||
SliderRow(
|
SliderRow(
|
||||||
title = stringResource(R.string.pass_filter_hours),
|
title = stringResource(R.string.pass_filter_hours),
|
||||||
value = hoursValue.intValue.toDouble(),
|
value = hoursIndex.intValue.toDouble(),
|
||||||
valuePostfix = "h",
|
displayValue = "${hourSteps[hoursIndex.intValue]}h",
|
||||||
valueResId = R.drawable.ic_clock,
|
valueResId = R.drawable.ic_clock,
|
||||||
valueRange = 1f..240f
|
valueRange = 0f..(hourSteps.size - 1).toFloat(),
|
||||||
) { hoursValue.intValue = it.toInt() }
|
steps = hourSteps.size - 2
|
||||||
|
) { hoursIndex.intValue = it.toInt().coerceIn(0, hourSteps.size - 1) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,9 +116,11 @@ fun PassesDialog(hours: Int, elevation: Double, cancel: () -> Unit, accept: (Int
|
|||||||
private fun SliderRow(
|
private fun SliderRow(
|
||||||
title: String,
|
title: String,
|
||||||
value: Double,
|
value: Double,
|
||||||
valuePostfix: String,
|
displayValue: String,
|
||||||
valueResId: Int,
|
valueResId: Int,
|
||||||
valueRange: ClosedFloatingPointRange<Float>,
|
valueRange: ClosedFloatingPointRange<Float>,
|
||||||
|
steps: Int = 0,
|
||||||
|
accentColor: Color = MaterialTheme.colorScheme.primary,
|
||||||
onChange: (Float) -> Unit
|
onChange: (Float) -> Unit
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -119,17 +141,17 @@ private fun SliderRow(
|
|||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = valueResId),
|
painter = painterResource(id = valueResId),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = accentColor,
|
||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier.size(20.dp)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "${value.toInt()}$valuePostfix",
|
text = displayValue,
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = accentColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Slider(value = value.toFloat(), onValueChange = onChange, valueRange = valueRange)
|
Slider(value = value.toFloat(), onValueChange = onChange, valueRange = valueRange, steps = steps)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,12 +162,12 @@ private fun RadiosDialogPreview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit) {
|
internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit) {
|
||||||
val selected = remember { mutableStateListOf<String>().apply { addAll(modes) } }
|
val selected = remember { mutableStateOf(modes.toSet()) }
|
||||||
val select = { mode: String ->
|
val toggle = { mode: String ->
|
||||||
if (selected.contains(mode)) selected.remove(mode) else selected.add(mode)
|
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
|
||||||
}
|
}
|
||||||
val onAccept = { accept(selected.toList()).also { cancel() } }
|
val onAccept = { accept(selected.value.toList()).also { cancel() } }
|
||||||
SharedDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) {
|
SharedDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(240.dp),
|
columns = GridCells.Adaptive(240.dp),
|
||||||
@@ -156,34 +178,49 @@ fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List<String>)
|
|||||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||||
) {
|
) {
|
||||||
itemsIndexed(allModes) { index, item ->
|
itemsIndexed(allModes) { index, item ->
|
||||||
Surface {
|
Row(
|
||||||
Row(
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
modifier = Modifier
|
||||||
modifier = Modifier
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
.clickable { toggle(item) }
|
||||||
.clickable { select(item) }
|
) {
|
||||||
) {
|
Text(
|
||||||
Text(
|
text = "${index + 1}).",
|
||||||
text = "${index + 1}).",
|
modifier = Modifier.padding(start = 16.dp, end = 8.dp),
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 8.dp),
|
fontWeight = FontWeight.Normal,
|
||||||
fontWeight = FontWeight.Normal,
|
color = MaterialTheme.colorScheme.primary
|
||||||
color = MaterialTheme.colorScheme.primary
|
)
|
||||||
)
|
Text(
|
||||||
Text(
|
text = item,
|
||||||
text = item,
|
modifier = Modifier.weight(1f),
|
||||||
modifier = Modifier.weight(1f),
|
fontWeight = FontWeight.Medium,
|
||||||
fontWeight = FontWeight.Medium,
|
maxLines = 1,
|
||||||
maxLines = 1,
|
overflow = TextOverflow.Ellipsis
|
||||||
overflow = TextOverflow.Ellipsis
|
)
|
||||||
)
|
Checkbox(
|
||||||
Checkbox(
|
checked = item in selected.value,
|
||||||
checked = selected.contains(item),
|
onCheckedChange = null,
|
||||||
onCheckedChange = null,
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToggleRow(title: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
+228
-148
@@ -17,6 +17,11 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.feature.passes
|
package com.rtbishop.look4sat.feature.passes
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
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
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -29,23 +34,23 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
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.pulltorefresh.PullToRefreshBox
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
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.platform.LocalContext
|
||||||
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.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -55,59 +60,60 @@ 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.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavGraphBuilder
|
|
||||||
import androidx.navigation.compose.composable
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.DeepSpaceObject
|
import com.rtbishop.look4sat.core.domain.predict.DeepSpaceObject
|
||||||
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
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.Screen
|
|
||||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||||
|
import com.rtbishop.look4sat.core.presentation.SwipeableItem
|
||||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
|
import com.rtbishop.look4sat.core.presentation.elevationColor
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
private val sdfDate = SimpleDateFormat("EEE dd MMM", Locale.ENGLISH)
|
@Composable
|
||||||
private val sdfTime = SimpleDateFormat("HH:mm:ss", Locale.ENGLISH)
|
fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
fun NavGraphBuilder.passesDestination(navigateToRadar: (Int, Long) -> Unit) {
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
composable(Screen.Passes.route) {
|
val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
|
||||||
val viewModel = viewModel(
|
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
modelClass = PassesViewModel::class.java,
|
PassesScreen(uiState, viewModel::onAction, navigateToRadar)
|
||||||
factory = PassesViewModel.Factory
|
|
||||||
)
|
|
||||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
|
||||||
PassesScreen(uiState, navigateToRadar)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PassesScreen(uiState: PassesState, navigateToRadar: (Int, Long) -> Unit) {
|
private fun PassesScreen(
|
||||||
val refreshList = { uiState.takeAction(PassesAction.RefreshPasses) }
|
uiState: PassesState,
|
||||||
val showPassesDialog = { uiState.takeAction(PassesAction.TogglePassesDialog) }
|
onAction: (PassesAction) -> Unit,
|
||||||
val showRadiosDialog = { uiState.takeAction(PassesAction.ToggleRadiosDialog) }
|
navigateToRadar: (Int, Long) -> Unit
|
||||||
|
) {
|
||||||
if (uiState.isPassesDialogShown) {
|
if (uiState.isPassesDialogShown) {
|
||||||
PassesDialog(
|
PassesDialog(
|
||||||
hours = uiState.hours,
|
hours = uiState.hours,
|
||||||
elevation = uiState.elevation,
|
elevation = uiState.elevation,
|
||||||
cancel = showPassesDialog
|
showDeepSpace = uiState.showDeepSpace,
|
||||||
) { hours, elevation ->
|
cancel = { onAction(PassesAction.TogglePassesDialog) }
|
||||||
uiState.takeAction(PassesAction.FilterPasses(hours, elevation))
|
) { hours, elevation, showDeepSpace ->
|
||||||
|
onAction(PassesAction.FilterPasses(hours, elevation, showDeepSpace))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (uiState.isRadiosDialogShown) {
|
if (uiState.isRadiosDialogShown) {
|
||||||
RadiosDialog(modes = uiState.modes, cancel = showRadiosDialog) { modes ->
|
RadiosDialog(
|
||||||
uiState.takeAction(PassesAction.FilterRadios(modes))
|
modes = uiState.modes,
|
||||||
|
cancel = { onAction(PassesAction.ToggleRadiosDialog) }
|
||||||
|
) { modes ->
|
||||||
|
onAction(PassesAction.FilterRadios(modes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (uiState.shouldSeeWhatsNew) {
|
if (uiState.shouldSeeWhatsNew) {
|
||||||
@@ -115,35 +121,37 @@ private fun PassesScreen(uiState: PassesState, navigateToRadar: (Int, Long) -> U
|
|||||||
title = stringResource(R.string.pass_whatsnew_title),
|
title = stringResource(R.string.pass_whatsnew_title),
|
||||||
text = stringResource(R.string.pass_whatsnew_message)
|
text = stringResource(R.string.pass_whatsnew_message)
|
||||||
) {
|
) {
|
||||||
uiState.takeAction(PassesAction.DismissWhatsNew)
|
onAction(PassesAction.DismissWhatsNew)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val gridState = rememberLazyGridState()
|
|
||||||
ScreenColumn(
|
ScreenColumn(
|
||||||
topBar = { isVerticalLayout ->
|
topBar = { isVerticalLayout ->
|
||||||
TopBar(
|
TopBar(
|
||||||
isVerticalLayout = isVerticalLayout,
|
isVerticalLayout = isVerticalLayout,
|
||||||
startAction = {
|
startAction = {
|
||||||
IconCard(action = showPassesDialog, resId = R.drawable.ic_filter)
|
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
|
||||||
},
|
},
|
||||||
topInfo = {
|
topInfo = {
|
||||||
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
|
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
|
||||||
},
|
},
|
||||||
bottomInfo = {
|
bottomInfo = {
|
||||||
NextPassRow(pass = uiState.nextPass)
|
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
||||||
},
|
},
|
||||||
endAction = {
|
endAction = {
|
||||||
IconCard(action = showRadiosDialog, resId = R.drawable.ic_radios)
|
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { _ ->
|
) { _ ->
|
||||||
PassesList(
|
PassesList(
|
||||||
isRefreshing = uiState.isRefreshing,
|
isRefreshing = uiState.isRefreshing,
|
||||||
|
isUtc = uiState.isUtc,
|
||||||
passes = uiState.itemsList,
|
passes = uiState.itemsList,
|
||||||
|
groupedPasses = uiState.groupedPasses,
|
||||||
|
sunTimes = uiState.sunTimes,
|
||||||
|
focusedCatNum = uiState.focusedCatNum,
|
||||||
navigateToRadar = navigateToRadar,
|
navigateToRadar = navigateToRadar,
|
||||||
refreshPasses = refreshList,
|
onAction = onAction
|
||||||
gridState = gridState
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,10 +160,13 @@ private fun PassesScreen(uiState: PassesState, navigateToRadar: (Int, Long) -> U
|
|||||||
@Composable
|
@Composable
|
||||||
private fun PassesList(
|
private fun PassesList(
|
||||||
isRefreshing: Boolean,
|
isRefreshing: Boolean,
|
||||||
|
isUtc: Boolean,
|
||||||
passes: List<OrbitalPass>,
|
passes: List<OrbitalPass>,
|
||||||
|
groupedPasses: Map<String, List<OrbitalPass>>,
|
||||||
|
sunTimes: Map<String, Pair<String, String>>,
|
||||||
|
focusedCatNum: Int?,
|
||||||
navigateToRadar: (Int, Long) -> Unit,
|
navigateToRadar: (Int, Long) -> Unit,
|
||||||
refreshPasses: () -> Unit,
|
onAction: (PassesAction) -> Unit
|
||||||
gridState: LazyGridState
|
|
||||||
) {
|
) {
|
||||||
val isVerticalLayout = isVerticalLayout()
|
val isVerticalLayout = isVerticalLayout()
|
||||||
val refreshState = rememberPullToRefreshState()
|
val refreshState = rememberPullToRefreshState()
|
||||||
@@ -163,7 +174,7 @@ private fun PassesList(
|
|||||||
PullToRefreshBox(
|
PullToRefreshBox(
|
||||||
isRefreshing = isRefreshing,
|
isRefreshing = isRefreshing,
|
||||||
state = refreshState,
|
state = refreshState,
|
||||||
onRefresh = refreshPasses,
|
onRefresh = { onAction(PassesAction.RefreshPasses) },
|
||||||
indicator = {
|
indicator = {
|
||||||
PullToRefreshDefaults.Indicator(
|
PullToRefreshDefaults.Indicator(
|
||||||
state = refreshState,
|
state = refreshState,
|
||||||
@@ -177,18 +188,37 @@ private fun PassesList(
|
|||||||
if (passes.isEmpty()) {
|
if (passes.isEmpty()) {
|
||||||
EmptyListCard(message = stringResource(R.string.pass_empty_list_message))
|
EmptyListCard(message = stringResource(R.string.pass_empty_list_message))
|
||||||
} else {
|
} else {
|
||||||
LazyVerticalGrid(
|
AnimatedContent(
|
||||||
state = gridState,
|
targetState = focusedCatNum,
|
||||||
columns = GridCells.Adaptive(320.dp),
|
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
|
||||||
modifier = Modifier.fillMaxSize()
|
label = "PassesFocusTransition"
|
||||||
) {
|
) { targetFocus ->
|
||||||
items(items = passes, key = { item -> item.catNum + item.aosTime }) { pass ->
|
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp), modifier = Modifier.fillMaxSize()) {
|
||||||
NearEarthPass(
|
for ((dateLabel, dayPasses) in groupedPasses) {
|
||||||
pass = pass,
|
val headerVisible = targetFocus == null || dayPasses.any { it.catNum == targetFocus }
|
||||||
navigateToRadar = navigateToRadar,
|
if (headerVisible) {
|
||||||
modifier = Modifier.animateItem(),
|
stickyHeader(key = "header_$dateLabel") {
|
||||||
isVerticalLayout = isVerticalLayout
|
val (rise, set) = sunTimes[dateLabel] ?: ("--:--" to "--:--")
|
||||||
)
|
StickyDateHeader(label = dateLabel, sunriseTime = rise, sunsetTime = set)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val visiblePasses = if (targetFocus == null) dayPasses
|
||||||
|
else dayPasses.filter { it.catNum == targetFocus }
|
||||||
|
items(items = visiblePasses, key = { item -> item.catNum + item.aosTime }) { pass ->
|
||||||
|
SwipeableItem(
|
||||||
|
onSwipeRight = { onAction(PassesAction.FocusCatNum(pass.catNum)) },
|
||||||
|
onSwipeLeft = { onAction(PassesAction.ClearFocus) }
|
||||||
|
) {
|
||||||
|
PassItem(
|
||||||
|
pass = pass,
|
||||||
|
navigateToRadar = navigateToRadar,
|
||||||
|
modifier = Modifier.animateItem(),
|
||||||
|
isVerticalLayout = isVerticalLayout,
|
||||||
|
isUtc = isUtc
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,13 +226,52 @@ private fun PassesList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StickyDateHeader(label: String, sunriseTime: String, sunsetTime: String) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_sun),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Text(text = sunriseTime, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_moon),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Text(text = sunsetTime, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeepSpacePassPreview() {
|
private fun DeepSpacePassPreview() {
|
||||||
val data = OrbitalData("Satellite", 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 45000, 0.0)
|
val data = OrbitalData("Satellite", 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 45000, 0.0)
|
||||||
val satellite = DeepSpaceObject(data)
|
val satellite = DeepSpaceObject(data)
|
||||||
val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f)
|
val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f)
|
||||||
MainTheme { NearEarthPass(pass = pass, { _, _ -> }) }
|
MainTheme { PassItem(pass = pass, { _, _ -> }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@@ -211,126 +280,137 @@ private fun NearEarthPassPreview() {
|
|||||||
val data = OrbitalData("Satellite", 0.0, 15.0, 0.0, 0.0, 0.0, 0.0, 0.0, 45000, 0.0)
|
val data = OrbitalData("Satellite", 0.0, 15.0, 0.0, 0.0, 0.0, 0.0, 0.0, 45000, 0.0)
|
||||||
val satellite = NearEarthObject(data)
|
val satellite = NearEarthObject(data)
|
||||||
val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f)
|
val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f)
|
||||||
MainTheme { NearEarthPass(pass = pass, { _, _ -> }) }
|
MainTheme { PassItem(pass = pass, { _, _ -> }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NearEarthPass(
|
private fun PassItem(
|
||||||
pass: OrbitalPass,
|
pass: OrbitalPass,
|
||||||
navigateToRadar: (Int, Long) -> Unit,
|
navigateToRadar: (Int, Long) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isVerticalLayout: Boolean = true
|
isVerticalLayout: Boolean = true,
|
||||||
|
isUtc: Boolean = false
|
||||||
) {
|
) {
|
||||||
val passSatId = stringResource(id = R.string.pass_satId, pass.catNum)
|
val passSatId = stringResource(id = R.string.pass_satId, pass.catNum)
|
||||||
val horizontalPadding = if (isVerticalLayout) 6.dp else 10.dp
|
val horizontalPadding = if (isVerticalLayout) 6.dp else 10.dp
|
||||||
Surface(color = MaterialTheme.colorScheme.background, modifier = modifier) {
|
val timeZone = remember(isUtc) {
|
||||||
Surface(modifier = Modifier
|
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
.padding(bottom = 2.dp)
|
}
|
||||||
.clickable { navigateToRadar(pass.catNum, pass.aosTime) }) {
|
val sdfTime = remember(isUtc) {
|
||||||
Column(
|
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
|
||||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
}
|
||||||
modifier = Modifier
|
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
|
||||||
.background(color = MaterialTheme.colorScheme.surface)
|
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
|
||||||
.padding(horizontal = horizontalPadding, vertical = 4.dp)
|
val durationStr = remember(pass.aosTime, pass.losTime) {
|
||||||
|
val seconds = (pass.losTime - pass.aosTime) / 1000
|
||||||
|
"${seconds / 60}m ${seconds % 60}s"
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier.clickable { navigateToRadar(pass.catNum, pass.aosTime) }
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(color = MaterialTheme.colorScheme.surface)
|
||||||
|
.padding(horizontal = horizontalPadding, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = "$passSatId - ",
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = pass.name,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(end = 6.dp)
|
||||||
|
.infiniteMarquee(),
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
val elevColor = elevationColor(pass.maxElevation)
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_elevation),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = elevColor,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "${pass.maxElevation}°",
|
||||||
|
color = elevColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = "$passSatId - ",
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = pass.name,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 6.dp)
|
|
||||||
.infiniteMarquee(),
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.ic_elevation),
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "${pass.maxElevation}°",
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
modifier = Modifier.weight(1f),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.Start,
|
||||||
modifier = Modifier.fillMaxWidth()
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Row(
|
if (pass.isDeepSpace) {
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
horizontalArrangement = Arrangement.Start,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
if (pass.isDeepSpace) {
|
|
||||||
Text(text = stringResource(R.string.pass_deep_space), fontSize = 15.sp)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = sdfDate.format(Date(pass.aosTime)),
|
|
||||||
fontSize = 15.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.ic_altitude),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(
|
Text(
|
||||||
text = "${pass.altitude} km",
|
text = stringResource(R.string.pass_deep_space),
|
||||||
fontSize = 15.sp
|
fontSize = 15.sp,
|
||||||
)
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
}
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
horizontalArrangement = Arrangement.End,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(
|
|
||||||
id = R.string.pass_aosLos,
|
|
||||||
pass.aosAzimuth.toInt(),
|
|
||||||
pass.losAzimuth.toInt()
|
|
||||||
),
|
|
||||||
fontSize = 15.sp
|
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
Text(text = durationStr, fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
modifier = Modifier.weight(1f),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.Center,
|
||||||
modifier = Modifier.fillMaxWidth()
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(text = "${pass.altitude} km", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
val defaultTime = " - - : - - "
|
|
||||||
Text(
|
Text(
|
||||||
text = if (pass.isDeepSpace) defaultTime else sdfTime.format(Date(pass.aosTime)),
|
text = stringResource(
|
||||||
fontSize = 15.sp
|
id = R.string.pass_aosLos,
|
||||||
)
|
pass.aosAzimuth.toInt(),
|
||||||
LinearProgressIndicator(
|
pass.losAzimuth.toInt()
|
||||||
progress = { if (pass.isDeepSpace) 100f else pass.progress },
|
),
|
||||||
drawStopIndicator = {},
|
fontSize = 15.sp,
|
||||||
modifier = modifier.fillMaxWidth(0.75f)
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = if (pass.isDeepSpace) defaultTime else sdfTime.format(Date(pass.losTime)),
|
|
||||||
fontSize = 15.sp
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
val defaultTime = " - - : - - "
|
||||||
|
Text(
|
||||||
|
text = if (pass.isDeepSpace) defaultTime else aosTimeStr,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { if (pass.isDeepSpace) 100f else pass.progress },
|
||||||
|
drawStopIndicator = {},
|
||||||
|
modifier = Modifier.fillMaxWidth(0.75f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (pass.isDeepSpace) defaultTime else losTimeStr,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.background)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,26 +20,31 @@ package com.rtbishop.look4sat.feature.passes
|
|||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
|
|
||||||
data class PassesState(
|
data class PassesState(
|
||||||
val isPassesDialogShown: Boolean,
|
val isPassesDialogShown: Boolean = false,
|
||||||
val isRadiosDialogShown: Boolean,
|
val isRadiosDialogShown: Boolean = false,
|
||||||
val isRefreshing: Boolean,
|
val isRefreshing: Boolean = true,
|
||||||
val isUtc: Boolean,
|
val isUtc: Boolean = false,
|
||||||
val nextPass: OrbitalPass,
|
val nextPass: OrbitalPass,
|
||||||
val nextTime: String,
|
val nextTime: String = "00:00:00",
|
||||||
val isNextTimeAos: Boolean,
|
val isNextTimeAos: Boolean = true,
|
||||||
val hours: Int,
|
val hours: Int = 24,
|
||||||
val elevation: Double,
|
val elevation: Double = 16.0,
|
||||||
val modes: List<String>,
|
val showDeepSpace: Boolean = true,
|
||||||
val itemsList: List<OrbitalPass>,
|
val modes: List<String> = emptyList(),
|
||||||
val shouldSeeWhatsNew: Boolean,
|
val itemsList: List<OrbitalPass> = emptyList(),
|
||||||
val takeAction: (PassesAction) -> Unit
|
val groupedPasses: Map<String, List<OrbitalPass>> = emptyMap(),
|
||||||
|
val shouldSeeWhatsNew: Boolean = false,
|
||||||
|
val sunTimes: Map<String, Pair<String, String>> = emptyMap(),
|
||||||
|
val focusedCatNum: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed class PassesAction {
|
sealed interface PassesAction {
|
||||||
data object DismissWhatsNew : PassesAction()
|
data object DismissWhatsNew : PassesAction
|
||||||
data class FilterPasses(val hoursAhead: Int, val minElevation: Double) : PassesAction()
|
data class FilterPasses(val hoursAhead: Int, val minElevation: Double, val showDeepSpace: Boolean) : PassesAction
|
||||||
data class FilterRadios(val modes: List<String>) : PassesAction()
|
data class FilterRadios(val modes: List<String>) : PassesAction
|
||||||
data object RefreshPasses : PassesAction()
|
data object RefreshPasses : PassesAction
|
||||||
data object TogglePassesDialog : PassesAction()
|
data object TogglePassesDialog : PassesAction
|
||||||
data object ToggleRadiosDialog : PassesAction()
|
data object ToggleRadiosDialog : PassesAction
|
||||||
|
data class FocusCatNum(val catNum: Int) : PassesAction
|
||||||
|
data object ClearFocus : PassesAction
|
||||||
}
|
}
|
||||||
+147
-69
@@ -18,19 +18,18 @@
|
|||||||
package com.rtbishop.look4sat.feature.passes
|
package com.rtbishop.look4sat.feature.passes
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.round
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||||
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
|
||||||
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
|
||||||
@@ -38,110 +37,189 @@ import kotlinx.coroutines.flow.collectLatest
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
class PassesViewModel(
|
class PassesViewModel(
|
||||||
private val satelliteRepo: ISatelliteRepo,
|
private val satelliteRepo: ISatelliteRepo,
|
||||||
private val settingsRepo: ISettingsRepo
|
private val settingsRepo: ISettingsRepo
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val defaultPass = getDefaultPass()
|
||||||
private val _uiState = MutableStateFlow(
|
private val _uiState = MutableStateFlow(
|
||||||
PassesState(
|
PassesState(
|
||||||
isPassesDialogShown = false,
|
|
||||||
isRadiosDialogShown = false,
|
|
||||||
isRefreshing = true,
|
|
||||||
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
||||||
nextTime = "00:00:00",
|
nextPass = defaultPass,
|
||||||
isNextTimeAos = true,
|
|
||||||
nextPass = getDefaultPass(),
|
|
||||||
hours = settingsRepo.passesSettings.value.hoursAhead,
|
hours = settingsRepo.passesSettings.value.hoursAhead,
|
||||||
elevation = settingsRepo.passesSettings.value.minElevation,
|
elevation = settingsRepo.passesSettings.value.minElevation,
|
||||||
|
showDeepSpace = settingsRepo.passesSettings.value.showDeepSpace,
|
||||||
modes = settingsRepo.passesSettings.value.selectedModes,
|
modes = settingsRepo.passesSettings.value.selectedModes,
|
||||||
itemsList = emptyList(),
|
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew
|
||||||
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew,
|
|
||||||
takeAction = ::handleAction
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
private var processing: Job? = null
|
|
||||||
val uiState: StateFlow<PassesState> = _uiState
|
val uiState: StateFlow<PassesState> = _uiState
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
// Refresh indicator: mirrors the repo's isCalculating state
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
delay(1000)
|
satelliteRepo.isCalculating.collect { calculating ->
|
||||||
satelliteRepo.passes.collectLatest { passes ->
|
_uiState.update { it.copy(isRefreshing = calculating) }
|
||||||
processing?.cancelAndJoin()
|
}
|
||||||
processing = viewModelScope.launch {
|
}
|
||||||
while (isActive) {
|
// React to settings changes: update UTC flag and whatsNew
|
||||||
val timeNow = System.currentTimeMillis()
|
viewModelScope.launch {
|
||||||
val newPasses = satelliteRepo.processPasses(passes, timeNow)
|
settingsRepo.otherSettings.collectLatest { settings ->
|
||||||
setPassInfo(newPasses, timeNow)
|
_uiState.update { it.copy(isUtc = settings.stateOfUtc, shouldSeeWhatsNew = settings.shouldSeeWhatsNew) }
|
||||||
_uiState.update { it.copy(isRefreshing = false, itemsList = newPasses) }
|
}
|
||||||
delay(1000)
|
}
|
||||||
|
// Main tick loop: reacts to new passes, then ticks every second
|
||||||
|
viewModelScope.launch {
|
||||||
|
satelliteRepo.passes.collectLatest { allPasses ->
|
||||||
|
while (isActive) {
|
||||||
|
val timeNow = System.currentTimeMillis()
|
||||||
|
val isUtc = _uiState.value.isUtc
|
||||||
|
val showDeepSpace = _uiState.value.showDeepSpace
|
||||||
|
val filtered = allPasses
|
||||||
|
.let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
|
||||||
|
val processed = computePassProgress(filtered, timeNow)
|
||||||
|
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
||||||
|
val sunTimes = computeSunTimes(processed, isUtc)
|
||||||
|
val grouped = groupPasses(processed, isUtc)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
itemsList = processed,
|
||||||
|
groupedPasses = grouped,
|
||||||
|
sunTimes = sunTimes,
|
||||||
|
nextPass = nextPass,
|
||||||
|
nextTime = nextTime,
|
||||||
|
isNextTimeAos = isAos
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
delay(1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
|
||||||
settingsRepo.otherSettings.collectLatest { settings ->
|
|
||||||
_uiState.update { it.copy(shouldSeeWhatsNew = settings.shouldSeeWhatsNew) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleAction(action: PassesAction) {
|
fun onAction(action: PassesAction) {
|
||||||
when (action) {
|
when (action) {
|
||||||
PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed()
|
PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed()
|
||||||
is PassesAction.FilterPasses -> applyFilter(action.hoursAhead, action.minElevation, uiState.value.modes)
|
is PassesAction.FilterPasses ->
|
||||||
is PassesAction.FilterRadios -> applyFilter(uiState.value.hours, uiState.value.elevation, action.modes)
|
applyFilter(action.hoursAhead, action.minElevation, action.showDeepSpace, _uiState.value.modes)
|
||||||
|
is PassesAction.FilterRadios ->
|
||||||
|
applyFilter(_uiState.value.hours, _uiState.value.elevation, _uiState.value.showDeepSpace, action.modes)
|
||||||
PassesAction.RefreshPasses -> refreshPasses()
|
PassesAction.RefreshPasses -> refreshPasses()
|
||||||
PassesAction.TogglePassesDialog -> toggleFilterDialog()
|
PassesAction.TogglePassesDialog ->
|
||||||
PassesAction.ToggleRadiosDialog -> toggleRadiosDialog()
|
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
|
||||||
|
PassesAction.ToggleRadiosDialog ->
|
||||||
|
_uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) }
|
||||||
|
is PassesAction.FocusCatNum -> _uiState.update { it.copy(focusedCatNum = action.catNum) }
|
||||||
|
PassesAction.ClearFocus -> _uiState.update { it.copy(focusedCatNum = null) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyFilter(hoursAhead: Int, minElevation: Double, modes: List<String>) = viewModelScope.launch {
|
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
|
||||||
_uiState.update { it.copy(isRefreshing = true) }
|
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
||||||
processing?.cancelAndJoin()
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
settingsRepo.setPassesSettings(PassesSettings(hoursAhead, minElevation, modes))
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
_uiState.update { it.copy(hours = hoursAhead, elevation = minElevation, modes = modes) }
|
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
|
||||||
|
val sdfTime = SimpleDateFormat("HH:mm", Locale.ENGLISH).also { it.timeZone = tz }
|
||||||
|
val result = LinkedHashMap<String, Pair<String, String>>()
|
||||||
|
// DeepSpace group always shows today's sun times
|
||||||
|
if (passes.any { it.isDeepSpace }) {
|
||||||
|
val riseSet = CelestialComputer.findSunRiseSet(stationPos, System.currentTimeMillis())
|
||||||
|
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
||||||
|
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
||||||
|
result["DeepSpace (period >225min)"] = rise to set
|
||||||
|
}
|
||||||
|
for (pass in passes) {
|
||||||
|
if (pass.isDeepSpace) continue
|
||||||
|
val label = sdfDate.format(Date(pass.aosTime))
|
||||||
|
if (label in result) continue
|
||||||
|
val riseSet = CelestialComputer.findSunRiseSet(stationPos, pass.aosTime)
|
||||||
|
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
||||||
|
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
||||||
|
result[label] = rise to set
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
|
||||||
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
|
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
|
||||||
|
val ordered = LinkedHashMap<String, List<OrbitalPass>>()
|
||||||
|
val deepSpace = passes.filter { it.isDeepSpace }
|
||||||
|
if (deepSpace.isNotEmpty()) ordered["DeepSpace (period >225min)"] = deepSpace
|
||||||
|
passes.filter { !it.isDeepSpace }
|
||||||
|
.groupByTo(LinkedHashMap()) { sdfDate.format(Date(it.aosTime)) }
|
||||||
|
.forEach { (k, v) -> ordered[k] = v }
|
||||||
|
return ordered
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Computes live progress for each pass, filtering out expired ones. */
|
||||||
|
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
|
||||||
|
val result = ArrayList<OrbitalPass>(passList.size)
|
||||||
|
for (pass in passList) {
|
||||||
|
if (!pass.isDeepSpace && time > pass.aosTime) {
|
||||||
|
val deltaNow = time.minus(pass.aosTime).toFloat()
|
||||||
|
val deltaTotal = pass.losTime.minus(pass.aosTime).toFloat()
|
||||||
|
val newProgress = (deltaNow / deltaTotal).round(2)
|
||||||
|
if (newProgress >= 1.0f) continue
|
||||||
|
if (newProgress != pass.progress) {
|
||||||
|
result.add(pass.copy(progress = newProgress))
|
||||||
|
} else {
|
||||||
|
result.add(pass)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.add(pass)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves the next upcoming or active pass and its countdown timer. */
|
||||||
|
private fun resolveNextPass(
|
||||||
|
passes: List<OrbitalPass>,
|
||||||
|
timeNow: Long
|
||||||
|
): Triple<OrbitalPass, String, Boolean> {
|
||||||
|
val upcoming = passes.firstOrNull { it.aosTime > timeNow }
|
||||||
|
if (upcoming != null) {
|
||||||
|
return Triple(upcoming, (upcoming.aosTime - timeNow).toTimerString(), true)
|
||||||
|
}
|
||||||
|
if (passes.isNotEmpty()) {
|
||||||
|
val lastPass = passes.last()
|
||||||
|
return Triple(lastPass, (lastPass.losTime - timeNow).toTimerString(), false)
|
||||||
|
}
|
||||||
|
return Triple(defaultPass, "00:00:00", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyFilter(
|
||||||
|
hoursAhead: Int,
|
||||||
|
minElevation: Double,
|
||||||
|
showDeepSpace: Boolean,
|
||||||
|
modes: List<String>
|
||||||
|
) = viewModelScope.launch {
|
||||||
|
settingsRepo.setPassesSettings(PassesSettings(showDeepSpace, hoursAhead, minElevation, modes))
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(hours = hoursAhead, elevation = minElevation, showDeepSpace = showDeepSpace, modes = modes)
|
||||||
|
}
|
||||||
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
|
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshPasses() = viewModelScope.launch {
|
private fun refreshPasses() = viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isRefreshing = true) }
|
val (_, hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
|
||||||
processing?.cancelAndJoin()
|
|
||||||
val (hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
|
|
||||||
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
|
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setPassInfo(passes: List<OrbitalPass>, timeNow: Long) {
|
|
||||||
if (passes.isEmpty()) return
|
|
||||||
try {
|
|
||||||
val nextPass = passes.first { it.aosTime.minus(timeNow) > 0 }
|
|
||||||
val time = nextPass.aosTime.minus(timeNow).toTimerString()
|
|
||||||
_uiState.update { it.copy(nextPass = nextPass, nextTime = time, isNextTimeAos = true) }
|
|
||||||
} catch (_: NoSuchElementException) {
|
|
||||||
val lastPass = passes.last()
|
|
||||||
val time = lastPass.losTime.minus(timeNow).toTimerString()
|
|
||||||
_uiState.update { it.copy(nextPass = lastPass, nextTime = time, isNextTimeAos = false) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toggleFilterDialog() {
|
|
||||||
val currentDialogState = _uiState.value.isPassesDialogShown
|
|
||||||
_uiState.update { it.copy(isPassesDialogShown = currentDialogState.not()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toggleRadiosDialog() {
|
|
||||||
val currentDialogState = _uiState.value.isRadiosDialogShown
|
|
||||||
_uiState.update { it.copy(isRadiosDialogShown = currentDialogState.not()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
|
|
||||||
initializer {
|
initializer {
|
||||||
val container = (this[applicationKey] as IContainerProvider).getMainContainer()
|
PassesViewModel(
|
||||||
PassesViewModel(container.satelliteRepo, container.settingsRepo)
|
satelliteRepo = container.satelliteRepo,
|
||||||
|
settingsRepo = container.settingsRepo
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.convention.composeFeaturePlugin)
|
alias(libs.plugins.convention.featurePlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|||||||
@@ -17,15 +17,17 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.feature.radar
|
package com.rtbishop.look4sat.feature.radar
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -33,282 +35,153 @@ import androidx.compose.foundation.layout.Row
|
|||||||
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.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.PrimaryTabRow
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.rotate
|
|
||||||
import androidx.compose.ui.keepScreenOn
|
import androidx.compose.ui.keepScreenOn
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavGraphBuilder
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import androidx.navigation.compose.composable
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import androidx.navigation.navArgument
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
|
||||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.Screen
|
|
||||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
|
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||||
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
fun NavGraphBuilder.radarDestination(navigateUp: () -> Unit) {
|
private enum class RadarPage(val title: String) {
|
||||||
val radarRoute = "${Screen.Radar.route}?catNum={catNum}&aosTime={aosTime}"
|
Transceivers("Transceivers"),
|
||||||
val radarArgs = listOf(
|
Sstv("SSTV")
|
||||||
navArgument("catNum") { defaultValue = 0 },
|
|
||||||
navArgument("aosTime") { defaultValue = 0L }
|
|
||||||
)
|
|
||||||
composable(radarRoute, radarArgs) {
|
|
||||||
val viewModel = viewModel(RadarViewModel::class.java, factory = RadarViewModel.Factory)
|
|
||||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
|
||||||
RadarScreen(uiState, navigateUp)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) {
|
fun RadarDestination(navigateUp: () -> Unit) {
|
||||||
// BluetoothCIV.init(LocalContext.current)
|
val context = LocalContext.current
|
||||||
val addToCalendar: () -> Unit = {
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
uiState.currentPass?.let { pass ->
|
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
||||||
uiState.sendAction(RadarAction.AddToCalendar(pass.name, pass.aosTime, pass.losTime))
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
}
|
// Sync actual permission state on every recomposition so it survives screen re-entry
|
||||||
|
val hasPermission = ContextCompat.checkSelfPermission(
|
||||||
|
context, Manifest.permission.RECORD_AUDIO
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
LaunchedEffect(hasPermission) {
|
||||||
|
viewModel.onAction(RadarAction.SstvPermissionResult(hasPermission))
|
||||||
}
|
}
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { granted -> viewModel.onAction(RadarAction.SstvPermissionResult(granted)) }
|
||||||
|
RadarScreen(uiState, viewModel::onAction, navigateUp, requestMicPermission = {
|
||||||
|
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RadarScreen(
|
||||||
|
uiState: RadarState,
|
||||||
|
onAction: (RadarAction) -> Unit,
|
||||||
|
navigateUp: () -> Unit,
|
||||||
|
requestMicPermission: () -> Unit
|
||||||
|
) {
|
||||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||||
if (upcomingPass.losTime < System.currentTimeMillis()) navigateUp()
|
val addToCalendar: () -> Unit = {
|
||||||
|
uiState.currentPass?.let { onAction(RadarAction.AddToCalendar(it.name, it.aosTime, it.losTime)) }
|
||||||
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.layoutPadding()
|
.layoutPadding()
|
||||||
.keepScreenOn(), verticalArrangement = Arrangement.spacedBy(6.dp)
|
.keepScreenOn(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
) {
|
) {
|
||||||
if (isVerticalLayout()) {
|
val isVertical = isVerticalLayout()
|
||||||
|
if (isVertical) {
|
||||||
TopBar {
|
TopBar {
|
||||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
|
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||||
}
|
}
|
||||||
TopBar { NextPassRow(pass = upcomingPass) }
|
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
|
||||||
} else {
|
} else {
|
||||||
TopBar {
|
TopBar {
|
||||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
|
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||||
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f))
|
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
|
||||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if(isVerticalLayout()) {
|
if (isVertical) {
|
||||||
ElevatedCard(modifier = Modifier.weight(1f)) {
|
RadarCard(uiState, Modifier.weight(1f))
|
||||||
Box(contentAlignment = Alignment.Center) {
|
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
||||||
if (uiState.orbitalPos == null) {
|
|
||||||
ElevatedCard(modifier = Modifier.fillMaxSize()) {
|
|
||||||
EmptyListCard(message = "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
uiState.orbitalPos?.let { position ->
|
|
||||||
RadarViewCompose(
|
|
||||||
item = position,
|
|
||||||
items = uiState.satTrack,
|
|
||||||
azimElev = uiState.orientationValues,
|
|
||||||
shouldShowSweep = uiState.shouldShowSweep,
|
|
||||||
shouldUseCompass = uiState.shouldUseCompass,
|
|
||||||
modifier = Modifier.align(Alignment.Center)
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(horizontal = 6.dp, vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
RadarTextTop(
|
|
||||||
position.azimuth,
|
|
||||||
stringResource(R.string.radar_az_text),
|
|
||||||
true
|
|
||||||
)
|
|
||||||
RadarTextTop(
|
|
||||||
position.elevation,
|
|
||||||
stringResource(R.string.radar_el_text),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
RadarTextBottom(
|
|
||||||
position.altitude,
|
|
||||||
stringResource(R.string.radar_alt_text),
|
|
||||||
true
|
|
||||||
)
|
|
||||||
RadarTextBottom(
|
|
||||||
position.distance,
|
|
||||||
stringResource(R.string.radar_dist_text),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ElevatedCard(modifier = Modifier.weight(1f)) {
|
|
||||||
if (uiState.transmitters.isEmpty()) {
|
|
||||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
|
||||||
modifier = Modifier.padding(32.dp)
|
|
||||||
) {
|
|
||||||
Text(text = """¯\_(ツ)_/¯""", fontSize = 32.sp)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.empty_list_message),
|
|
||||||
fontSize = 21.sp,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.radar_no_data),
|
|
||||||
fontSize = 18.sp,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
TransmittersList(
|
|
||||||
transmitters = uiState.transmitters,
|
|
||||||
selectedUuid = uiState.selectedTransmitterUuid,
|
|
||||||
onSelect = { uuid ->
|
|
||||||
if (uiState.selectedTransmitterUuid != null) {
|
|
||||||
uiState.sendAction(RadarAction.SelectTransmitter(uuid))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (uiState.orbitalPos?.eclipsed == true) {
|
|
||||||
EclipsedIndicator()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
ElevatedCard(modifier = Modifier.weight(1f)) {
|
RadarCard(uiState, Modifier.weight(1f))
|
||||||
Box(contentAlignment = Alignment.Center) {
|
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
||||||
if (uiState.orbitalPos == null) {
|
}
|
||||||
ElevatedCard(modifier = Modifier.fillMaxSize()) {
|
}
|
||||||
EmptyListCard(message = "")
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
uiState.orbitalPos?.let { position ->
|
@Composable
|
||||||
RadarViewCompose(
|
private fun PagerCard(
|
||||||
item = position,
|
uiState: RadarState,
|
||||||
items = uiState.satTrack,
|
onAction: (RadarAction) -> Unit,
|
||||||
azimElev = uiState.orientationValues,
|
requestMicPermission: () -> Unit,
|
||||||
shouldShowSweep = uiState.shouldShowSweep,
|
modifier: Modifier = Modifier
|
||||||
shouldUseCompass = uiState.shouldUseCompass,
|
) {
|
||||||
modifier = Modifier.align(Alignment.Center)
|
val pages = RadarPage.entries
|
||||||
)
|
val pagerState = rememberPagerState(pageCount = { pages.size })
|
||||||
Column(
|
val coroutineScope = rememberCoroutineScope()
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier
|
ElevatedCard(modifier = modifier) {
|
||||||
.fillMaxSize()
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
.padding(horizontal = 6.dp, vertical = 4.dp)
|
PrimaryTabRow(selectedTabIndex = pagerState.currentPage) {
|
||||||
) {
|
pages.forEachIndexed { index, page ->
|
||||||
Row(
|
Tab(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
selected = pagerState.currentPage == index,
|
||||||
modifier = Modifier.fillMaxWidth()
|
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
|
||||||
) {
|
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||||
RadarTextTop(
|
)
|
||||||
position.azimuth,
|
|
||||||
stringResource(R.string.radar_az_text),
|
|
||||||
true
|
|
||||||
)
|
|
||||||
RadarTextTop(
|
|
||||||
position.elevation,
|
|
||||||
stringResource(R.string.radar_el_text),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
RadarTextBottom(
|
|
||||||
position.altitude,
|
|
||||||
stringResource(R.string.radar_alt_text),
|
|
||||||
true
|
|
||||||
)
|
|
||||||
RadarTextBottom(
|
|
||||||
position.distance,
|
|
||||||
stringResource(R.string.radar_dist_text),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ElevatedCard(modifier = Modifier.weight(1f)) {
|
}
|
||||||
if (uiState.transmitters.isEmpty()) {
|
HorizontalPager(
|
||||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
|
state = pagerState,
|
||||||
Column(
|
modifier = Modifier.fillMaxSize()
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
) { pageIndex ->
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
when (pages[pageIndex]) {
|
||||||
modifier = Modifier.padding(32.dp)
|
RadarPage.Transceivers -> TransceiversPage(
|
||||||
) {
|
transceivers = uiState.transceivers.transmitters,
|
||||||
Text(text = """¯\_(ツ)_/¯""", fontSize = 32.sp)
|
selectedUuid = uiState.transceivers.selectedUuid,
|
||||||
Text(
|
radioControl = uiState.radioControl,
|
||||||
text = stringResource(R.string.empty_list_message),
|
onAction = onAction
|
||||||
fontSize = 21.sp,
|
)
|
||||||
textAlign = TextAlign.Center
|
RadarPage.Sstv -> SstvPage(
|
||||||
)
|
sstv = uiState.sstv,
|
||||||
Text(
|
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
|
||||||
text = stringResource(R.string.radar_no_data),
|
onAction = onAction,
|
||||||
fontSize = 18.sp,
|
requestMicPermission = requestMicPermission
|
||||||
textAlign = TextAlign.Center
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
TransmittersList(
|
|
||||||
transmitters = uiState.transmitters,
|
|
||||||
selectedUuid = uiState.selectedTransmitterUuid,
|
|
||||||
onSelect = { uuid ->
|
|
||||||
if (uiState.selectedTransmitterUuid != null) {
|
|
||||||
uiState.sendAction(RadarAction.SelectTransmitter(uuid))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (uiState.orbitalPos?.eclipsed == true) {
|
|
||||||
EclipsedIndicator()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -316,180 +189,111 @@ private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RadarTextTop(value: Double, text: String, isLeft: Boolean) {
|
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
|
||||||
val alignment = if (isLeft) Alignment.Start else Alignment.End
|
val satellitePos = uiState.orbitalPos
|
||||||
val degValue = stringResource(R.string.radar_az_value, value.toDegrees())
|
val shouldAnimateBorder = satellitePos?.aboveHorizon == true && satellitePos.eclipsed
|
||||||
Column(horizontalAlignment = alignment) {
|
// Always call these composables unconditionally — conditional composable calls violate
|
||||||
Text(text = degValue, fontSize = 18.sp)
|
// Compose's slot-table stability rules and can crash or produce incorrect state
|
||||||
Text(text = text, fontSize = 15.sp)
|
val infiniteTransition = rememberInfiniteTransition(label = "eclipsedBorder")
|
||||||
}
|
val borderAlpha by infiniteTransition.animateFloat(
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RadarTextBottom(value: Double, text: String, isLeft: Boolean) {
|
|
||||||
val alignment = if (isLeft) Alignment.Start else Alignment.End
|
|
||||||
val degValue = stringResource(R.string.radar_alt_value, value)
|
|
||||||
Column(horizontalAlignment = alignment) {
|
|
||||||
Text(text = text, fontSize = 15.sp)
|
|
||||||
Text(text = degValue, fontSize = 18.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TransmittersList(
|
|
||||||
transmitters: List<SatRadio>,
|
|
||||||
selectedUuid: String?,
|
|
||||||
onSelect: (String) -> Unit
|
|
||||||
) {
|
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
|
||||||
items(items = transmitters, key = { item -> item.uuid }) { radio ->
|
|
||||||
TransmitterItem(
|
|
||||||
radio,
|
|
||||||
(selectedUuid != null),
|
|
||||||
(radio.uuid == selectedUuid)
|
|
||||||
) { onSelect(radio.uuid) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
private fun TransmitterItemPreview() {
|
|
||||||
val transmitter = SatRadio(
|
|
||||||
"", "Extremely powerful transmitter", true, 10000000000L, 10000000000L,
|
|
||||||
null, 10000000000L, 10000000000L, "FSK AX.100 Mode 5", true, 0
|
|
||||||
)
|
|
||||||
MainTheme { TransmitterItem(transmitter, isClickable = true, isSelected = true, onClick = {}) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EclipsedIndicator() {
|
|
||||||
val bgColor = MaterialTheme.colorScheme.primary
|
|
||||||
val bgShape = MaterialTheme.shapes.medium
|
|
||||||
val infiniteTransition = rememberInfiniteTransition()
|
|
||||||
val textAlpha = infiniteTransition.animateFloat(
|
|
||||||
initialValue = 1.0f,
|
initialValue = 1.0f,
|
||||||
targetValue = 0.0f,
|
targetValue = 0.0f,
|
||||||
animationSpec = infiniteRepeatable(
|
animationSpec = infiniteRepeatable(
|
||||||
animation = tween(
|
animation = tween(durationMillis = 1000, delayMillis = 25, easing = LinearEasing),
|
||||||
durationMillis = 1000,
|
|
||||||
delayMillis = 25,
|
|
||||||
easing = LinearEasing
|
|
||||||
),
|
|
||||||
repeatMode = RepeatMode.Reverse
|
repeatMode = RepeatMode.Reverse
|
||||||
)
|
),
|
||||||
|
label = "eclipsedBorderAlpha"
|
||||||
)
|
)
|
||||||
Box(
|
val borderModifier = if (shouldAnimateBorder) {
|
||||||
modifier = Modifier
|
Modifier.border(
|
||||||
.fillMaxSize()
|
width = 0.5.dp,
|
||||||
.alpha(textAlpha.value)
|
color = MaterialTheme.colorScheme.primary.copy(alpha = borderAlpha),
|
||||||
.border(width = 2.dp, color = bgColor, shape = bgShape),
|
shape = MaterialTheme.shapes.medium
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.radar_eclipsed),
|
|
||||||
fontSize = 18.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.background,
|
|
||||||
modifier = Modifier
|
|
||||||
.background(color = bgColor, shape = bgShape)
|
|
||||||
.padding(12.dp)
|
|
||||||
)
|
)
|
||||||
}
|
} else Modifier
|
||||||
}
|
ElevatedCard(modifier = modifier.then(borderModifier)) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
@Composable
|
val position = uiState.orbitalPos
|
||||||
private fun TransmitterItem(
|
if (position == null) {
|
||||||
radio: SatRadio,
|
ElevatedCard(modifier = Modifier.fillMaxSize()) {
|
||||||
isClickable: Boolean,
|
EmptyListCard(message = "")
|
||||||
isSelected: Boolean,
|
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
val title = if (radio.isInverted) "INVERTED: ${radio.info}" else radio.info
|
|
||||||
val fullTitle = "$title - (${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"})"
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.background,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.then(if (isClickable) Modifier.clickable { onClick() } else Modifier)
|
|
||||||
) {
|
|
||||||
Surface(modifier = Modifier.padding(bottom = 2.dp)) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
|
||||||
) {
|
|
||||||
Box {
|
|
||||||
Text(
|
|
||||||
text = fullTitle,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 6.dp)
|
|
||||||
.infiniteMarquee()
|
|
||||||
)
|
|
||||||
if (isSelected) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.ic_radios),
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.background(color = MaterialTheme.colorScheme.surface)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
FrequencyRow(radio = radio, isDownlink = true)
|
} else {
|
||||||
FrequencyRow(radio = radio, isDownlink = false)
|
RadarViewCompose(
|
||||||
|
item = position,
|
||||||
|
items = uiState.satTrack,
|
||||||
|
azimElev = uiState.orientationValues,
|
||||||
|
shouldShowSweep = uiState.shouldShowSweep,
|
||||||
|
shouldUseCompass = uiState.shouldUseCompass,
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
sunPosition = uiState.sunPosition,
|
||||||
|
moonPosition = uiState.moonPosition,
|
||||||
|
)
|
||||||
|
PositionOverlay(position)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FrequencyRow(radio: SatRadio, isDownlink: Boolean, modifier: Modifier = Modifier) {
|
private fun PositionOverlay(position: OrbitalPos) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier.fillMaxWidth()) {
|
Column(
|
||||||
val rotateMod = Modifier.rotate(if (isDownlink) 90f else -90f)
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
val weightMod = Modifier.weight(1f)
|
modifier = Modifier
|
||||||
val desc = if (isDownlink) stringResource(R.string.radar_downlink) else stringResource(R.string.radar_uplink)
|
.fillMaxSize()
|
||||||
Text(
|
.padding(horizontal = 6.dp, vertical = 4.dp)
|
||||||
text = if (isDownlink) "D:" else "U:",
|
) {
|
||||||
textAlign = TextAlign.Center,
|
Row(
|
||||||
fontSize = 18.sp,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
modifier = Modifier.fillMaxWidth()
|
||||||
modifier = modifier
|
) {
|
||||||
.size(width = 24.dp, height = 24.dp)
|
RadarLabel(
|
||||||
.semantics { contentDescription = desc }
|
value = stringResource(R.string.radar_az_value, position.azimuth.toDegrees()),
|
||||||
)
|
label = stringResource(R.string.radar_az_text),
|
||||||
FrequencyText(if (isDownlink) radio.downlinkLow else radio.uplinkLow, weightMod)
|
alignment = Alignment.Start,
|
||||||
Text(
|
labelFirst = false
|
||||||
text = "-",
|
)
|
||||||
textAlign = TextAlign.Center,
|
RadarLabel(
|
||||||
fontSize = 21.sp,
|
value = stringResource(R.string.radar_az_value, position.elevation.toDegrees()),
|
||||||
fontWeight = FontWeight.Bold,
|
label = stringResource(R.string.radar_el_text),
|
||||||
color = MaterialTheme.colorScheme.primary,
|
alignment = Alignment.End,
|
||||||
modifier = modifier
|
labelFirst = false
|
||||||
)
|
)
|
||||||
FrequencyText(if (isDownlink) radio.downlinkHigh else radio.uplinkHigh, weightMod)
|
}
|
||||||
Icon(
|
Row(
|
||||||
painter = painterResource(id = R.drawable.ic_arrow),
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
modifier = Modifier.fillMaxWidth()
|
||||||
contentDescription = null,
|
) {
|
||||||
modifier = rotateMod.size(width = 24.dp, height = 24.dp)
|
RadarLabel(
|
||||||
)
|
value = stringResource(R.string.radar_alt_value, position.altitude),
|
||||||
|
label = stringResource(R.string.radar_alt_text),
|
||||||
|
alignment = Alignment.Start,
|
||||||
|
labelFirst = true
|
||||||
|
)
|
||||||
|
RadarLabel(
|
||||||
|
value = stringResource(R.string.radar_alt_value, position.distance),
|
||||||
|
label = stringResource(R.string.radar_dist_text),
|
||||||
|
alignment = Alignment.End,
|
||||||
|
labelFirst = true
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
|
private fun RadarLabel(
|
||||||
val noLinkText = stringResource(R.string.radar_no_link)
|
value: String,
|
||||||
val freqValue = frequency?.let { it / 1000000f }
|
label: String,
|
||||||
Text(
|
alignment: Alignment.Horizontal,
|
||||||
text = freqValue?.let { stringResource(id = R.string.radar_link_low, it) } ?: noLinkText,
|
labelFirst: Boolean
|
||||||
textAlign = TextAlign.Center,
|
) {
|
||||||
fontSize = 21.sp,
|
Column(horizontalAlignment = alignment) {
|
||||||
fontWeight = FontWeight.Bold,
|
if (labelFirst) {
|
||||||
color = MaterialTheme.colorScheme.primary,
|
Text(text = label, fontSize = 15.sp)
|
||||||
modifier = modifier
|
Text(text = value, fontSize = 18.sp)
|
||||||
)
|
} else {
|
||||||
|
Text(text = value, fontSize = 18.sp)
|
||||||
|
Text(text = label, fontSize = 15.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -18,25 +18,80 @@
|
|||||||
package com.rtbishop.look4sat.feature.radar
|
package com.rtbishop.look4sat.feature.radar
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.sstv.SstvFrame
|
||||||
|
|
||||||
data class RadarState(
|
data class RadioPanelState(
|
||||||
val currentPass: OrbitalPass?,
|
val label: String = "",
|
||||||
val currentTime: String,
|
val isConnected: Boolean = false,
|
||||||
val isCurrentTimeAos: Boolean,
|
val frequencyHz: Long? = null,
|
||||||
val orientationValues: Pair<Float, Float>,
|
val frequencyDisplay: String = "---",
|
||||||
val orbitalPos: OrbitalPos?,
|
val mode: String? = null
|
||||||
val satTrack: List<OrbitalPos>,
|
|
||||||
val shouldShowSweep: Boolean,
|
|
||||||
val shouldUseCompass: Boolean,
|
|
||||||
val transmitters: List<SatRadio>,
|
|
||||||
val selectedTransmitterUuid: String?,
|
|
||||||
val selectedFrequency: Long?,
|
|
||||||
val sendAction: (RadarAction) -> Unit
|
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed class RadarAction {
|
data class RadioControlSubState(
|
||||||
data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction()
|
val txPanel: RadioPanelState = RadioPanelState("TX (Uplink)"),
|
||||||
data class SelectTransmitter(val uuid: String) : RadarAction()
|
val rxPanel: RadioPanelState = RadioPanelState("RX (Downlink)"),
|
||||||
|
val selectedTransponderUuid: String? = null,
|
||||||
|
val txBaseFrequencyHz: Long? = null,
|
||||||
|
val ctcssTone: Double? = null,
|
||||||
|
val isTracking: Boolean = false,
|
||||||
|
val errorMessage: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TransceiverSubState(
|
||||||
|
val transmitters: List<SatRadio> = emptyList(),
|
||||||
|
val selectedUuid: String? = null,
|
||||||
|
val selectedFrequency: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RadarState(
|
||||||
|
val currentPass: OrbitalPass? = null,
|
||||||
|
val currentTime: String = "00:00:00",
|
||||||
|
val isTimeAos: Boolean = true,
|
||||||
|
val isUtc: Boolean = false,
|
||||||
|
val orientationValues: Pair<Float, Float> = 0f to 0f,
|
||||||
|
val orbitalPos: OrbitalPos? = null,
|
||||||
|
val satTrack: List<OrbitalPos> = emptyList(),
|
||||||
|
val shouldShowSweep: Boolean = false,
|
||||||
|
val shouldUseCompass: Boolean = false,
|
||||||
|
val sunPosition: CelestialComputer.SunPosition? = null,
|
||||||
|
val moonPosition: CelestialComputer.MoonPosition? = null,
|
||||||
|
val transceivers: TransceiverSubState = TransceiverSubState(),
|
||||||
|
val radioControl: RadioControlSubState = RadioControlSubState(),
|
||||||
|
val sstv: SstvSubState = SstvSubState()
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class SstvStatus { Idle, Recording }
|
||||||
|
|
||||||
|
data class SstvSubState(
|
||||||
|
val status: SstvStatus = SstvStatus.Idle,
|
||||||
|
val isSaving: Boolean = false,
|
||||||
|
val hasPermission: Boolean = false,
|
||||||
|
val selectedMode: String = "Auto",
|
||||||
|
val supportedModes: List<String> = emptyList(),
|
||||||
|
val currentFrame: SstvFrame? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface RadarAction {
|
||||||
|
data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : RadarAction
|
||||||
|
data class SelectTransmitter(val uuid: String) : RadarAction
|
||||||
|
|
||||||
|
// Radio control actions
|
||||||
|
data class SetTxFrequency(val frequencyHz: Long) : RadarAction
|
||||||
|
data class AdjustTxFrequency(val deltaHz: Long) : RadarAction
|
||||||
|
data class SetCtcssTone(val toneHz: Double?) : RadarAction
|
||||||
|
data object ToggleTracking : RadarAction
|
||||||
|
data object ConnectRadios : RadarAction
|
||||||
|
data object DisconnectRadios : RadarAction
|
||||||
|
|
||||||
|
// SSTV actions
|
||||||
|
data object SstvStartRecording : RadarAction
|
||||||
|
data object SstvStopRecording : RadarAction
|
||||||
|
data object SstvSaveImage : RadarAction
|
||||||
|
data object SstvReset : RadarAction
|
||||||
|
data class SstvSelectMode(val modeName: String) : RadarAction
|
||||||
|
data class SstvPermissionResult(val granted: Boolean) : RadarAction
|
||||||
}
|
}
|
||||||
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user