Compare commits

..
6 Commits
29 changed files with 677 additions and 388 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
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
+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
## 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
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.
`AGENTS.md` contains the architecture, module boundaries, implementation details, conventions, and gotchas.
+8
View File
@@ -29,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,6 +69,8 @@ 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
@@ -72,13 +81,47 @@ 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 = { 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 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 context = LocalContext.current
@@ -139,6 +182,7 @@ fun MainScreen() {
PassesDestination { catNum, aosTime ->
container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar)
// navigateToRadar()
}
}
entry<Screen.Radar> {
+1
View File
@@ -8,5 +8,6 @@ plugins {
}
tasks.register("clean", Delete::class.java) {
description = "Cleans the build directory"
delete(rootProject.layout.buildDirectory)
}
@@ -19,10 +19,9 @@ package com.rtbishop.look4sat.core.data.injection
import android.bluetooth.BluetoothManager
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorManager
import android.hardware.display.DisplayManager
import android.location.LocationManager
import android.view.WindowManager
import androidx.room.Room
import com.rtbishop.look4sat.core.data.database.Look4SatDb
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
@@ -120,9 +119,8 @@ class MainContainer(private val context: Context) : IMainContainer {
override fun provideSensorsRepo(): ISensorsRepo {
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
val sensor = manager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
val window = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
return SensorsRepo(manager,sensor,window)
val displayManager = context.getSystemService(DisplayManager::class.java)
return SensorsRepo(manager, displayManager)
}
private fun provideDatabaseRepo(): IDatabaseRepo {
@@ -22,8 +22,9 @@ import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.hardware.display.DisplayManager
import android.view.Display
import android.view.Surface
import android.view.WindowManager
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
@@ -31,13 +32,16 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlin.math.round
private const val SMOOTHING_FACTOR = 0.15f
private const val SENSOR_RATE_US = 16_000
class SensorsRepo(
private val sensorManager: SensorManager,
private val sensor: Sensor?,
private val windowManager: WindowManager
) : SensorEventListener, ISensorsRepo {
private val displayManager: DisplayManager?
) : ISensorsRepo, SensorEventListener {
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 tempMatrix = FloatArray(9)
private val orientationValues = FloatArray(3)
@@ -45,11 +49,7 @@ class SensorsRepo(
private var smoothPitch = 0f
private var hasInitialReading = false
companion object {
private const val SMOOTHING_FACTOR = 0.15f
}
override val orientation: StateFlow<Pair<Float, Float>> = _orientation
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
return GeomagneticField(
@@ -62,7 +62,7 @@ class SensorsRepo(
override fun enableSensor() {
hasInitialReading = false
sensor?.let { sensorManager.registerListener(this, it, 8000) }
sensor?.let { sensorManager.registerListener(this, it, SENSOR_RATE_US) }
}
override fun disableSensor() = sensorManager.unregisterListener(this)
@@ -70,16 +70,11 @@ class SensorsRepo(
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
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 {
return try {
@Suppress("DEPRECATION")
windowManager.defaultDisplay.rotation
} catch (_: Exception) {
Surface.ROTATION_0
}
return displayManager?.getDisplay(Display.DEFAULT_DISPLAY)?.rotation ?: Surface.ROTATION_0
}
private fun remapForRotation(rotation: Int) {
@@ -101,30 +96,23 @@ class SensorsRepo(
if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
}
private fun updateOrientation(rotationVector: FloatArray) {
SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector)
private fun handleSensorEvent(event: SensorEvent) {
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
remapForRotation(getDisplayRotation())
SensorManager.getOrientation(rotationMatrix, orientationValues)
val azimuth = (orientationValues[0] * RAD2DEG).toFloat()
val azimuth = normalizeAzimuth((orientationValues[0] * RAD2DEG).toFloat())
val pitch = (orientationValues[1] * RAD2DEG).toFloat()
val magneticAzimuth = (azimuth + 360f) % 360f
if (!hasInitialReading) {
smoothAzimuth = magneticAzimuth
smoothAzimuth = azimuth
smoothPitch = pitch
hasInitialReading = true
} else {
smoothAzimuth = lowPassAngle(smoothAzimuth, magneticAzimuth)
smoothAzimuth = lowPassAngle(smoothAzimuth, azimuth)
smoothPitch = lowPass(smoothPitch, pitch)
}
_orientation.value = Pair(
round(smoothAzimuth * 10) / 10,
round(smoothPitch * 10) / 10
)
_sensorData.value = Pair(round(smoothAzimuth * 10) / 10, round(smoothPitch * 10) / 10)
}
/** Standard exponential low-pass filter. */
private fun lowPass(previous: Float, current: Float): Float {
return previous + SMOOTHING_FACTOR * (current - previous)
}
@@ -135,9 +123,10 @@ class SensorsRepo(
*/
private fun lowPassAngle(previous: Float, current: Float): Float {
var delta = current - previous
// Normalise delta into the range (-180, 180]
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> {
val typesString = preferences.getString(keySelectedTypes, null)
if (typesString.isNullOrEmpty()) return listOf("Amateur")
val typesString = preferences.getString(keySelectedTypes, "Amateur")
if (typesString.isNullOrEmpty()) return emptyList()
return typesString.split(separatorComma)
}
//endregion
@@ -74,7 +74,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
}
override suspend fun insertRadios(radios: List<SatRadio>) {
look4SatDao.deleteRadios()
look4SatDao.insertRadios(radios.toFrameworkRadios())
}
@@ -24,6 +24,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.ByteArrayInputStream
import java.io.InputStream
class RemoteSource(
@@ -45,7 +46,10 @@ class RemoteSource(
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
try {
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) {
println("RemoteSource network stream exception: $exception")
null
@@ -21,7 +21,7 @@ import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.StateFlow
interface ISensorsRepo {
val orientation: StateFlow<Pair<Float, Float>>
val sensorData: StateFlow<Pair<Float, Float>>
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
fun enableSensor()
fun disableSensor()
@@ -47,11 +47,12 @@ class SstvDecoder(
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val frames: SharedFlow<SstvFrame> = _frames
val supportedModes: List<String> = buildList {
add("Raw"); add("HF Fax"); decoder.allModes.mapTo(this) { it.name }
}
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()
}
@@ -64,12 +65,31 @@ class SstvDecoder(
}
private fun emitFrame() {
val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf() else null
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 }
@@ -136,6 +156,10 @@ internal class SyncPulseDetector(sampleRate: Int) {
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])
@@ -200,7 +224,6 @@ internal class DecoderEngine(
private val visBitLen: Int
private val visLen: Int
private val rawMode: SstvMode
private val hfFaxMode: SstvMode
private val modes5ms: ArrayList<SstvMode>
private val modes9ms: ArrayList<SstvMode>
private val modes20ms: ArrayList<SstvMode>
@@ -217,6 +240,10 @@ internal class DecoderEngine(
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)
@@ -231,7 +258,6 @@ internal class DecoderEngine(
syncTolerance = round(0.03 * sampleRate).toInt()
lineTolerance = round(0.001 * sampleRate).toInt()
rawMode = RawMode(rawName, sampleRate)
hfFaxMode = HfFaxMode(sampleRate)
val robot36 = Robot36Mode(sampleRate)
currentMode = robot36
curLineSamples = robot36.scanLineSamples
@@ -301,11 +327,7 @@ internal class DecoderEngine(
}
fun setMode(name: String) {
if (rawMode.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
val mode = allModes.firstOrNull { it.name == name }
if (mode == currentMode) {
lockMode = true; return
}
@@ -335,6 +357,10 @@ internal class DecoderEngine(
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) {
@@ -424,6 +450,9 @@ internal class DecoderEngine(
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)
}
@@ -494,7 +523,7 @@ internal class DecoderEngine(
if (lockMode && mode != currentMode) return false
mode.resetState()
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
lastSync = sIdx + mode.firstSyncPulseIndex; curLineSamples = mode.scanLineSamples; lastOffset = ldrOffset
var oldest = lastSync - (pulses.size - 1) * curLineSamples
@@ -527,6 +556,15 @@ internal class DecoderEngine(
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 =
@@ -18,7 +18,6 @@
package com.rtbishop.look4sat.core.domain.sstv
import kotlin.math.PI
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.pow
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 abs(): Float = sqrt(real * real + imag * imag)
fun arg(): Float = atan2(imag, real)
fun mul(other: Complex): Complex {
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) {
private val tree = FloatArray(2 * length)
private var leaf = length
private val buf = FloatArray(length)
private var pos = 0
private var runningSum = 0f
fun add(input: Float) {
tree[leaf] = input
var child = leaf
var parent = leaf / 2
while (parent > 0) {
tree[parent] = tree[child] + tree[child xor 1]; child = parent; parent /= 2
}
if (++leaf >= tree.size) leaf = length
runningSum += input - buf[pos]
buf[pos] = input
if (++pos >= length) pos = 0
}
fun sum(): Float = tree[1]
fun sum(): Float = runningSum
fun sum(input: Float): Float {
add(input); return sum()
}
@@ -134,8 +132,15 @@ internal class Phasor(freq: Double, rate: Double) {
val omega = 2 * PI * freq / rate
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) {
@@ -145,11 +150,30 @@ internal class FmDemodulator(bandwidth: Double, sampleRate: Double) {
private var prev = 0f
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
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) {
@@ -257,9 +257,16 @@ internal class Robot36Mode(sampleRate: Int) : SstvMode {
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] =
@@ -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 {
override val visCode = -1
override val width = -1
@@ -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
@@ -49,12 +50,21 @@ 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
@@ -387,3 +397,76 @@ fun elevationColor(elevation: Double): Color {
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
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>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* Merged RadarScreen and RadioControlScreen functionality
\n\n* Added SSTV image decoding functionality to RadarScreen
\n\n* Added colored elevation and decay check to satellite passes
* Fixed transceivers retention on a failed fetch from server
\n\n* Fixed SSTV frequency display, no transceiver selected hint
\n\n* Fixed All satellite category retention on Satellites screen
</string>
<!-- 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
* Added SSTV image decoding functionality to RadarScreen
* Added colored elevation and decay check to satellite passes
* Fixed transceivers retention on a failed fetch from server
* Fixed SSTV frequency display, no transceiver selected hint
* 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.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.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding
@@ -170,13 +171,14 @@ private fun PagerCard(
) { pageIndex ->
when (pages[pageIndex]) {
RadarPage.Transceivers -> TransceiversPage(
transceivers = uiState.transmitters,
selectedUuid = uiState.selectedTransmitterUuid,
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
)
@@ -189,25 +191,26 @@ private fun PagerCard(
@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
@@ -294,4 +297,3 @@ private fun RadarLabel(
}
}
}
@@ -34,7 +34,6 @@ data class RadioPanelState(
data class RadioControlSubState(
val txPanel: RadioPanelState = RadioPanelState("TX (Uplink)"),
val rxPanel: RadioPanelState = RadioPanelState("RX (Downlink)"),
val transponders: List<SatRadio> = emptyList(),
val selectedTransponderUuid: String? = null,
val txBaseFrequencyHz: Long? = null,
val ctcssTone: Double? = null,
@@ -42,11 +41,16 @@ data class RadioControlSubState(
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,
@@ -55,17 +59,16 @@ 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, Saving }
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(),
@@ -77,7 +80,6 @@ sealed interface RadarAction {
data class SelectTransmitter(val uuid: String) : RadarAction
// Radio control actions
data class SelectTransponder(val uuid: String) : RadarAction
data class SetTxFrequency(val frequencyHz: Long) : RadarAction
data class AdjustTxFrequency(val deltaHz: Long) : RadarAction
data class SetCtcssTone(val toneHz: Double?) : 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
@@ -64,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(
@@ -88,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) {
@@ -127,7 +149,6 @@ fun RadarViewCompose(
}
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))
}
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) }
}
@@ -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.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
@@ -48,7 +49,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.Locale
import kotlin.time.Duration.Companion.milliseconds
class RadarViewModel(
private val bluetoothReporter: IReporter,
@@ -69,10 +70,15 @@ class RadarViewModel(
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,
orientationValues = sensorsRepo.sensorData.value,
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors,
sstv = SstvSubState(selectedMode = settingsRepo.otherSettings.value.sstvMode)
@@ -91,7 +97,7 @@ class RadarViewModel(
if (!settingsRepo.otherSettings.value.stateOfSensors) return
viewModelScope.launch {
sensorsRepo.enableSensor()
sensorsRepo.orientation.collect { data ->
sensorsRepo.sensorData.collect { data ->
val orientationValues = (data.first + magDeclination) to data.second
_uiState.update { it.copy(orientationValues = orientationValues) }
}
@@ -101,52 +107,75 @@ class RadarViewModel(
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
)
}
}
}
}
// --- Pass loading split into focused functions ---
private fun collectPassAndStartTickLoop() {
viewModelScope.launch {
val passes = satelliteRepo.passes.value
val (catNum, aosTime) = satelliteRepo.selectedPass.value
val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull() ?: return@launch
_uiState.update { it.copy(currentPass = pass) }
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
_uiState.update { it.copy(selectedTransmitterUuid = 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) }
}
val pass = findCurrentPass() ?: return@launch
val allRadios = loadPassData(pass)
while (isActive) {
tickPass(pass, transmittersList)
delay(1000)
tickPass(pass, allRadios)
delay(1000.milliseconds)
}
}
}
private suspend fun tickPass(pass: OrbitalPass, transmittersList: List<SatRadio>) {
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)
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 isLos = !pass.isDeepSpace && timeNow > pass.losTime
_uiState.update {
it.copy(
currentTime = time, isTimeAos = isAos, isLos = isLos,
orbitalPos = pos, sunPosition = sunPos, moonPosition = moonPos
currentTime = time, isTimeAos = isAos,
orbitalPos = pos, sunPosition = cachedSunPos, moonPosition = cachedMoonPos
)
}
processRadios(transmittersList, pass.orbitalObject, timeNow)
processRadios(allRadios, pass.orbitalObject, timeNow)
sendPassData(pos)
}
@@ -191,24 +220,15 @@ class RadarViewModel(
when (action) {
is RadarAction.AddToCalendar -> addToCalendar(action.name, action.aosTime, action.losTime)
is RadarAction.SelectTransmitter -> {
val previousUuid = _uiState.value.selectedTransmitterUuid
_uiState.update {
val newUuid = if (it.selectedTransmitterUuid == action.uuid) null else action.uuid
it.copy(selectedTransmitterUuid = newUuid)
// 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) }
}
// 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.AdjustTxFrequency -> trackingService.adjustTxBaseFrequency(action.deltaHz)
@@ -236,10 +256,10 @@ class RadarViewModel(
RadarAction.SstvSaveImage -> {
val frame = _uiState.value.sstv.currentFrame ?: 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 {
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")
}
}
@@ -249,7 +269,7 @@ class RadarViewModel(
settingsRepo.updateOtherSettings { it.copy(sstvMode = action.modeName) }
}
RadarAction.SstvReset -> {
sstvDecoder?.clearPixels() // Keep decoder alive, just reset pixels
sstvDecoder?.clearPixels()
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
}
}
@@ -285,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)
}
}
@@ -299,12 +320,9 @@ class RadarViewModel(
private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) {
val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time)
val isFreqEnabled =
settingsRepo.rcSettings.value.frequencyState || settingsRepo.rcSettings.value.bluetoothFrequencyState
_uiState.update { state ->
// Derive the frequency to report from the user's current selection (if any)
val freq = if (isFreqEnabled && state.selectedTransmitterUuid != null) {
val selectedRadio = transmitters.firstOrNull { it.uuid == state.selectedTransmitterUuid }
val freq = if (state.transceivers.selectedUuid != null) {
val selectedRadio = transmitters.firstOrNull { it.uuid == state.transceivers.selectedUuid }
selectedRadio?.let { radio ->
val low = radio.downlinkLow
val high = radio.downlinkHigh
@@ -316,10 +334,11 @@ class RadarViewModel(
}
} else null
if (state.transmitters == transmitters && state.selectedFrequency == freq) {
val current = state.transceivers
if (current.transmitters == transmitters && current.selectedFrequency == freq) {
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) {
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 ->
@@ -362,13 +382,6 @@ class RadarViewModel(
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 {
initializer {
@@ -55,12 +55,14 @@ 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
) {
@@ -180,59 +182,72 @@ internal fun SstvPage(
)
}
// Bottom control bar — dark, blends with the black canvas
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp)
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
// Mode button — opens dialog
ElevatedCard(
modifier = Modifier.weight(1f).height(48.dp),
onClick = { showModeDialog.value = true },
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surface
)
// 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()
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = "Mode: ${sstv.selectedMode}",
fontSize = 14.sp,
maxLines = 1,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.infiniteMarquee()
// 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
)
// Record / Stop button
val playAction = {
when (sstv.status) {
SstvStatus.Idle -> onAction(RadarAction.SstvStartRecording)
SstvStatus.Recording -> onAction(RadarAction.SstvStopRecording)
SstvStatus.Saving -> {}
// 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)
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)
}
}
}
}
@@ -62,8 +62,10 @@ 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(
@@ -82,7 +84,7 @@ fun TransceiversPage(
if (selectedUuid != null) {
val index = transceivers.indexOfFirst { it.uuid == selectedUuid }
if (index >= 0) {
kotlinx.coroutines.delay(300)
kotlinx.coroutines.delay(300.milliseconds)
listState.animateScrollToItem(index)
}
}
@@ -325,7 +327,7 @@ private fun ExpandedRadioControl(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "${RadarViewModel.formatFrequency(radioControl.txBaseFrequencyHz)} MHz",
text = "${formatFrequency(radioControl.txBaseFrequencyHz)} MHz",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
@@ -335,7 +337,7 @@ private fun ExpandedRadioControl(
val upHigh = radio.uplinkHigh
if (upLow != null && upHigh != null && upLow != upHigh) {
Text(
text = "(${RadarViewModel.formatFrequency(upLow)} – ${RadarViewModel.formatFrequency(upHigh)})",
text = "(${formatFrequency(upLow)} – ${formatFrequency(upHigh)})",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
+7 -7
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "440"
appVersionCode = "442"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.0"
appVersionName = "4.4.2"
#noinspection GradleDependency,UnusedVersionCatalogEntry
compileSdk = "36"
#noinspection UnusedVersionCatalogEntry
@@ -18,18 +18,18 @@ androidx-core-ktx = "1.18.0"
androidx-core-splashscreen = "1.2.0"
androidx-room = "2.8.4"
compose-bom = "2026.05.01"
compose-bom = "2026.06.00"
compose-activity = "1.13.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-serialization = "1.11.0"
other-okhttp = "5.3.2"
other-okhttp = "5.4.0"
other-osmdroid = "6.1.20"
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
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
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME