Compare commits

..
6 Commits
29 changed files with 622 additions and 333 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# GitHub Copilot Instructions
Read `AGENTS.md` first, then `CLAUDE.md`.
`AGENTS.md` contains the architecture, module boundaries, implementation details, conventions, and gotchas.
+1
View File
@@ -62,6 +62,7 @@ jobs:
- name: Deploy Bundle to Google Play - name: Deploy Bundle to Google Play
run: | run: |
gem install multi_json
gem install fastlane --no-document gem install fastlane --no-document
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1) AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
+113
View File
@@ -0,0 +1,113 @@
# Look4Sat AI Agent Instructions
This is the canonical project guide for all AI assistants working on Look4Sat.
All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) point here.
---
## Project Overview
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
satellites using TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
data download.
## Architecture
**MVI (Model-View-Intent)** with unidirectional data flow:
- `State` data class → exposed via `StateFlow` from ViewModel
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
- Jetpack Compose UI observes state and recomposes reactively
**Clean Architecture layers:**
| Module | Responsibility |
|----------------------|---------------------------------------------------------------------|
| `app` | Entry point. Aggregates all modules |
| `core:data` | Android library. Room DB, OkHttp networking, repo implementations |
| `core:domain` | Pure Kotlin (JVM). Orbital math (SGP4/SDP4), models, repo contracts |
| `core:presentation` | Android library. Compose theme, shared UI components, NavKeys |
| `feature:map` | OSMDroid map with ground tracks |
| `feature:passes` | Pass predictions and upcoming events |
| `feature:radar` | Polar radar view of satellite positions, SSTV image decoding |
| `feature:satellites` | Satellite list, filtering, selection |
| `feature:settings` | User preferences |
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
## Build & Run
```shell
# Debug build
./gradlew assembleDebug
# Release build (minified, shrunk resources)
./gradlew assembleRelease
# Run tests
./gradlew test
```
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
## Key Libraries
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
- **Room** (KSP code generation) for local satellite/TLE storage
- **OkHttp** 5.x for TLE downloads
- **OSMDroid** for map rendering
- **Kotlin Serialization** for navigation args and data parsing
- **Coroutines** + `StateFlow` for async/reactive patterns
## Conventions
- **Minimal dependencies**: Avoid adding libraries when a simple manual solution exists. Fewer deps = less maintenance.
- **DI**: Manual — ViewModels use companion `factory()` methods with `IMainContainer` interface.
- **Navigation**: Type-safe Compose Navigation3 with `@Serializable` data classes as nav keys.
- **State naming**: `<Feature>State` data class + `<Feature>Action` sealed interface per feature.
- **No feature-to-feature deps**: All cross-feature communication goes through core layers.
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh).
## Data Formats & Migration
**TLE vs. OMM/CSV format:**
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
- **TLE format**: Traditional 3-line element format (deprecated). NORAD catalog numbers are 5-digit integers, which
are running out of space. Celestrak has signaled that TLE format will eventually be phased out.
- **OMM/CSV format**: The future standard. CSV files contain the same orbital parameters as TLE but use ISO 8601
timestamps and support larger NORAD IDs. Celestrak and SatNOGS already provide OMM data in CSV format.
**Current implementation:**
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly
- TLE data is downloaded from configured sources and stored in Room database
- When downloading satellite data, the app automatically detects format and parses accordingly
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching
**Migration path:**
As NORAD catalog space becomes constrained, OMM/CSV will become the primary format. Look4Sat is already positioned
to handle this transition without code changes — existing users can continue using TLE files while new sources
transition to OMM/CSV automatically.
## Code Style
- Prefer **short, focused functions** — single responsibility, easy to read.
- **Exceptions**: Composable functions and math-heavy algorithms (SGP4/SDP4) may be longer.
- Strict code style — no dead code, no unused imports, consistent formatting.
## Roadmap
- **KMP migration**: `core:domain` is to become a fully shareable KMM module. Keep it pure Kotlin/JVM.
## Gotchas
- Orbital math lives in `core:domain/predict/` — it's dense vector math (SGP4/SDP4). Tread carefully.
- TLE/OMM data must be refreshed weekly for accurate predictions (satellite orbits decay). TLE format is legacy and
will eventually be deprecated in favor of OMM/CSV as NORAD catalog numbers approach the 5-digit limit.
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
+2 -107
View File
@@ -1,110 +1,5 @@
# CLAUDE.md # CLAUDE.md
## Project Overview Read `AGENTS.md` first, then follow the instructions there.
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active `AGENTS.md` contains the architecture, module boundaries, implementation details, conventions, and gotchas.
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.
+8
View File
@@ -29,6 +29,14 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- <intent-filter android:autoVerify="true">-->
<!-- <action android:name="android.intent.action.VIEW" />-->
<!-- <category android:name="android.intent.category.DEFAULT" />-->
<!-- <category android:name="android.intent.category.BROWSABLE" />-->
<!-- <data android:scheme="https" />-->
<!-- <data android:host="github.com" />-->
<!-- <data android:pathPattern="/rt-bishop/Look4Sat/passes.*" />-->
<!-- </intent-filter>-->
</activity> </activity>
<meta-data <meta-data
@@ -25,6 +25,10 @@ import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -39,11 +44,13 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -62,6 +69,8 @@ import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
import com.rtbishop.look4sat.core.presentation.RadarDestination
import com.rtbishop.look4sat.core.presentation.Screen import com.rtbishop.look4sat.core.presentation.Screen
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
@@ -72,13 +81,47 @@ import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
import com.rtbishop.look4sat.feature.settings.SettingsDestination import com.rtbishop.look4sat.feature.settings.SettingsDestination
@Composable @Composable
fun MainScreen() { 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 backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull() val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { if (backStack.size > 1) backStack.removeAt(backStack.size - 1) } val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350)) 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 context = LocalContext.current
@@ -139,6 +182,7 @@ fun MainScreen() {
PassesDestination { catNum, aosTime -> PassesDestination { catNum, aosTime ->
container.satelliteRepo.selectPass(catNum, aosTime) container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar) backStack.add(Screen.Radar)
// navigateToRadar()
} }
} }
entry<Screen.Radar> { entry<Screen.Radar> {
+1
View File
@@ -8,5 +8,6 @@ plugins {
} }
tasks.register("clean", Delete::class.java) { tasks.register("clean", Delete::class.java) {
description = "Cleans the build directory"
delete(rootProject.layout.buildDirectory) delete(rootProject.layout.buildDirectory)
} }
@@ -19,10 +19,9 @@ package com.rtbishop.look4sat.core.data.injection
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
import android.content.Context import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorManager import android.hardware.SensorManager
import android.hardware.display.DisplayManager
import android.location.LocationManager import android.location.LocationManager
import android.view.WindowManager
import androidx.room.Room import androidx.room.Room
import com.rtbishop.look4sat.core.data.database.Look4SatDb import com.rtbishop.look4sat.core.data.database.Look4SatDb
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
@@ -120,9 +119,8 @@ class MainContainer(private val context: Context) : IMainContainer {
override fun provideSensorsRepo(): ISensorsRepo { override fun provideSensorsRepo(): ISensorsRepo {
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
val sensor = manager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) val displayManager = context.getSystemService(DisplayManager::class.java)
val window = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager return SensorsRepo(manager, displayManager)
return SensorsRepo(manager,sensor,window)
} }
private fun provideDatabaseRepo(): IDatabaseRepo { private fun provideDatabaseRepo(): IDatabaseRepo {
@@ -22,8 +22,9 @@ import android.hardware.Sensor
import android.hardware.SensorEvent import android.hardware.SensorEvent
import android.hardware.SensorEventListener import android.hardware.SensorEventListener
import android.hardware.SensorManager import android.hardware.SensorManager
import android.hardware.display.DisplayManager
import android.view.Display
import android.view.Surface import android.view.Surface
import android.view.WindowManager
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
@@ -31,13 +32,16 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlin.math.round import kotlin.math.round
private const val SMOOTHING_FACTOR = 0.15f
private const val SENSOR_RATE_US = 16_000
class SensorsRepo( class SensorsRepo(
private val sensorManager: SensorManager, private val sensorManager: SensorManager,
private val sensor: Sensor?, private val displayManager: DisplayManager?
private val windowManager: WindowManager ) : ISensorsRepo, SensorEventListener {
) : SensorEventListener, ISensorsRepo {
private val _orientation = MutableStateFlow(Pair(0f, 0f)) private val _sensorData = MutableStateFlow(Pair(0f, 0f))
private val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
private val rotationMatrix = FloatArray(9) private val rotationMatrix = FloatArray(9)
private val tempMatrix = FloatArray(9) private val tempMatrix = FloatArray(9)
private val orientationValues = FloatArray(3) private val orientationValues = FloatArray(3)
@@ -45,11 +49,7 @@ class SensorsRepo(
private var smoothPitch = 0f private var smoothPitch = 0f
private var hasInitialReading = false private var hasInitialReading = false
companion object { override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
private const val SMOOTHING_FACTOR = 0.15f
}
override val orientation: StateFlow<Pair<Float, Float>> = _orientation
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float { override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
return GeomagneticField( return GeomagneticField(
@@ -62,7 +62,7 @@ class SensorsRepo(
override fun enableSensor() { override fun enableSensor() {
hasInitialReading = false hasInitialReading = false
sensor?.let { sensorManager.registerListener(this, it, 8000) } sensor?.let { sensorManager.registerListener(this, it, SENSOR_RATE_US) }
} }
override fun disableSensor() = sensorManager.unregisterListener(this) override fun disableSensor() = sensorManager.unregisterListener(this)
@@ -70,16 +70,11 @@ class SensorsRepo(
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
override fun onSensorChanged(event: SensorEvent) { override fun onSensorChanged(event: SensorEvent) {
if (event.sensor == sensor) updateOrientation(event.values) if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) handleSensorEvent(event)
} }
private fun getDisplayRotation(): Int { private fun getDisplayRotation(): Int {
return try { return displayManager?.getDisplay(Display.DEFAULT_DISPLAY)?.rotation ?: Surface.ROTATION_0
@Suppress("DEPRECATION")
windowManager.defaultDisplay.rotation
} catch (_: Exception) {
Surface.ROTATION_0
}
} }
private fun remapForRotation(rotation: Int) { private fun remapForRotation(rotation: Int) {
@@ -101,30 +96,23 @@ class SensorsRepo(
if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9) if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
} }
private fun updateOrientation(rotationVector: FloatArray) { private fun handleSensorEvent(event: SensorEvent) {
SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector) SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
remapForRotation(getDisplayRotation()) remapForRotation(getDisplayRotation())
SensorManager.getOrientation(rotationMatrix, orientationValues) SensorManager.getOrientation(rotationMatrix, orientationValues)
val azimuth = (orientationValues[0] * RAD2DEG).toFloat() val azimuth = normalizeAzimuth((orientationValues[0] * RAD2DEG).toFloat())
val pitch = (orientationValues[1] * RAD2DEG).toFloat() val pitch = (orientationValues[1] * RAD2DEG).toFloat()
val magneticAzimuth = (azimuth + 360f) % 360f
if (!hasInitialReading) { if (!hasInitialReading) {
smoothAzimuth = magneticAzimuth smoothAzimuth = azimuth
smoothPitch = pitch smoothPitch = pitch
hasInitialReading = true hasInitialReading = true
} else { } else {
smoothAzimuth = lowPassAngle(smoothAzimuth, magneticAzimuth) smoothAzimuth = lowPassAngle(smoothAzimuth, azimuth)
smoothPitch = lowPass(smoothPitch, pitch) smoothPitch = lowPass(smoothPitch, pitch)
} }
_sensorData.value = Pair(round(smoothAzimuth * 10) / 10, round(smoothPitch * 10) / 10)
_orientation.value = Pair(
round(smoothAzimuth * 10) / 10,
round(smoothPitch * 10) / 10
)
} }
/** Standard exponential low-pass filter. */
private fun lowPass(previous: Float, current: Float): Float { private fun lowPass(previous: Float, current: Float): Float {
return previous + SMOOTHING_FACTOR * (current - previous) return previous + SMOOTHING_FACTOR * (current - previous)
} }
@@ -135,9 +123,10 @@ class SensorsRepo(
*/ */
private fun lowPassAngle(previous: Float, current: Float): Float { private fun lowPassAngle(previous: Float, current: Float): Float {
var delta = current - previous var delta = current - previous
// Normalise delta into the range (-180, 180]
while (delta > 180f) delta -= 360f while (delta > 180f) delta -= 360f
while (delta <= -180f) delta += 360f while (delta <= -180f) delta += 360f
return (previous + SMOOTHING_FACTOR * delta + 360f) % 360f return normalizeAzimuth(previous + SMOOTHING_FACTOR * delta)
} }
private fun normalizeAzimuth(value: Float): Float = (value + 360f) % 360f
} }
@@ -113,8 +113,8 @@ class SettingsRepo(
} }
private fun getSelectedTypes(): List<String> { private fun getSelectedTypes(): List<String> {
val typesString = preferences.getString(keySelectedTypes, null) val typesString = preferences.getString(keySelectedTypes, "Amateur")
if (typesString.isNullOrEmpty()) return listOf("Amateur") if (typesString.isNullOrEmpty()) return emptyList()
return typesString.split(separatorComma) return typesString.split(separatorComma)
} }
//endregion //endregion
@@ -74,7 +74,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
} }
override suspend fun insertRadios(radios: List<SatRadio>) { override suspend fun insertRadios(radios: List<SatRadio>) {
look4SatDao.deleteRadios()
look4SatDao.insertRadios(radios.toFrameworkRadios()) look4SatDao.insertRadios(radios.toFrameworkRadios())
} }
@@ -24,6 +24,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.io.ByteArrayInputStream
import java.io.InputStream import java.io.InputStream
class RemoteSource( class RemoteSource(
@@ -45,7 +46,10 @@ class RemoteSource(
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) { override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
try { try {
val networkRequest = Request.Builder().url(url).build() val networkRequest = Request.Builder().url(url).build()
httpClient.newCall(networkRequest).execute().body.byteStream() httpClient.newCall(networkRequest).execute().use { response ->
if (!response.isSuccessful) return@withContext null
ByteArrayInputStream(response.body.bytes())
}
} catch (exception: Exception) { } catch (exception: Exception) {
println("RemoteSource network stream exception: $exception") println("RemoteSource network stream exception: $exception")
null null
@@ -21,7 +21,7 @@ import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
interface ISensorsRepo { interface ISensorsRepo {
val orientation: StateFlow<Pair<Float, Float>> val sensorData: StateFlow<Pair<Float, Float>>
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
fun enableSensor() fun enableSensor()
fun disableSensor() fun disableSensor()
@@ -47,11 +47,12 @@ class SstvDecoder(
onBufferOverflow = BufferOverflow.DROP_OLDEST onBufferOverflow = BufferOverflow.DROP_OLDEST
) )
val frames: SharedFlow<SstvFrame> = _frames val frames: SharedFlow<SstvFrame> = _frames
val supportedModes: List<String> = buildList { val supportedModes: List<String> = decoder.allModes.map { it.name }
add("Raw"); add("HF Fax"); decoder.allModes.mapTo(this) { it.name }
}
suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) { 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) val hasNewLines = decoder.process(samples, channelSelect)
if (hasNewLines) emitFrame() if (hasNewLines) emitFrame()
} }
@@ -64,12 +65,31 @@ class SstvDecoder(
} }
private fun emitFrame() { private fun emitFrame() {
val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf() else null
val imageWidth = imageBuffer.width val imageWidth = imageBuffer.width
val imageHeight = imageBuffer.height 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 val modeName = decoder.currentMode.name
_frames.tryEmit(SstvFrame(imagePixels, imageWidth, imageHeight, modeName)) _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 enum class SyncPulseWidth { FiveMs, NineMs, TwentyMs }
@@ -136,6 +156,10 @@ internal class SyncPulseDetector(sampleRate: Int) {
fun process(buffer: FloatArray, channelSelect: Int): Boolean { fun process(buffer: FloatArray, channelSelect: Int): Boolean {
var detected = false var detected = false
val channels = if (channelSelect > 0) 2 else 1 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) { for (i in 0 until buffer.size / channels) {
when (channelSelect) { when (channelSelect) {
1 -> baseBand.set(buffer[2 * i]) 1 -> baseBand.set(buffer[2 * i])
@@ -200,7 +224,6 @@ internal class DecoderEngine(
private val visBitLen: Int private val visBitLen: Int
private val visLen: Int private val visLen: Int
private val rawMode: SstvMode private val rawMode: SstvMode
private val hfFaxMode: SstvMode
private val modes5ms: ArrayList<SstvMode> private val modes5ms: ArrayList<SstvMode>
private val modes9ms: ArrayList<SstvMode> private val modes9ms: ArrayList<SstvMode>
private val modes20ms: ArrayList<SstvMode> private val modes20ms: ArrayList<SstvMode>
@@ -217,6 +240,10 @@ internal class DecoderEngine(
init { init {
imageBuffer.line = -1 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 val pfLen = round(0.0025 * sampleRate).toInt() or 1
pulseFilterDelay = (pfLen - 1) / 2 pulseFilterDelay = (pfLen - 1) / 2
pulseFilter = MovingAverage(pfLen) pulseFilter = MovingAverage(pfLen)
@@ -231,7 +258,6 @@ internal class DecoderEngine(
syncTolerance = round(0.03 * sampleRate).toInt() syncTolerance = round(0.03 * sampleRate).toInt()
lineTolerance = round(0.001 * sampleRate).toInt() lineTolerance = round(0.001 * sampleRate).toInt()
rawMode = RawMode(rawName, sampleRate) rawMode = RawMode(rawName, sampleRate)
hfFaxMode = HfFaxMode(sampleRate)
val robot36 = Robot36Mode(sampleRate) val robot36 = Robot36Mode(sampleRate)
currentMode = robot36 currentMode = robot36
curLineSamples = robot36.scanLineSamples curLineSamples = robot36.scanLineSamples
@@ -301,11 +327,7 @@ internal class DecoderEngine(
} }
fun setMode(name: String) { fun setMode(name: String) {
if (rawMode.name == name) { val mode = allModes.firstOrNull { it.name == name }
lockMode = true; imageBuffer.line = -1; currentMode = rawMode; return
}
var mode = allModes.firstOrNull { it.name == name }
if (mode == null && hfFaxMode.name == name) mode = hfFaxMode
if (mode == currentMode) { if (mode == currentMode) {
lockMode = true; return lockMode = true; return
} }
@@ -335,6 +357,10 @@ internal class DecoderEngine(
return best 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() { private fun copyUnscaled() {
val w = minOf(scopeBuffer.width, pixelBuffer.width) val w = minOf(scopeBuffer.width, pixelBuffer.width)
for (row in 0 until pixelBuffer.height) { for (row in 0 until pixelBuffer.height) {
@@ -424,6 +450,9 @@ internal class DecoderEngine(
if ((amount <= 0) || (amount > sample)) return if ((amount <= 0) || (amount > sample)) return
sample -= amount; leaderBreak -= amount; lastSync -= amount sample -= amount; leaderBreak -= amount; lastSync -= amount
adjust(sync5ms, amount); adjust(sync9ms, amount); adjust(sync20ms, 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) scanLineBuffer.copyInto(scanLineBuffer, 0, amount, amount + sample)
} }
@@ -494,7 +523,7 @@ internal class DecoderEngine(
if (lockMode && mode != currentMode) return false if (lockMode && mode != currentMode) return false
mode.resetState() mode.resetState()
imageBuffer.width = mode.width; imageBuffer.height = mode.height imageBuffer.width = mode.width; imageBuffer.height = mode.height
imageBuffer.pixels = IntArray(mode.width * mode.height); imageBuffer.line = 0 imageBuffer.pixels.fill(0, 0, mode.width * mode.height); imageBuffer.line = 0
currentMode = mode currentMode = mode
lastSync = sIdx + mode.firstSyncPulseIndex; curLineSamples = mode.scanLineSamples; lastOffset = ldrOffset lastSync = sIdx + mode.firstSyncPulseIndex; curLineSamples = mode.scanLineSamples; lastOffset = ldrOffset
var oldest = lastSync - (pulses.size - 1) * curLineSamples var oldest = lastSync - (pulses.size - 1) * curLineSamples
@@ -527,6 +556,15 @@ internal class DecoderEngine(
var changed = false var changed = false
if (lockMode || imageBuffer.line in 0 until imageBuffer.height) { if (lockMode || imageBuffer.line in 0 until imageBuffer.height) {
if (currentMode != rawMode && abs(lineSamples - currentMode.scanLineSamples) > lineTolerance) return false 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 { } else {
val prev = currentMode; currentMode = detectMode(modes, lineSamples) val prev = currentMode; currentMode = detectMode(modes, lineSamples)
changed = changed =
@@ -18,7 +18,6 @@
package com.rtbishop.look4sat.core.domain.sstv package com.rtbishop.look4sat.core.domain.sstv
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.atan2
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.pow import kotlin.math.pow
import kotlin.math.round import kotlin.math.round
@@ -34,7 +33,6 @@ internal class Complex(var real: Float = 0f, var imag: Float = 0f) {
fun set(real: Float): Complex = set(real, 0f) fun set(real: Float): Complex = set(real, 0f)
fun abs(): Float = sqrt(real * real + imag * imag) fun abs(): Float = sqrt(real * real + imag * imag)
fun arg(): Float = atan2(imag, real)
fun mul(other: Complex): Complex { fun mul(other: Complex): Complex {
val tmp = real * other.real - imag * other.imag val tmp = real * other.real - imag * other.imag
@@ -74,21 +72,21 @@ internal object WindowFunctions {
} }
} }
// 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) { internal open class MovingSum(val length: Int) {
private val tree = FloatArray(2 * length) private val buf = FloatArray(length)
private var leaf = length private var pos = 0
private var runningSum = 0f
fun add(input: Float) { fun add(input: Float) {
tree[leaf] = input runningSum += input - buf[pos]
var child = leaf buf[pos] = input
var parent = leaf / 2 if (++pos >= length) pos = 0
while (parent > 0) {
tree[parent] = tree[child] + tree[child xor 1]; child = parent; parent /= 2
}
if (++leaf >= tree.size) leaf = length
} }
fun sum(): Float = tree[1] fun sum(): Float = runningSum
fun sum(input: Float): Float { fun sum(input: Float): Float {
add(input); return sum() add(input); return sum()
} }
@@ -134,8 +132,15 @@ internal class Phasor(freq: Double, rate: Double) {
val omega = 2 * PI * freq / rate val omega = 2 * PI * freq / rate
Complex(cos(omega).toFloat(), sin(omega).toFloat()) Complex(cos(omega).toFloat(), sin(omega).toFloat())
} }
private var count = 0
fun rotate(): Complex = value.div(value.mul(delta).abs()) // 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) { internal class FmDemodulator(bandwidth: Double, sampleRate: Double) {
@@ -145,11 +150,30 @@ internal class FmDemodulator(bandwidth: Double, sampleRate: Double) {
private var prev = 0f private var prev = 0f
fun demodulate(input: Complex): Float { fun demodulate(input: Complex): Float {
val phase = input.arg() // 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 var delta = phase - prev; prev = phase
if (delta < -pi) delta += twoPi else if (delta > pi) delta -= twoPi if (delta < -pi) delta += twoPi else if (delta > pi) delta -= twoPi
return scale * delta 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) { internal class ComplexFirFilter(val length: Int) {
@@ -257,9 +257,16 @@ internal class Robot36Mode(sampleRate: Int) : SstvMode {
val lPos = lumBegin + (i * lumSamples) / width val lPos = lumBegin + (i * lumSamples) / width
val cPos = chromBegin + (i * chromSamples) / width val cPos = chromBegin + (i * chromSamples) / width
if (even) { 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]) pixelBuffer.pixels[i] = ColorConverter.rgb(scratch[lPos], 0f, scratch[cPos])
} else { } else {
val evenYuv = pixelBuffer.pixels[i] 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) val oddYuv = ColorConverter.rgb(scratch[lPos], scratch[cPos], 0f)
pixelBuffer.pixels[i] = ColorConverter.yuv2rgb((evenYuv and 0x00ff00ff) or (oddYuv and 0x0000ff00)) pixelBuffer.pixels[i] = ColorConverter.yuv2rgb((evenYuv and 0x00ff00ff) or (oddYuv and 0x0000ff00))
pixelBuffer.pixels[i + width] = pixelBuffer.pixels[i + width] =
@@ -389,53 +396,6 @@ internal class PdMode(
} }
} }
internal class HfFaxMode(private val sampleRate: Int) : SstvMode {
override val name = "HF Fax"
override val visCode = -1
override val width = 640
override val height = 1200
override val firstPixelSampleIndex = 0
override val firstSyncPulseIndex = -1
override val scanLineSamples get() = sampleRate / 2
private val ema = Ema()
private val cumulated = FloatArray(width)
var horizontalShift = 0; private set
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
ema.setCutoff(width.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)
var bestIdx = 0
var bestVal = 0f
for (i in 0 until width) {
val pos = (i * lineSamples) / width
val color = ColorConverter.gray(scratch[pos])
pixelBuffer.pixels[i] = color
// Grayscale: R==G==B, so luminance is simply the channel value normalized
val gray = (color and 0xFF) / 255f
cumulated[i] = cumulated[i] * 0.99f + gray * 0.01f
if (cumulated[i] > bestVal) {
bestIdx = i; bestVal = cumulated[i]
}
}
horizontalShift = bestIdx
pixelBuffer.width = width; pixelBuffer.height = 1
return true
}
}
internal class RawMode(override val name: String, sampleRate: Int) : SstvMode { internal class RawMode(override val name: String, sampleRate: Int) : SstvMode {
override val visCode = -1 override val visCode = -1
override val width = -1 override val width = -1
@@ -40,6 +40,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedButton import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -49,12 +50,21 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
@@ -387,3 +397,76 @@ fun elevationColor(elevation: Double): Color {
else -> Color(0xFF66BB6A) // soft green for high elevation 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)
}
@@ -38,3 +38,33 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
@Serializable @Serializable
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs) data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
} }
@Serializable
data object RadarDestination : NavKey
interface IDeeplinkMatcher {
fun match(deeplink: String): NavKey?
}
object PassDetailsMatcher : IDeeplinkMatcher {
val passDetailsRegex = """https://github.com/rt-bishop/Look4Sat/passes/(.*)""".toRegex()
override fun match(deeplink: String): NavKey? {
val passMatch = passDetailsRegex.find(deeplink)
passMatch?.let { match ->
val passId = match.groupValues[1]
if (passId.isNotEmpty()) return RadarDestination
}
return null
}
}
class DeeplinkResolver(private val fallbackDestination: NavKey = Screen.Passes) {
private val matchers: List<IDeeplinkMatcher> = listOf(PassDetailsMatcher)
fun resolve(deeplink: String): NavKey {
matchers.forEach { it.match(deeplink)?.let { match -> return match } }
return fallbackDestination
}
}
@@ -49,9 +49,9 @@
\n\nPlease update the database at least weekly to get accurate predictions.</string> \n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string> <string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false"> <string name="pass_whatsnew_message" translatable="false">
* Merged RadarScreen and RadioControlScreen functionality * Fixed transceivers retention on a failed fetch from server
\n\n* Added SSTV image decoding functionality to RadarScreen \n\n* Fixed SSTV frequency display, no transceiver selected hint
\n\n* Added colored elevation and decay check to satellite passes \n\n* Fixed All satellite category retention on Satellites screen
</string> </string>
<!-- Radar screen --> <!-- Radar screen -->
@@ -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,3 +1,3 @@
* Merged RadarScreen and RadioControlScreen functionality * Fixed transceivers retention on a failed fetch from server
* Added SSTV image decoding functionality to RadarScreen * Fixed SSTV frequency display, no transceiver selected hint
* Added colored elevation and decay check to satellite passes * Fixed All satellite category retention on Satellites screen
@@ -66,6 +66,7 @@ import com.rtbishop.look4sat.core.presentation.NextPassRow
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.TimerRow import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.formatFrequency
import com.rtbishop.look4sat.core.presentation.getDefaultPass import com.rtbishop.look4sat.core.presentation.getDefaultPass
import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding import com.rtbishop.look4sat.core.presentation.layoutPadding
@@ -170,13 +171,14 @@ private fun PagerCard(
) { pageIndex -> ) { pageIndex ->
when (pages[pageIndex]) { when (pages[pageIndex]) {
RadarPage.Transceivers -> TransceiversPage( RadarPage.Transceivers -> TransceiversPage(
transceivers = uiState.transmitters, transceivers = uiState.transceivers.transmitters,
selectedUuid = uiState.selectedTransmitterUuid, selectedUuid = uiState.transceivers.selectedUuid,
radioControl = uiState.radioControl, radioControl = uiState.radioControl,
onAction = onAction onAction = onAction
) )
RadarPage.Sstv -> SstvPage( RadarPage.Sstv -> SstvPage(
sstv = uiState.sstv, sstv = uiState.sstv,
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
onAction = onAction, onAction = onAction,
requestMicPermission = requestMicPermission requestMicPermission = requestMicPermission
) )
@@ -189,7 +191,9 @@ private fun PagerCard(
@Composable @Composable
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) { private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
val satellitePos = uiState.orbitalPos val satellitePos = uiState.orbitalPos
val borderModifier = if (satellitePos?.aboveHorizon == true && satellitePos.eclipsed) { 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 infiniteTransition = rememberInfiniteTransition(label = "eclipsedBorder")
val borderAlpha by infiniteTransition.animateFloat( val borderAlpha by infiniteTransition.animateFloat(
initialValue = 1.0f, initialValue = 1.0f,
@@ -200,14 +204,13 @@ private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
), ),
label = "eclipsedBorderAlpha" label = "eclipsedBorderAlpha"
) )
val borderModifier = if (shouldAnimateBorder) {
Modifier.border( Modifier.border(
width = 0.5.dp, width = 0.5.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = borderAlpha), color = MaterialTheme.colorScheme.primary.copy(alpha = borderAlpha),
shape = MaterialTheme.shapes.medium shape = MaterialTheme.shapes.medium
) )
} else { } else Modifier
Modifier
}
ElevatedCard(modifier = modifier.then(borderModifier)) { ElevatedCard(modifier = modifier.then(borderModifier)) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
val position = uiState.orbitalPos val position = uiState.orbitalPos
@@ -294,4 +297,3 @@ private fun RadarLabel(
} }
} }
} }
@@ -34,7 +34,6 @@ data class RadioPanelState(
data class RadioControlSubState( data class RadioControlSubState(
val txPanel: RadioPanelState = RadioPanelState("TX (Uplink)"), val txPanel: RadioPanelState = RadioPanelState("TX (Uplink)"),
val rxPanel: RadioPanelState = RadioPanelState("RX (Downlink)"), val rxPanel: RadioPanelState = RadioPanelState("RX (Downlink)"),
val transponders: List<SatRadio> = emptyList(),
val selectedTransponderUuid: String? = null, val selectedTransponderUuid: String? = null,
val txBaseFrequencyHz: Long? = null, val txBaseFrequencyHz: Long? = null,
val ctcssTone: Double? = null, val ctcssTone: Double? = null,
@@ -42,11 +41,16 @@ data class RadioControlSubState(
val errorMessage: String? = null val errorMessage: String? = null
) )
data class TransceiverSubState(
val transmitters: List<SatRadio> = emptyList(),
val selectedUuid: String? = null,
val selectedFrequency: Long? = null,
)
data class RadarState( data class RadarState(
val currentPass: OrbitalPass? = null, val currentPass: OrbitalPass? = null,
val currentTime: String = "00:00:00", val currentTime: String = "00:00:00",
val isTimeAos: Boolean = true, val isTimeAos: Boolean = true,
val isLos: Boolean = false,
val isUtc: Boolean = false, val isUtc: Boolean = false,
val orientationValues: Pair<Float, Float> = 0f to 0f, val orientationValues: Pair<Float, Float> = 0f to 0f,
val orbitalPos: OrbitalPos? = null, val orbitalPos: OrbitalPos? = null,
@@ -55,17 +59,16 @@ data class RadarState(
val shouldUseCompass: Boolean = false, val shouldUseCompass: Boolean = false,
val sunPosition: CelestialComputer.SunPosition? = null, val sunPosition: CelestialComputer.SunPosition? = null,
val moonPosition: CelestialComputer.MoonPosition? = null, val moonPosition: CelestialComputer.MoonPosition? = null,
val transmitters: List<SatRadio> = emptyList(), val transceivers: TransceiverSubState = TransceiverSubState(),
val selectedTransmitterUuid: String? = null,
val selectedFrequency: Long? = null,
val radioControl: RadioControlSubState = RadioControlSubState(), val radioControl: RadioControlSubState = RadioControlSubState(),
val sstv: SstvSubState = SstvSubState() val sstv: SstvSubState = SstvSubState()
) )
enum class SstvStatus { Idle, Recording, Saving } enum class SstvStatus { Idle, Recording }
data class SstvSubState( data class SstvSubState(
val status: SstvStatus = SstvStatus.Idle, val status: SstvStatus = SstvStatus.Idle,
val isSaving: Boolean = false,
val hasPermission: Boolean = false, val hasPermission: Boolean = false,
val selectedMode: String = "Auto", val selectedMode: String = "Auto",
val supportedModes: List<String> = emptyList(), val supportedModes: List<String> = emptyList(),
@@ -77,7 +80,6 @@ sealed interface RadarAction {
data class SelectTransmitter(val uuid: String) : RadarAction data class SelectTransmitter(val uuid: String) : RadarAction
// Radio control actions // Radio control actions
data class SelectTransponder(val uuid: String) : RadarAction
data class SetTxFrequency(val frequencyHz: Long) : RadarAction data class SetTxFrequency(val frequencyHz: Long) : RadarAction
data class AdjustTxFrequency(val deltaHz: Long) : RadarAction data class AdjustTxFrequency(val deltaHz: Long) : RadarAction
data class SetCtcssTone(val toneHz: Double?) : RadarAction data class SetCtcssTone(val toneHz: Double?) : RadarAction
@@ -17,6 +17,7 @@
*/ */
package com.rtbishop.look4sat.feature.radar package com.rtbishop.look4sat.feature.radar
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
@@ -64,7 +65,7 @@ import kotlin.math.sin
private const val CIRCLES = 3 private const val CIRCLES = 3
private const val STROKE_WIDTH = 6f private const val STROKE_WIDTH = 6f
private const val SWEEP_INCREMENT = 360f / 12f / 60f private const val SWEEP_DURATION_MS = 8_000
@Composable @Composable
fun RadarViewCompose( fun RadarViewCompose(
@@ -88,23 +89,44 @@ fun RadarViewCompose(
animationSpec = infiniteRepeatable(tween(1000)), animationSpec = infiniteRepeatable(tween(1000)),
label = "animScale" 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 measurer = rememberTextMeasurer()
val sunPainter = painterResource(R.drawable.ic_sun) val sunPainter = painterResource(R.drawable.ic_sun)
val moonPainter = painterResource(R.drawable.ic_moon) 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 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 trackPath by remember { mutableStateOf(Path()) }
var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) } 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)) { Canvas(modifier = modifier.aspectRatio(1f)) {
val radius = size.minDimension / 2f * 0.95f 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) trackPath = createTrackPath(items, radius)
trackEffect = createTrackEffect(trackPath) trackEffect = createTrackEffect(trackPath)
cachedSweepBrush = makeSweepBrush(center, primaryColor)
cachedRadius = radius 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) { 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) drawRadar(radius, radarColor)
drawElevationLabels(radius, primaryColor, measurer) drawElevationLabels(radius, primaryColor, measurer)
translate(center.x, center.y) { translate(center.x, center.y) {
@@ -127,7 +149,6 @@ fun RadarViewCompose(
} }
if (shouldUseCompass) drawAim(azimElev.first, azimElev.second, radius, aimColor) if (shouldUseCompass) drawAim(azimElev.first, azimElev.second, radius, aimColor)
} }
sweepDegrees = (sweepDegrees + SWEEP_INCREMENT) % 360f
} }
} }
} }
@@ -172,10 +193,13 @@ private fun DrawScope.drawAim(azim: Float, elev: Float, radius: Float, color: Co
drawCircle(color, size / 2, pos, style = Stroke(STROKE_WIDTH)) 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 colors = listOf(Color.Transparent, color.copy(alpha = 0.5f), color)
val colorStops = listOf(0.64f, 0.995f, 1f) 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) } rotate(-90 + degrees, center) { drawCircle(brush, radius, style = Fill) }
} }
@@ -40,6 +40,7 @@ import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.utility.round import com.rtbishop.look4sat.core.domain.utility.round
import com.rtbishop.look4sat.core.domain.utility.toDegrees import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.domain.utility.toTimerString import com.rtbishop.look4sat.core.domain.utility.toTimerString
import com.rtbishop.look4sat.core.presentation.formatFrequency
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -48,7 +49,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Locale import kotlin.time.Duration.Companion.milliseconds
class RadarViewModel( class RadarViewModel(
private val bluetoothReporter: IReporter, private val bluetoothReporter: IReporter,
@@ -69,10 +70,15 @@ class RadarViewModel(
private var sstvDecoder: SstvDecoder? = null private var sstvDecoder: SstvDecoder? = null
private var sstvRecordingJob: Job? = 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( private val _uiState = MutableStateFlow(
RadarState( RadarState(
isUtc = settingsRepo.otherSettings.value.stateOfUtc, isUtc = settingsRepo.otherSettings.value.stateOfUtc,
orientationValues = sensorsRepo.orientation.value, orientationValues = sensorsRepo.sensorData.value,
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep, shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors, shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors,
sstv = SstvSubState(selectedMode = settingsRepo.otherSettings.value.sstvMode) sstv = SstvSubState(selectedMode = settingsRepo.otherSettings.value.sstvMode)
@@ -91,7 +97,7 @@ class RadarViewModel(
if (!settingsRepo.otherSettings.value.stateOfSensors) return if (!settingsRepo.otherSettings.value.stateOfSensors) return
viewModelScope.launch { viewModelScope.launch {
sensorsRepo.enableSensor() sensorsRepo.enableSensor()
sensorsRepo.orientation.collect { data -> sensorsRepo.sensorData.collect { data ->
val orientationValues = (data.first + magDeclination) to data.second val orientationValues = (data.first + magDeclination) to data.second
_uiState.update { it.copy(orientationValues = orientationValues) } _uiState.update { it.copy(orientationValues = orientationValues) }
} }
@@ -101,52 +107,75 @@ class RadarViewModel(
private fun collectSettingsChanges() { private fun collectSettingsChanges() {
viewModelScope.launch { viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings -> settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update { it.copy(isUtc = settings.stateOfUtc) } _uiState.update {
it.copy(
isUtc = settings.stateOfUtc,
shouldShowSweep = settings.stateOfSweep,
shouldUseCompass = settings.stateOfSensors
)
}
} }
} }
} }
// --- Pass loading split into focused functions ---
private fun collectPassAndStartTickLoop() { private fun collectPassAndStartTickLoop() {
viewModelScope.launch { viewModelScope.launch {
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 passes = satelliteRepo.passes.value
val (catNum, aosTime) = satelliteRepo.selectedPass.value val (catNum, aosTime) = satelliteRepo.selectedPass.value
val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull() ?: return@launch return passes.find { it.catNum == catNum && it.aosTime == aosTime }
_uiState.update { it.copy(currentPass = pass) } ?: passes.firstOrNull()
val transmittersList = satelliteRepo.getRadiosWithId(pass.catNum)
transponders = transmittersList.filter { it.downlinkLow != null }
_uiState.update { state ->
state.copy(radioControl = state.radioControl.copy(transponders = transponders))
} }
if (transmittersList.isNotEmpty()) {
val firstUuid = transmittersList.first().uuid // Loads transmitters and satellite track for pass, sets initial state, returns full radio list
_uiState.update { it.copy(selectedTransmitterUuid = firstUuid) } 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) } transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) }
} }
if (!pass.isDeepSpace) { if (!pass.isDeepSpace) {
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime) val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
_uiState.update { it.copy(satTrack = track) } _uiState.update { it.copy(satTrack = track) }
} }
while (isActive) { return allRadios
tickPass(pass, transmittersList)
delay(1000)
}
}
} }
private suspend fun tickPass(pass: OrbitalPass, transmittersList: List<SatRadio>) { // --- Per-second tick ---
private suspend fun tickPass(pass: OrbitalPass, allRadios: List<SatRadio>) {
val timeNow = System.currentTimeMillis() val timeNow = System.currentTimeMillis()
val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow) val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
val sunPos = CelestialComputer.getSunPosition(stationPos, timeNow)
val moonPos = CelestialComputer.getMoonPosition(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) val (time, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
val isLos = !pass.isDeepSpace && timeNow > pass.losTime
_uiState.update { _uiState.update {
it.copy( it.copy(
currentTime = time, isTimeAos = isAos, isLos = isLos, currentTime = time, isTimeAos = isAos,
orbitalPos = pos, sunPosition = sunPos, moonPosition = moonPos orbitalPos = pos, sunPosition = cachedSunPos, moonPosition = cachedMoonPos
) )
} }
processRadios(transmittersList, pass.orbitalObject, timeNow) processRadios(allRadios, pass.orbitalObject, timeNow)
sendPassData(pos) sendPassData(pos)
} }
@@ -191,24 +220,15 @@ class RadarViewModel(
when (action) { when (action) {
is RadarAction.AddToCalendar -> addToCalendar(action.name, action.aosTime, action.losTime) is RadarAction.AddToCalendar -> addToCalendar(action.name, action.aosTime, action.losTime)
is RadarAction.SelectTransmitter -> { is RadarAction.SelectTransmitter -> {
val previousUuid = _uiState.value.selectedTransmitterUuid // Compute toggle state before the update so we don't read post-update value
_uiState.update { val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
val newUuid = if (it.selectedTransmitterUuid == action.uuid) null else action.uuid val newUuid = if (isTogglingOff) null else action.uuid
it.copy(selectedTransmitterUuid = newUuid) _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) }
} }
// Also set this as the active transponder for radio tracking.
// Always use the original (un-Dopplered) transponders list so that the
// TX base frequency is computed from the clean nominal values.
// Only update the service when selecting a *different* transponder to
// avoid resetting a user-adjusted TX base on re-expand.
val selected = transponders.find { it.uuid == action.uuid }
if (selected != null && _uiState.value.selectedTransmitterUuid != null && previousUuid != action.uuid) {
trackingService.setTransponder(selected)
}
}
is RadarAction.SelectTransponder -> {
val transponder = transponders.find { it.uuid == action.uuid } ?: return
trackingService.setTransponder(transponder)
} }
is RadarAction.SetTxFrequency -> trackingService.setTxBaseFrequency(action.frequencyHz) is RadarAction.SetTxFrequency -> trackingService.setTxBaseFrequency(action.frequencyHz)
is RadarAction.AdjustTxFrequency -> trackingService.adjustTxBaseFrequency(action.deltaHz) is RadarAction.AdjustTxFrequency -> trackingService.adjustTxBaseFrequency(action.deltaHz)
@@ -236,10 +256,10 @@ class RadarViewModel(
RadarAction.SstvSaveImage -> { RadarAction.SstvSaveImage -> {
val frame = _uiState.value.sstv.currentFrame ?: return val frame = _uiState.value.sstv.currentFrame ?: return
val pixels = frame.imagePixels ?: return val pixels = frame.imagePixels ?: return
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Saving)) } _uiState.update { it.copy(sstv = it.sstv.copy(isSaving = true)) }
viewModelScope.launch { viewModelScope.launch {
saveImage(pixels, frame.imageWidth, frame.imageHeight, frame.modeName) saveImage(pixels, frame.imageWidth, frame.imageHeight, frame.modeName)
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Idle)) } _uiState.update { it.copy(sstv = it.sstv.copy(isSaving = false)) }
showToast("Image saved") showToast("Image saved")
} }
} }
@@ -249,7 +269,7 @@ class RadarViewModel(
settingsRepo.updateOtherSettings { it.copy(sstvMode = action.modeName) } settingsRepo.updateOtherSettings { it.copy(sstvMode = action.modeName) }
} }
RadarAction.SstvReset -> { RadarAction.SstvReset -> {
sstvDecoder?.clearPixels() // Keep decoder alive, just reset pixels sstvDecoder?.clearPixels()
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) } _uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
} }
} }
@@ -285,13 +305,14 @@ class RadarViewModel(
frequencyEnabled: Boolean, frequencyEnabled: Boolean,
frequencyFormat: String 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 azimuth = orbitalPos.azimuth.toDegrees().round(2)
val elevation = orbitalPos.elevation.toDegrees().round(2) val elevation = orbitalPos.elevation.toDegrees().round(2)
reporter.reportRotation(rotatorFormat, azimuth, elevation) reporter.reportRotation(rotatorFormat, azimuth, elevation)
} }
if (frequencyEnabled) { if (frequencyEnabled) {
_uiState.value.selectedFrequency?.let { freq -> _uiState.value.transceivers.selectedFrequency?.let { freq ->
reporter.reportFrequency(frequencyFormat, freq) reporter.reportFrequency(frequencyFormat, freq)
} }
} }
@@ -299,12 +320,9 @@ class RadarViewModel(
private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) { private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) {
val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time) val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time)
val isFreqEnabled =
settingsRepo.rcSettings.value.frequencyState || settingsRepo.rcSettings.value.bluetoothFrequencyState
_uiState.update { state -> _uiState.update { state ->
// Derive the frequency to report from the user's current selection (if any) val freq = if (state.transceivers.selectedUuid != null) {
val freq = if (isFreqEnabled && state.selectedTransmitterUuid != null) { val selectedRadio = transmitters.firstOrNull { it.uuid == state.transceivers.selectedUuid }
val selectedRadio = transmitters.firstOrNull { it.uuid == state.selectedTransmitterUuid }
selectedRadio?.let { radio -> selectedRadio?.let { radio ->
val low = radio.downlinkLow val low = radio.downlinkLow
val high = radio.downlinkHigh val high = radio.downlinkHigh
@@ -316,10 +334,11 @@ class RadarViewModel(
} }
} else null } else null
if (state.transmitters == transmitters && state.selectedFrequency == freq) { val current = state.transceivers
if (current.transmitters == transmitters && current.selectedFrequency == freq) {
return@update state return@update state
} }
state.copy(transmitters = transmitters, selectedFrequency = freq) state.copy(transceivers = current.copy(transmitters = transmitters, selectedFrequency = freq))
} }
} }
@@ -327,6 +346,7 @@ class RadarViewModel(
if (sstvDecoder == null) { if (sstvDecoder == null) {
val decoder = SstvDecoder(sampleRate = audioCapture.sampleRate) val decoder = SstvDecoder(sampleRate = audioCapture.sampleRate)
sstvDecoder = decoder sstvDecoder = decoder
decoder.lockMode(_uiState.value.sstv.selectedMode)
_uiState.update { it.copy(sstv = it.sstv.copy(supportedModes = decoder.supportedModes)) } _uiState.update { it.copy(sstv = it.sstv.copy(supportedModes = decoder.supportedModes)) }
viewModelScope.launch { viewModelScope.launch {
decoder.frames.collect { frame -> decoder.frames.collect { frame ->
@@ -362,13 +382,6 @@ class RadarViewModel(
218.1, 225.7, 233.6, 241.8, 250.3 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(container: IMainContainer) = viewModelFactory { fun factory(container: IMainContainer) = viewModelFactory {
initializer { initializer {
@@ -55,12 +55,14 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import com.rtbishop.look4sat.core.presentation.IconCard 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.R
import com.rtbishop.look4sat.core.presentation.infiniteMarquee import com.rtbishop.look4sat.core.presentation.infiniteMarquee
@Composable @Composable
internal fun SstvPage( internal fun SstvPage(
sstv: SstvSubState, sstv: SstvSubState,
dopplerFrequency: String?,
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit requestMicPermission: () -> Unit
) { ) {
@@ -180,13 +182,26 @@ internal fun SstvPage(
) )
} }
// Bottom control bar — dark, blends with the black canvas // Bottom control bar — dark, blends with the black canvas
Row( Column(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp) .padding(horizontal = 8.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
// Doppler-corrected downlink frequency hint
OutlinedText(
text = dopplerFrequency?.let { "RX: $it Hz" } ?: "No transceiver selected",
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 // Mode button — opens dialog
ElevatedCard( ElevatedCard(
@@ -215,14 +230,13 @@ internal fun SstvPage(
IconCard( IconCard(
action = { onAction(RadarAction.SstvSaveImage) }, action = { onAction(RadarAction.SstvSaveImage) },
resId = R.drawable.ic_save, resId = R.drawable.ic_save,
enabled = sstv.currentFrame?.imagePixels != null enabled = sstv.currentFrame?.imagePixels != null && !sstv.isSaving
) )
// Record / Stop button // Record / Stop button
val playAction = { val playAction = {
when (sstv.status) { when (sstv.status) {
SstvStatus.Idle -> onAction(RadarAction.SstvStartRecording) SstvStatus.Idle -> onAction(RadarAction.SstvStartRecording)
SstvStatus.Recording -> onAction(RadarAction.SstvStopRecording) SstvStatus.Recording -> onAction(RadarAction.SstvStopRecording)
SstvStatus.Saving -> {}
} }
} }
val playColors = CardDefaults.elevatedCardColors( val playColors = CardDefaults.elevatedCardColors(
@@ -238,3 +252,4 @@ internal fun SstvPage(
} }
} }
} }
}
@@ -62,8 +62,10 @@ import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.formatFrequency
import com.rtbishop.look4sat.core.presentation.infiniteMarquee import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import java.util.Locale import java.util.Locale
import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
fun TransceiversPage( fun TransceiversPage(
@@ -82,7 +84,7 @@ fun TransceiversPage(
if (selectedUuid != null) { if (selectedUuid != null) {
val index = transceivers.indexOfFirst { it.uuid == selectedUuid } val index = transceivers.indexOfFirst { it.uuid == selectedUuid }
if (index >= 0) { if (index >= 0) {
kotlinx.coroutines.delay(300) kotlinx.coroutines.delay(300.milliseconds)
listState.animateScrollToItem(index) listState.animateScrollToItem(index)
} }
} }
@@ -325,7 +327,7 @@ private fun ExpandedRadioControl(
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Text( Text(
text = "${RadarViewModel.formatFrequency(radioControl.txBaseFrequencyHz)} MHz", text = "${formatFrequency(radioControl.txBaseFrequencyHz)} MHz",
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
@@ -335,7 +337,7 @@ private fun ExpandedRadioControl(
val upHigh = radio.uplinkHigh val upHigh = radio.uplinkHigh
if (upLow != null && upHigh != null && upLow != upHigh) { if (upLow != null && upHigh != null && upLow != upHigh) {
Text( Text(
text = "(${RadarViewModel.formatFrequency(upLow)} – ${RadarViewModel.formatFrequency(upHigh)})", text = "(${formatFrequency(upLow)} – ${formatFrequency(upHigh)})",
fontSize = 12.sp, fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
+7 -7
View File
@@ -1,8 +1,8 @@
[versions] [versions]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionCode = "440" appVersionCode = "442"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.0" appVersionName = "4.4.2"
#noinspection GradleDependency,UnusedVersionCatalogEntry #noinspection GradleDependency,UnusedVersionCatalogEntry
compileSdk = "36" compileSdk = "36"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
@@ -18,18 +18,18 @@ androidx-core-ktx = "1.18.0"
androidx-core-splashscreen = "1.2.0" androidx-core-splashscreen = "1.2.0"
androidx-room = "2.8.4" androidx-room = "2.8.4"
compose-bom = "2026.05.01" compose-bom = "2026.06.00"
compose-activity = "1.13.0" compose-activity = "1.13.0"
compose-lifecycle = "2.10.0" compose-lifecycle = "2.10.0"
compose-navigation3 = "1.1.2" compose-navigation3 = "1.1.3"
google-ksp = "2.3.7" google-ksp = "2.3.8"
kotlin = "2.3.21" kotlin = "2.4.0"
kotlin-coroutines = "1.11.0" kotlin-coroutines = "1.11.0"
kotlin-serialization = "1.11.0" kotlin-serialization = "1.11.0"
other-okhttp = "5.3.2" other-okhttp = "5.4.0"
other-osmdroid = "6.1.20" other-osmdroid = "6.1.20"
test-junit4 = "4.13.2" test-junit4 = "4.13.2"
+2 -2
View File
@@ -1,7 +1,7 @@
#Mon May 25 11:20:48 BST 2026 #Sun Jun 21 14:57:04 BST 2026
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME