mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81d397a09d | ||
|
|
ec1a55c93d | ||
|
|
e5033cb2e9 | ||
|
|
39b786ae82 | ||
|
|
79809aaec0 | ||
|
|
f7f5a73c4c | ||
|
|
18c8ac1822 | ||
|
|
22607e7710 | ||
|
|
ad0ff6859e | ||
|
|
a39b1716e7 | ||
|
|
8a2339cfbf | ||
|
|
821fa0dfe7 | ||
|
|
ce8c2a4834 | ||
|
|
8cc4ef5610 | ||
|
|
dde22faf46 |
No files matched your search
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -25,50 +25,55 @@ jobs:
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Assemble Artifacts
|
||||
run: |
|
||||
./gradlew assembleRelease
|
||||
./gradlew bundleRelease
|
||||
run: ./gradlew assembleRelease bundleRelease
|
||||
|
||||
- name: Sign APK
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
id: sign_apk
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/apk/release
|
||||
signingKeyBase64: ${{ secrets.KEY_STORE }}
|
||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: "36.0.0"
|
||||
run: |
|
||||
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
|
||||
BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
|
||||
${BUILD_TOOLS}apksigner sign \
|
||||
--ks keystore.jks \
|
||||
--ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
|
||||
--ks-key-alias ${{ secrets.KEY_ALIAS }} \
|
||||
--key-pass pass:${{ secrets.KEY_PASSWORD }} \
|
||||
--out app/build/outputs/apk/release/look4sat.apk \
|
||||
"$APK"
|
||||
rm keystore.jks
|
||||
|
||||
- name: Sign Bundle
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
id: sign_bundle
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/bundle/release
|
||||
signingKeyBase64: ${{ secrets.KEY_STORE }}
|
||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: "36.0.0"
|
||||
|
||||
- name: Rename Artifacts
|
||||
run: |
|
||||
mv ${{steps.sign_apk.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.apk
|
||||
mv ${{steps.sign_bundle.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.aab
|
||||
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
||||
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
|
||||
-keystore keystore.jks \
|
||||
-storepass ${{ secrets.KEY_STORE_PASSWORD }} \
|
||||
-keypass ${{ secrets.KEY_PASSWORD }} \
|
||||
"$AAB" ${{ secrets.KEY_ALIAS }}
|
||||
rm keystore.jks
|
||||
|
||||
- name: Deploy Bundle
|
||||
uses: r0adkll/upload-google-play@v1
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
serviceAccountJsonPlainText: ${{secrets.SERVICE_ACCOUNT_JSON}}
|
||||
packageName: com.rtbishop.look4sat
|
||||
releaseFiles: app/build/outputs/apk/release/look4sat.aab
|
||||
track: production
|
||||
whatsNewDirectory: fastlane/metadata/android/en-US/whatsnew
|
||||
ruby-version: '3.3'
|
||||
|
||||
- name: Deploy Bundle to Google Play
|
||||
run: |
|
||||
gem install multi_json
|
||||
gem install fastlane --no-document
|
||||
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
||||
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
|
||||
fastlane supply \
|
||||
--aab "$AAB" \
|
||||
--json_key service_account.json \
|
||||
--package_name com.rtbishop.look4sat \
|
||||
--track production \
|
||||
--skip_upload_images true \
|
||||
--skip_upload_screenshots true \
|
||||
rm service_account.json
|
||||
|
||||
- name: Create Release
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## 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.
|
||||
@@ -11,12 +11,15 @@
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round">
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
@@ -26,6 +29,14 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- <intent-filter android:autoVerify="true">-->
|
||||
<!-- <action android:name="android.intent.action.VIEW" />-->
|
||||
<!-- <category android:name="android.intent.category.DEFAULT" />-->
|
||||
<!-- <category android:name="android.intent.category.BROWSABLE" />-->
|
||||
<!-- <data android:scheme="https" />-->
|
||||
<!-- <data android:host="github.com" />-->
|
||||
<!-- <data android:pathPattern="/rt-bishop/Look4Sat/passes.*" />-->
|
||||
<!-- </intent-filter>-->
|
||||
</activity>
|
||||
|
||||
<meta-data
|
||||
|
||||
@@ -25,6 +25,10 @@ import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -32,6 +36,7 @@ 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
|
||||
@@ -39,11 +44,13 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -62,25 +69,60 @@ 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.hasEnoughHeight
|
||||
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
||||
import com.rtbishop.look4sat.feature.map.MapDestination
|
||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||
import com.rtbishop.look4sat.feature.radiocontrol.RadioControlDestination
|
||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
fun NavRoot(deeplink: String? = null) {
|
||||
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||
val deeplinkResolver = DeeplinkResolver()
|
||||
LaunchedEffect(deeplink) {
|
||||
deeplink?.let {
|
||||
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
|
||||
rootBackStack.add(destination)
|
||||
}
|
||||
}
|
||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
||||
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
||||
NavDisplay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
backStack = rootBackStack,
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { slideInTransition },
|
||||
popTransitionSpec = { slideOutTransition },
|
||||
predictivePopTransitionSpec = { slideOutTransition },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
|
||||
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
|
||||
entry<RadarDestination> {
|
||||
Scaffold { innerPadding ->
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
innerPadding.calculateTopPadding()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
val backStack = rememberNavBackStack(Screen.Passes)
|
||||
val currentKey = backStack.lastOrNull()
|
||||
val navigateBack: () -> Unit = { backStack.removeAt(backStack.size - 1) }
|
||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
||||
// val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
||||
// val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar(), Screen.Map, Screen.Settings)
|
||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
|
||||
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
@@ -138,25 +180,13 @@ fun MainScreen() {
|
||||
}
|
||||
entry<Screen.Passes> {
|
||||
PassesDestination { catNum, aosTime ->
|
||||
backStack.add(Screen.Radar(catNum, aosTime))
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
// navigateToRadar()
|
||||
}
|
||||
}
|
||||
entry<Screen.Radar> { route ->
|
||||
RadarDestination(
|
||||
catNum = route.catNum,
|
||||
aosTime = route.aosTime,
|
||||
navigateUp = navigateBack,
|
||||
navigateToRadioControl = { catNum, aosTime ->
|
||||
backStack.add(Screen.RadioControl(catNum, aosTime))
|
||||
}
|
||||
)
|
||||
}
|
||||
entry<Screen.RadioControl> { route ->
|
||||
RadioControlDestination(
|
||||
catNum = route.catNum,
|
||||
aosTime = route.aosTime,
|
||||
navigateUp = navigateBack
|
||||
)
|
||||
entry<Screen.Radar> {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
}
|
||||
entry<Screen.Map> {
|
||||
MapDestination()
|
||||
@@ -184,7 +214,8 @@ fun MainScreen() {
|
||||
.clickable {
|
||||
val pass = trackingState.currentPass
|
||||
if (pass != null) {
|
||||
backStack.add(Screen.RadioControl(pass.catNum, pass.aosTime))
|
||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
|
||||
-1
@@ -34,7 +34,6 @@ internal class ApplicationPlugin : Plugin<Project> {
|
||||
implementation(project(":feature:map"))
|
||||
implementation(project(":feature:passes"))
|
||||
implementation(project(":feature:radar"))
|
||||
implementation(project(":feature:radiocontrol"))
|
||||
implementation(project(":feature:satellites"))
|
||||
implementation(project(":feature:settings"))
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
|
||||
@@ -11,5 +11,6 @@
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
</manifest>
|
||||
+1
-1
@@ -64,7 +64,7 @@ class RadioTrackingService(
|
||||
Log.i(tag, "Connecting TX=$txAddr RX=$rxAddr")
|
||||
|
||||
if (txAddr.isBlank() && rxAddr.isBlank()) {
|
||||
_state.update { it.copy(errorMessage = "No radio addresses configured. Set them in Settings → FT-817.") }
|
||||
_state.update { it.copy(errorMessage = "No radio addresses configured in Settings") }
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.injection
|
||||
|
||||
import android.bluetooth.BluetoothManager
|
||||
@@ -20,6 +37,8 @@ import com.rtbishop.look4sat.core.data.repository.SettingsRepo
|
||||
import com.rtbishop.look4sat.core.data.source.LocalSource
|
||||
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
||||
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
||||
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
||||
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
||||
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
@@ -33,6 +52,8 @@ import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
@@ -59,6 +80,10 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
|
||||
override fun provideShowToast(): IShowToast = ShowToast(context)
|
||||
|
||||
override fun provideAudioCapture(): IAudioCapture = AudioCapture()
|
||||
|
||||
override fun provideSaveImage(): ISaveImage = SaveImage(context)
|
||||
|
||||
override fun provideBluetoothReporter(): IReporter {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
val rc = settingsRepo.rcSettings.value
|
||||
|
||||
+30
-36
@@ -40,28 +40,22 @@ class DatabaseRepo(
|
||||
private val settingsRepo: ISettingsRepo
|
||||
) : IDatabaseRepo {
|
||||
|
||||
private companion object {
|
||||
val tleTypes = setOf("Amsat", "R4UAB", "Other")
|
||||
val zippedTleTypes = setOf("McCants", "Classified")
|
||||
}
|
||||
private val customSourceType = "Other"
|
||||
|
||||
override suspend fun updateTLEFromFile(uri: String) = withContext(dispatcher) {
|
||||
remoteSource.getFileStream(uri)?.let { stream ->
|
||||
val entries = dataParser.parseTLEStream(stream)
|
||||
val entries = dataParser.parseTLEStream(unwrapIfZipped(uri, stream))
|
||||
localSource.insertEntries(entries)
|
||||
settingsRepo.setSatelliteTypeIds("Other", entries.map { it.catnum })
|
||||
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
|
||||
}
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
}
|
||||
|
||||
override suspend fun updateTransceiversFromFile(uri: String) = withContext(dispatcher) {
|
||||
remoteSource.getFileStream(uri)
|
||||
?.let { dataParser.parseJSONStream(it) }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let {
|
||||
localSource.deleteRadios()
|
||||
localSource.insertRadios(it)
|
||||
}
|
||||
remoteSource.getFileStream(uri)?.let { stream ->
|
||||
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
||||
localSource.insertRadios(transceivers)
|
||||
}
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
}
|
||||
|
||||
@@ -69,28 +63,26 @@ class DatabaseRepo(
|
||||
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||
val tleUrls = buildMap {
|
||||
putAll(Sources.satelliteDataUrls)
|
||||
if (dataSourcesSettings.useCustomTLE) put("Other", dataSourcesSettings.tleUrl)
|
||||
}.filterValues { it.isNotEmpty() }
|
||||
|
||||
val radioUrls = buildList {
|
||||
add(Sources.RADIO_DATA_URL)
|
||||
if (dataSourcesSettings.useCustomTransceivers) add(dataSourcesSettings.transceiversUrl)
|
||||
}
|
||||
|
||||
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
|
||||
}.filterValues { it.isNotBlank() }
|
||||
val radioUrls = buildMap {
|
||||
putAll(Sources.transceiversDataUrls)
|
||||
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
|
||||
}.filterValues { it.isNotBlank() }
|
||||
// launch all network requests concurrently
|
||||
val tleJobs = tleUrls.map { (type, url) -> async { type to remoteSource.getNetworkStream(url) } }
|
||||
val radioJobs = radioUrls.map { url -> async { remoteSource.getNetworkStream(url) } }
|
||||
|
||||
// parse satellite data
|
||||
val importedEntries = tleJobs.awaitAll().flatMap { (type, stream) ->
|
||||
stream?.let { parseSatelliteStream(type, it) }.orEmpty().also { satellites ->
|
||||
settingsRepo.setSatelliteTypeIds(type, satellites.map { it.catnum })
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
// parse radio data
|
||||
val importedRadios = radioJobs.awaitAll().filterNotNull().flatMap { dataParser.parseJSONStream(it) }
|
||||
|
||||
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
||||
}
|
||||
// insert parsed data into the database
|
||||
localSource.insertEntries(importedEntries)
|
||||
localSource.insertRadios(importedRadios)
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
@@ -102,10 +94,9 @@ class DatabaseRepo(
|
||||
setUpdateSuccessful(0L)
|
||||
}
|
||||
|
||||
private suspend fun parseSatelliteStream(type: String, stream: InputStream): List<OrbitalData> = when (type) {
|
||||
in tleTypes -> dataParser.parseTLEStream(stream)
|
||||
in zippedTleTypes -> dataParser.parseTLEStream(ZipInputStream(stream).apply { nextEntry })
|
||||
else -> dataParser.parseCSVStream(stream)
|
||||
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> = when {
|
||||
url.contains("FORMAT=csv", ignoreCase = true) -> dataParser.parseCSVStream(stream)
|
||||
else -> dataParser.parseTLEStream(stream)
|
||||
}
|
||||
|
||||
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
||||
@@ -113,4 +104,7 @@ class DatabaseRepo(
|
||||
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
|
||||
}
|
||||
+14
-6
@@ -52,6 +52,13 @@ class SatelliteRepo(
|
||||
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
|
||||
override val satellites: StateFlow<List<OrbitalObject>> = _satellites
|
||||
|
||||
private val _selectedPass = MutableStateFlow(0 to 0L)
|
||||
override val selectedPass: StateFlow<Pair<Int, Long>> = _selectedPass
|
||||
|
||||
override fun selectPass(catNum: Int, aosTime: Long) {
|
||||
_selectedPass.value = catNum to aosTime
|
||||
}
|
||||
|
||||
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||
|
||||
override suspend fun initRepository() = withContext(dispatcher) {
|
||||
@@ -139,17 +146,18 @@ class SatelliteRepo(
|
||||
val passes = mutableListOf<OrbitalPass>()
|
||||
val endDate = time + hours * 60L * 60L * 1000L
|
||||
val quarterOrbitMin = (this.data.orbitalPeriod / 4.0).toInt()
|
||||
val decayed = this.data.hasDecayed(time)
|
||||
var startDate = time
|
||||
var shouldRewind = true
|
||||
var lastAosDate: Long
|
||||
var count = 0
|
||||
if (this.willBeSeen(pos)) {
|
||||
if (this.data.isDeepSpace) {
|
||||
passes.add(getGeoPass(this, pos, time))
|
||||
passes.add(getGeoPass(this, pos, time, decayed))
|
||||
} else {
|
||||
do {
|
||||
if (count > 0) shouldRewind = false
|
||||
val pass = getLeoPass(this, pos, startDate, shouldRewind)
|
||||
val pass = getLeoPass(this, pos, startDate, shouldRewind, decayed)
|
||||
lastAosDate = pass.aosTime
|
||||
passes.add(pass)
|
||||
startDate = pass.losTime + (quarterOrbitMin * 3) * 60L * 1000L
|
||||
@@ -160,17 +168,17 @@ class SatelliteRepo(
|
||||
return passes
|
||||
}
|
||||
|
||||
private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPass {
|
||||
private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, decayed: Boolean): OrbitalPass {
|
||||
val satPos = sat.getPosition(pos, time)
|
||||
val aos = time - 24 * 60L * 60L * 1000L
|
||||
val los = time + 24 * 60L * 60L * 1000L // val tca = (aos + los) / 2
|
||||
val az = satPos.azimuth.toDegrees().round(1)
|
||||
val elev = satPos.elevation.toDegrees().round(1)
|
||||
val alt = satPos.altitude
|
||||
return OrbitalPass(aos, az, los, az, alt.toInt(), elev, sat)
|
||||
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()
|
||||
var calendarTimeMillis = time
|
||||
var elevation: Double
|
||||
@@ -234,6 +242,6 @@ class SatelliteRepo(
|
||||
val alt = tcaPos.altitude
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ class SettingsRepo(
|
||||
private val keyUpdateTimestamp = "updateTimestamp"
|
||||
private val keyShouldSeeWarning = "shouldSeeWarning"
|
||||
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
||||
private val keySstvMode = "sstvMode"
|
||||
private val keyUseCustomTle = "useCustomTle"
|
||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||
private val keyTleUrl = "tleUrl"
|
||||
@@ -333,6 +334,7 @@ class SettingsRepo(
|
||||
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
|
||||
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
||||
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
||||
putString(keySstvMode, new.sstvMode)
|
||||
}
|
||||
new
|
||||
}
|
||||
@@ -346,7 +348,8 @@ class SettingsRepo(
|
||||
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
|
||||
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
||||
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true)
|
||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto"
|
||||
)
|
||||
//endregion
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,8 @@ data class OtherSettings(
|
||||
val stateOfLightTheme: Boolean,
|
||||
val stateOfNightMode: Boolean = false,
|
||||
val shouldSeeWarning: Boolean,
|
||||
val shouldSeeWhatsNew: Boolean
|
||||
val shouldSeeWhatsNew: Boolean,
|
||||
val sstvMode: String = "Auto"
|
||||
)
|
||||
|
||||
data class DataSourcesSettings(
|
||||
|
||||
+5
-4
@@ -242,7 +242,8 @@ object CelestialComputer {
|
||||
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
|
||||
val th = fixAngle((teg - observer.longitude) * DEG2RAD)
|
||||
// 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))
|
||||
@@ -563,9 +564,9 @@ object CelestialComputer {
|
||||
// ── Internal helpers ──
|
||||
|
||||
private fun observerGeodetic(pos: GeoPos): DoubleArray {
|
||||
// [lat_rad, lon_rad, alt_km] — longitude negated so that
|
||||
// mod2PI(thetaGJD + obsGeo[1]) == mod2PI(thetaGJD + lon_rad)
|
||||
return doubleArrayOf(pos.latitude * DEG2RAD, -pos.longitude * DEG2RAD, pos.altitude / 1000.0)
|
||||
// [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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,7 +27,8 @@ data class OrbitalData(
|
||||
val argper: Double,
|
||||
val meanan: Double,
|
||||
val catnum: Int,
|
||||
val bstar: Double
|
||||
val bstar: Double,
|
||||
val ndot: Double = 0.0
|
||||
) {
|
||||
val xincl: Double = incl * DEG2RAD
|
||||
val xnodeo: Double = raan * DEG2RAD
|
||||
@@ -37,4 +38,29 @@ data class OrbitalData(
|
||||
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
|
||||
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)
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,10 @@ data class OrbitalPass(
|
||||
val altitude: Int = 1000,
|
||||
val maxElevation: Double = 75.0,
|
||||
val orbitalObject: OrbitalObject,
|
||||
val progress: Float = 0.0f
|
||||
val progress: Float = 0.0f,
|
||||
val hasDecayed: Boolean = false
|
||||
) {
|
||||
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
|
||||
}
|
||||
+22
-1
@@ -1,6 +1,25 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
@@ -10,6 +29,7 @@ interface IMainContainer {
|
||||
val selectionRepo: ISelectionRepo
|
||||
val satelliteRepo: ISatelliteRepo
|
||||
val databaseRepo: IDatabaseRepo
|
||||
val radioTrackingService: IRadioTrackingService
|
||||
fun provideAddToCalendar(): IAddToCalendar
|
||||
fun provideShowToast(): IShowToast
|
||||
fun provideBluetoothReporter(): IReporter
|
||||
@@ -17,7 +37,8 @@ interface IMainContainer {
|
||||
fun provideSensorsRepo(): ISensorsRepo
|
||||
fun provideTxRadioController(): IRadioController
|
||||
fun provideRxRadioController(): IRadioController
|
||||
val radioTrackingService: IRadioTrackingService
|
||||
fun provideAudioCapture(): IAudioCapture
|
||||
fun provideSaveImage(): ISaveImage
|
||||
}
|
||||
|
||||
interface IContainerProvider {
|
||||
|
||||
+6
@@ -34,6 +34,12 @@ interface ISatelliteRepo {
|
||||
/** 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()
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
package com.rtbishop.look4sat.core.domain.source
|
||||
|
||||
object Sources {
|
||||
const val RADIO_DATA_URL = "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||
val satelliteDataUrls = mapOf(
|
||||
"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",
|
||||
@@ -49,4 +48,7 @@ object Sources {
|
||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||
"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
|
||||
}
|
||||
@@ -58,8 +58,6 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
|
||||
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
|
||||
val name = values[0]
|
||||
val timestamp = values[2]
|
||||
@@ -84,7 +82,8 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
argper = values[7].toDouble(),
|
||||
meanan = values[8].toDouble(),
|
||||
catnum = values[11].toInt(),
|
||||
bstar = values[14].toDouble()
|
||||
bstar = values[14].toDouble(),
|
||||
ndot = values[15].toDouble()
|
||||
)
|
||||
}.onFailure { println("CSV parsing exception: $it") }.getOrNull()
|
||||
|
||||
@@ -101,11 +100,14 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
argper = line2.substring(34, 42).toDouble(),
|
||||
meanan = line2.substring(43, 51).toDouble(),
|
||||
catnum = line1.substring(2, 7).trim().toInt(),
|
||||
bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble())
|
||||
bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble()),
|
||||
ndot = line1.substring(33, 43).trim().toDouble()
|
||||
)
|
||||
}.onFailure { println("TLE parsing exception: $it") }.getOrNull()
|
||||
|
||||
private fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
|
||||
fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
|
||||
fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
|
||||
val daysInMonth = intArrayOf(31, if (isLeapYear(year)) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
||||
return daysInMonth.take(month - 1).sum() + dayOfMonth
|
||||
}
|
||||
|
||||
@@ -59,10 +59,53 @@ class DataParserTest {
|
||||
@Test
|
||||
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
|
||||
val parsedList = dataParser.parseCSVStream(validCSVStream)
|
||||
assert(parsedList.size == 2)
|
||||
assert(parsedList[0].epoch == 21320.51955234)
|
||||
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
|
||||
fun `Given invalid CSV stream returns empty list`() = runTest(testDispatcher) {
|
||||
assert(dataParser.parseCSVStream(invalidCSVStream).isEmpty())
|
||||
@@ -71,10 +114,41 @@ class DataParserTest {
|
||||
@Test
|
||||
fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) {
|
||||
val parsedList = dataParser.parseTLEStream(validTLEStream)
|
||||
assert(parsedList.size == 2)
|
||||
assert(parsedList[0].epoch == 21320.51955234)
|
||||
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
|
||||
fun `Given invalid TLE stream returns empty list`() = runTest(testDispatcher) {
|
||||
assert(dataParser.parseTLEStream(invalidTLEStream).isEmpty())
|
||||
@@ -85,6 +159,43 @@ class DataParserTest {
|
||||
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
|
||||
fun `Given invalid JSON stream returns empty list`() = runTest(testDispatcher) {
|
||||
assert(dataParser.parseJSONStream(invalidJSONStream).isEmpty())
|
||||
@@ -96,10 +207,36 @@ class DataParserTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Function isLeapYear returns correct data`() = runTest(testDispatcher) {
|
||||
val years = listOf(1900, 1984, 1994, 2016, 2022, 2024, 2042, 2048)
|
||||
val answers = listOf(false, true, false, true, false, true, false, true)
|
||||
fun `isLeapYear returns correct results`() {
|
||||
val years = listOf(1900, 1984, 1994, 2000, 2016, 2022, 2024, 2042, 2048, 2100)
|
||||
val expected = listOf(false, true, false, true, true, false, true, false, true, false)
|
||||
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)
|
||||
}
|
||||
}
|
||||
+101
-16
@@ -40,6 +40,7 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedButton
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -48,12 +49,22 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
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.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.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
@@ -151,16 +162,17 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
val elevColor = elevationColor(pass.maxElevation)
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_elevation),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
tint = elevColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "${pass.maxElevation}°",
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
color = elevColor
|
||||
)
|
||||
}
|
||||
Row(
|
||||
@@ -178,12 +190,6 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_altitude),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(text = "${pass.altitude} km", fontSize = 15.sp)
|
||||
}
|
||||
Text(
|
||||
@@ -212,14 +218,11 @@ fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier) {
|
||||
ElevatedCard(modifier = Modifier.size(48.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = action)
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) { Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier) }
|
||||
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier, enabled: Boolean = true) {
|
||||
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,3 +388,85 @@ fun TopBar(
|
||||
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)
|
||||
}
|
||||
+31
-4
@@ -30,10 +30,7 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
||||
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
||||
|
||||
@Serializable
|
||||
data class Radar(val catNum: Int = 0, val aosTime: Long = 0L) : Screen(R.drawable.ic_radar, R.string.nav_radar)
|
||||
|
||||
@Serializable
|
||||
data class RadioControl(val catNum: Int = 0, val aosTime: Long = 0L) : Screen(0, 0)
|
||||
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)
|
||||
@@ -41,3 +38,33 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
||||
@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>
|
||||
+1
-1
@@ -5,5 +5,5 @@
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M4,20h16v2L4,22zM4,2h16v2L4,4zM13,9h3l-4,-4 -4,4h3v6L8,15l4,4 4,-4h-3z" />
|
||||
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M8,5v14l11,-7z" />
|
||||
</vector>
|
||||
@@ -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>
|
||||
@@ -130,8 +130,16 @@
|
||||
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
||||
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
|
||||
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
|
||||
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
|
||||
|
||||
<string name="prefs_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>
|
||||
|
||||
</resources>
|
||||
@@ -49,12 +49,9 @@
|
||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* Added fixes for Turkish translation, by Emre Can Akdaş (TA3ECR)
|
||||
\n* Added passes sticky header with sunrise/sunset times
|
||||
\n* Added current moon/sun positions to the Radar Screen
|
||||
\n* Added current moon/sun positions to the Map Screen
|
||||
\n* Added red night mode filter overlay for the whole app
|
||||
|
||||
* Enabled continuous SSTV decoding for manually selected types (Play button enables/disables decoding)
|
||||
\n\n* Added selected transceiver\'s frequency display to SSTV page
|
||||
\n\n* Fixed Reset/Save/Play buttons behavior and states on SSTV page
|
||||
</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
@@ -187,12 +184,14 @@
|
||||
|
||||
<string name="prefs_outro_title">I would like to say thanks to</string>
|
||||
<string name="prefs_outro_thanks" translatable="false">
|
||||
• Look4Sat users and contributors!
|
||||
\n• David A. B. Johnson (predict4java)
|
||||
\n• Dave Moten (predict4java)
|
||||
\n• Alexandru Csete (Gpredict)
|
||||
\n• Dr T.S. Kelso (Celestrak)
|
||||
\n• Libre Space Foundation (SatNOGS)</string>
|
||||
* 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)
|
||||
\n* xdsopl and Robot36 contributors!
|
||||
</string>
|
||||
<string name="prefs_outro_license">The app comes with no warranty</string>
|
||||
|
||||
</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,5 +1,3 @@
|
||||
* Added fixes for Turkish translation, by Emre Can Akdaş (TA3ECR)
|
||||
* Added passes sticky header with sunrise/sunset times
|
||||
* Added current moon/sun positions to the Radar Screen
|
||||
* Added current moon/sun positions to the Map Screen
|
||||
* Added red night mode filter overlay for the whole app
|
||||
* Enabled continuous SSTV decoding for manually selected types (Play button enables/disables decoding)
|
||||
* Added selected transceiver\'s frequency display to SSTV page
|
||||
* Fixed Reset/Save/Play buttons behavior and states on SSTV page
|
||||
@@ -1,5 +0,0 @@
|
||||
* Added fixes for Turkish translation, by Emre Can Akdaş (TA3ECR)
|
||||
* Added passes sticky header with sunrise/sunset times
|
||||
* Added current moon/sun positions to the Radar Screen
|
||||
* Added current moon/sun positions to the Map Screen
|
||||
* Added red night mode filter overlay for the whole app
|
||||
@@ -123,10 +123,7 @@ private val moonIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
fun MapDestination() {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel = viewModel(
|
||||
modelClass = MapViewModel::class.java,
|
||||
factory = MapViewModel.factory(container)
|
||||
)
|
||||
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val mapView = rememberMapViewWithLifecycle()
|
||||
MapScreen(uiState, viewModel::onAction, mapView)
|
||||
|
||||
@@ -76,7 +76,8 @@ class MapViewModel(
|
||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
|
||||
}
|
||||
}
|
||||
selectDefaultSatellite(-1)
|
||||
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
|
||||
selectDefaultSatellite(if (selectedCatNum != 0) selectedCatNum else -1)
|
||||
}
|
||||
|
||||
fun onAction(action: MapAction) {
|
||||
@@ -126,10 +127,16 @@ class MapViewModel(
|
||||
val dateNow = Date()
|
||||
getStationPosition()
|
||||
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) {
|
||||
dateNow.time = System.currentTimeMillis()
|
||||
updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
|
||||
delay(updateFreq)
|
||||
delay(effectiveRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -54,6 +55,7 @@ import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import com.rtbishop.look4sat.core.presentation.elevationColor
|
||||
|
||||
private val allModes = listOf(
|
||||
"AFSK", "AFSK S-Net", "AFSK SALSAT", "AHRPT", "AM", "APT", "BPSK", "BPSK PMT-A3",
|
||||
@@ -96,7 +98,8 @@ internal fun PassesDialog(
|
||||
value = elevationValueNew.doubleValue,
|
||||
displayValue = "${elevationValueNew.doubleValue.toInt()}°",
|
||||
valueResId = R.drawable.ic_elevation,
|
||||
valueRange = 0f..60f
|
||||
valueRange = 0f..60f,
|
||||
accentColor = elevationColor(elevationValueNew.doubleValue)
|
||||
) { elevationValueNew.doubleValue = it.toDouble() }
|
||||
SliderRow(
|
||||
title = stringResource(R.string.pass_filter_hours),
|
||||
@@ -117,6 +120,7 @@ private fun SliderRow(
|
||||
valueResId: Int,
|
||||
valueRange: ClosedFloatingPointRange<Float>,
|
||||
steps: Int = 0,
|
||||
accentColor: Color = MaterialTheme.colorScheme.primary,
|
||||
onChange: (Float) -> Unit
|
||||
) {
|
||||
Column(
|
||||
@@ -137,14 +141,14 @@ private fun SliderRow(
|
||||
Icon(
|
||||
painter = painterResource(id = valueResId),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = displayValue,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
color = accentColor
|
||||
)
|
||||
}
|
||||
Slider(value = value.toFloat(), onValueChange = onChange, valueRange = valueRange, steps = steps)
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
*/
|
||||
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.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -29,11 +34,8 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -45,8 +47,6 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -72,8 +72,10 @@ import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
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.TopBar
|
||||
import com.rtbishop.look4sat.core.presentation.elevationColor
|
||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -85,10 +87,7 @@ import java.util.TimeZone
|
||||
fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel = viewModel(
|
||||
modelClass = PassesViewModel::class.java,
|
||||
factory = PassesViewModel.factory(container)
|
||||
)
|
||||
val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
|
||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||
PassesScreen(uiState, viewModel::onAction, navigateToRadar)
|
||||
}
|
||||
@@ -125,7 +124,6 @@ private fun PassesScreen(
|
||||
onAction(PassesAction.DismissWhatsNew)
|
||||
}
|
||||
}
|
||||
val gridState = rememberLazyGridState()
|
||||
ScreenColumn(
|
||||
topBar = { isVerticalLayout ->
|
||||
TopBar(
|
||||
@@ -149,10 +147,11 @@ private fun PassesScreen(
|
||||
isRefreshing = uiState.isRefreshing,
|
||||
isUtc = uiState.isUtc,
|
||||
passes = uiState.itemsList,
|
||||
groupedPasses = uiState.groupedPasses,
|
||||
sunTimes = uiState.sunTimes,
|
||||
focusedCatNum = uiState.focusedCatNum,
|
||||
navigateToRadar = navigateToRadar,
|
||||
refreshPasses = { onAction(PassesAction.RefreshPasses) },
|
||||
gridState = gridState,
|
||||
sunTimes = uiState.sunTimes
|
||||
onAction = onAction
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -163,50 +162,19 @@ private fun PassesList(
|
||||
isRefreshing: Boolean,
|
||||
isUtc: Boolean,
|
||||
passes: List<OrbitalPass>,
|
||||
groupedPasses: Map<String, List<OrbitalPass>>,
|
||||
sunTimes: Map<String, Pair<String, String>>,
|
||||
focusedCatNum: Int?,
|
||||
navigateToRadar: (Int, Long) -> Unit,
|
||||
refreshPasses: () -> Unit,
|
||||
gridState: LazyGridState,
|
||||
sunTimes: Map<String, Pair<String, String>>
|
||||
onAction: (PassesAction) -> Unit
|
||||
) {
|
||||
val isVerticalLayout = isVerticalLayout()
|
||||
val refreshState = rememberPullToRefreshState()
|
||||
val timeZone = remember(isUtc) { if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault() }
|
||||
val sdfDate = remember(isUtc) {
|
||||
SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = timeZone }
|
||||
}
|
||||
|
||||
// Group passes by calendar day so we know where headers go
|
||||
val groupedPasses = remember(passes, isUtc) {
|
||||
passes.filter { !it.isDeepSpace }.groupBy { sdfDate.format(Date(it.aosTime)) } +
|
||||
if (passes.any { it.isDeepSpace }) mapOf("Deep Space" to passes.filter { it.isDeepSpace }) else emptyMap()
|
||||
}
|
||||
|
||||
// Derive the sticky header label from the first visible item index
|
||||
val stickyHeader by remember(gridState, groupedPasses) {
|
||||
derivedStateOf {
|
||||
val firstVisible = gridState.firstVisibleItemIndex
|
||||
var itemIndex = 0
|
||||
var header = groupedPasses.keys.firstOrNull() ?: ""
|
||||
for ((key, items) in groupedPasses) {
|
||||
header = key
|
||||
itemIndex += 1 + items.size
|
||||
if (itemIndex > firstVisible) break
|
||||
}
|
||||
header
|
||||
}
|
||||
}
|
||||
|
||||
// The sticky overlay should only show when the first in-list header has scrolled out of view.
|
||||
// firstVisibleItemIndex == 0 means the first header is still visible — hide the overlay.
|
||||
val showStickyOverlay by remember(gridState) {
|
||||
derivedStateOf { gridState.firstVisibleItemIndex > 0 || gridState.firstVisibleItemScrollOffset > 0 }
|
||||
}
|
||||
|
||||
ElevatedCard(modifier = Modifier.fillMaxSize()) {
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
state = refreshState,
|
||||
onRefresh = refreshPasses,
|
||||
onRefresh = { onAction(PassesAction.RefreshPasses) },
|
||||
indicator = {
|
||||
PullToRefreshDefaults.Indicator(
|
||||
state = refreshState,
|
||||
@@ -220,31 +188,35 @@ private fun PassesList(
|
||||
if (passes.isEmpty()) {
|
||||
EmptyListCard(message = stringResource(R.string.pass_empty_list_message))
|
||||
} else {
|
||||
Column {
|
||||
// Sticky header overlay — only visible once the in-list header scrolls away
|
||||
if (showStickyOverlay) {
|
||||
val (rise, set) = sunTimes[stickyHeader] ?: ("--:--" to "--:--")
|
||||
StickyDateHeader(label = stickyHeader, sunriseTime = rise, sunsetTime = set)
|
||||
}
|
||||
LazyVerticalGrid(
|
||||
state = gridState,
|
||||
columns = GridCells.Adaptive(320.dp),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = focusedCatNum,
|
||||
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
|
||||
label = "PassesFocusTransition"
|
||||
) { targetFocus ->
|
||||
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp), modifier = Modifier.fillMaxSize()) {
|
||||
for ((dateLabel, dayPasses) in groupedPasses) {
|
||||
// In-list section header (scrolls away, drives the sticky overlay)
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
val (rise, set) = sunTimes[dateLabel] ?: ("--:--" to "--:--")
|
||||
StickyDateHeader(label = dateLabel, sunriseTime = rise, sunsetTime = set)
|
||||
val headerVisible = targetFocus == null || dayPasses.any { it.catNum == targetFocus }
|
||||
if (headerVisible) {
|
||||
stickyHeader(key = "header_$dateLabel") {
|
||||
val (rise, set) = sunTimes[dateLabel] ?: ("--:--" to "--:--")
|
||||
StickyDateHeader(label = dateLabel, sunriseTime = rise, sunsetTime = set)
|
||||
}
|
||||
}
|
||||
items(items = dayPasses, key = { item -> item.catNum + item.aosTime }) { pass ->
|
||||
PassItem(
|
||||
pass = pass,
|
||||
navigateToRadar = navigateToRadar,
|
||||
modifier = Modifier.animateItem(),
|
||||
isVerticalLayout = isVerticalLayout,
|
||||
isUtc = isUtc
|
||||
)
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -360,16 +332,17 @@ private fun PassItem(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
val elevColor = elevationColor(pass.maxElevation)
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_elevation),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
tint = elevColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "${pass.maxElevation}°",
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
color = elevColor
|
||||
)
|
||||
}
|
||||
Row(
|
||||
@@ -397,13 +370,6 @@ private fun PassItem(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_altitude),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(text = "${pass.altitude} km", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
Row(
|
||||
|
||||
@@ -32,9 +32,10 @@ data class PassesState(
|
||||
val showDeepSpace: Boolean = true,
|
||||
val modes: List<String> = emptyList(),
|
||||
val itemsList: List<OrbitalPass> = emptyList(),
|
||||
val groupedPasses: Map<String, List<OrbitalPass>> = emptyMap(),
|
||||
val shouldSeeWhatsNew: Boolean = false,
|
||||
// Map of dateLabel -> Pair(sunriseTime, sunsetTime) for each day group
|
||||
val sunTimes: Map<String, Pair<String, String>> = emptyMap()
|
||||
val sunTimes: Map<String, Pair<String, String>> = emptyMap(),
|
||||
val focusedCatNum: Int? = null
|
||||
)
|
||||
|
||||
sealed interface PassesAction {
|
||||
@@ -44,4 +45,6 @@ sealed interface PassesAction {
|
||||
data object RefreshPasses : PassesAction
|
||||
data object TogglePassesDialog : PassesAction
|
||||
data object ToggleRadiosDialog : PassesAction
|
||||
data class FocusCatNum(val catNum: Int) : PassesAction
|
||||
data object ClearFocus : PassesAction
|
||||
}
|
||||
+45
-30
@@ -74,37 +74,31 @@ class PassesViewModel(
|
||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc, shouldSeeWhatsNew = settings.shouldSeeWhatsNew) }
|
||||
}
|
||||
}
|
||||
// Local tick loop — computes pass progress, countdown timer, and per-day sun times every second
|
||||
// Main tick loop: reacts to new passes, then ticks every second
|
||||
viewModelScope.launch {
|
||||
var lastSunTimesKey = "" // track when we need to recompute sun times
|
||||
while (isActive) {
|
||||
val timeNow = System.currentTimeMillis()
|
||||
val isUtc = _uiState.value.isUtc
|
||||
val showDeepSpace = _uiState.value.showDeepSpace
|
||||
val allPasses = satelliteRepo.passes.value
|
||||
val filtered = if (showDeepSpace) allPasses else allPasses.filter { !it.isDeepSpace }
|
||||
val processed = computePassProgress(filtered, timeNow)
|
||||
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
||||
|
||||
// Recompute per-day sun times only when passes list or UTC setting changes
|
||||
val sunTimesKey = "${processed.firstOrNull()?.aosTime}-${processed.lastOrNull()?.aosTime}-$isUtc"
|
||||
val sunTimes = if (sunTimesKey != lastSunTimesKey) {
|
||||
lastSunTimesKey = sunTimesKey
|
||||
computeSunTimes(processed, isUtc)
|
||||
} else {
|
||||
_uiState.value.sunTimes
|
||||
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)
|
||||
}
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
itemsList = processed,
|
||||
nextPass = nextPass,
|
||||
nextTime = nextTime,
|
||||
isNextTimeAos = isAos,
|
||||
sunTimes = sunTimes
|
||||
)
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,16 +115,25 @@ class PassesViewModel(
|
||||
_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) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Computes sunrise/sunset strings for each unique calendar day in the pass list. */
|
||||
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
|
||||
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
||||
val stationPos = settingsRepo.stationPosition.value
|
||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||
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))
|
||||
@@ -143,6 +146,18 @@ class PassesViewModel(
|
||||
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)
|
||||
|
||||
@@ -17,15 +17,17 @@
|
||||
*/
|
||||
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.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -33,65 +35,67 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.PrimaryTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
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.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.keepScreenOn
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||
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.R
|
||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||
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.infiniteMarquee
|
||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private enum class RadarPage(val title: String) {
|
||||
Transceivers("Transceivers"),
|
||||
Sstv("SSTV")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RadarDestination(
|
||||
catNum: Int = 0,
|
||||
aosTime: Long = 0L,
|
||||
navigateUp: () -> Unit,
|
||||
navigateToRadioControl: (Int, Long) -> Unit = { _, _ -> }
|
||||
) {
|
||||
fun RadarDestination(navigateUp: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel = viewModel(
|
||||
modelClass = RadarViewModel::class.java,
|
||||
key = "$catNum-$aosTime",
|
||||
factory = RadarViewModel.factory(catNum, aosTime, container)
|
||||
)
|
||||
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
RadarScreen(uiState, viewModel::onAction, navigateUp, navigateToRadioControl)
|
||||
// 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
|
||||
@@ -99,20 +103,11 @@ private fun RadarScreen(
|
||||
uiState: RadarState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
navigateUp: () -> Unit,
|
||||
navigateToRadioControl: (Int, Long) -> Unit
|
||||
requestMicPermission: () -> Unit
|
||||
) {
|
||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||
LaunchedEffect(uiState.isLos) { if (uiState.isLos) navigateUp() }
|
||||
|
||||
val addToCalendar: () -> Unit = {
|
||||
uiState.currentPass?.let { pass ->
|
||||
onAction(RadarAction.AddToCalendar(pass.name, pass.aosTime, pass.losTime))
|
||||
}
|
||||
}
|
||||
val openRadioControl: () -> Unit = {
|
||||
uiState.currentPass?.let { pass ->
|
||||
navigateToRadioControl(pass.catNum, pass.aosTime)
|
||||
}
|
||||
uiState.currentPass?.let { onAction(RadarAction.AddToCalendar(it.name, it.aosTime, it.losTime)) }
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -123,26 +118,71 @@ private fun RadarScreen(
|
||||
val isVertical = isVerticalLayout()
|
||||
if (isVertical) {
|
||||
TopBar {
|
||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||
IconCard(action = openRadioControl, resId = R.drawable.ic_radios)
|
||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||
}
|
||||
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
|
||||
} else {
|
||||
TopBar {
|
||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
|
||||
IconCard(action = openRadioControl, resId = R.drawable.ic_radios)
|
||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||
}
|
||||
}
|
||||
if (isVertical) {
|
||||
RadarCard(uiState, Modifier.weight(1f))
|
||||
TransmittersCard(uiState.transmitters, uiState.selectedTransmitterUuid, onAction, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
RadarCard(uiState, Modifier.weight(1f))
|
||||
TransmittersCard(uiState.transmitters, uiState.selectedTransmitterUuid, onAction, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PagerCard(
|
||||
uiState: RadarState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
requestMicPermission: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val pages = RadarPage.entries
|
||||
val pagerState = rememberPagerState(pageCount = { pages.size })
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
ElevatedCard(modifier = modifier) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
PrimaryTabRow(selectedTabIndex = pagerState.currentPage) {
|
||||
pages.forEachIndexed { index, page ->
|
||||
Tab(
|
||||
selected = pagerState.currentPage == index,
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
|
||||
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) { pageIndex ->
|
||||
when (pages[pageIndex]) {
|
||||
RadarPage.Transceivers -> TransceiversPage(
|
||||
transceivers = uiState.transceivers.transmitters,
|
||||
selectedUuid = uiState.transceivers.selectedUuid,
|
||||
radioControl = uiState.radioControl,
|
||||
onAction = onAction
|
||||
)
|
||||
RadarPage.Sstv -> SstvPage(
|
||||
sstv = uiState.sstv,
|
||||
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
|
||||
onAction = onAction,
|
||||
requestMicPermission = requestMicPermission
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,25 +191,26 @@ private fun RadarScreen(
|
||||
@Composable
|
||||
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
|
||||
val satellitePos = uiState.orbitalPos
|
||||
val borderModifier = if (satellitePos?.aboveHorizon == true && satellitePos.eclipsed) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "eclipsedBorder")
|
||||
val borderAlpha by infiniteTransition.animateFloat(
|
||||
initialValue = 1.0f,
|
||||
targetValue = 0.0f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1000, delayMillis = 25, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "eclipsedBorderAlpha"
|
||||
)
|
||||
val shouldAnimateBorder = satellitePos?.aboveHorizon == true && satellitePos.eclipsed
|
||||
// Always call these composables unconditionally — conditional composable calls violate
|
||||
// Compose's slot-table stability rules and can crash or produce incorrect state
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "eclipsedBorder")
|
||||
val borderAlpha by infiniteTransition.animateFloat(
|
||||
initialValue = 1.0f,
|
||||
targetValue = 0.0f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1000, delayMillis = 25, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "eclipsedBorderAlpha"
|
||||
)
|
||||
val borderModifier = if (shouldAnimateBorder) {
|
||||
Modifier.border(
|
||||
width = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = borderAlpha),
|
||||
shape = MaterialTheme.shapes.medium
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
} else Modifier
|
||||
ElevatedCard(modifier = modifier.then(borderModifier)) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
val position = uiState.orbitalPos
|
||||
@@ -256,181 +297,3 @@ private fun RadarLabel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransmittersCard(
|
||||
transmitters: List<SatRadio>,
|
||||
selectedUuid: String?,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
ElevatedCard(modifier = modifier) {
|
||||
if (transmitters.isEmpty()) {
|
||||
EmptyTransmittersContent()
|
||||
} else {
|
||||
TransmittersList(
|
||||
transmitters = transmitters,
|
||||
selectedUuid = selectedUuid,
|
||||
onSelect = { uuid ->
|
||||
if (selectedUuid != null) {
|
||||
onAction(RadarAction.SelectTransmitter(uuid))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyTransmittersContent() {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransmittersList(
|
||||
transmitters: List<SatRadio>,
|
||||
selectedUuid: String?,
|
||||
onSelect: (String) -> Unit
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(items = transmitters, key = { it.uuid }) { radio ->
|
||||
TransmitterItem(
|
||||
radio = radio,
|
||||
isClickable = selectedUuid != null,
|
||||
isSelected = radio.uuid == selectedUuid,
|
||||
onClick = { 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 TransmitterItem(
|
||||
radio: SatRadio,
|
||||
isClickable: Boolean,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val title = if (radio.isInverted) "INVERTED: ${radio.info}" else radio.info
|
||||
val fullTitle = "$title - (${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"})"
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (isClickable) Modifier.clickable { onClick() } else Modifier)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
) {
|
||||
Box {
|
||||
Text(
|
||||
text = fullTitle,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
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)
|
||||
FrequencyRow(radio = radio, isDownlink = false)
|
||||
}
|
||||
HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.background)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FrequencyRow(radio: SatRadio, isDownlink: Boolean) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
val desc = if (isDownlink) stringResource(R.string.radar_downlink)
|
||||
else stringResource(R.string.radar_uplink)
|
||||
Text(
|
||||
text = if (isDownlink) "D:" else "U:",
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.semantics { contentDescription = desc }
|
||||
)
|
||||
FrequencyText(
|
||||
frequency = if (isDownlink) radio.downlinkLow else radio.uplinkLow,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "-",
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 21.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
FrequencyText(
|
||||
frequency = if (isDownlink) radio.downlinkHigh else radio.uplinkHigh,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_arrow),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.rotate(if (isDownlink) 90f else -90f)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
|
||||
val text = frequency?.let {
|
||||
stringResource(id = R.string.radar_link_low, it / 1000000f)
|
||||
} ?: stringResource(R.string.radar_no_link)
|
||||
Text(
|
||||
text = text,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 21.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
@@ -21,12 +21,36 @@ 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.OrbitalPos
|
||||
import com.rtbishop.look4sat.core.domain.sstv.SstvFrame
|
||||
|
||||
data class RadioPanelState(
|
||||
val label: String = "",
|
||||
val isConnected: Boolean = false,
|
||||
val frequencyHz: Long? = null,
|
||||
val frequencyDisplay: String = "---",
|
||||
val mode: String? = null
|
||||
)
|
||||
|
||||
data class RadioControlSubState(
|
||||
val txPanel: RadioPanelState = RadioPanelState("TX (Uplink)"),
|
||||
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 isLos: Boolean = false,
|
||||
val isUtc: Boolean = false,
|
||||
val orientationValues: Pair<Float, Float> = 0f to 0f,
|
||||
val orbitalPos: OrbitalPos? = null,
|
||||
@@ -35,12 +59,39 @@ data class RadarState(
|
||||
val shouldUseCompass: Boolean = false,
|
||||
val sunPosition: CelestialComputer.SunPosition? = null,
|
||||
val moonPosition: CelestialComputer.MoonPosition? = null,
|
||||
val transmitters: List<SatRadio> = emptyList(),
|
||||
val selectedTransmitterUuid: String? = null,
|
||||
val selectedFrequency: Long? = 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
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.radar
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
@@ -38,6 +39,7 @@ import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.PathMeasure
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.StampedPathEffectStyle
|
||||
import androidx.compose.ui.graphics.SweepGradientShader
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
@@ -63,7 +65,7 @@ import kotlin.math.sin
|
||||
|
||||
private const val CIRCLES = 3
|
||||
private const val STROKE_WIDTH = 6f
|
||||
private const val SWEEP_INCREMENT = 360f / 12f / 60f
|
||||
private const val SWEEP_DURATION_MS = 8_000
|
||||
|
||||
@Composable
|
||||
fun RadarViewCompose(
|
||||
@@ -87,23 +89,44 @@ fun RadarViewCompose(
|
||||
animationSpec = infiniteRepeatable(tween(1000)),
|
||||
label = "animScale"
|
||||
)
|
||||
// Drive the sweep from the animation framework to eliminate state mutation inside the draw block
|
||||
val sweepTransition = rememberInfiniteTransition(label = "sweep")
|
||||
val sweepDegrees by sweepTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 360f,
|
||||
animationSpec = infiniteRepeatable(tween(SWEEP_DURATION_MS, easing = LinearEasing)),
|
||||
label = "sweepDegrees"
|
||||
)
|
||||
val measurer = rememberTextMeasurer()
|
||||
val sunPainter = painterResource(R.drawable.ic_sun)
|
||||
val moonPainter = painterResource(R.drawable.ic_moon)
|
||||
var sweepDegrees by remember { mutableFloatStateOf(0f) }
|
||||
// Track path cache — keyed by both canvas size and items reference so it rebuilds
|
||||
// when the satellite track data arrives asynchronously after the first composition
|
||||
var cachedRadius by remember { mutableFloatStateOf(0f) }
|
||||
var cachedItemsRef by remember { mutableStateOf<List<OrbitalPos>>(emptyList()) }
|
||||
var cachedSweepColor by remember { mutableStateOf(Color.Unspecified) }
|
||||
var trackPath by remember { mutableStateOf(Path()) }
|
||||
var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
|
||||
// ShaderBrush is cached to avoid allocating a new GPU shader object every frame
|
||||
var cachedSweepBrush by remember { mutableStateOf<ShaderBrush?>(null) }
|
||||
|
||||
Canvas(modifier = modifier.aspectRatio(1f)) {
|
||||
val radius = size.minDimension / 2f * 0.95f
|
||||
if (radius != cachedRadius) {
|
||||
// Rebuild track path and sweep brush when canvas size or track data changes
|
||||
if (radius != cachedRadius || items !== cachedItemsRef) {
|
||||
trackPath = createTrackPath(items, radius)
|
||||
trackEffect = createTrackEffect(trackPath)
|
||||
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
||||
cachedRadius = radius
|
||||
cachedItemsRef = items
|
||||
cachedSweepColor = primaryColor
|
||||
} else if (primaryColor != cachedSweepColor) {
|
||||
// Rebuild brush on theme change without waiting for a size change
|
||||
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
||||
cachedSweepColor = primaryColor
|
||||
}
|
||||
rotate(if (shouldUseCompass) -azimElev.first else 0f) {
|
||||
if (shouldShowSweep) drawSweep(center, sweepDegrees, radius, primaryColor)
|
||||
if (shouldShowSweep) cachedSweepBrush?.let { drawSweep(center, sweepDegrees, radius, it) }
|
||||
drawRadar(radius, radarColor)
|
||||
drawElevationLabels(radius, primaryColor, measurer)
|
||||
translate(center.x, center.y) {
|
||||
@@ -126,7 +149,6 @@ fun RadarViewCompose(
|
||||
}
|
||||
if (shouldUseCompass) drawAim(azimElev.first, azimElev.second, radius, aimColor)
|
||||
}
|
||||
sweepDegrees = (sweepDegrees + SWEEP_INCREMENT) % 360f
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,10 +193,13 @@ private fun DrawScope.drawAim(azim: Float, elev: Float, radius: Float, color: Co
|
||||
drawCircle(color, size / 2, pos, style = Stroke(STROKE_WIDTH))
|
||||
}
|
||||
|
||||
private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, color: Color) {
|
||||
private fun makeSweepBrush(center: Offset, color: Color): ShaderBrush {
|
||||
val colors = listOf(Color.Transparent, color.copy(alpha = 0.5f), color)
|
||||
val colorStops = listOf(0.64f, 0.995f, 1f)
|
||||
val brush = ShaderBrush(SweepGradientShader(center, colors, colorStops))
|
||||
return ShaderBrush(SweepGradientShader(center, colors, colorStops))
|
||||
}
|
||||
|
||||
private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, brush: ShaderBrush) {
|
||||
rotate(-90 + degrees, center) { drawCircle(brush, radius, style = Fill) }
|
||||
}
|
||||
|
||||
@@ -220,7 +245,7 @@ private fun DrawScope.drawBodyIcon(
|
||||
translate(pos.x - half, pos.y - half)
|
||||
}) {
|
||||
with(painter) {
|
||||
draw(Size(iconSize, iconSize), colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(color))
|
||||
draw(Size(iconSize, iconSize), colorFilter = ColorFilter.tint(color))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+253
-79
@@ -24,16 +24,24 @@ import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
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.ISensorsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.sstv.SstvDecoder
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||
import com.rtbishop.look4sat.core.domain.utility.round
|
||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -41,83 +49,163 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class RadarViewModel(
|
||||
private val catNum: Int,
|
||||
private val aosTime: Long,
|
||||
private val bluetoothReporter: IReporter,
|
||||
private val networkReporter: IReporter,
|
||||
private val satelliteRepo: ISatelliteRepo,
|
||||
private val settingsRepo: ISettingsRepo,
|
||||
private val sensorsRepo: ISensorsRepo,
|
||||
private val addToCalendar: IAddToCalendar
|
||||
private val addToCalendar: IAddToCalendar,
|
||||
private val trackingService: IRadioTrackingService,
|
||||
private val audioCapture: IAudioCapture,
|
||||
private val saveImage: ISaveImage,
|
||||
private val showToast: IShowToast
|
||||
) : ViewModel() {
|
||||
|
||||
private val stationPos = settingsRepo.stationPosition.value
|
||||
private val magDeclination = sensorsRepo.getMagDeclination(stationPos)
|
||||
private var transponders: List<SatRadio> = emptyList()
|
||||
private var sstvDecoder: SstvDecoder? = null
|
||||
private var sstvRecordingJob: Job? = null
|
||||
|
||||
// Celestial positions change slowly, recompute at most once per minute
|
||||
private var lastCelestialUpdateMs = 0L
|
||||
private var cachedSunPos: CelestialComputer.SunPosition? = null
|
||||
private var cachedMoonPos: CelestialComputer.MoonPosition? = null
|
||||
|
||||
private val _uiState = MutableStateFlow(
|
||||
RadarState(
|
||||
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
||||
orientationValues = sensorsRepo.orientation.value,
|
||||
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
|
||||
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors
|
||||
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors,
|
||||
sstv = SstvSubState(selectedMode = settingsRepo.otherSettings.value.sstvMode)
|
||||
)
|
||||
)
|
||||
val uiState: StateFlow<RadarState> = _uiState
|
||||
|
||||
init {
|
||||
// Compass sensor collection
|
||||
if (settingsRepo.otherSettings.value.stateOfSensors) {
|
||||
viewModelScope.launch {
|
||||
sensorsRepo.enableSensor()
|
||||
sensorsRepo.orientation.collect { data ->
|
||||
val orientationValues = (data.first + magDeclination) to data.second
|
||||
_uiState.update { it.copy(orientationValues = orientationValues) }
|
||||
}
|
||||
collectCompassSensor()
|
||||
collectSettingsChanges()
|
||||
collectPassAndStartTickLoop()
|
||||
collectRadioTrackingState()
|
||||
}
|
||||
|
||||
private fun collectCompassSensor() {
|
||||
if (!settingsRepo.otherSettings.value.stateOfSensors) return
|
||||
viewModelScope.launch {
|
||||
sensorsRepo.enableSensor()
|
||||
sensorsRepo.orientation.collect { data ->
|
||||
val orientationValues = (data.first + magDeclination) to data.second
|
||||
_uiState.update { it.copy(orientationValues = orientationValues) }
|
||||
}
|
||||
}
|
||||
// React to UTC setting changes
|
||||
}
|
||||
|
||||
private fun collectSettingsChanges() {
|
||||
viewModelScope.launch {
|
||||
settingsRepo.otherSettings.collectLatest { settings ->
|
||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isUtc = settings.stateOfUtc,
|
||||
shouldShowSweep = settings.stateOfSweep,
|
||||
shouldUseCompass = settings.stateOfSensors
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Resolve which pass we're tracking and start the tick loop
|
||||
}
|
||||
|
||||
// --- Pass loading split into focused functions ---
|
||||
|
||||
private fun collectPassAndStartTickLoop() {
|
||||
viewModelScope.launch {
|
||||
val passes = satelliteRepo.passes.value
|
||||
val currentPass = passes.find { it.catNum == catNum && it.aosTime == aosTime }
|
||||
?: passes.firstOrNull()
|
||||
currentPass?.let { satPass ->
|
||||
_uiState.update { it.copy(currentPass = satPass) }
|
||||
val transmitters = satelliteRepo.getRadiosWithId(satPass.catNum)
|
||||
// Compute track once (it doesn't change for a given pass)
|
||||
if (!satPass.isDeepSpace) {
|
||||
val track = satelliteRepo.getTrack(
|
||||
satPass.orbitalObject, stationPos, satPass.aosTime, satPass.losTime
|
||||
)
|
||||
_uiState.update { it.copy(satTrack = track) }
|
||||
}
|
||||
// Tick loop — position, timer, and radio updates every second
|
||||
while (isActive) {
|
||||
val timeNow = System.currentTimeMillis()
|
||||
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
|
||||
val sunPos = CelestialComputer.getSunPosition(stationPos, timeNow)
|
||||
val moonPos = CelestialComputer.getMoonPosition(stationPos, timeNow)
|
||||
val (time, isAos) = computeTimer(satPass.isDeepSpace, satPass.aosTime, satPass.losTime, timeNow)
|
||||
val isLos = !satPass.isDeepSpace && timeNow > satPass.losTime
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
currentTime = time,
|
||||
isTimeAos = isAos,
|
||||
isLos = isLos,
|
||||
orbitalPos = pos,
|
||||
sunPosition = sunPos,
|
||||
moonPosition = moonPos
|
||||
val pass = findCurrentPass() ?: return@launch
|
||||
val allRadios = loadPassData(pass)
|
||||
while (isActive) {
|
||||
tickPass(pass, allRadios)
|
||||
delay(1000.milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findCurrentPass(): OrbitalPass? {
|
||||
val passes = satelliteRepo.passes.value
|
||||
val (catNum, aosTime) = satelliteRepo.selectedPass.value
|
||||
return passes.find { it.catNum == catNum && it.aosTime == aosTime }
|
||||
?: passes.firstOrNull()
|
||||
}
|
||||
|
||||
// Loads transmitters and satellite track for pass, sets initial state, returns full radio list
|
||||
private suspend fun loadPassData(pass: OrbitalPass): List<SatRadio> {
|
||||
_uiState.update { it.copy(currentPass = pass) }
|
||||
val allRadios = satelliteRepo.getRadiosWithId(pass.catNum)
|
||||
transponders = allRadios.filter { it.downlinkLow != null }
|
||||
if (allRadios.isNotEmpty()) {
|
||||
val firstUuid = allRadios.first().uuid
|
||||
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = firstUuid)) }
|
||||
transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) }
|
||||
}
|
||||
if (!pass.isDeepSpace) {
|
||||
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
|
||||
_uiState.update { it.copy(satTrack = track) }
|
||||
}
|
||||
return allRadios
|
||||
}
|
||||
|
||||
// --- Per-second tick ---
|
||||
|
||||
private suspend fun tickPass(pass: OrbitalPass, allRadios: List<SatRadio>) {
|
||||
val timeNow = System.currentTimeMillis()
|
||||
val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
|
||||
|
||||
// Recompute celestial positions at most once per minute (they move very slowly)
|
||||
if (timeNow - lastCelestialUpdateMs >= 60_000L) {
|
||||
cachedSunPos = CelestialComputer.getSunPosition(stationPos, timeNow)
|
||||
cachedMoonPos = CelestialComputer.getMoonPosition(stationPos, timeNow)
|
||||
lastCelestialUpdateMs = timeNow
|
||||
}
|
||||
|
||||
val (time, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
currentTime = time, isTimeAos = isAos,
|
||||
orbitalPos = pos, sunPosition = cachedSunPos, moonPosition = cachedMoonPos
|
||||
)
|
||||
}
|
||||
processRadios(allRadios, pass.orbitalObject, timeNow)
|
||||
sendPassData(pos)
|
||||
}
|
||||
|
||||
private fun collectRadioTrackingState() {
|
||||
viewModelScope.launch {
|
||||
trackingService.state.collect { svc ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
radioControl = state.radioControl.copy(
|
||||
txPanel = RadioPanelState(
|
||||
label = "TX (Uplink)",
|
||||
isConnected = svc.txConnected,
|
||||
frequencyHz = svc.txFrequencyHz,
|
||||
frequencyDisplay = svc.txFrequencyHz?.let { formatFrequency(it) } ?: "---",
|
||||
mode = svc.txMode
|
||||
),
|
||||
rxPanel = RadioPanelState(
|
||||
label = "RX (Downlink)",
|
||||
isConnected = svc.rxConnected,
|
||||
frequencyHz = svc.rxFrequencyHz,
|
||||
frequencyDisplay = svc.rxFrequencyHz?.let { formatFrequency(it) } ?: "---",
|
||||
mode = svc.rxMode
|
||||
),
|
||||
txBaseFrequencyHz = svc.txBaseFrequencyHz,
|
||||
ctcssTone = svc.ctcssTone,
|
||||
isTracking = svc.isActive,
|
||||
selectedTransponderUuid = svc.selectedTransponder?.uuid,
|
||||
errorMessage = svc.errorMessage
|
||||
)
|
||||
}
|
||||
processRadios(transmitters, satPass.orbitalObject, timeNow)
|
||||
sendPassData(pos)
|
||||
delay(1000)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,7 +219,59 @@ class RadarViewModel(
|
||||
fun onAction(action: RadarAction) {
|
||||
when (action) {
|
||||
is RadarAction.AddToCalendar -> addToCalendar(action.name, action.aosTime, action.losTime)
|
||||
is RadarAction.SelectTransmitter -> _uiState.update { it.copy(selectedTransmitterUuid = action.uuid) }
|
||||
is RadarAction.SelectTransmitter -> {
|
||||
// Compute toggle state before the update so we don't read post-update value
|
||||
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
|
||||
val newUuid = if (isTogglingOff) null else action.uuid
|
||||
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = newUuid)) }
|
||||
// Only update the tracking service when selecting a different transponder to
|
||||
// avoid resetting a user-adjusted TX base on re-expand
|
||||
if (!isTogglingOff) {
|
||||
transponders.find { it.uuid == action.uuid }?.let { trackingService.setTransponder(it) }
|
||||
}
|
||||
}
|
||||
is RadarAction.SetTxFrequency -> trackingService.setTxBaseFrequency(action.frequencyHz)
|
||||
is RadarAction.AdjustTxFrequency -> trackingService.adjustTxBaseFrequency(action.deltaHz)
|
||||
is RadarAction.SetCtcssTone -> trackingService.setCtcssTone(action.toneHz)
|
||||
RadarAction.ToggleTracking -> {
|
||||
val svc = trackingService.state.value
|
||||
if (svc.isActive) {
|
||||
trackingService.stopTracking()
|
||||
} else {
|
||||
val pass = _uiState.value.currentPass ?: return
|
||||
val transponder = svc.selectedTransponder ?: return
|
||||
trackingService.startTracking(pass, transponder, svc.txBaseFrequencyHz)
|
||||
}
|
||||
}
|
||||
RadarAction.ConnectRadios -> viewModelScope.launch { trackingService.connectRadios() }
|
||||
RadarAction.DisconnectRadios -> viewModelScope.launch { trackingService.disconnectRadios() }
|
||||
|
||||
// SSTV actions
|
||||
is RadarAction.SstvPermissionResult -> {
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(hasPermission = action.granted)) }
|
||||
if (action.granted) initSstvDecoder()
|
||||
}
|
||||
RadarAction.SstvStartRecording -> startSstvRecording()
|
||||
RadarAction.SstvStopRecording -> stopSstvRecording()
|
||||
RadarAction.SstvSaveImage -> {
|
||||
val frame = _uiState.value.sstv.currentFrame ?: return
|
||||
val pixels = frame.imagePixels ?: return
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(isSaving = true)) }
|
||||
viewModelScope.launch {
|
||||
saveImage(pixels, frame.imageWidth, frame.imageHeight, frame.modeName)
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(isSaving = false)) }
|
||||
showToast("Image saved")
|
||||
}
|
||||
}
|
||||
is RadarAction.SstvSelectMode -> {
|
||||
sstvDecoder?.lockMode(action.modeName)
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(selectedMode = action.modeName)) }
|
||||
settingsRepo.updateOtherSettings { it.copy(sstvMode = action.modeName) }
|
||||
}
|
||||
RadarAction.SstvReset -> {
|
||||
sstvDecoder?.clearPixels()
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,13 +305,14 @@ class RadarViewModel(
|
||||
frequencyEnabled: Boolean,
|
||||
frequencyFormat: String
|
||||
) {
|
||||
if (rotatorEnabled) {
|
||||
// Only send rotator commands when the satellite is above the horizon
|
||||
if (rotatorEnabled && orbitalPos.aboveHorizon) {
|
||||
val azimuth = orbitalPos.azimuth.toDegrees().round(2)
|
||||
val elevation = orbitalPos.elevation.toDegrees().round(2)
|
||||
reporter.reportRotation(rotatorFormat, azimuth, elevation)
|
||||
}
|
||||
if (frequencyEnabled) {
|
||||
_uiState.value.selectedFrequency?.let { freq ->
|
||||
_uiState.value.transceivers.selectedFrequency?.let { freq ->
|
||||
reporter.reportFrequency(frequencyFormat, freq)
|
||||
}
|
||||
}
|
||||
@@ -182,48 +323,81 @@ class RadarViewModel(
|
||||
val isFreqEnabled =
|
||||
settingsRepo.rcSettings.value.frequencyState || settingsRepo.rcSettings.value.bluetoothFrequencyState
|
||||
_uiState.update { state ->
|
||||
if (!isFreqEnabled) {
|
||||
// Skip update if nothing changed
|
||||
if (state.transmitters == transmitters && state.selectedTransmitterUuid == null && state.selectedFrequency == null) {
|
||||
return@update state
|
||||
val freq = if (isFreqEnabled && state.transceivers.selectedUuid != null) {
|
||||
val selectedRadio = transmitters.firstOrNull { it.uuid == state.transceivers.selectedUuid }
|
||||
selectedRadio?.let { radio ->
|
||||
val low = radio.downlinkLow
|
||||
val high = radio.downlinkHigh
|
||||
when {
|
||||
low != null && high != null -> (low + high) / 2
|
||||
low != null -> low
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
return@update state.copy(
|
||||
transmitters = transmitters,
|
||||
selectedTransmitterUuid = null,
|
||||
selectedFrequency = null
|
||||
)
|
||||
}
|
||||
val selectedUuid = state.selectedTransmitterUuid ?: transmitters.firstOrNull()?.uuid
|
||||
val selectedRadio = transmitters.firstOrNull { it.uuid == selectedUuid }
|
||||
val freq = selectedRadio?.let { radio ->
|
||||
val low = radio.downlinkLow
|
||||
val high = radio.downlinkHigh
|
||||
when {
|
||||
low != null && high != null -> (low + high) / 2
|
||||
low != null -> low
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
// Skip update if nothing changed
|
||||
if (state.transmitters == transmitters && state.selectedTransmitterUuid == selectedUuid && state.selectedFrequency == freq) {
|
||||
} else null
|
||||
|
||||
val current = state.transceivers
|
||||
if (current.transmitters == transmitters && current.selectedFrequency == freq) {
|
||||
return@update state
|
||||
}
|
||||
state.copy(transmitters = transmitters, selectedTransmitterUuid = selectedUuid, selectedFrequency = freq)
|
||||
state.copy(transceivers = current.copy(transmitters = transmitters, selectedFrequency = freq))
|
||||
}
|
||||
}
|
||||
|
||||
private fun initSstvDecoder() {
|
||||
if (sstvDecoder == null) {
|
||||
val decoder = SstvDecoder(sampleRate = audioCapture.sampleRate)
|
||||
sstvDecoder = decoder
|
||||
decoder.lockMode(_uiState.value.sstv.selectedMode)
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(supportedModes = decoder.supportedModes)) }
|
||||
viewModelScope.launch {
|
||||
decoder.frames.collect { frame ->
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = frame)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startSstvRecording() {
|
||||
if (sstvRecordingJob?.isActive == true) return
|
||||
initSstvDecoder()
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Recording)) }
|
||||
sstvRecordingJob = viewModelScope.launch {
|
||||
audioCapture.audioFlow().collect { buffer ->
|
||||
sstvDecoder?.feedSamples(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopSstvRecording() {
|
||||
sstvRecordingJob?.cancel()
|
||||
sstvRecordingJob = null
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Idle)) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(catNum: Int, aosTime: Long, container: IMainContainer) = viewModelFactory {
|
||||
|
||||
val CTCSS_TONES = listOf(
|
||||
67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5,
|
||||
94.8, 97.4, 100.0, 103.5, 107.2, 110.9, 114.8, 118.8, 123.0, 127.3, 131.8, 136.5,
|
||||
141.3, 146.2, 151.4, 156.7, 162.2, 167.9, 173.8, 179.9, 186.2, 192.8, 203.5, 210.7,
|
||||
218.1, 225.7, 233.6, 241.8, 250.3
|
||||
)
|
||||
|
||||
|
||||
fun factory(container: IMainContainer) = viewModelFactory {
|
||||
initializer {
|
||||
RadarViewModel(
|
||||
catNum = catNum,
|
||||
aosTime = aosTime,
|
||||
bluetoothReporter = container.provideBluetoothReporter(),
|
||||
networkReporter = container.provideNetworkReporter(),
|
||||
satelliteRepo = container.satelliteRepo,
|
||||
settingsRepo = container.settingsRepo,
|
||||
sensorsRepo = container.provideSensorsRepo(),
|
||||
addToCalendar = container.provideAddToCalendar()
|
||||
addToCalendar = container.provideAddToCalendar(),
|
||||
trackingService = container.radioTrackingService,
|
||||
audioCapture = container.provideAudioCapture(),
|
||||
saveImage = container.provideSaveImage(),
|
||||
showToast = container.provideShowToast()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* 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.radar
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.OutlinedText
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||
|
||||
@Composable
|
||||
internal fun SstvPage(
|
||||
sstv: SstvSubState,
|
||||
dopplerFrequency: String?,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
requestMicPermission: () -> Unit
|
||||
) {
|
||||
if (!sstv.hasPermission) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(text = "🎙️", fontSize = 48.sp)
|
||||
Text(
|
||||
text = "Microphone access needed",
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Button(onClick = requestMicPermission) {
|
||||
Text("Grant permission")
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Mode selection dialog
|
||||
val showModeDialog = remember { mutableStateOf(false) }
|
||||
|
||||
if (showModeDialog.value) {
|
||||
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
|
||||
Dialog(onDismissRequest = { showModeDialog.value = false }) {
|
||||
ElevatedCard {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "SSTV Mode",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight(0.5f)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||
) {
|
||||
items(allModes) { mode ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clickable {
|
||||
onAction(RadarAction.SstvSelectMode(mode))
|
||||
showModeDialog.value = false
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = mode,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 16.dp)
|
||||
)
|
||||
RadioButton(
|
||||
selected = mode == sstv.selectedMode,
|
||||
onClick = null,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main SSTV view — image fills entire area, controls overlay at bottom
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
) {
|
||||
// Decoded image or placeholder — centered in available space
|
||||
val frame = sstv.currentFrame
|
||||
val pixels = frame?.imagePixels
|
||||
if (pixels != null && frame.imageWidth > 0 && frame.imageHeight > 0) {
|
||||
val bitmap = remember(pixels, frame.imageWidth, frame.imageHeight) {
|
||||
Bitmap.createBitmap(
|
||||
pixels,
|
||||
frame.imageWidth,
|
||||
frame.imageHeight,
|
||||
Bitmap.Config.ARGB_8888
|
||||
).asImageBitmap()
|
||||
}
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
contentDescription = "Decoded SSTV image",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = if (sstv.status == SstvStatus.Recording) "Listening…" else "No signal",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
// Bottom control bar — dark, blends with the black canvas
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
// Doppler-corrected downlink frequency hint
|
||||
if (dopplerFrequency != null) {
|
||||
OutlinedText(
|
||||
text = "RX: $dopplerFrequency Hz",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fillColor = MaterialTheme.colorScheme.primary,
|
||||
outlineColor = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// Mode button — opens dialog
|
||||
ElevatedCard(
|
||||
modifier = Modifier.weight(1f).height(48.dp),
|
||||
onClick = { showModeDialog.value = true },
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "Mode: ${sstv.selectedMode}",
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.infiniteMarquee()
|
||||
)
|
||||
}
|
||||
}
|
||||
// Reset button — clears decoder state and image
|
||||
IconCard(
|
||||
action = { onAction(RadarAction.SstvReset) },
|
||||
resId = R.drawable.ic_delete
|
||||
)
|
||||
// Save button — always visible, enabled when there are pixels
|
||||
IconCard(
|
||||
action = { onAction(RadarAction.SstvSaveImage) },
|
||||
resId = R.drawable.ic_save,
|
||||
enabled = sstv.currentFrame?.imagePixels != null && !sstv.isSaving
|
||||
)
|
||||
// Record / Stop button
|
||||
val playAction = {
|
||||
when (sstv.status) {
|
||||
SstvStatus.Idle -> onAction(RadarAction.SstvStartRecording)
|
||||
SstvStatus.Recording -> onAction(RadarAction.SstvStopRecording)
|
||||
}
|
||||
}
|
||||
val playColors = CardDefaults.elevatedCardColors(
|
||||
containerColor = if (sstv.status == SstvStatus.Recording) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surface
|
||||
)
|
||||
val playIcon = if (sstv.status == SstvStatus.Recording) R.drawable.ic_pause else R.drawable.ic_play
|
||||
ElevatedCard(modifier = Modifier.size(48.dp), onClick = playAction, colors = playColors) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Icon(painter = painterResource(playIcon), contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
/*
|
||||
* 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.radar
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Composable
|
||||
fun TransceiversPage(
|
||||
transceivers: List<SatRadio>,
|
||||
selectedUuid: String?,
|
||||
radioControl: RadioControlSubState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (transceivers.isEmpty()) {
|
||||
EmptyTransceiversContent(modifier)
|
||||
} else {
|
||||
val listState = rememberLazyListState()
|
||||
// Snap the expanded item to the top of the visible area
|
||||
LaunchedEffect(selectedUuid) {
|
||||
if (selectedUuid != null) {
|
||||
val index = transceivers.indexOfFirst { it.uuid == selectedUuid }
|
||||
if (index >= 0) {
|
||||
kotlinx.coroutines.delay(300.milliseconds)
|
||||
listState.animateScrollToItem(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
LazyColumn(modifier = modifier.fillMaxSize(), state = listState) {
|
||||
itemsIndexed(items = transceivers, key = { _, radio -> radio.uuid }) { _, radio ->
|
||||
val isExpanded = radio.uuid == selectedUuid
|
||||
TransceiverItem(
|
||||
radio = radio,
|
||||
isExpanded = isExpanded,
|
||||
radioControl = radioControl,
|
||||
onAction = onAction,
|
||||
onToggle = { onAction(RadarAction.SelectTransmitter(radio.uuid)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransceiverItem(
|
||||
radio: SatRadio,
|
||||
isExpanded: Boolean,
|
||||
radioControl: RadioControlSubState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
onToggle: () -> Unit
|
||||
) {
|
||||
val bgColor = if (isExpanded) MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
else MaterialTheme.colorScheme.surface
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(bgColor)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { onToggle() }
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
) {
|
||||
// Header: [arrow slot] [name - (mode)] [icon slot]
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// Left icon slot — always reserved
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(20.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_arrow),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.rotate(if (isExpanded) 270f else 90f)
|
||||
)
|
||||
}
|
||||
// Title with mode
|
||||
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
|
||||
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
|
||||
val fullTitle = "$title ($mode)"
|
||||
Text(
|
||||
text = fullTitle,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.infiniteMarquee()
|
||||
)
|
||||
// Right arrow slot — always reserved
|
||||
val iconTint = if (isExpanded) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.outlineVariant
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_radios),
|
||||
contentDescription = null,
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Frequency rows
|
||||
UnifiedFrequencyRow(
|
||||
label = "TX",
|
||||
frequencyLow = radio.uplinkLow,
|
||||
frequencyHigh = radio.uplinkHigh,
|
||||
isConnected = if (isExpanded) radioControl.txPanel.isConnected else null
|
||||
)
|
||||
UnifiedFrequencyRow(
|
||||
label = "RX",
|
||||
frequencyLow = radio.downlinkLow,
|
||||
frequencyHigh = radio.downlinkHigh,
|
||||
isConnected = if (isExpanded) radioControl.rxPanel.isConnected else null
|
||||
)
|
||||
}
|
||||
|
||||
// Expanded CAT control area
|
||||
AnimatedVisibility(
|
||||
visible = isExpanded,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically()
|
||||
) {
|
||||
ExpandedRadioControl(
|
||||
radio = radio,
|
||||
radioControl = radioControl,
|
||||
onAction = onAction
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.background)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UnifiedFrequencyRow(
|
||||
label: String,
|
||||
frequencyLow: Long?,
|
||||
frequencyHigh: Long?,
|
||||
isConnected: Boolean?
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// TX:/RX: label
|
||||
Text(
|
||||
text = "$label:",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.width(24.dp)
|
||||
)
|
||||
// Low frequency
|
||||
FrequencyText(
|
||||
frequency = frequencyLow,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
// Dash — always centered
|
||||
Text(
|
||||
text = "–",
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.width(16.dp)
|
||||
)
|
||||
// High frequency
|
||||
FrequencyText(
|
||||
frequency = frequencyHigh,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
// Connection dot on the right
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.width(24.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
when (isConnected) {
|
||||
true -> Color(0xFF4CAF50)
|
||||
false -> Color(0xFFE57373)
|
||||
null -> MaterialTheme.colorScheme.outlineVariant
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExpandedRadioControl(
|
||||
radio: SatRadio,
|
||||
radioControl: RadioControlSubState,
|
||||
onAction: (RadarAction) -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(bottom = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
||||
)
|
||||
|
||||
// Frequency tuner
|
||||
if (radioControl.txBaseFrequencyHz != null) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "TX Base: ",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "${formatFrequency(radioControl.txBaseFrequencyHz)} MHz",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
val upLow = radio.uplinkLow
|
||||
val upHigh = radio.uplinkHigh
|
||||
if (upLow != null && upHigh != null && upLow != upHigh) {
|
||||
Text(
|
||||
text = "(${formatFrequency(upLow)} – ${formatFrequency(upHigh)})",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
FREQ_ADJUSTMENTS.forEach { (delta, label) ->
|
||||
CardButton(
|
||||
onClick = { onAction(RadarAction.AdjustTxFrequency(delta)) },
|
||||
text = label,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CTCSS (only for FM uplink)
|
||||
if (radio.uplinkMode?.uppercase() == "FM") {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "CTCSS",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
val chipModifier = Modifier.width(64.dp)
|
||||
FilterChip(
|
||||
selected = radioControl.ctcssTone == null,
|
||||
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
|
||||
label = {
|
||||
Text(
|
||||
text = "Off",
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
},
|
||||
modifier = chipModifier
|
||||
)
|
||||
RadarViewModel.CTCSS_TONES.forEach { tone ->
|
||||
FilterChip(
|
||||
selected = radioControl.ctcssTone == tone,
|
||||
onClick = { onAction(RadarAction.SetCtcssTone(tone)) },
|
||||
label = {
|
||||
Text(
|
||||
text = String.format(Locale.ENGLISH, "%.1f", tone),
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
},
|
||||
modifier = chipModifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Control buttons
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
|
||||
CardButton(
|
||||
onClick = { onAction(RadarAction.ConnectRadios) },
|
||||
text = "Connect",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
} else {
|
||||
CardButton(
|
||||
onClick = { onAction(RadarAction.DisconnectRadios) },
|
||||
text = "Disconnect",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
CardButton(
|
||||
onClick = { onAction(RadarAction.ToggleTracking) },
|
||||
text = if (radioControl.isTracking) "Stop" else "Track",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
// Error
|
||||
radioControl.errorMessage?.let { msg ->
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||
Text(text = msg, color = MaterialTheme.colorScheme.error, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
|
||||
val text = frequency?.let {
|
||||
stringResource(id = R.string.radar_link_low, it / 1000000f)
|
||||
} ?: stringResource(R.string.radar_no_link)
|
||||
Text(
|
||||
text = text,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
private val FREQ_ADJUSTMENTS =
|
||||
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
|
||||
@@ -1,7 +0,0 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.featurePlugin)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.rtbishop.look4sat.feature.radiocontrol"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest>
|
||||
|
||||
</manifest>
|
||||
-396
@@ -1,396 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.radiocontrol
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.keepScreenOn
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun RadioControlDestination(catNum: Int = 0, aosTime: Long = 0L, navigateUp: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel = viewModel(
|
||||
modelClass = RadioControlViewModel::class.java,
|
||||
key = "$catNum-$aosTime",
|
||||
factory = RadioControlViewModel.factory(catNum, aosTime, container)
|
||||
)
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
RadioControlScreen(uiState, viewModel::onAction, navigateUp)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RadioControlScreen(
|
||||
uiState: RadioControlState,
|
||||
onAction: (RadioControlAction) -> Unit,
|
||||
navigateUp: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.layoutPadding()
|
||||
.keepScreenOn(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
val currentPass = uiState.currentPass ?: getDefaultPass()
|
||||
val isVertical = isVerticalLayout()
|
||||
if (isVertical) {
|
||||
TopBar {
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
}
|
||||
TopBar { NextPassRow(pass = currentPass) }
|
||||
} else {
|
||||
TopBar {
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
|
||||
NextPassRow(pass = currentPass, modifier = Modifier.weight(1f))
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
}
|
||||
}
|
||||
|
||||
RadioPanel(panel = uiState.txPanel)
|
||||
RadioPanel(panel = uiState.rxPanel)
|
||||
|
||||
PositionRow(
|
||||
azimuth = uiState.azimuth,
|
||||
elevation = uiState.elevation,
|
||||
distance = uiState.distance
|
||||
)
|
||||
|
||||
val selectedTransponder = uiState.transponders.find {
|
||||
it.uuid == uiState.selectedTransponderUuid
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
item {
|
||||
TransponderSelector(
|
||||
transponders = uiState.transponders,
|
||||
selectedUuid = uiState.selectedTransponderUuid,
|
||||
onAction = onAction
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedTransponder?.uplinkMode?.uppercase() == "FM") {
|
||||
item {
|
||||
CtcssSelector(
|
||||
ctcssTone = uiState.ctcssTone,
|
||||
onAction = onAction
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.txBaseFrequencyHz != null) {
|
||||
item {
|
||||
FrequencyTuner(
|
||||
txBaseFrequencyHz = uiState.txBaseFrequencyHz,
|
||||
selectedTransponder = selectedTransponder,
|
||||
onAction = onAction
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
uiState.errorMessage?.let { msg ->
|
||||
item {
|
||||
Text(
|
||||
text = msg,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ControlButtons(
|
||||
isConnected = uiState.txPanel.isConnected || uiState.rxPanel.isConnected,
|
||||
isTracking = uiState.isTracking,
|
||||
onAction = onAction
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RadioPanel(panel: RadioPanelState) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (panel.isConnected) Color(0xFF4CAF50) else Color(0xFFE57373))
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = panel.label, fontSize = 14.sp)
|
||||
}
|
||||
Text(
|
||||
text = panel.frequencyDisplay,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = panel.mode ?: "--",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PositionRow(azimuth: String, elevation: String, distance: String) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp)
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(text = "Az", fontSize = 12.sp)
|
||||
Text(text = "$azimuth°", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(text = "El", fontSize = 12.sp)
|
||||
Text(text = "$elevation°", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(text = "Dist", fontSize = 12.sp)
|
||||
Text(text = "$distance km", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransponderSelector(
|
||||
transponders: List<SatRadio>,
|
||||
selectedUuid: String?,
|
||||
onAction: (RadioControlAction) -> Unit
|
||||
) {
|
||||
if (transponders.isEmpty()) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = "No transponders with uplink+downlink available",
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
transponders.forEach { radio ->
|
||||
TransponderItem(
|
||||
radio = radio,
|
||||
isSelected = radio.uuid == selectedUuid,
|
||||
onSelect = { onAction(RadioControlAction.SelectTransponder(radio.uuid)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransponderItem(radio: SatRadio, isSelected: Boolean, onSelect: () -> Unit) {
|
||||
val invLabel = if (radio.isInverted) " INV" else ""
|
||||
val modeLabel = "${radio.uplinkMode ?: "--"}/${radio.downlinkMode ?: "--"}$invLabel"
|
||||
Surface(
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surface,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelect() }
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(text = radio.info, modifier = Modifier.weight(1f))
|
||||
Text(text = modeLabel, fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun CtcssSelector(
|
||||
ctcssTone: Double?,
|
||||
onAction: (RadioControlAction) -> Unit
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) {
|
||||
Text(text = "CTCSS Tone", color = MaterialTheme.colorScheme.primary)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
FilterChip(
|
||||
selected = ctcssTone == null,
|
||||
onClick = { onAction(RadioControlAction.SetCtcssTone(null)) },
|
||||
label = { Text("Off") }
|
||||
)
|
||||
RadioControlViewModel.CTCSS_TONES.forEach { tone ->
|
||||
FilterChip(
|
||||
selected = ctcssTone == tone,
|
||||
onClick = { onAction(RadioControlAction.SetCtcssTone(tone)) },
|
||||
label = { Text(String.format(Locale.ENGLISH, "%.1f", tone)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val FREQ_ADJUSTMENTS =
|
||||
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
|
||||
|
||||
@Composable
|
||||
private fun FrequencyTuner(
|
||||
txBaseFrequencyHz: Long,
|
||||
selectedTransponder: SatRadio?,
|
||||
onAction: (RadioControlAction) -> Unit
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
) {
|
||||
Text(text = "TX Base Frequency", color = MaterialTheme.colorScheme.primary)
|
||||
if (selectedTransponder != null) {
|
||||
val upLow = selectedTransponder.uplinkLow
|
||||
val upHigh = selectedTransponder.uplinkHigh
|
||||
val dnLow = selectedTransponder.downlinkLow
|
||||
val dnHigh = selectedTransponder.downlinkHigh
|
||||
if (upLow != null && upHigh != null && upLow != upHigh) {
|
||||
Text(
|
||||
text = "UP: ${RadioControlViewModel.formatFrequency(upLow)} - ${RadioControlViewModel.formatFrequency(upHigh)}",
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (dnLow != null && dnHigh != null && dnLow != dnHigh) {
|
||||
Text(
|
||||
text = "DN: ${RadioControlViewModel.formatFrequency(dnLow)} - ${RadioControlViewModel.formatFrequency(dnHigh)}",
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "${RadioControlViewModel.formatFrequency(txBaseFrequencyHz)} MHz",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
FREQ_ADJUSTMENTS.forEach { (delta, label) ->
|
||||
CardButton(
|
||||
onClick = { onAction(RadioControlAction.AdjustTxFrequency(delta)) },
|
||||
text = label,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ControlButtons(
|
||||
isConnected: Boolean,
|
||||
isTracking: Boolean,
|
||||
onAction: (RadioControlAction) -> Unit
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
if (!isConnected) {
|
||||
CardButton(
|
||||
onClick = { onAction(RadioControlAction.ConnectRadios) },
|
||||
text = "Connect",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
} else {
|
||||
CardButton(
|
||||
onClick = { onAction(RadioControlAction.DisconnectRadios) },
|
||||
text = "Disconnect",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
CardButton(
|
||||
onClick = { onAction(RadioControlAction.ToggleTracking) },
|
||||
text = if (isTracking) "Stop Tracking" else "Start Tracking",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.radiocontrol
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
|
||||
data class RadioPanelState(
|
||||
val label: String,
|
||||
val isConnected: Boolean,
|
||||
val frequencyHz: Long?,
|
||||
val frequencyDisplay: String,
|
||||
val mode: String?
|
||||
)
|
||||
|
||||
data class RadioControlState(
|
||||
val currentPass: OrbitalPass?,
|
||||
val currentTime: String,
|
||||
val isCurrentTimeAos: Boolean,
|
||||
val azimuth: String,
|
||||
val elevation: String,
|
||||
val distance: String,
|
||||
val txPanel: RadioPanelState,
|
||||
val rxPanel: RadioPanelState,
|
||||
val transponders: List<SatRadio>,
|
||||
val selectedTransponderUuid: String?,
|
||||
val txBaseFrequencyHz: Long?,
|
||||
val ctcssTone: Double?,
|
||||
val isTracking: Boolean,
|
||||
val errorMessage: String?
|
||||
)
|
||||
|
||||
sealed interface RadioControlAction {
|
||||
data class SelectTransponder(val uuid: String) : RadioControlAction
|
||||
data class SetTxFrequency(val frequencyHz: Long) : RadioControlAction
|
||||
data class AdjustTxFrequency(val deltaHz: Long) : RadioControlAction
|
||||
data class SetCtcssTone(val toneHz: Double?) : RadioControlAction
|
||||
data object ToggleTracking : RadioControlAction
|
||||
data object ConnectRadios : RadioControlAction
|
||||
data object DisconnectRadios : RadioControlAction
|
||||
}
|
||||
-202
@@ -1,202 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.radiocontrol
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
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.utility.toDegrees
|
||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
|
||||
class RadioControlViewModel(
|
||||
private val catNum: Int,
|
||||
private val aosTime: Long,
|
||||
private val trackingService: IRadioTrackingService,
|
||||
private val satelliteRepo: ISatelliteRepo,
|
||||
settingsRepo: ISettingsRepo
|
||||
) : ViewModel() {
|
||||
|
||||
private val stationPos = settingsRepo.stationPosition.value
|
||||
private var currentPass: OrbitalPass? = null
|
||||
private var transponders: List<SatRadio> = emptyList()
|
||||
|
||||
private val _uiState = MutableStateFlow(
|
||||
RadioControlState(
|
||||
currentPass = null,
|
||||
currentTime = "00:00:00",
|
||||
isCurrentTimeAos = true,
|
||||
azimuth = "0.0",
|
||||
elevation = "0.0",
|
||||
distance = "0.0",
|
||||
txPanel = RadioPanelState("TX (Uplink)", false, null, "---", null),
|
||||
rxPanel = RadioPanelState("RX (Downlink)", false, null, "---", null),
|
||||
transponders = emptyList(),
|
||||
selectedTransponderUuid = null,
|
||||
txBaseFrequencyHz = null,
|
||||
ctcssTone = null,
|
||||
isTracking = false,
|
||||
errorMessage = null
|
||||
)
|
||||
)
|
||||
val uiState: StateFlow<RadioControlState> = _uiState
|
||||
|
||||
init {
|
||||
// Resolve pass and load transponders
|
||||
viewModelScope.launch {
|
||||
val passes = satelliteRepo.passes.value
|
||||
val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime }
|
||||
?: passes.firstOrNull()
|
||||
currentPass = pass
|
||||
pass?.let { satPass ->
|
||||
val allRadios = satelliteRepo.getRadiosWithId(satPass.catNum)
|
||||
transponders = allRadios.filter { it.downlinkLow != null }
|
||||
_uiState.update {
|
||||
it.copy(currentPass = satPass, transponders = transponders)
|
||||
}
|
||||
// If service is already tracking this pass, sync the selected transponder
|
||||
val svcState = trackingService.state.value
|
||||
if (svcState.isActive && svcState.currentPass?.catNum == satPass.catNum) {
|
||||
_uiState.update {
|
||||
it.copy(selectedTransponderUuid = svcState.selectedTransponder?.uuid)
|
||||
}
|
||||
}
|
||||
// Tick loop — timer and satellite position updates every second
|
||||
while (isActive) {
|
||||
val timeNow = System.currentTimeMillis()
|
||||
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
|
||||
val (timeStr, isAos) = computeTimer(satPass.isDeepSpace, satPass.aosTime, satPass.losTime, timeNow)
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
currentTime = timeStr,
|
||||
isCurrentTimeAos = isAos,
|
||||
azimuth = String.format(Locale.ENGLISH, "%.1f", pos.azimuth.toDegrees()),
|
||||
elevation = String.format(Locale.ENGLISH, "%.1f", pos.elevation.toDegrees()),
|
||||
distance = String.format(Locale.ENGLISH, "%.0f", pos.distance)
|
||||
)
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Observe service state for radio-specific updates (panels, frequencies, tracking status)
|
||||
viewModelScope.launch {
|
||||
trackingService.state.collect { svc ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
txPanel = RadioPanelState(
|
||||
label = "TX (Uplink)",
|
||||
isConnected = svc.txConnected,
|
||||
frequencyHz = svc.txFrequencyHz,
|
||||
frequencyDisplay = svc.txFrequencyHz?.let { formatFrequency(it) } ?: "---",
|
||||
mode = svc.txMode
|
||||
),
|
||||
rxPanel = RadioPanelState(
|
||||
label = "RX (Downlink)",
|
||||
isConnected = svc.rxConnected,
|
||||
frequencyHz = svc.rxFrequencyHz,
|
||||
frequencyDisplay = svc.rxFrequencyHz?.let { formatFrequency(it) } ?: "---",
|
||||
mode = svc.rxMode
|
||||
),
|
||||
txBaseFrequencyHz = svc.txBaseFrequencyHz,
|
||||
ctcssTone = svc.ctcssTone,
|
||||
isTracking = svc.isActive,
|
||||
selectedTransponderUuid = svc.selectedTransponder?.uuid,
|
||||
errorMessage = svc.errorMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeTimer(isDeepSpace: Boolean, aosTime: Long, losTime: Long, timeNow: Long): Pair<String, Boolean> {
|
||||
return when {
|
||||
isDeepSpace -> 0L.toTimerString() to false
|
||||
aosTime > timeNow -> (aosTime - timeNow).toTimerString() to true
|
||||
else -> (losTime - timeNow).toTimerString() to false
|
||||
}
|
||||
}
|
||||
|
||||
fun onAction(action: RadioControlAction) {
|
||||
when (action) {
|
||||
is RadioControlAction.SelectTransponder -> {
|
||||
val transponder = transponders.find { it.uuid == action.uuid } ?: return
|
||||
trackingService.setTransponder(transponder)
|
||||
}
|
||||
is RadioControlAction.SetTxFrequency -> trackingService.setTxBaseFrequency(action.frequencyHz)
|
||||
is RadioControlAction.AdjustTxFrequency -> trackingService.adjustTxBaseFrequency(action.deltaHz)
|
||||
is RadioControlAction.SetCtcssTone -> trackingService.setCtcssTone(action.toneHz)
|
||||
RadioControlAction.ToggleTracking -> {
|
||||
val svc = trackingService.state.value
|
||||
if (svc.isActive) {
|
||||
trackingService.stopTracking()
|
||||
} else {
|
||||
val pass = currentPass ?: return
|
||||
val transponder = svc.selectedTransponder ?: return
|
||||
trackingService.startTracking(pass, transponder, svc.txBaseFrequencyHz)
|
||||
}
|
||||
}
|
||||
RadioControlAction.ConnectRadios -> viewModelScope.launch { trackingService.connectRadios() }
|
||||
RadioControlAction.DisconnectRadios -> viewModelScope.launch { trackingService.disconnectRadios() }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
val CTCSS_TONES = listOf(
|
||||
67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5,
|
||||
94.8, 97.4, 100.0, 103.5, 107.2, 110.9, 114.8, 118.8, 123.0, 127.3, 131.8, 136.5,
|
||||
141.3, 146.2, 151.4, 156.7, 162.2, 167.9, 173.8, 179.9, 186.2, 192.8, 203.5, 210.7,
|
||||
218.1, 225.7, 233.6, 241.8, 250.3
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fun factory(catNum: Int, aosTime: Long, container: IMainContainer) = viewModelFactory {
|
||||
initializer {
|
||||
RadioControlViewModel(
|
||||
catNum = catNum,
|
||||
aosTime = aosTime,
|
||||
trackingService = container.radioTrackingService,
|
||||
satelliteRepo = container.satelliteRepo,
|
||||
settingsRepo = container.settingsRepo
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-4
@@ -75,10 +75,7 @@ import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||
fun SatellitesDestination(navigateUp: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel = viewModel(
|
||||
modelClass = SatellitesViewModel::class.java,
|
||||
factory = SatellitesViewModel.factory(container)
|
||||
)
|
||||
val viewModel: SatellitesViewModel = viewModel(factory = SatellitesViewModel.factory(container))
|
||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||
SatellitesScreen(uiState, viewModel::onAction, navigateUp)
|
||||
}
|
||||
|
||||
+44
-16
@@ -81,10 +81,7 @@ import java.util.Locale
|
||||
fun SettingsDestination() {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel = viewModel(
|
||||
modelClass = SettingsViewModel::class.java,
|
||||
factory = SettingsViewModel.factory(container)
|
||||
)
|
||||
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
SettingsScreen(uiState, viewModel::onAction)
|
||||
}
|
||||
@@ -188,32 +185,63 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
val gitHubUrl = stringResource(R.string.prefs_github_url)
|
||||
val licenseUrl = stringResource(R.string.prefs_license_url)
|
||||
val privacyUrl = stringResource(R.string.prefs_privacy_url)
|
||||
val safeOpenUri: (String) -> Unit = { url ->
|
||||
try { uriHandler.openUri(url) } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
ScreenColumn(
|
||||
topBar = { isVerticalLayout ->
|
||||
if (isVerticalLayout) {
|
||||
TopBar {
|
||||
TopCard(onClick = { uriHandler.openUri(appUrl) }, version = uiState.appVersionName, modifier = Modifier.weight(1f))
|
||||
PrimaryIconCard(onClick = { uriHandler.openUri(donateUrl) }, resId = R.drawable.ic_pound)
|
||||
TopCard(
|
||||
onClick = { safeOpenUri(appUrl) },
|
||||
version = uiState.appVersionName,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
|
||||
}
|
||||
TopBar {
|
||||
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
BotCard(onClick = { uriHandler.openUri(fdroidUrl) }, R.drawable.ic_fdroid, fdroidTitle, modifier = Modifier.weight(1f))
|
||||
BotCard(onClick = { uriHandler.openUri(gitHubUrl) }, R.drawable.ic_github, gitHubTitle, modifier = Modifier.weight(1f))
|
||||
BotCard(
|
||||
onClick = { safeOpenUri(fdroidUrl) },
|
||||
resId = R.drawable.ic_fdroid,
|
||||
text = fdroidTitle,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
BotCard(
|
||||
onClick = { safeOpenUri(gitHubUrl) },
|
||||
resId = R.drawable.ic_github,
|
||||
text = gitHubTitle,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
IconCard(action = { uriHandler.openUri(licenseUrl) }, resId = R.drawable.ic_license)
|
||||
IconCard(action = { uriHandler.openUri(privacyUrl) }, resId = R.drawable.ic_policy)
|
||||
IconCard(action = { safeOpenUri(licenseUrl) }, resId = R.drawable.ic_license)
|
||||
IconCard(action = { safeOpenUri(privacyUrl) }, resId = R.drawable.ic_policy)
|
||||
}
|
||||
} else {
|
||||
TopBar {
|
||||
PrimaryIconCard(onClick = { uriHandler.openUri(donateUrl) }, resId = R.drawable.ic_pound)
|
||||
TopCard(onClick = { uriHandler.openUri(appUrl) }, version = uiState.appVersionName, modifier = Modifier.weight(1f))
|
||||
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
|
||||
TopCard(
|
||||
onClick = { safeOpenUri(appUrl) },
|
||||
version = uiState.appVersionName,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
BotCard(onClick = { uriHandler.openUri(fdroidUrl) }, R.drawable.ic_fdroid, fdroidTitle, modifier = Modifier.weight(1f))
|
||||
BotCard(onClick = { uriHandler.openUri(gitHubUrl) }, R.drawable.ic_github, gitHubTitle, modifier = Modifier.weight(1f))
|
||||
BotCard(
|
||||
onClick = { safeOpenUri(fdroidUrl) },
|
||||
resId = R.drawable.ic_fdroid,
|
||||
text = fdroidTitle,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
BotCard(
|
||||
onClick = { safeOpenUri(gitHubUrl) },
|
||||
resId = R.drawable.ic_github,
|
||||
text = gitHubTitle,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
IconCard(action = { uriHandler.openUri(licenseUrl) }, resId = R.drawable.ic_license)
|
||||
IconCard(action = { uriHandler.openUri(privacyUrl) }, resId = R.drawable.ic_policy)
|
||||
IconCard(action = { safeOpenUri(licenseUrl) }, resId = R.drawable.ic_license)
|
||||
IconCard(action = { safeOpenUri(privacyUrl) }, resId = R.drawable.ic_policy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "430"
|
||||
appVersionCode = "441"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.3.0"
|
||||
appVersionName = "4.4.1"
|
||||
#noinspection GradleDependency,UnusedVersionCatalogEntry
|
||||
compileSdk = "36"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
@@ -12,21 +12,21 @@ jdkVersion = "17"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
packageName = "com.rtbishop.look4sat"
|
||||
|
||||
android-gradle-plugin = "9.2.0"
|
||||
android-gradle-plugin = "9.2.1"
|
||||
|
||||
androidx-core-ktx = "1.18.0"
|
||||
androidx-core-splashscreen = "1.2.0"
|
||||
androidx-room = "2.8.4"
|
||||
|
||||
compose-bom = "2026.04.01"
|
||||
compose-bom = "2026.05.01"
|
||||
compose-activity = "1.13.0"
|
||||
compose-lifecycle = "2.10.0"
|
||||
compose-navigation3 = "1.1.1"
|
||||
compose-navigation3 = "1.1.2"
|
||||
|
||||
google-ksp = "2.3.6"
|
||||
google-ksp = "2.3.7"
|
||||
|
||||
kotlin = "2.3.21"
|
||||
kotlin-coroutines = "1.10.2"
|
||||
kotlin = "2.4.0"
|
||||
kotlin-coroutines = "1.11.0"
|
||||
kotlin-serialization = "1.11.0"
|
||||
|
||||
other-okhttp = "5.3.2"
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
#Tue Apr 28 15:46:23 BST 2026
|
||||
#Mon May 25 11:20:48 BST 2026
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -24,7 +24,6 @@ include(
|
||||
":feature:map",
|
||||
":feature:passes",
|
||||
":feature:radar",
|
||||
":feature:radiocontrol",
|
||||
":feature:satellites",
|
||||
":feature:settings"
|
||||
)
|
||||
Reference in new issue
Block a user