Compare commits

..
36 Commits
Author SHA1 Message Date
Arty Bishop 7cdc2952dc v4.4.6 - AMSAT status page, fully customizable data sources 2026-08-13 09:47:26 +02:00
Arty Bishop bd51044690 Added custom frequency offset setting to network reporting 2026-08-12 20:17:26 +02:00
PingouinFerreux 1982c2d3dc Fixed broken star history chart in README (#240) 2026-08-12 18:22:53 +02:00
Arty Bishop 2f4e3f5802 Added the ability to fully customize data sources via import 2026-08-12 13:01:45 +02:00
Arty Bishop 24eebdef74 Consolidated app dialogs and tweaked bottom sheets 2026-08-11 14:14:14 +02:00
Arty Bishop 3f5b48f270 Integrated the AMSAT status page created by MCKero6423 2026-08-11 14:05:55 +02:00
atsunatsuandatsunatsu 060fa2dfcd Added remembering per-satellite doppler offset (#237)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-11 14:02:15 +02:00
atsunatsuandatsunatsu 8b5960282f Broaden linear transponder detection, logic fixes (#236)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-08 21:31:44 +02:00
mckero 10eb84690e Added AMSAT satellite status tracking page (#234) 2026-08-08 14:26:36 +02:00
bf25292bf8 Fixed recalculating Radar track on station position change (#235)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
Co-authored-by: wty2019wty <74123961+wty2019wty@users.noreply.github.com>
2026-08-08 14:10:55 +02:00
Arty Bishop 8fbfcb3712 v4.4.5 - Pass progress hotfix, swapped modes/filter dialogs 2026-08-05 13:14:50 +02:00
Arty Bishop 602b1553a2 v4.4.4 - Icom CAT, components, filters and sources tweaks 2026-08-04 20:51:22 +02:00
atsunatsuandatsunatsu bd0881fb4b Added linear transponder Doppler calculator (#232)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-04 18:45:12 +02:00
atsunatsuandatsunatsu a9dd3e6e55 Localized pass date and time formats for Chinese (#231)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-04 18:12:10 +02:00
Lukas 905995ab44 Added configurable Radar offset to the sensors output (#230) 2026-08-04 18:08:44 +02:00
Arty Bishop f9806d7f7f Replaced the types selection dialog with modes selection 2026-07-31 16:04:39 +02:00
Arty Bishop 2d3adfc6a6 Added small tweaks to Sources, Components and strings 2026-07-31 12:27:25 +02:00
Arty Bishop f585372594 Added cleartext traffic support for custom TLE URLs #227 2026-07-30 17:15:02 +02:00
Arty Bishop 4585332b1a Fixed manual OMM (.csv) data import, tweaked messaging 2026-07-30 15:56:50 +02:00
Arty Bishop 6d2b0ced49 Implemented AOS window and elevation highlight filters 2026-07-30 13:28:37 +02:00
Arty Bishop e86bc2c700 Added a few tweaks to SSTV sensitivity and reception 2026-07-30 12:01:54 +02:00
Arty Bishop 9a52fdfde5 Added support for Icom IC-705 CAT (#229) 2026-07-30 11:43:03 +02:00
Rui Oliveira 07504a2a95 Add the option to override the frequency from the radio 2026-07-24 20:29:51 +01:00
Rui Oliveira 81525b6dd3 Map AFSK (e.g. ISS APRS) to FM 2026-07-24 20:10:37 +01:00
Rui Oliveira c7089ab314 Fix setting the mode (e.g. FM, USB, LSB) 2026-07-24 20:10:10 +01:00
Rui Oliveira 77dfb7bb05 Remove PTT-aware logic, which is not necessary
I was setting the Tx frequency only when we were
in Tx mode, but the Icom 705 will accept the
command to set the Tx frequency even when in Rx mode, so
there is no need to check the PTT state before sending the command.
2026-07-24 20:09:43 +01:00
Rui Oliveira 58fb688593 Fix bugs in setting the Tx frequency
Uses 0x25 01 command to set the Tx frequency,
which is the correct command when in split mode.
2026-07-24 20:09:01 +01:00
Rui Oliveira dc596b4348 Fix bugs in setting Rx frequency 2026-07-24 20:08:46 +01:00
Rui Oliveira f9b1a97a37 Refactor "Disconnect" and "Track" logic 2026-07-24 20:08:23 +01:00
Rui Oliveira d700432829 Add initial support for Icom IC-705 radio
- Added support for Icom IC-705 radio model with split mode functionality.
- Updated RadioTrackingService to handle single-radio split mode and dual-radio configurations.
- Modified MainContainer to provide appropriate radio controllers based on selected model.
- Enhanced SettingsRepo to include split mode preference in radio control settings.
- Updated UI in SettingsDialog to allow toggling of split mode for IC-705 and adjust device selection accordingly.
- Improved handling of baud rates based on selected radio model.
- Added extended operations in IRadioController for IC-705 specific commands.
2026-07-24 20:07:11 +01:00
Arty Bishop b17ea2d918 v4.4.3 - Added required tweaks to support Android 17 (API 37) 2026-06-27 14:15:05 +01:00
Arty Bishop 976fdfd949 Added Star History graph to README, tweaked AGENTS 2026-06-27 12:31:24 +01:00
dependabot[bot] d8db258bd8 Bump actions/checkout from 6 to 7 (#221)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 16:38:32 +01:00
Arty Bishop 7b6fb5eb8f v4.4.2 - Fixed transceivers retention, various minor tweaks 2026-06-21 15:19:47 +01:00
Arty Bishop f4aef4f4f0 Fixed SSTV frequency display and "All" category retention 2026-06-21 15:10:28 +01:00
Arty Bishop 9147323db2 Cleaned up SensorsRepo class, removed deprecated calls 2026-06-21 15:03:45 +01:00
100 changed files with 6799 additions and 1486 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.
+4
View File
@@ -4,3 +4,7 @@ updates:
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "bundler"
directory: "/"
schedule:
interval: "never"
+3 -3
View File
@@ -16,13 +16,13 @@ jobs:
contents: write
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup Java
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'
java-version: '21'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
@@ -58,7 +58,7 @@ jobs:
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
ruby-version: '3.4'
- name: Deploy Bundle to Google Play
run: |
+1
View File
@@ -64,3 +64,4 @@ fastlane/readme.md
/app/release/output-metadata.json
/app/release/
/.kotlin/sessions/
.hermes/
+121
View File
@@ -0,0 +1,121 @@
# Look4Sat AI Agent Instructions
This is the canonical project guide for all AI assistants working on Look4Sat.
All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) point here.
---
## Project Overview
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
satellites using Celestrak/SatNOGS orbital data, calculates positions via SGP4/SDP4, and predicts passes relative to
the user's location. Features include polar radar visualization, SSTV image decoding, and ground track mapping. No ads,
no tracking, no network required after initial data download.
## Architecture & Design
**MVI (Model-View-Intent)** with unidirectional data flow:
- `State` data class (named `<Feature>State`) exposed via `StateFlow` from ViewModel
- `Action` sealed interface (named `<Feature>Action`) dispatched to ViewModel's `onAction()`
- Jetpack Compose UI observes state and recomposes reactively
**Clean Architecture layers:**
| Module | Responsibility |
|----------------------|---------------------------------------------------------------------|
| `app` | Entry point. Aggregates all modules |
| `core:data` | Android library. Room DB, OkHttp networking, repo implementations |
| `core:domain` | Pure Kotlin (JVM). Orbital math (SGP4/SDP4), models, repo contracts |
| `core:presentation` | Android library. Compose theme, shared UI components, NavKeys |
| `feature:map` | OSMDroid map with ground tracks |
| `feature:passes` | Pass predictions and upcoming events |
| `feature:radar` | Polar radar view of satellite positions, SSTV image decoding |
| `feature:satellites` | Satellite list, filtering, selection |
| `feature:settings` | User preferences |
**Feature isolation:**
- `feature:*` modules depend only on `core:domain` and `core:presentation`.
- No feature-to-feature dependencies; cross-feature communication goes through core layers.
## Build & Platform
```shell
# Debug build
./gradlew assembleDebug
# Release build (minified, shrunk resources)
./gradlew assembleRelease
# Run tests
./gradlew test
```
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
- **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/`
## Tech Stack
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
- **Navigation3**: Type-safe navigation with `@Serializable` nav keys
- **Room** (KSP code generation) for local satellite/orbital storage
- **OkHttp** 5.x for data downloads
- **OSMDroid** for map rendering
- **Kotlin Serialization** for navigation args and parsing
- **Coroutines** + `StateFlow` for async/reactive patterns
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh)
## Data Formats & Migration
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
- **TLE format**: Legacy 3-line element format limited by 5-digit NORAD IDs
- **OMM/CSV format**: Successor format with ISO 8601 timestamps and larger NORAD ID support
- New 5-digit NORAD IDs are exhausted; TLE is officially deprecated and OMM/CSV is the clear default
- `DataParser.kt` supports both via `parseTLEStream()` and `parseCSVStream()`
- Downloads auto-detect format; both produce identical `OrbitalData` objects
- Existing code already supports transparent source transition without feature changes
- Refresh orbital data weekly for accurate pass prediction (orbital decay)
## Engineering Heuristics (Lazy = Efficient)
- Treat "lazy" as efficient, not careless: the best code is the code never written.
- First understand the task and trace the real flow end-to-end, then climb this ladder:
1. Does this need to be built now? (YAGNI)
2. Does it already exist in this codebase? Reuse helpers/patterns before rewriting.
3. Does Kotlin/Java stdlib already solve it?
4. Does the Android/platform API already solve it?
5. Does an already-installed dependency solve it?
6. Can this be simpler (including one-liner simple)?
7. Only then: write the minimum code that works.
- Prefer deletion to addition, boring over clever, and the fewest touched files.
- Avoid new abstractions, dependencies, and boilerplate unless explicitly requested.
- Manual DI only: ViewModels use companion `factory()` methods with `IMainContainer`.
- Release builds use ProGuard: avoid reflection-heavy libraries unless explicitly approved.
- When two options are similar in size, choose the edge-case-correct one.
- If you keep a deliberate simplification (for example O(n^2) scan or global lock), leave a short comment with the ceiling and upgrade path.
- For complex asks, challenge scope when appropriate: "Do you need X, or does Y already cover it?"
## Bug-Fix Policy
- Fix root cause, not just the reported symptom.
- If touching a shared function, inspect callers and prefer one shared fix over per-caller patches.
- The smallest correct diff wins only after behavior is understood.
## Roadmap
- **KMP migration**: `core:domain` is to become a fully shareable KMM module. Keep it pure Kotlin/JVM.
## Gotchas
- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully.
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules.
## Copilot Working Mode: Code-Only
- Default to code changes only. Provide explanations in chat only.
- If documentation seems useful, ask first before creating files.
- Do NOT create any `.md` documentation files unless explicitly requested.
- Do NOT add README, guides, summaries, migration notes, or how-to files unless asked.
- Prefer minimal diffs focused on requested implementation.
- Default validation is static checks (`get_errors`). Do NOT run Gradle compile/test tasks unless explicitly requested.
+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.
+10
View File
@@ -33,3 +33,13 @@ It is now and always will be completely ad-free and open-source.
* Showing the satellite positional data, footprint and ground track on the map
* Custom TLE satellite data import is available via Three Line Element .txt files
* Offline first: calculations are made offline. Weekly TLE data update is recommended.
## Star History
<a href="https://star-history.dera.page/#rt-bishop/Look4Sat&type=timeline&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
</picture>
</a>
+3 -2
View File
@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission
@@ -18,8 +19,8 @@
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:usesCleartextTraffic="true">
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round">
<activity
android:name=".MainActivity"
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
observeNightFilterState()
setContent {
MainTheme(isDarkTheme = true) { MainScreen() }
MainTheme(isDarkTheme = true) { NavRoot() }
}
}
@@ -25,8 +25,6 @@ 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
@@ -44,12 +42,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.Surface
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.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
@@ -69,7 +68,10 @@ 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.domain.repository.RadioTrackingState
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
import com.rtbishop.look4sat.core.presentation.RadarDestination
import com.rtbishop.look4sat.core.presentation.Screen
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
@@ -79,37 +81,42 @@ import com.rtbishop.look4sat.feature.passes.PassesDestination
import com.rtbishop.look4sat.feature.radar.RadarDestination
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
import com.rtbishop.look4sat.feature.settings.SettingsDestination
import com.rtbishop.look4sat.feature.status.SatStatusDestination
@Composable
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)
}
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
}
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 })
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
slideOutHorizontally(tween(300)) { -it / 3 }
// Reverse: outgoing slides out to the right, incoming drifts in from the left
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
slideOutHorizontally(tween(300)) { it }
NavDisplay(
modifier = Modifier.fillMaxSize(),
backStack = rootBackStack,
onBack = navigateBack,
transitionSpec = { slideInTransition },
popTransitionSpec = { slideOutTransition },
predictivePopTransitionSpec = { slideOutTransition },
transitionSpec = { pushTransition },
popTransitionSpec = { popTransition },
predictivePopTransitionSpec = { popTransition },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) }
entry<RadarDestination> {
Scaffold { innerPadding ->
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
RadarDestination(navigateUp = navigateBack)
innerPadding.calculateTopPadding()
}
}
}
@@ -117,132 +124,147 @@ fun NavRoot(deeplink: String? = null) {
}
@Composable
fun MainScreen(navigateToRadar: () -> Unit = {}) {
val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
private fun MainScreen(navigateToRadar: () -> Unit = {}) {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
NavigationSuiteScaffold(
navigationSuiteItems = {
navItems.forEach { screen ->
val isSelected = when (currentKey) {
is Screen.Satellites -> screen is Screen.Satellites
is Screen.Passes -> screen is Screen.Passes
is Screen.Radar -> screen is Screen.Radar
is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings
else -> false
}
item(
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
label = { Text(stringResource(screen.titleResId)) },
selected = isSelected,
onClick = {
if (isSelected) return@item
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
if (screen !is Screen.Passes) backStack.add(screen)
}
)
}
},
navigationSuiteColors = NavigationSuiteDefaults.colors(
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
),
layoutType = when {
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
else -> NavigationSuiteType.ShortNavigationBarMedium
}
val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
val fadeTransition = fadeIn(animationSpec = tween(300)) togetherWith
fadeOut(animationSpec = tween(300))
val navItems =
listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings)
CompositionLocalProvider(
LocalElevationThresholds provides ElevationThresholds(
low = otherSettings.lowElevation,
high = otherSettings.highElevation
)
) {
Column {
NavDisplay(
backStack = backStack,
modifier = Modifier.weight(1f),
onBack = navigateBack,
transitionSpec = { fadeTransition },
popTransitionSpec = { fadeTransition },
predictivePopTransitionSpec = { fadeTransition },
entryDecorators = listOf(
// Required for saving Compose state per entry
rememberSaveableStateHolderNavEntryDecorator(),
// Required for ViewModel scoping per entry
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Screen.Satellites> {
SatellitesDestination(navigateUp = navigateBack)
NavigationSuiteScaffold(
navigationSuiteItems = {
navItems.forEach { screen ->
val isSelected = when (currentKey) {
is Screen.Satellites -> screen is Screen.Satellites
is Screen.Passes -> screen is Screen.Passes
is Screen.Status -> screen is Screen.Status
is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings
else -> false
}
entry<Screen.Passes> {
PassesDestination { catNum, aosTime ->
container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar)
// navigateToRadar()
item(
icon = {
Icon(
painter = painterResource(screen.iconResId),
contentDescription = stringResource(screen.titleResId)
)
},
label = { Text(stringResource(screen.titleResId)) },
selected = isSelected,
onClick = {
if (isSelected) return@item
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
if (screen !is Screen.Passes) backStack.add(screen)
}
}
entry<Screen.Radar> {
RadarDestination(navigateUp = navigateBack)
}
entry<Screen.Map> {
MapDestination()
}
entry<Screen.Settings> {
SettingsDestination()
}
)
}
)
// Radio tracking status banner
if (trackingState.isActive) {
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
val alpha by infiniteTransition.animateFloat(
initialValue = 1f, targetValue = 0.4f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
), label = "pulseAlpha"
},
navigationSuiteColors = NavigationSuiteDefaults.colors(
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
),
layoutType = when {
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
else -> NavigationSuiteType.ShortNavigationBarMedium
}
) {
Column {
NavDisplay(
backStack = backStack,
modifier = Modifier.weight(1f),
onBack = navigateBack,
transitionSpec = { fadeTransition },
popTransitionSpec = { fadeTransition },
predictivePopTransitionSpec = { fadeTransition },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Screen.Satellites> {
SatellitesDestination(navigateUp = navigateBack)
}
entry<Screen.Passes> {
PassesDestination { catNum, aosTime ->
container.satelliteRepo.selectPass(catNum, aosTime)
navigateToRadar()
}
}
entry<Screen.Status> { SatStatusDestination() }
entry<Screen.Map> { MapDestination() }
entry<Screen.Settings> { SettingsDestination() }
}
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer)
.clickable {
if (trackingState.isActive) {
TrackingBanner(
state = trackingState,
onClick = {
val pass = trackingState.currentPass
if (pass != null) {
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
backStack.add(Screen.Radar)
navigateToRadar()
}
}
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(Color(0xFF4CAF50).copy(alpha = alpha))
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.weight(1f)
)
val txOk = if (trackingState.txConnected) "TX" else ""
val rxOk = if (trackingState.rxConnected) "RX" else ""
Text(
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
}
}
@Composable
private fun TrackingBanner(state: RadioTrackingState, onClick: () -> Unit) {
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
val alpha by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 0.4f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing), repeatMode = RepeatMode.Reverse
),
label = "pulseAlpha"
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer)
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(Color(0xFF4CAF50).copy(alpha = alpha))
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Tracking: ${state.currentPass?.name ?: ""}",
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.weight(1f)
)
val connections = listOfNotNull(
"TX".takeIf { state.txConnected }, "RX".takeIf { state.rxConnected }
)
Text(
text = connections.joinToString("/"),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
@@ -36,6 +36,7 @@ internal class ApplicationPlugin : Plugin<Project> {
implementation(project(":feature:radar"))
implementation(project(":feature:satellites"))
implementation(project(":feature:settings"))
implementation(project(":feature:status"))
implementation(libs.androidx.core.splashscreen)
implementation(libs.compose.material3.adaptive)
implementation(libs.compose.navigation3)
+1
View File
@@ -8,5 +8,6 @@ plugins {
}
tasks.register("clean", Delete::class.java) {
description = "Cleans the build directory"
delete(rootProject.layout.buildDirectory)
}
+1
View File
@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission
@@ -45,7 +45,12 @@ interface Look4SatDao {
@Query("DELETE FROM entries")
suspend fun deleteEntries()
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
@Query(
"""
SELECT DISTINCT catnum FROM radios WHERE isAlive = 1
AND (downlinkMode IN (:modes) OR uplinkMode IN (:modes))
"""
)
suspend fun getIdsWithModes(modes: List<String>): List<Int>
@Query("SELECT COUNT(*) FROM radios")
@@ -17,6 +17,9 @@
*/
package com.rtbishop.look4sat.core.data.framework
import java.util.Locale
import kotlin.math.roundToLong
object Ft817CatProtocol {
const val CMD_SET_FREQ: Byte = 0x01
@@ -50,7 +53,7 @@ object Ft817CatProtocol {
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
val freq10Hz = frequencyHz / 10
val bcd = ByteArray(4)
val digits = String.format("%08d", freq10Hz)
val digits = String.format(Locale.US, "%08d", freq10Hz)
for (i in 0 until 4) {
val high = digits[i * 2] - '0'
val low = digits[i * 2 + 1] - '0'
@@ -78,8 +81,8 @@ object Ft817CatProtocol {
* 67.0 Hz → 670 (in 0.1 Hz) → BCD [0x06, 0x70]
*/
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
val tone01Hz = (toneHz * 10).toLong()
val digits = String.format("%04d", tone01Hz)
val tone01Hz = (toneHz * 10).roundToLong()
val digits = String.format(Locale.US, "%04d", tone01Hz)
val bcd = ByteArray(2)
for (i in 0 until 2) {
val high = digits[i * 2] - '0'
@@ -95,7 +98,7 @@ object Ft817CatProtocol {
}
fun buildSetModeCommand(mode: String): ByteArray? {
val modeByte = MODE_TO_BYTE[mode.uppercase()] ?: return null
val modeByte = MODE_TO_BYTE[mode.uppercase(Locale.US)] ?: return null
return byteArrayOf(modeByte, 0x00, 0x00, 0x00, CMD_SET_MODE)
}
@@ -29,6 +29,7 @@ import kotlinx.coroutines.withContext
import java.io.InputStream
import java.io.OutputStream
import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds
class Ft817Controller(
private val bluetoothManager: BluetoothManager,
@@ -39,10 +40,12 @@ class Ft817Controller(
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
private val ioMutex = Mutex()
private val commandDelayMs = 200L
private val maxAckReadFailures = 3
private var socket: BluetoothSocket? = null
private var outputStream: OutputStream? = null
private var inputStream: InputStream? = null
private var ackReadFailureCount = 0
override var isConnected: Boolean = false
private set
@@ -57,6 +60,7 @@ class Ft817Controller(
socket = btSocket
outputStream = btSocket.outputStream
inputStream = btSocket.inputStream
ackReadFailureCount = 0
isConnected = true
Log.i(tag, "Connected to $deviceAddress")
true
@@ -79,6 +83,7 @@ class Ft817Controller(
inputStream = null
outputStream = null
socket = null
ackReadFailureCount = 0
isConnected = false
Log.i(tag, "Disconnected from $deviceAddress")
}
@@ -112,8 +117,8 @@ class Ft817Controller(
ioMutex.withLock {
val sent = sendCommand(Ft817CatProtocol.buildReadFreqModeCommand())
if (!sent) return@withContext null
delay(commandDelayMs)
val response = readResponse(5) ?: return@withContext null
delay(commandDelayMs.milliseconds)
val response = readResponse() ?: return@withContext null
Ft817CatProtocol.parseReadResponse(response)
}
}
@@ -127,44 +132,76 @@ class Ft817Controller(
}
private suspend fun sendCommand(bytes: ByteArray): Boolean {
return try {
outputStream?.write(bytes) ?: return false
outputStream?.flush()
delay(commandDelayMs)
true
} catch (e: Exception) {
Log.e(tag, "Send error: ${e.message}")
isConnected = false
false
return withContext(Dispatchers.IO) {
try {
outputStream?.write(bytes) ?: return@withContext false
outputStream?.flush()
delay(commandDelayMs.milliseconds)
true
} catch (e: Exception) {
Log.e(tag, "Send error: ${e.message}")
isConnected = false
false
}
}
}
/** Send command and read the 1-byte ACK response (0x00 = OK). */
private suspend fun sendCommandWithAck(bytes: ByteArray): Boolean {
if (!sendCommand(bytes)) return false
return try {
val ack = inputStream?.read() ?: return false
ack == 0x00
} catch (e: Exception) {
Log.e(tag, "ACK read error: ${e.message}")
true // command was sent, ACK read failed - continue anyway
return withContext(Dispatchers.IO) {
try {
val ack = inputStream?.read() ?: run {
ackReadFailureCount = 0
isConnected = false
return@withContext false
}
if (ack < 0) {
ackReadFailureCount = 0
Log.i(tag, "ACK stream closed by remote device")
isConnected = false
return@withContext false
}
ackReadFailureCount = 0
ack == 0x00
} catch (e: Exception) {
ackReadFailureCount += 1
Log.w(tag, "ACK read error (${ackReadFailureCount}/$maxAckReadFailures): ${e.message}")
if (ackReadFailureCount >= maxAckReadFailures) {
Log.e(tag, "Too many ACK read errors, marking radio disconnected")
isConnected = false
false
} else {
true // Command was sent, treat transient ACK read failures as best-effort
}
}
}
}
private fun readResponse(length: Int): ByteArray? {
return try {
val buffer = ByteArray(length)
var read = 0
while (read < length) {
val count = inputStream?.read(buffer, read, length - read) ?: return null
if (count < 0) return null
read += count
private suspend fun readResponse(): ByteArray? {
return withContext(Dispatchers.IO) {
try {
val responseSize = 5
val buffer = ByteArray(responseSize)
var read = 0
while (read < responseSize) {
val count = inputStream?.read(buffer, read, responseSize - read) ?: run {
isConnected = false
return@withContext null
}
if (count < 0) {
Log.i(tag, "Response stream closed by remote device")
isConnected = false
return@withContext null
}
read += count
}
buffer
} catch (e: Exception) {
Log.e(tag, "Read error: ${e.message}")
isConnected = false
null
}
buffer
} catch (e: Exception) {
Log.e(tag, "Read error: ${e.message}")
isConnected = false
null
}
}
}
@@ -0,0 +1,364 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.framework
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothSocket
import android.util.Log
import com.rtbishop.look4sat.core.domain.repository.IRadioController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.io.OutputStream
import java.util.UUID
/**
* Icom IC-705 CI-V controller over Bluetooth SPP.
*
* The IC-705 emits broadcast frames continuously (band scope, UTC, signal
* level, …). A reply to any command we send may therefore be buried in
* that noise. All response reads drain up to [ACK_TIMEOUT_MS] and scan the
* entire accumulated buffer for the frame we expect rather than assuming
* the very next byte is the response.
*/
class Ic705Controller(
private val bluetoothManager: BluetoothManager,
private val deviceAddress: String
) : IRadioController {
private val tag = "IC705"
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
private val ioMutex = Mutex()
/** Time budget (ms) to wait for a response amid broadcast noise. */
private val ACK_TIMEOUT_MS = 500L
/** Polling interval while draining the input buffer. */
private val POLL_INTERVAL_MS = 20L
/** Small pause after writing a command before reading the response. */
private val WRITE_SETTLE_MS = 50L
private var socket: BluetoothSocket? = null
private var outputStream: OutputStream? = null
private var inputStream: InputStream? = null
override var isConnected: Boolean = false
private set
// ── Connection ──────────────────────────────────────────────────────────
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
if (isConnected) return@withContext true
if (deviceAddress.isBlank()) return@withContext false
try {
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
btSocket.connect()
socket = btSocket
outputStream = btSocket.outputStream
inputStream = btSocket.inputStream
isConnected = true
// Enter VFO mode — frequency/mode commands return FA if the radio
// is in memory-channel mode. Safe to send regardless of current state.
Log.i(tag, "Connected to $deviceAddress — entering VFO mode")
val vfoCmd = IcomCivProtocol.buildEnterVfoModeCommand()
Log.d(tag, "CMD enterVfoMode → ${IcomCivProtocol.toHex(vfoCmd)}")
ioMutex.withLock { sendAndWaitAck(vfoCmd) }
true
} catch (e: Exception) {
Log.e(tag, "Connect error: ${e.message}")
isConnected = false
false
}
}
override suspend fun disconnect() {
withContext(Dispatchers.IO) {
try {
inputStream?.close()
outputStream?.close()
socket?.close()
} catch (e: Exception) {
Log.e(tag, "Disconnect error: ${e.message}")
} finally {
inputStream = null
outputStream = null
socket = null
isConnected = false
Log.i(tag, "Disconnected from $deviceAddress")
}
}
}
// ── IRadioController – standard operations ──────────────────────────────
override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
Log.d(tag, "setFrequency: ${frequencyHz}Hz")
ioMutex.withLock {
val cmd = IcomCivProtocol.buildSetFreqCommand(frequencyHz)
Log.d(tag, "CMD setFreq → ${IcomCivProtocol.toHex(cmd)}")
sendAndWaitAck(cmd)
}
}
override suspend fun setMode(mode: String): Boolean = withContext(Dispatchers.IO) {
val cmd = IcomCivProtocol.buildSetModeCommand(mode) ?: run {
Log.w(tag, "setMode: unknown mode '$mode'")
return@withContext false
}
Log.d(tag, "setMode: $mode")
Log.d(tag, "CMD setMode → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
override suspend fun setCtcssMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
Log.d(tag, "setCtcssMode: $enabled")
val cmd = IcomCivProtocol.buildCtcssModeCommand(enabled)
Log.d(tag, "CMD ctcssMode → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
override suspend fun setCtcssTone(toneHz: Double): Boolean = withContext(Dispatchers.IO) {
Log.d(tag, "setCtcssTone: ${toneHz}Hz")
val cmd = IcomCivProtocol.buildSetCtcssToneCommand(toneHz)
Log.d(tag, "CMD ctcssTone → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
override suspend fun readFrequencyAndMode(): Pair<Long, String>? = withContext(Dispatchers.IO) {
ioMutex.withLock {
val cmd = IcomCivProtocol.buildReadFreqCommand()
Log.d(tag, "CMD readFreq → ${IcomCivProtocol.toHex(cmd)}")
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_READ_FREQ) ?: return@withContext null
// Read-freq reply payload: [cmd byte already stripped by parseResponse] [5 freq bytes] [mode] [filter]
IcomCivProtocol.parseFreqModePayload(payload).also {
if (it != null) Log.d(tag, "readFreqMode: ${it.first}Hz, ${it.second}")
else Log.w(tag, "readFreqMode: parse failed, payload=${IcomCivProtocol.toHex(payload)}")
}
}
}
override suspend fun pttOn(): Boolean = withContext(Dispatchers.IO) {
Log.w(tag, "pttOn: not used for IC-705")
true
}
override suspend fun pttOff(): Boolean = withContext(Dispatchers.IO) {
Log.w(tag, "pttOff: not used for IC-705")
true
}
// ── IRadioController – IC-705 extended operations ───────────────────────
/** Select the band for [frequencyHz] via CMD 0x1A sub 0x00 (band stacking register). */
override suspend fun setBand(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
val cmd = IcomCivProtocol.buildBandSelectCommand(frequencyHz) ?: run {
Log.w(tag, "setBand: no band code for ${frequencyHz}Hz — skipping")
return@withContext false
}
Log.d(tag, "CMD setBand (${frequencyHz}Hz) → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
/** Select VFO-A (main/RX) or VFO-B (sub/TX). */
override suspend fun setVfo(vfoA: Boolean): Boolean = withContext(Dispatchers.IO) {
val cmd = if (vfoA) IcomCivProtocol.buildSelectVfoACommand()
else IcomCivProtocol.buildSelectVfoBCommand()
Log.d(tag, "CMD selectVFO${if (vfoA) "A" else "B"} → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
/**
* Enable or disable SPLIT mode (TX on sub-VFO while listening on main VFO).
*/
override suspend fun setSplitMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
val cmd = IcomCivProtocol.buildSplitModeCommand(enabled)
Log.d(tag, "CMD split ${if (enabled) "ON" else "OFF"} → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
/**
* Set the frequency of the **currently active** VFO (CMD 0x25 sub 0x00).
* In split mode the radio automatically switches active VFO on PTT, so
* always writing to the active VFO is the correct strategy.
*/
override suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
Log.d(tag, "setWorkingFrequency (0x25/00): ${frequencyHz}Hz")
val cmd = IcomCivProtocol.buildSetWorkingFreqCommand(frequencyHz)
Log.d(tag, "CMD setWorkingFreq → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
/**
* Set TX VFO frequency via CMD 0x25 sub 0x01 (unselected VFO).
* Sent every tracking cycle in split mode alongside [setWorkingFrequency].
*/
override suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
Log.d(tag, "setTxVfoFrequency (0x25/01): ${frequencyHz}Hz")
val cmd = IcomCivProtocol.buildSetUnselectedVfoFreqCommand(frequencyHz)
Log.d(tag, "CMD setTxVfoFreq → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
/**
* Read the frequency of the currently active VFO (CMD 0x25 sub 0x00).
* Used for tuning detection in split mode.
*/
override suspend fun readWorkingFrequency(): Long? = withContext(Dispatchers.IO) {
ioMutex.withLock {
val cmd = IcomCivProtocol.buildReadWorkingFreqCommand()
Log.d(tag, "CMD readWorkingFreq → ${IcomCivProtocol.toHex(cmd)}")
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_SELECTED_VFO_FREQ) ?: return@withContext null
// Response payload: [sub] [5 freq bytes] — CMD byte already stripped by parseResponse
Log.d(tag, "readWorkingFreq: got ${payload.size} bytes: ${IcomCivProtocol.toHex(payload)}")
if (payload.size < 6) {
Log.w(tag, "readWorkingFreq: payload too short (${payload.size} bytes)")
return@withContext null
}
val freqBcd = payload.sliceArray(1..5)
val freq = IcomCivProtocol.decodeFrequencyBcd(freqBcd)
Log.d(tag, "readWorkingFreq: ${freq}Hz")
freq
}
}
/**
* Read the frequency of the inactive/TX VFO (CMD 0x25 sub 0x01).
* Used for tuning detection in split mode.
*/
override suspend fun readTxVfoFrequency(): Long? = withContext(Dispatchers.IO) {
ioMutex.withLock {
val cmd = IcomCivProtocol.buildReadTxVfoFreqCommand()
Log.d(tag, "CMD readTxVfoFreq → ${IcomCivProtocol.toHex(cmd)}")
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_SELECTED_VFO_FREQ) ?: return@withContext null
// Response payload: [sub] [5 freq bytes] — CMD byte already stripped by parseResponse
Log.d(tag, "readTxVfoFreq: got ${payload.size} bytes: ${IcomCivProtocol.toHex(payload)}")
if (payload.size < 6) {
Log.w(tag, "readTxVfoFreq: payload too short (${payload.size} bytes)")
return@withContext null
}
val freqBcd = payload.sliceArray(1..5)
val freq = IcomCivProtocol.decodeFrequencyBcd(freqBcd)
Log.d(tag, "readTxVfoFreq: ${freq}Hz")
freq
}
}
// ── Internal I/O helpers ────────────────────────────────────────────────
/**
* Write [cmd] to the radio and drain the input stream for up to
* [ACK_TIMEOUT_MS], looking for an OK/NG acknowledgement frame.
*/
private suspend fun sendAndWaitAck(cmd: ByteArray): Boolean {
if (!write(cmd)) return false
delay(WRITE_SETTLE_MS)
val buf = drainWithTimeout(ACK_TIMEOUT_MS)
val ok = IcomCivProtocol.containsAck(buf)
if (!ok) Log.w(tag, "ACK not found in ${buf.size} bytes: ${IcomCivProtocol.toHex(buf)}")
return ok
}
/**
* Write [cmd] to the radio and drain the input stream for up to
* [ACK_TIMEOUT_MS], scanning for a response frame carrying [expectCmd].
* Returns the payload bytes of that frame, or null on timeout/error.
*/
private suspend fun sendAndReadResponse(cmd: ByteArray, expectCmd: Byte): ByteArray? {
if (!write(cmd)) return null
delay(WRITE_SETTLE_MS)
val buf = drainWithTimeout(ACK_TIMEOUT_MS)
val response = IcomCivProtocol.parseResponse(buf, expectCmd)
if (response == null) {
Log.w(tag, "No response for cmd 0x${String.format("%02X", expectCmd.toInt() and 0xFF)} " +
"in ${buf.size} bytes: ${IcomCivProtocol.toHex(buf)}")
}
return response?.payload
}
/**
* Drain whatever bytes the radio has buffered within a [timeoutMs] window.
* Exits early as soon as a complete CI-V frame addressed to us is present
* in the buffer (i.e., FE FE E0 A4 … FD), so we don't waste the remaining
* timeout on responses that already arrived.
*/
private suspend fun drainWithTimeout(timeoutMs: Long): ByteArray {
val result = mutableListOf<Byte>()
val deadline = System.currentTimeMillis() + timeoutMs
val stream = inputStream ?: return ByteArray(0)
while (System.currentTimeMillis() < deadline) {
try {
val available = stream.available()
if (available > 0) {
val chunk = ByteArray(available)
val read = stream.read(chunk)
if (read > 0) {
result.addAll(chunk.take(read))
// Exit early once we have a complete frame for us
if (hasCompleteFrameForUs(result)) break
}
} else {
delay(POLL_INTERVAL_MS)
}
} catch (e: Exception) {
Log.e(tag, "Drain error: ${e.message}")
isConnected = false
break
}
}
return result.toByteArray()
}
/**
* Returns true if [buf] contains a complete CI-V frame addressed to the
* controller (FE FE [ADDR_CTRL] [ADDR_IC705] … FD).
* CI-V data bytes cannot be 0xFD, so the first 0xFD after the header is
* always the frame terminator.
*/
private fun hasCompleteFrameForUs(buf: List<Byte>): Boolean {
var i = 0
while (i < buf.size - 4) {
if (buf[i] == IcomCivProtocol.PREAMBLE &&
buf[i + 1] == IcomCivProtocol.PREAMBLE &&
buf[i + 2] == IcomCivProtocol.ADDR_CTRL &&
buf[i + 3] == IcomCivProtocol.ADDR_IC705
) {
for (k in i + 4 until buf.size) {
if (buf[k] == IcomCivProtocol.END_OF_MSG) return true
}
return false // header found but no FD yet
}
i++
}
return false
}
private fun write(bytes: ByteArray): Boolean {
return try {
outputStream?.write(bytes)
outputStream?.flush()
true
} catch (e: Exception) {
Log.e(tag, "Write error: ${e.message}")
isConnected = false
false
}
}
}
@@ -0,0 +1,352 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.framework
import java.util.Locale
/**
* Icom CI-V protocol encoder/decoder for the IC-705.
*
* Frame structure:
* FE FE <DEST> <SRC> <CMD> [<SUB>] [<DATA...>] FD
*
* IC-705 default CI-V address : 0xA4
* Controller (us) address : 0xE0
*/
object IcomCivProtocol {
// ── Framing constants ──────────────────────────────────────────────────
const val PREAMBLE: Byte = 0xFE.toByte()
const val END_OF_MSG: Byte = 0xFD.toByte()
const val ACK_OK: Byte = 0xFB.toByte()
const val ACK_NG: Byte = 0xFA.toByte()
// ── Address constants ──────────────────────────────────────────────────
/** Default CI-V address of the IC-705. */
const val ADDR_IC705: Byte = 0xA4.toByte()
/** Default CI-V address of the controller (us). */
const val ADDR_CTRL: Byte = 0xE0.toByte()
// ── Command bytes ──────────────────────────────────────────────────────
/** Read operating frequency (main VFO). */
const val CMD_READ_FREQ: Byte = 0x03
/** Set operating frequency (main VFO). */
const val CMD_SET_FREQ: Byte = 0x05
/** Set operating mode. */
const val CMD_SET_MODE: Byte = 0x06
/** Select VFO / memory. */
const val CMD_SELECT_VFO: Byte = 0x07
/**
* Select operating mode (VFO vs memory-channel).
* Sub 0x00 = VFO mode. Must be sent after connect if the radio is in
* memory-channel mode — frequency/mode commands return FA until it is.
*/
const val CMD_SELECT_OP_MODE: Byte = 0x08
/** Set repeater duplex / SPLIT. */
const val CMD_DUPLEX_SPLIT: Byte = 0x0F
/** Band stacking register / band select (sub 0x00 = select, data = BCD band number). */
const val CMD_BAND_SELECT: Byte = 0x1A
/** Read/write CTCSS tone frequency. */
const val CMD_CTCSS_TONE: Byte = 0x1B
/** Read/write misc settings (used for enabling CTCSS encode). */
const val CMD_MISC_SETTING: Byte = 0x16
/** Read/write selected-VFO frequency (cmd 0x25). */
const val CMD_SELECTED_VFO_FREQ: Byte = 0x25
// ── Sub-command bytes ──────────────────────────────────────────────────
/** Sub for CMD_SELECT_VFO: select VFO-A (main). */
const val SUB_VFO_A: Byte = 0x00
/** Sub for CMD_SELECT_VFO: select VFO-B (sub). */
const val SUB_VFO_B: Byte = 0x01
/** Sub for CMD_DUPLEX_SPLIT: simplex / split OFF. */
const val SUB_SPLIT_OFF: Byte = 0x00
/** Sub for CMD_DUPLEX_SPLIT: SPLIT ON. */
const val SUB_SPLIT_ON: Byte = 0x01
/** Sub for CMD_SELECTED_VFO_FREQ: selected (active) VFO frequency. */
const val SUB_SELECTED_VFO: Byte = 0x00
/** Sub for CMD_SELECTED_VFO_FREQ: unselected (inactive / TX in split) VFO frequency. */
const val SUB_UNSELECTED_VFO: Byte = 0x01
/** Sub for CMD_MISC_SETTING: CTCSS/DTCS tone squelch. */
const val SUB_CTCSS_SETTING: Byte = 0x42.toByte()
// ── Mode bytes ────────────────────────────────────────────────────────
/** Maps mode strings (upper-case) → IC-705 mode bytes. */
val MODE_TO_BYTE: Map<String, Byte> = mapOf(
"LSB" to 0x00,
"USB" to 0x01,
"AM" to 0x02,
"CW" to 0x03,
"RTTY" to 0x04,
"FM" to 0x05,
"WFM" to 0x06,
"CW-R" to 0x07,
"RTTY-R" to 0x08,
"DV" to 0x12,
"AFSK" to 0x05 // AFSK uses FM modulation
)
val BYTE_TO_MODE: Map<Byte, String> = MODE_TO_BYTE.entries.associate { it.value to it.key }
// ── Frequency BCD encoding ─────────────────────────────────────────────
/**
* Encode a frequency in Hz to the IC-705's 5-byte BCD format.
*
* The IC-705 uses 5 bytes, LSB pair first, with 1 Hz resolution.
* Example: 145,500,000 Hz → "0145500000" → pairs LSB→MSB:
* [00, 00, 50, 45, 01]
*/
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
val digits = String.format(Locale.US, "%010d", frequencyHz)
val bcd = ByteArray(5)
for (i in 0 until 5) {
// digits are MSB first; we want pair index 0 = LSB pair
val pairIndex = 4 - i
val high = digits[pairIndex * 2] - '0'
val low = digits[pairIndex * 2 + 1] - '0'
bcd[i] = ((high shl 4) or low).toByte()
}
return bcd
}
/**
* Decode 5-byte BCD frequency (LSB pair first) to Hz.
*/
fun decodeFrequencyBcd(bcd: ByteArray): Long {
// Build digit string MSB→LSB by reversing the byte order
var freqHz = 0L
for (i in 4 downTo 0) {
val b = bcd[i].toInt() and 0xFF
val high = b shr 4
val low = b and 0x0F
freqHz = freqHz * 100 + high * 10 + low
}
return freqHz
}
/**
* Encode a CTCSS tone (Hz, e.g. 67.0) to 2-byte BCD (0.1 Hz resolution).
* 67.0 → 670 (tenths of Hz) → BCD bytes [0x06, 0x70].
*/
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
val tone01 = (toneHz * 10).toLong()
val digits = String.format(Locale.US, "%04d", tone01)
return byteArrayOf(
((digits[0] - '0') shl 4 or (digits[1] - '0')).toByte(),
((digits[2] - '0') shl 4 or (digits[3] - '0')).toByte()
)
}
// ── Message builders ───────────────────────────────────────────────────
/** Wrap payload bytes in a CI-V frame: FE FE DEST SRC ... FD. */
private fun frame(vararg payload: Byte): ByteArray {
return byteArrayOf(PREAMBLE, PREAMBLE, ADDR_IC705, ADDR_CTRL) +
payload +
byteArrayOf(END_OF_MSG)
}
/** Set operating frequency via CMD 0x05 (main VFO). */
fun buildSetFreqCommand(frequencyHz: Long): ByteArray {
return frame(CMD_SET_FREQ, *encodeFrequencyBcd(frequencyHz))
}
/**
* Set selected-VFO frequency via CMD 0x25 sub 0x00.
* This updates whichever VFO is currently active (RX or TX after split).
*/
fun buildSetWorkingFreqCommand(frequencyHz: Long): ByteArray {
return frame(CMD_SELECTED_VFO_FREQ, SUB_SELECTED_VFO, *encodeFrequencyBcd(frequencyHz))
}
/**
* Set unselected-VFO frequency via CMD 0x25 sub 0x01.
* In split mode while PTT is pressed the IC-705 makes VFO-B active, so
* this command targets VFO-A (the RX VFO) — and vice-versa when in RX.
* Use this to update the TX VFO when PTT is on.
*/
fun buildSetUnselectedVfoFreqCommand(frequencyHz: Long): ByteArray {
return frame(CMD_SELECTED_VFO_FREQ, SUB_UNSELECTED_VFO, *encodeFrequencyBcd(frequencyHz))
}
/** Read operating frequency (CMD 0x03). */
fun buildReadFreqCommand(): ByteArray = frame(CMD_READ_FREQ)
/** Read selected (active) VFO frequency (CMD 0x25 sub 0x00). */
fun buildReadWorkingFreqCommand(): ByteArray = frame(CMD_SELECTED_VFO_FREQ, SUB_SELECTED_VFO)
/** Read unselected (inactive/TX in split) VFO frequency (CMD 0x25 sub 0x01). */
fun buildReadTxVfoFreqCommand(): ByteArray = frame(CMD_SELECTED_VFO_FREQ, SUB_UNSELECTED_VFO)
/**
* Select band via CMD 0x1A sub 0x00.
* Band codes are BCD-numbered: 1=160m, 2=80m, …, 9=10m, 0x10=6m, 0x11=2m, 0x12=70cm, 0x13=23cm.
* Returns null if [frequencyHz] doesn't fall in a known amateur band.
*/
fun buildBandSelectCommand(frequencyHz: Long): ByteArray? {
val code = bandCodeForFrequency(frequencyHz) ?: return null
return frame(CMD_BAND_SELECT, 0x00, code)
}
/**
* Map a frequency in Hz to the IC-705 band stacking register code.
* Codes are BCD (band number in decimal expressed as hex nibbles).
*/
fun bandCodeForFrequency(frequencyHz: Long): Byte? = when {
frequencyHz in 1_800_000L ..1_999_999L -> 0x01 // 160 m
frequencyHz in 3_500_000L ..3_999_999L -> 0x02 // 80 m
frequencyHz in 7_000_000L ..7_299_999L -> 0x03 // 40 m
frequencyHz in 10_100_000L ..10_149_999L -> 0x04 // 30 m
frequencyHz in 14_000_000L ..14_349_999L -> 0x05 // 20 m
frequencyHz in 18_068_000L ..18_167_999L -> 0x06 // 17 m
frequencyHz in 21_000_000L ..21_449_999L -> 0x07 // 15 m
frequencyHz in 24_890_000L ..24_989_999L -> 0x08 // 12 m
frequencyHz in 28_000_000L ..29_699_999L -> 0x09 // 10 m
frequencyHz in 50_000_000L ..53_999_999L -> 0x10 // 6 m (BCD 10)
frequencyHz in 144_000_000L ..147_999_999L -> 0x11 // 2 m (BCD 11)
frequencyHz in 420_000_000L ..449_999_999L -> 0x12 // 70 cm (BCD 12)
frequencyHz in 1_240_000_000L ..1_299_999_999L -> 0x13 // 23 cm (BCD 13)
else -> null
}
/** Set operating mode (CMD 0x06). Filter byte is omitted — radio uses its default filter for the mode. */
fun buildSetModeCommand(mode: String): ByteArray? {
val modeByte = MODE_TO_BYTE[mode.uppercase(Locale.US)] ?: return null
return frame(CMD_SET_MODE, modeByte)
}
/** Select VFO-A (CMD 0x07 sub 0x00). */
fun buildSelectVfoACommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_A)
/** Select VFO-B (CMD 0x07 sub 0x01). */
fun buildSelectVfoBCommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_B)
/**
* Enter VFO operating mode (CMD 0x08 sub 0x00).
* Sent after connect — if the radio is in memory-channel mode frequency
* and mode commands return FA until this is issued.
*/
fun buildEnterVfoModeCommand(): ByteArray = frame(CMD_SELECT_OP_MODE, 0x00)
/** Enable or disable SPLIT mode (CMD 0x0F). */
fun buildSplitModeCommand(enable: Boolean): ByteArray {
val sub = if (enable) SUB_SPLIT_ON else SUB_SPLIT_OFF
return frame(CMD_DUPLEX_SPLIT, sub)
}
/**
* Enable/disable CTCSS encode (CMD 0x16 sub 0x42).
* 0x01 = CTCSS encoder ON, 0x00 = OFF.
*/
fun buildCtcssModeCommand(enabled: Boolean): ByteArray {
val value: Byte = if (enabled) 0x01 else 0x00
return frame(CMD_MISC_SETTING, SUB_CTCSS_SETTING, value)
}
/**
* Set CTCSS tone frequency (CMD 0x1B sub 0x00).
*/
fun buildSetCtcssToneCommand(toneHz: Double): ByteArray {
val bcd = encodeCtcssToneBcd(toneHz)
return frame(CMD_CTCSS_TONE, 0x00, *bcd)
}
// ── Response parsing ───────────────────────────────────────────────────
/**
* Find and parse a complete CI-V response frame from a buffer.
*
* Returns the bytes between "FE FE E0 A4 <CMD>" and FD, or null if no
* complete frame was found. The search is tolerant of interleaved
* broadcast traffic.
*
* @param buf bytes accumulated from the radio
* @param expectCmd the command byte we are looking for in the reply, or
* null to accept any command response from the radio
*/
fun parseResponse(buf: ByteArray, expectCmd: Byte?): ParsedResponse? {
var i = 0
while (i < buf.size - 5) {
// Look for FE FE preamble
if (buf[i] != PREAMBLE || buf[i + 1] != PREAMBLE) { i++; continue }
val dest = buf[i + 2]
val src = buf[i + 3]
val cmd = buf[i + 4]
// We only care about frames addressed to us from the radio
if (dest != ADDR_CTRL || src != ADDR_IC705) { i++; continue }
// Find the terminating FD
val fdIdx = buf.indexOf(END_OF_MSG, startIndex = i + 5)
if (fdIdx < 0) break // incomplete frame, wait for more data
val payload = buf.copyOfRange(i + 5, fdIdx)
if (expectCmd == null || cmd == expectCmd) {
return ParsedResponse(cmd, payload, fdIdx + 1)
}
i = fdIdx + 1
}
return null
}
private fun ByteArray.indexOf(b: Byte, startIndex: Int): Int {
for (k in startIndex until size) if (this[k] == b) return k
return -1
}
/**
* Check whether a buffer contains an OK acknowledgement (FB FD) from
* the radio. Tolerates broadcast noise before the ACK.
*/
fun containsAck(buf: ByteArray): Boolean {
var i = 0
while (i < buf.size - 5) {
if (buf[i] != PREAMBLE || buf[i + 1] != PREAMBLE) { i++; continue }
val dest = buf[i + 2]
val src = buf[i + 3]
val cmd = buf[i + 4]
if (dest != ADDR_CTRL || src != ADDR_IC705) { i++; continue }
// Skip to FD
val fdIdx = buf.indexOf(END_OF_MSG, startIndex = i + 5)
if (fdIdx < 0) break
if (cmd == ACK_OK) return true
if (cmd == ACK_NG) return false
i = fdIdx + 1
}
return false
}
/**
* Parse frequency + mode from a CMD_READ_FREQ reply payload.
* Payload layout after stripping command byte: [5 freq bytes] [mode byte] [filter byte]
*/
fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? {
if (payload.size < 6) return null
val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5))
val mode = BYTE_TO_MODE[payload[5]] ?: return null
return freqHz to mode
}
/** Hex dump of bytes, useful for debug logging. */
fun toHex(bytes: ByteArray): String =
bytes.joinToString(" ") { String.format(Locale.US, "%02X", it.toInt() and 0xFF) }
data class ParsedResponse(
val cmd: Byte,
val payload: ByteArray,
/** Index in the source buffer immediately after the FD terminator. */
val nextOffset: Int
)
}
@@ -17,8 +17,10 @@
*/
package com.rtbishop.look4sat.core.data.framework
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.repository.IReporter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -31,18 +33,30 @@ class NetworkReporter(
private val rotatorServer: String,
private val rotatorPort: Int,
private val frequencyServer: String,
private val frequencyPort: Int
private val frequencyPort: Int,
private val frequencyOffsetHz: Long = 0L
) : IReporter {
private val writeMutex = Mutex()
private val connectionMutex = Mutex()
private val frequencyCommands = Channel<String>(Channel.CONFLATED)
private var rotatorSocket: SocketChannel? = null
private var rotatorConnected = false
private var rotatorConnecting = false
private var frequencySocket: SocketChannel? = null
private var frequencyConnected = false
private var frequencyConnecting = false
init {
// Keep only the latest frequency command to avoid stale backlog and effective lag.
reporterScope.launch {
for (command in frequencyCommands) {
ensureFrequencyConnected()
if (!frequencyConnected) continue
write(frequencySocket, command) { resetFrequencyConnection() }
}
}
}
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
reporterScope.launch {
@@ -58,46 +72,43 @@ class NetworkReporter(
}
override fun reportFrequency(format: String, frequency: Long) {
reporterScope.launch {
ensureFrequencyConnected()
if (!frequencyConnected) return@launch
val command = format
.replace($$"$FREQ", frequency.toString())
.unescapeControlChars()
write(frequencySocket, command) { frequencyConnected = false }
}
val clampedOffset = frequencyOffsetHz.coerceIn(
Constants.FREQ_OFFSET_MIN_HZ,
Constants.FREQ_OFFSET_MAX_HZ
)
val correctedFreq = frequency.coerceAtLeast(0L).safeAdd(clampedOffset).coerceAtLeast(0L)
val command = format
.replace($$"$FREQ", correctedFreq.toString())
.unescapeControlChars()
frequencyCommands.trySend(command)
}
private fun ensureRotatorConnected() {
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
reporterScope.launch {
private suspend fun ensureRotatorConnected() {
connectionMutex.withLock {
if (rotatorConnected || rotatorServer.isBlank()) return
try {
rotatorConnecting = true
resetRotatorConnection()
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
rotatorConnected = true
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
} catch (e: Exception) {
println("NetworkReporter rotator connect error: ${e.message}")
rotatorConnected = false
} finally {
rotatorConnecting = false
resetRotatorConnection()
}
}
}
private fun ensureFrequencyConnected() {
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
reporterScope.launch {
private suspend fun ensureFrequencyConnected() {
connectionMutex.withLock {
if (frequencyConnected || frequencyServer.isBlank()) return
try {
frequencyConnecting = true
resetFrequencyConnection()
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
frequencyConnected = true
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
} catch (e: Exception) {
println("NetworkReporter frequency connect error: ${e.message}")
frequencyConnected = false
} finally {
frequencyConnecting = false
resetFrequencyConnection()
}
}
}
@@ -106,7 +117,9 @@ class NetworkReporter(
try {
writeMutex.withLock {
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
socket?.write(buffer)
while (buffer.hasRemaining()) {
socket?.write(buffer)
}
}
} catch (e: Exception) {
println("NetworkReporter write error: ${e.message}")
@@ -114,6 +127,32 @@ class NetworkReporter(
}
}
private fun resetRotatorConnection() {
rotatorConnected = false
closeQuietly(rotatorSocket)
rotatorSocket = null
}
private fun resetFrequencyConnection() {
frequencyConnected = false
closeQuietly(frequencySocket)
frequencySocket = null
}
private fun closeQuietly(socket: SocketChannel?) {
try {
socket?.close()
} catch (_: Exception) {}
}
private fun Long.safeAdd(delta: Long): Long {
return when {
delta > 0 && this > Long.MAX_VALUE - delta -> Long.MAX_VALUE
delta < 0 && this < Long.MIN_VALUE - delta -> Long.MIN_VALUE
else -> this + delta
}
}
private fun String.unescapeControlChars(): String =
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
}
@@ -19,8 +19,10 @@ package com.rtbishop.look4sat.core.data.framework
import android.bluetooth.BluetoothManager
import android.util.Log
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT
import com.rtbishop.look4sat.core.domain.repository.IRadioController
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
@@ -29,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -44,6 +47,8 @@ class RadioTrackingService(
) : IRadioTrackingService {
private val tag = "RadioTracking"
/** Delay between each step of the split-mode init sequence (ms). */
private val INIT_STEP_DELAY_MS = 200L
private val _state = MutableStateFlow(RadioTrackingState())
override val state: StateFlow<RadioTrackingState> = _state
@@ -51,227 +56,427 @@ class RadioTrackingService(
private var rxController: IRadioController? = null
private var trackingJob: Job? = null
// ── Connection ──────────────────────────────────────────────────────────
override suspend fun connectRadios() {
// Disconnect old controllers if any
txController?.disconnect()
rxController?.disconnect()
// Read current addresses from settings
val rcSettings = settingsRepo.radioControlSettings.value
val txAddr = rcSettings.txRadioAddress
val rxAddr = rcSettings.rxRadioAddress
val txAddr = rcSettings.txRadioAddress
val rxAddr = rcSettings.rxRadioAddress
val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705
val isSplit = isIcom && rcSettings.splitMode
Log.i(tag, "Connecting TX=$txAddr RX=$rxAddr")
Log.i(tag, "connectRadios model=${rcSettings.radioModel} split=$isSplit TX=$txAddr RX=$rxAddr")
if (txAddr.isBlank() && rxAddr.isBlank()) {
_state.update { it.copy(errorMessage = "No radio addresses configured in Settings") }
return
}
val tx = Ft817Controller(bluetoothManager, txAddr)
val rx = Ft817Controller(bluetoothManager, rxAddr)
txController = tx
rxController = rx
_state.update { it.copy(errorMessage = null) }
val txOk = if (txAddr.isNotBlank()) tx.connect() else false
val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false
_state.update {
it.copy(
txConnected = txOk,
rxConnected = rxOk,
errorMessage = when {
!txOk && !rxOk -> "Could not connect to TX and RX radios"
!txOk -> "Could not connect to TX radio ($txAddr)"
!rxOk -> "Could not connect to RX radio ($rxAddr)"
else -> null
}
)
if (isSplit) {
// Single-radio split mode: only TX slot is used
if (txAddr.isBlank()) {
_state.update { it.copy(errorMessage = "No radio address configured in Settings") }
return
}
val tx = makeController(isIcom, txAddr)
txController = tx
rxController = null
_state.update { it.copy(errorMessage = null) }
val txOk = tx.connect()
_state.update {
it.copy(
txConnected = txOk,
rxConnected = false,
errorMessage = if (!txOk) "Could not connect to radio ($txAddr)" else null
)
}
Log.i(tag, "IC-705 split mode connected: txOk=$txOk")
} else {
if (txAddr.isBlank() && rxAddr.isBlank()) {
_state.update { it.copy(errorMessage = "No radio addresses configured in Settings") }
return
}
val tx = makeController(isIcom, txAddr)
val rx = makeController(isIcom, rxAddr)
txController = tx
rxController = rx
_state.update { it.copy(errorMessage = null) }
val txOk = if (txAddr.isNotBlank()) tx.connect() else false
val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false
_state.update {
it.copy(
txConnected = txOk,
rxConnected = rxOk,
errorMessage = when {
!txOk && !rxOk -> "Could not connect to TX and RX radios"
!txOk -> "Could not connect to TX radio ($txAddr)"
!rxOk -> "Could not connect to RX radio ($rxAddr)"
else -> null
}
)
}
Log.i(tag, "Dual-radio connected: txOk=$txOk rxOk=$rxOk")
}
}
private fun makeController(isIcom: Boolean, address: String): IRadioController =
if (isIcom) Ic705Controller(bluetoothManager, address)
else Ft817Controller(bluetoothManager, address)
override suspend fun disconnectRadios() {
stopTracking()
txController?.disconnect()
rxController?.disconnect()
txController = null
rxController = null
_state.update {
it.copy(
txConnected = false,
rxConnected = false,
isActive = false
)
}
_state.update { it.copy(txConnected = false, rxConnected = false, isActive = false) }
}
// ── Tracking ────────────────────────────────────────────────────────────
override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) {
_state.update {
it.copy(
isActive = true,
currentPass = pass,
selectedTransponder = transponder,
txBaseFrequencyHz = txBaseFreqHz
isActive = true,
currentPass = pass,
selectedTransponder = transponder,
txBaseFrequencyHz = txBaseFreqHz
)
}
trackingJob?.cancel()
trackingJob = appScope.launch {
// Set modes on both radios at tracking start
val tx = txController
val rx = rxController
val txMode = transponder.uplinkMode
val rxMode = transponder.downlinkMode
?: transponder.uplinkMode?.let {
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
}
if (tx != null && tx.isConnected && txMode != null) {
tx.setMode(txMode)
Log.i(tag, "TX mode set to $txMode")
}
if (rx != null && rx.isConnected && rxMode != null) {
rx.setMode(rxMode)
Log.i(tag, "RX mode set to $rxMode")
}
// Set CTCSS if FM
if (txMode?.uppercase() == "FM") {
_state.value.ctcssTone?.let { tone ->
tx?.setCtcssTone(tone)
tx?.setCtcssMode(true)
}
}
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
var lastSetTxFreq = 0.0
var lastSetRxFreq = 0.0
var tuningRadio = "" // "", "tx", or "rx" - which radio the user is tuning
var lastReadFreq = 0L
var stableCount = 0
val rcSettings = settingsRepo.radioControlSettings.value
val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705
val isSplit = isIcom && rcSettings.splitMode
while (isActive) {
val currentState = _state.value
if (!currentState.isActive) break
val satPass = currentState.currentPass ?: break
val xpdr = currentState.selectedTransponder ?: break
var txBaseFreq = currentState.txBaseFrequencyHz
val stationPos = settingsRepo.stationPosition.value
val timeNow = System.currentTimeMillis()
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
val tx = txController
val rx = rxController
val hasUplink = txBaseFreq != null
val c = com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT
val v = pos.distanceRate * 1000.0
if (tuningRadio.isNotEmpty()) {
// --- User is tuning: keep reading, wait for stabilization ---
val radio = if (tuningRadio == "tx") tx else rx
if (radio != null && radio.isConnected) {
val readResult = radio.readFrequencyAndMode()
if (readResult != null) {
val (freq, _) = readResult
if (kotlin.math.abs(freq - lastReadFreq) <= 20) {
stableCount++
} else {
stableCount = 0
lastReadFreq = freq
}
// Stable for 2 reads → user stopped turning
if (stableCount >= 2) {
if (tuningRadio == "tx" && txBaseFreq != null) {
val newBase = (freq.toDouble() * c / (c + v)).toLong()
if (newBase > 0) {
txBaseFreq = newBase
_state.update { it.copy(txBaseFrequencyHz = newBase) }
Log.i(tag, "TX tuning done → base=$newBase")
}
} else if (tuningRadio == "rx") {
val rxNominal = (freq.toDouble() * c / (c - v)).toLong()
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
if (newTxBase != null && newTxBase > 0) {
txBaseFreq = newTxBase
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
Log.i(tag, "RX tuning done → txBase=$newTxBase")
}
}
tuningRadio = ""
stableCount = 0
lastSetTxFreq = 0.0
lastSetRxFreq = 0.0
}
}
}
} else {
// --- Normal tracking: read, detect changes, command ---
// TX dial feedback
if (hasUplink && tx != null && tx.isConnected && lastSetTxFreq > 0.0) {
val readResult = tx.readFrequencyAndMode()
if (readResult != null) {
val (actualTxFreq, _) = readResult
if (kotlin.math.abs(actualTxFreq - lastSetTxFreq) >= 20.0) {
tuningRadio = "tx"
lastReadFreq = actualTxFreq
stableCount = 0
Log.i(tag, "TX tuning detected (read=$actualTxFreq, lastSet=$lastSetTxFreq)")
}
}
}
// RX dial feedback (only if TX not tuning)
if (tuningRadio.isEmpty() && rx != null && rx.isConnected && lastSetRxFreq > 0.0) {
val readResult = rx.readFrequencyAndMode()
if (readResult != null) {
val (actualRxFreq, _) = readResult
if (kotlin.math.abs(actualRxFreq - lastSetRxFreq) >= 20.0) {
tuningRadio = "rx"
lastReadFreq = actualRxFreq
stableCount = 0
Log.i(tag, "RX tuning detected (read=$actualRxFreq, lastSet=$lastSetRxFreq)")
}
}
}
}
// Compute Doppler-corrected frequencies
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
val rxBaseFreq = if (txBaseFreq != null) {
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
} else {
xpdr.downlinkLow
}
val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) }
// Command radios (only when not tuning)
if (tuningRadio.isEmpty()) {
if (tx != null && tx.isConnected && txRadioFreq != null) {
tx.setFrequency(txRadioFreq)
lastSetTxFreq = txRadioFreq.toDouble()
}
if (rx != null && rx.isConnected && rxRadioFreq != null) {
rx.setFrequency(rxRadioFreq)
lastSetRxFreq = rxRadioFreq.toDouble()
}
}
_state.update {
it.copy(
txConnected = tx?.isConnected ?: false,
rxConnected = rx?.isConnected ?: false,
txFrequencyHz = txRadioFreq,
rxFrequencyHz = rxRadioFreq,
azimuth = Math.toDegrees(pos.azimuth),
elevation = Math.toDegrees(pos.elevation),
distance = pos.distance
)
}
delay(1000)
}
if (isSplit) {
trackingJob = appScope.launch { runSplitTracking(transponder, txBaseFreqHz) }
} else {
trackingJob = appScope.launch { runDualRadioTracking(transponder, txBaseFreqHz) }
}
}
// ── Dual-radio tracking (Yaesu or two IC-705s) ──────────────────────────
private suspend fun runDualRadioTracking(transponder: SatRadio, initialTxBaseFreqHz: Long?) {
val tx = txController
val rx = rxController
// Initial setup: set band/mode/CTCSS on both radios
val txMode = transponder.uplinkMode
val rxMode = transponder.downlinkMode
?: transponder.uplinkMode?.let {
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
}
Log.i(tag, "DualRadio start: txMode=$txMode rxMode=$rxMode")
if (tx != null && tx.isConnected && txMode != null) {
Log.d(tag, "Setting TX mode: $txMode")
tx.setMode(txMode)
}
if (rx != null && rx.isConnected && rxMode != null) {
Log.d(tag, "Setting RX mode: $rxMode")
rx.setMode(rxMode)
}
if (txMode?.uppercase() == "FM") {
_state.value.ctcssTone?.let { tone ->
Log.d(tag, "Setting CTCSS: ${tone}Hz")
tx?.setCtcssTone(tone)
tx?.setCtcssMode(true)
}
}
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
var lastSetTxFreq = 0.0
var lastSetRxFreq = 0.0
var tuningRadio = ""
var lastReadFreq = 0L
var stableCount = 0
while (currentCoroutineContext().isActive) {
val currentState = _state.value
if (!currentState.isActive) break
val satPass = currentState.currentPass ?: break
val xpdr = currentState.selectedTransponder ?: break
var txBaseFreq = currentState.txBaseFrequencyHz
val stationPos = settingsRepo.stationPosition.value
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis())
val txNow = txController
val rxNow = rxController
val v = pos.distanceRate * 1000.0
if (tuningRadio.isNotEmpty()) {
val radio = if (tuningRadio == "tx") txNow else rxNow
if (radio != null && radio.isConnected) {
val read = radio.readFrequencyAndMode()
if (read != null) {
val (freq, _) = read
if (kotlin.math.abs(freq - lastReadFreq) <= 20) stableCount++
else { stableCount = 0; lastReadFreq = freq }
if (stableCount >= 2) {
if (tuningRadio == "tx" && txBaseFreq != null) {
val newBase = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong()
if (newBase > 0) {
txBaseFreq = newBase
_state.update { it.copy(txBaseFrequencyHz = newBase) }
Log.i(tag, "TX tuning done → base=$newBase")
}
} else if (tuningRadio == "rx") {
val rxNominal = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong()
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
if (newTxBase != null && newTxBase > 0) {
txBaseFreq = newTxBase
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
Log.i(tag, "RX tuning done → txBase=$newTxBase")
}
}
tuningRadio = ""
stableCount = 0
lastSetTxFreq = 0.0
lastSetRxFreq = 0.0
}
}
}
} else {
// Detect manual dial changes
if (txBaseFreq != null && txNow != null && txNow.isConnected && lastSetTxFreq > 0.0) {
val read = txNow.readFrequencyAndMode()
if (read != null && kotlin.math.abs(read.first - lastSetTxFreq) >= 20.0) {
tuningRadio = "tx"
lastReadFreq = read.first
stableCount = 0
Log.i(tag, "TX tuning detected (read=${read.first}, lastSet=$lastSetTxFreq)")
}
}
if (tuningRadio.isEmpty() && rxNow != null && rxNow.isConnected && lastSetRxFreq > 0.0) {
val read = rxNow.readFrequencyAndMode()
if (read != null && kotlin.math.abs(read.first - lastSetRxFreq) >= 20.0) {
tuningRadio = "rx"
lastReadFreq = read.first
stableCount = 0
Log.i(tag, "RX tuning detected (read=${read.first}, lastSet=$lastSetRxFreq)")
}
}
}
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
val rxBaseFreq = if (txBaseFreq != null) {
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
} else xpdr.downlinkLow
val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) }
if (tuningRadio.isEmpty()) {
if (txNow != null && txNow.isConnected && txRadioFreq != null) {
txNow.setFrequency(txRadioFreq)
lastSetTxFreq = txRadioFreq.toDouble()
}
if (rxNow != null && rxNow.isConnected && rxRadioFreq != null) {
rxNow.setFrequency(rxRadioFreq)
lastSetRxFreq = rxRadioFreq.toDouble()
}
}
_state.update {
it.copy(
txConnected = txNow?.isConnected ?: false,
rxConnected = rxNow?.isConnected ?: false,
txFrequencyHz = txRadioFreq,
rxFrequencyHz = rxRadioFreq,
azimuth = Math.toDegrees(pos.azimuth),
elevation = Math.toDegrees(pos.elevation),
distance = pos.distance
)
}
delay(1000)
}
}
// ── IC-705 split-radio tracking ─────────────────────────────────────────
private suspend fun runSplitTracking(transponder: SatRadio, initialTxBaseFreqHz: Long?) {
val radio = txController ?: return
if (!radio.isConnected) return
val txMode = transponder.uplinkMode
val rxMode = transponder.downlinkMode
?: transponder.uplinkMode?.let {
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
}
// Compute nominal base frequencies
val txCenter = when {
transponder.uplinkLow != null && transponder.uplinkHigh != null ->
(transponder.uplinkLow!! + transponder.uplinkHigh!!) / 2
transponder.uplinkLow != null -> transponder.uplinkLow!!
else -> null
}
val rxNominal = if (txCenter != null) {
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
} else transponder.downlinkLow
val txBase = initialTxBaseFreqHz ?: txCenter
Log.i(tag, "IC-705 split setup: txBase=${txBase}Hz rxNominal=${rxNominal}Hz txMode=$txMode rxMode=$rxMode")
// ── Initial setup sequence ──────────────────────────────────────────
// Sequence per IC-705: explicitly select VFO, then band → freq → mode.
// ACK from each command gates the next — no fixed delays needed.
// VFO-A = RX (downlink)
Log.d(tag, "Split init: selecting VFO-A for RX (downlink)")
radio.setVfo(vfoA = true)
if (rxNominal != null) {
Log.d(tag, "Split init: VFO-A band for ${rxNominal}Hz")
radio.setBand(rxNominal)
Log.d(tag, "Split init: VFO-A freq=${rxNominal}Hz")
radio.setFrequency(rxNominal)
}
if (rxMode != null) {
Log.d(tag, "Split init: VFO-A mode=$rxMode")
radio.setMode(rxMode)
}
// VFO-B = TX (uplink)
Log.d(tag, "Split init: selecting VFO-B for TX (uplink)")
radio.setVfo(vfoA = false)
if (txBase != null) {
Log.d(tag, "Split init: VFO-B band for ${txBase}Hz")
radio.setBand(txBase)
Log.d(tag, "Split init: VFO-B freq=${txBase}Hz")
radio.setFrequency(txBase)
}
if (txMode != null) {
Log.d(tag, "Split init: VFO-B mode=$txMode")
radio.setMode(txMode)
}
if (txMode?.uppercase() == "FM") {
val tone = _state.value.ctcssTone
if (tone != null) {
Log.d(tag, "Split init: CTCSS=${tone}Hz")
radio.setCtcssTone(tone)
radio.setCtcssMode(true)
} else {
radio.setCtcssMode(false)
}
}
// Enable SPLIT on VFO-A (return display to RX VFO first)
Log.d(tag, "Split init: returning to VFO-A, then enabling SPLIT mode")
radio.setVfo(vfoA = true)
radio.setSplitMode(enabled = true)
_state.update { it.copy(txMode = txMode, rxMode = rxMode, txBaseFrequencyHz = txBase) }
Log.i(tag, "IC-705 split init done — entering tracking loop")
// ── Tracking loop with tuning detection ─────────────────────────────
var lastSetTxFreq = 0.0
var lastSetRxFreq = 0.0
var tuningRadio = "" // "tx" or "rx" when manual tuning detected
var lastReadFreq = 0L
var stableCount = 0
while (currentCoroutineContext().isActive) {
val currentState = _state.value
if (!currentState.isActive) break
val satPass = currentState.currentPass ?: break
val xpdr = currentState.selectedTransponder ?: break
var txBaseFreq = currentState.txBaseFrequencyHz
val stationPos = settingsRepo.stationPosition.value
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis())
val v = pos.distanceRate * 1000.0
if (tuningRadio.isNotEmpty()) {
// User is tuning — wait for frequency to stabilize
val readFreq = if (tuningRadio == "tx") radio.readTxVfoFrequency() else radio.readWorkingFrequency()
if (readFreq != null) {
if (kotlin.math.abs(readFreq - lastReadFreq) <= 20) stableCount++
else { stableCount = 0; lastReadFreq = readFreq }
if (stableCount >= 2) {
// Frequency stable — reverse-calculate base frequency
if (tuningRadio == "tx" && txBaseFreq != null) {
val newBase = (readFreq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong()
if (newBase > 0) {
txBaseFreq = newBase
_state.update { it.copy(txBaseFrequencyHz = newBase) }
Log.i(tag, "Split TX tuning done → base=$newBase")
}
} else if (tuningRadio == "rx") {
val rxNominal = (readFreq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong()
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
if (newTxBase != null && newTxBase > 0) {
txBaseFreq = newTxBase
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
Log.i(tag, "Split RX tuning done → txBase=$newTxBase")
}
}
tuningRadio = ""
stableCount = 0
lastSetTxFreq = 0.0
lastSetRxFreq = 0.0
}
}
} else {
// Detect manual dial changes
if (txBaseFreq != null && lastSetTxFreq > 0.0) {
val readTx = radio.readTxVfoFrequency()
if (readTx != null && kotlin.math.abs(readTx - lastSetTxFreq) >= 20.0) {
tuningRadio = "tx"
lastReadFreq = readTx
stableCount = 0
Log.i(tag, "Split TX tuning detected (read=${readTx}, lastSet=$lastSetTxFreq)")
}
}
if (tuningRadio.isEmpty() && lastSetRxFreq > 0.0) {
val readRx = radio.readWorkingFrequency()
if (readRx != null && kotlin.math.abs(readRx - lastSetRxFreq) >= 20.0) {
tuningRadio = "rx"
lastReadFreq = readRx
stableCount = 0
Log.i(tag, "Split RX tuning detected (read=${readRx}, lastSet=$lastSetRxFreq)")
}
}
}
// Determine Doppler-corrected frequencies
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
val rxBaseCalc = if (txBaseFreq != null) {
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
} else xpdr.downlinkLow
val rxRadioFreq = rxBaseCalc?.let { pos.getDownlinkFreq(it) }
if (radio.isConnected && tuningRadio.isEmpty()) {
// Update both VFOs every cycle — no PTT polling needed.
// 0x25/00 = active (RX) VFO, 0x25/01 = inactive (TX) VFO.
if (rxRadioFreq != null) {
Log.d(tag, "Split loop RX (0x25/00): ${rxRadioFreq}Hz")
radio.setWorkingFrequency(rxRadioFreq)
lastSetRxFreq = rxRadioFreq.toDouble()
}
if (txRadioFreq != null) {
Log.d(tag, "Split loop TX (0x25/01): ${txRadioFreq}Hz")
radio.setTxVfoFrequency(txRadioFreq)
lastSetTxFreq = txRadioFreq.toDouble()
}
}
_state.update {
it.copy(
txConnected = radio.isConnected,
rxConnected = false, // single radio
txFrequencyHz = txRadioFreq,
rxFrequencyHz = rxRadioFreq,
azimuth = Math.toDegrees(pos.azimuth),
elevation = Math.toDegrees(pos.elevation),
distance = pos.distance
)
}
delay(1000)
}
}
// ── Other IRadioTrackingService methods ─────────────────────────────────
override fun stopTracking() {
trackingJob?.cancel()
trackingJob = null
@@ -288,7 +493,6 @@ class RadioTrackingService(
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
}
rxMode?.let { rx?.setMode(it) }
if (transponder.uplinkMode?.uppercase() == "FM") {
_state.value.ctcssTone?.let { tone ->
tx?.setCtcssTone(tone)
@@ -302,21 +506,17 @@ class RadioTrackingService(
transponder.uplinkLow != null -> transponder.uplinkLow!!
else -> null
}
// Show nominal frequencies immediately
val rxNominal = if (txCenter != null) {
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
} else {
// Downlink-only transponder (beacon etc.) - use downlink directly
transponder.downlinkLow
}
} else transponder.downlinkLow
_state.update {
it.copy(
selectedTransponder = transponder,
txBaseFrequencyHz = txCenter,
txFrequencyHz = txCenter,
rxFrequencyHz = rxNominal,
txMode = transponder.uplinkMode,
rxMode = transponder.downlinkMode
txBaseFrequencyHz = txCenter,
txFrequencyHz = txCenter,
rxFrequencyHz = rxNominal,
txMode = transponder.uplinkMode,
rxMode = transponder.downlinkMode
?: transponder.uplinkMode?.let { m ->
TransponderMapper.mapUplinkModeToDownlinkMode(m, transponder.isInverted)
}
@@ -353,5 +553,4 @@ class RadioTrackingService(
}
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
}
}
@@ -19,16 +19,17 @@ 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
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
@@ -40,6 +41,7 @@ import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
import com.rtbishop.look4sat.core.data.usecase.SaveImage
import com.rtbishop.look4sat.core.data.usecase.ShowToast
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.IRadioController
@@ -65,12 +67,14 @@ import okhttp3.OkHttpClient
class MainContainer(private val context: Context) : IMainContainer {
private val localSource = provideLocalSource()
private val remoteSource = provideRemoteSource()
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
override val settingsRepo = provideSettingsRepo()
override val selectionRepo = provideSelectionRepo()
override val satelliteRepo = provideSatelliteRepo()
override val databaseRepo = provideDatabaseRepo()
override val amSatRepo by lazy { AmSatRepository(remoteSource) }
override val radioTrackingService: IRadioTrackingService by lazy {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
@@ -102,27 +106,44 @@ class MainContainer(private val context: Context) : IMainContainer {
rc.rotatorAddress,
rc.rotatorPort.toIntOrNull() ?: 0,
rc.frequencyAddress,
rc.frequencyPort.toIntOrNull() ?: 0
rc.frequencyPort.toIntOrNull() ?: 0,
rc.frequencyOffsetHz
)
}
override fun provideTxRadioController(): IRadioController {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val address = settingsRepo.radioControlSettings.value.txRadioAddress
return Ft817Controller(manager, address)
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val settings = settingsRepo.radioControlSettings.value
val address = settings.txRadioAddress
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
Ic705Controller(manager, address)
} else {
Ft817Controller(manager, address)
}
}
override fun provideRxRadioController(): IRadioController {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val address = settingsRepo.radioControlSettings.value.rxRadioAddress
return Ft817Controller(manager, address)
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val settings = settingsRepo.radioControlSettings.value
val address = settings.rxRadioAddress
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
Ic705Controller(manager, address)
} else {
Ft817Controller(manager, address)
}
}
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)
}
override fun providePairedBluetoothDevices(): List<Pair<String, String>> = buildList {
try {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
manager.adapter?.bondedDevices?.forEach { add(Pair(it.name ?: "Unknown", it.address ?: "")) }
} catch (_: SecurityException) {}
}
private fun provideDatabaseRepo(): IDatabaseRepo {
@@ -0,0 +1,158 @@
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.SatDay
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.TimeZone
/** One report from the AMSAT API (data layer model). */
private data class ApiReport(
val id: String,
val name: String,
val callsign: String,
val report: String,
val gridSquare: String,
val reportedTimeUtcSec: Long
)
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
val nowSec = System.currentTimeMillis() / 1000
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return@withContext null
val names = parseCatalog(catalogJson)
val reports = parseReports(reportsJson)
if (names.isEmpty() && reports.isEmpty()) return@withContext null
val statuses = buildStatuses(names, reports, nowSec)
val reportMap = reports.associate { it.id to toSatReport(it) }
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
}
/** Parse catalog JSON to list of satellite names */
private fun parseCatalog(json: String): List<String> {
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
} catch (_: Exception) {
emptyList()
}
}
/** Parse reports JSON to list of ApiReport domain objects */
private fun parseReports(json: String): List<ApiReport> {
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).mapNotNull { i ->
val o = arr.getJSONObject(i)
val iso = o.optString("reported_time", "")
if (iso.isEmpty()) null else ApiReport(
id = o.optString("id", ""),
name = o.optString("name", ""),
callsign = o.optString("callsign", ""),
report = o.optString("report", ""),
gridSquare = o.optString("grid_square", ""),
reportedTimeUtcSec = parseIsoUtcSec(iso)
)
}
} catch (_: Exception) {
emptyList()
}
}
/** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */
private fun parseIsoUtcSec(iso: String): Long {
return try {
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
} catch (_: Exception) {
0L
}
}
/** Build one SatStatus (5 days x 12 slots) per catalog satellite, slotting reports by age. */
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
val byName = reports.groupBy { it.name }
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
val labels = (0 until 3).map { d ->
utc.timeInMillis = (nowSec - d * 86400L) * 1000
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
}
return names.map { name ->
val slots = (0 until 36).map { slotIdx ->
val slotStart = nowSec - (slotIdx + 1) * 7200L
val slotEnd = nowSec - slotIdx * 7200L
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
if (inSlot.isEmpty()) {
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
} else {
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
SatSlot(
statusColor = statusColorOf(newest.report),
count = inSlot.size,
reportIds = inSlot.map { it.id }
)
}
}
val days = (0 until 3).map { d ->
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
}
SatStatus(name = name, days = days)
}
}
private fun toSatReport(r: ApiReport): SatReport {
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.timeInMillis = r.reportedTimeUtcSec * 1000
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
val y = cal.get(Calendar.YEAR)
val mo = (cal.get(Calendar.MONTH) + 1).toString().padStart(2, '0')
val d = cal.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0')
return SatReport(
id = r.id,
statusText = r.report,
call = r.callsign,
grid = r.gridSquare,
dateUtc = "$y-$mo-$d",
timeUtc = "$hh:$mm UTC"
)
}
/** Map status text to color value (for UI rendering). */
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
"heard", "crew active" -> ACTIVE_BLUE
"telemetry only" -> TLM_ORANGE
"not heard" -> NOT_HEARD_PINK
else -> CONFLICT_DEEP_ORANGE
}
companion object {
// AMSAT official status colors (from amsat.org/status)
private const val ACTIVE_BLUE = 0xFF648FFF
private const val TLM_ORANGE = 0xFFFFB000
private const val NOT_HEARD_PINK = 0xFFDC267F
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
private const val NO_REPORT_GRAY = 0xFFC0C0C0
}
}
@@ -23,7 +23,6 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.utility.DataParser
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async
@@ -40,44 +39,40 @@ class DatabaseRepo(
private val settingsRepo: ISettingsRepo
) : IDatabaseRepo {
private val customSourceType = "Other"
override suspend fun updateTLEFromFile(uri: String) = withContext(dispatcher) {
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream ->
val entries = dataParser.parseTLEStream(unwrapIfZipped(uri, stream))
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
localSource.insertEntries(entries)
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
importedCount = entries.size
}
setUpdateSuccessful(System.currentTimeMillis())
importedCount
}
override suspend fun updateTransceiversFromFile(uri: String) = withContext(dispatcher) {
override suspend fun updateTransceiversFromFile(uri: String): Int = withContext(dispatcher) {
var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream ->
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
localSource.insertRadios(transceivers)
importedCount = transceivers.size
}
setUpdateSuccessful(System.currentTimeMillis())
importedCount
}
override suspend fun updateFromRemote() = withContext(dispatcher) {
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
val tleUrls = buildMap {
putAll(Sources.satelliteDataUrls)
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
}.filterValues { it.isNotBlank() }
val radioUrls = buildMap {
putAll(Sources.transceiversDataUrls)
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
}.filterValues { it.isNotBlank() }
val settings = settingsRepo.dataSourcesSettings.value
fun normalizeUrl(url: String) = if (url.startsWith("http")) url else "https://$url"
val tleUrls = settings.satelliteUrls.filter { it.isNotBlank() }.map(::normalizeUrl)
val radioUrls = settings.transceiversUrls.filter { it.isNotBlank() }.map(::normalizeUrl)
// launch all network requests concurrently
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
// parse fetched data concurrently and associate with types
val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
// parse fetched data concurrently
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
}
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty()
}
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
@@ -94,9 +89,31 @@ class DatabaseRepo(
setUpdateSuccessful(0L)
}
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> = when {
url.contains("FORMAT=csv", ignoreCase = true) -> dataParser.parseCSVStream(stream)
else -> dataParser.parseTLEStream(stream)
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
val bufferedStream = stream.buffered()
return when {
hasCsvHint(url) || looksLikeCsv(bufferedStream) -> dataParser.parseCSVStream(bufferedStream)
else -> dataParser.parseTLEStream(bufferedStream)
}
}
private fun hasCsvHint(url: String): Boolean {
return url.contains("FORMAT=csv", ignoreCase = true) ||
url.endsWith(".csv", ignoreCase = true) ||
url.endsWith(".csv.zip", ignoreCase = true)
}
private fun looksLikeCsv(stream: InputStream): Boolean {
if (!stream.markSupported()) return false
stream.mark(4096)
val preview = ByteArray(4096)
val length = stream.read(preview)
stream.reset()
if (length <= 0) return false
val line = preview.decodeToString(0, length).lineSequence().firstOrNull()?.trim().orEmpty()
return line.contains("OBJECT_NAME", ignoreCase = true) ||
line.contains("NORAD_CAT_ID", ignoreCase = true) ||
line.count { it == ',' } >= 4
}
private suspend fun setUpdateSuccessful(timestamp: Long) {
@@ -34,8 +34,11 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import java.util.TimeZone
import kotlin.time.Duration.Companion.milliseconds
class SatelliteRepo(
private val dispatcher: CoroutineDispatcher,
@@ -62,11 +65,23 @@ class SatelliteRepo(
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
override suspend fun initRepository() = withContext(dispatcher) {
settingsRepo.selectedIds.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val (_, hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
}
combine(
settingsRepo.selectedIds,
settingsRepo.stationPosition
) { selectedIds, _ -> selectedIds }
.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val settings = settingsRepo.passesSettings.value
calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = settings.hoursAhead,
minElevation = settings.minElevation,
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settingsRepo.selectedSatModes.value
)
}
}
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
@@ -105,7 +120,15 @@ class SatelliteRepo(
}
}
override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>) {
override suspend fun calculatePasses(
time: Long,
hoursAhead: Int,
minElevation: Double,
aosStartMinute: Int,
aosEndMinute: Int,
invertAosTimeWindow: Boolean,
modes: List<String>
) {
_isCalculating.value = true
// Normalize to the start of the current minute so that coarse 60-second stepping
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
@@ -130,18 +153,40 @@ class SatelliteRepo(
val newPasses = ArrayList<OrbitalPass>()
for (list in passLists) {
for (pass in list) {
if (pass.losTime > time && pass.aosTime < timeFuture && pass.maxElevation > minElevation) {
if (
pass.losTime > time
&& pass.aosTime < timeFuture
&& pass.maxElevation > minElevation
&& (pass.isDeepSpace || isAosInRange(pass.aosTime, aosStartMinute, aosEndMinute, invertAosTimeWindow))
) {
newPasses.add(pass)
}
}
}
newPasses.sortBy { it.aosTime }
delay(1000) // Simulate loading time for better UX
delay(1000.milliseconds) // Simulate loading time for better UX
_passes.update { newPasses }
}
_isCalculating.value = false
}
private fun isAosInRange(
aosTime: Long,
aosStartMinute: Int,
aosEndMinute: Int,
invertAosTimeWindow: Boolean
): Boolean {
val offsetMillis = TimeZone.getDefault().getOffset(aosTime).toLong()
val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L)
val aosMinute = (localMillis / 60_000L).toInt()
val inRange = if (aosStartMinute <= aosEndMinute) {
aosMinute in aosStartMinute..aosEndMinute
} else {
aosMinute !in (aosEndMinute + 1)..<aosStartMinute
}
return if (invertAosTimeWindow) !inRange else inRange
}
private fun OrbitalObject.getPasses(pos: GeoPos, time: Long, hours: Int): List<OrbitalPass> {
val passes = mutableListOf<OrbitalPass>()
val endDate = time + hours * 60L * 60L * 1000L
@@ -38,16 +38,16 @@ class SelectionRepo(
) : ISelectionRepo {
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
private val currentQuery = MutableStateFlow("")
// Resolve type IDs once when types change, then filter items reactively.
// Resolve sat IDs once when modes change, then filter items reactively.
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
val catnumSet: Set<Int>? = if (types.isEmpty()) {
// Directly observe settingsRepo.selectedSatModes to ensure real-time sync across screens.
private val itemsWithModes = settingsRepo.selectedSatModes.flatMapLatest { list: List<String> ->
val catnumSet: Set<Int>? = if (list.isEmpty()) {
null // null = no filtering
} else {
val ids = settingsRepo.getSatelliteTypesIds(types)
val ids = localSource.getIdsWithModes(list)
if (ids.isEmpty()) null else ids.toHashSet()
}
currentItems.map { items ->
@@ -56,14 +56,18 @@ class SelectionRepo(
}
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
itemsWithTypes.map { items -> filterByQuery(items, query) }
itemsWithModes.map { items ->
filterByQuery(items, query).sortedWith(
compareByDescending<SatItem> { it.isSelected }
.thenBy { it.name }
.thenBy { it.catnum }
)
}
}
override fun getCurrentTypes() = currentTypes.value
override fun getCurrentModes() = settingsRepo.selectedSatModes.value
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
removeAt(0)
}
override fun getModesList() = Sources.satelliteModes
override suspend fun getEntriesFlow() = withContext(dispatcher) {
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
@@ -73,9 +77,8 @@ class SelectionRepo(
return@withContext itemsWithQuery
}
override suspend fun setTypes(types: List<String>) {
currentTypes.value = types
settingsRepo.setSelectedTypes(types)
override suspend fun setModes(modes: List<String>) {
settingsRepo.setSelectedSatModes(modes)
}
override suspend fun setQuery(query: String) {
@@ -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
}
@@ -29,14 +29,17 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.utility.positionToQth
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
import com.rtbishop.look4sat.core.domain.utility.round
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.json.JSONObject
class SettingsRepo(
private val locationManager: LocationManager,
@@ -54,6 +57,9 @@ class SettingsRepo(
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
private val keyFilterHoursAhead = "filterHoursAhead"
private val keyFilterMinElevation = "filterMinElevation"
private val keyFilterAosStartMinute = "filterAosStartMinute"
private val keyFilterAosEndMinute = "filterAosEndMinute"
private val keyFilterAosInvert = "filterAosInvert"
private val keyNumberOfRadios = "numberOfRadios"
private val keyNumberOfSatellites = "numberOfSatellites"
private val keyRotatorAddress = "rotatorAddress"
@@ -64,9 +70,9 @@ class SettingsRepo(
private val keyFrequencyAddress = "frequencyAddress"
private val keyFrequencyPort = "frequencyPort"
private val keyFrequencyFormat = "frequencyFormat"
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
private val keySelectedIds = "selectedIds"
private val keySelectedTypes = "selectedTypes"
private val keySelectedModes = "selectedModes"
private val keySelectedSatModes = "selectedSatModes"
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
private val keyStateOfSensors = "stateOfSensors"
private val keyStateOfSweep = "stateOfSweep"
@@ -82,17 +88,20 @@ class SettingsRepo(
private val keyShouldSeeWarning = "shouldSeeWarning"
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
private val keySstvMode = "sstvMode"
private val keyUseCustomTle = "useCustomTle"
private val keyUseCustomTransceivers = "useCustomTransceivers"
private val keyTleUrl = "tleUrl"
private val keyTransceiversUrl = "transceiversUrl"
private val keyLowElevation = "lowElevation"
private val keyHighElevation = "highElevation"
private val keyRadarCompassOffset = "radarCompassOffset"
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
private val keySatelliteUrls = "satelliteUrls"
private val keyTransceiversUrls = "transceiversUrls"
private val separatorComma = ","
private val separatorUrl = "\n"
//region # Satellites selection settings
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
private val _typesSelection = MutableStateFlow(getSelectedTypes())
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
override val selectedTypes: StateFlow<List<String>> = _typesSelection
override val selectedSatModes: StateFlow<List<String>> = _satelliteModeSelection
override fun setSelectedIds(ids: List<Int>) {
val selectionString = ids.joinToString(separatorComma)
@@ -100,10 +109,10 @@ class SettingsRepo(
_satelliteSelection.value = ids
}
override fun setSelectedTypes(types: List<String>) {
val typesString = types.joinToString(separatorComma)
preferences.edit { putString(keySelectedTypes, typesString) }
_typesSelection.value = types
override fun setSelectedSatModes(modes: List<String>) {
val modesString = modes.joinToString(separatorComma)
preferences.edit { putString(keySelectedSatModes, modesString) }
_satelliteModeSelection.value = modes
}
private fun getSelectedIds(): List<Int> {
@@ -112,10 +121,10 @@ class SettingsRepo(
return selectionString.split(separatorComma).map { it.toInt() }
}
private fun getSelectedTypes(): List<String> {
val typesString = preferences.getString(keySelectedTypes, null)
if (typesString.isNullOrEmpty()) return listOf("Amateur")
return typesString.split(separatorComma)
private fun getSelectedSatModes(): List<String> {
val modesString = preferences.getString(keySelectedSatModes, null)
if (modesString.isNullOrEmpty()) return emptyList()
return modesString.split(separatorComma).sorted()
}
//endregion
@@ -127,7 +136,9 @@ class SettingsRepo(
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
putInt(keyFilterHoursAhead, settings.hoursAhead)
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
_passesSettings.value = settings
}
@@ -135,9 +146,17 @@ class SettingsRepo(
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
val selectedModesString = preferences.getString(keySelectedModes, null)
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
return PassesSettings(showDeepSpace, hoursAhead, minElevation, selectedModes)
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
return PassesSettings(
showDeepSpace,
hoursAhead,
minElevation,
aosStartMinute,
aosEndMinute,
invertAosTimeWindow
)
}
//endregion
@@ -216,25 +235,6 @@ class SettingsRepo(
private val _databaseState = MutableStateFlow(getDatabaseState())
override val databaseState: StateFlow<DatabaseState> = _databaseState
override fun getSatelliteTypesIds(types: List<String>): List<Int> {
val idsSet = mutableSetOf<Int>()
types.forEach { type ->
val typeString = preferences.getString("type$type", null)
val typeIds = if (typeString.isNullOrBlank()) {
emptyList()
} else {
typeString.split(separatorComma).map { it.toInt() }
}
idsSet.addAll(typeIds)
}
return idsSet.toList()
}
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
if (type == "All") return
val typesString = ids.joinToString(separatorComma)
preferences.edit { putString("type$type", typesString) }
}
override fun updateDatabaseState(state: DatabaseState) = preferences.edit {
putInt(keyNumberOfSatellites, state.numberOfSatellites)
@@ -279,6 +279,10 @@ class SettingsRepo(
override val rcSettings: StateFlow<RCSettings> = _rcSettings
override fun updateRCSettings(settings: RCSettings) {
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
Constants.FREQ_OFFSET_MIN_HZ,
Constants.FREQ_OFFSET_MAX_HZ
)
preferences.edit {
putBoolean(keyRotatorState, settings.rotatorState)
putString(keyRotatorAddress, settings.rotatorAddress)
@@ -288,6 +292,7 @@ class SettingsRepo(
putString(keyFrequencyAddress, settings.frequencyAddress)
putString(keyFrequencyPort, settings.frequencyPort)
putString(keyFrequencyFormat, settings.frequencyFormat)
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
@@ -296,7 +301,7 @@ class SettingsRepo(
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
}
_rcSettings.value = settings
_rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
}
private fun getRCSettings(): RCSettings = RCSettings(
@@ -308,6 +313,8 @@ class SettingsRepo(
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
frequencyOffsetHz = preferences.getLong(keyFrequencyOffsetHz, 0L)
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ),
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
@@ -335,6 +342,10 @@ class SettingsRepo(
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
putString(keySstvMode, new.sstvMode)
putLong(keyLowElevation, new.lowElevation.toRawBits())
putLong(keyHighElevation, new.highElevation.toRawBits())
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
}
new
}
@@ -349,7 +360,11 @@ class SettingsRepo(
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto"
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f)
)
//endregion
@@ -359,19 +374,19 @@ class SettingsRepo(
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
preferences.edit {
putBoolean(keyUseCustomTle, settings.useCustomTLE)
putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers)
putString(keyTleUrl, settings.tleUrl)
putString(keyTransceiversUrl, settings.transceiversUrl)
putString(keySatelliteUrls, settings.satelliteUrls.joinToString(separatorUrl))
putString(keyTransceiversUrls, settings.transceiversUrls.joinToString(separatorUrl))
}
_dataSourcesSettings.value = settings
}
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
satelliteUrls = preferences.getString(keySatelliteUrls, null)
?.split(separatorUrl)?.filter { it.isNotBlank() }
?: Sources.satelliteDataUrls,
transceiversUrls = preferences.getString(keyTransceiversUrls, null)
?.split(separatorUrl)?.filter { it.isNotBlank() }
?: Sources.transceiversDataUrls
)
//endregion
@@ -383,6 +398,7 @@ class SettingsRepo(
private val keyTxRadioName = "txRadioName"
private val keyRxRadioName = "rxRadioName"
private val keyRadioBaudRate = "radioBaudRate"
private val keyRadioSplitMode = "radioSplitMode"
private val _radioControlSettings = MutableStateFlow(getRadioControlSettings())
override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings
@@ -396,18 +412,42 @@ class SettingsRepo(
putString(keyTxRadioName, settings.txRadioName)
putString(keyRxRadioName, settings.rxRadioName)
putInt(keyRadioBaudRate, settings.baudRate)
putBoolean(keyRadioSplitMode, settings.splitMode)
}
_radioControlSettings.value = settings
}
private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings(
enabled = preferences.getBoolean(keyRadioControlEnabled, false),
radioModel = preferences.getString(keyRadioModel, null) ?: "Yaesu FT-817/818",
radioModel = preferences.getString(keyRadioModel, null) ?: RadioControlSettings.MODEL_YAESU_FT817,
txRadioAddress = preferences.getString(keyTxRadioAddress, null) ?: "",
rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "",
txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio",
rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio",
baudRate = preferences.getInt(keyRadioBaudRate, 4800)
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
)
//endregion
private val keySatelliteOffsets = "satelliteOffsets"
override fun getSatelliteOffset(catnum: Int): String {
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
return try {
JSONObject(json).optString(catnum.toString(), "")
} catch (_: Exception) {
""
}
}
override fun setSatelliteOffset(catnum: Int, offset: String) {
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
val updated = try {
val obj = JSONObject(json)
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
obj.toString()
} catch (_: Exception) {
"""{"$catnum": "$offset"}"""
}
preferences.edit { putString(keySatelliteOffsets, updated) }
}
}
@@ -74,7 +74,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
}
override suspend fun insertRadios(radios: List<SatRadio>) {
look4SatDao.deleteRadios()
look4SatDao.insertRadios(radios.toFrameworkRadios())
}
@@ -45,10 +45,49 @@ 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()
val response = httpClient.newCall(networkRequest).execute()
if (!response.isSuccessful) {
response.close()
return@withContext null
}
// Return the body stream directly as the caller is responsible for closing it
// That returns the connection to OkHttp's pool
response.body.byteStream().buffered()
} catch (exception: Exception) {
println("RemoteSource network stream exception: $exception")
null
}
}
override suspend fun getAmSatCatalog(): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/catalog.php")
.header("User-Agent", "Look4Sat")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource getAmSatCatalog exception: $exception")
null
}
}
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit")
.header("User-Agent", "Look4Sat")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource getAmSatReports exception: $exception")
null
}
}
}
@@ -1,30 +1,29 @@
package com.rtbishop.look4sat.core.data
package com.rtbishop.look4sat.core.data.framework
import com.rtbishop.look4sat.core.data.framework.Ft817CatProtocol
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class Ft817CatProtocolTest {
@Test
fun encodeFrequencyBcd_145500000() {
val bcd = Ft817CatProtocol.encodeFrequencyBcd(145500000L)
assertContentEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00), bcd)
assertArrayEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00), bcd)
}
@Test
fun encodeFrequencyBcd_435100000() {
val bcd = Ft817CatProtocol.encodeFrequencyBcd(435100000L)
assertContentEquals(byteArrayOf(0x43, 0x51, 0x00, 0x00), bcd)
assertArrayEquals(byteArrayOf(0x43, 0x51, 0x00, 0x00), bcd)
}
@Test
fun encodeFrequencyBcd_7074000() {
val bcd = Ft817CatProtocol.encodeFrequencyBcd(7074000L)
assertContentEquals(byteArrayOf(0x00, 0x70, 0x74, 0x00), bcd)
assertArrayEquals(byteArrayOf(0x00, 0x70, 0x74, 0x00), bcd)
}
@Test
@@ -42,21 +41,21 @@ class Ft817CatProtocolTest {
val cmd = Ft817CatProtocol.buildSetFreqCommand(145500000L)
assertEquals(5, cmd.size)
assertEquals(0x01.toByte(), cmd[4])
assertContentEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01), cmd)
assertArrayEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01), cmd)
}
@Test
fun buildSetModeCommand_usb() {
val cmd = Ft817CatProtocol.buildSetModeCommand("USB")
assertNotNull(cmd)
assertContentEquals(byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x07), cmd)
assertArrayEquals(byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x07), cmd)
}
@Test
fun buildSetModeCommand_fm() {
val cmd = Ft817CatProtocol.buildSetModeCommand("FM")
assertNotNull(cmd)
assertContentEquals(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x07), cmd)
assertArrayEquals(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x07), cmd)
}
@Test
@@ -67,19 +66,19 @@ class Ft817CatProtocolTest {
@Test
fun encodeCtcssTone_67_0() {
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(67.0)
assertContentEquals(byteArrayOf(0x06, 0x70), bcd)
assertArrayEquals(byteArrayOf(0x06, 0x70), bcd)
}
@Test
fun encodeCtcssTone_74_4() {
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(74.4)
assertContentEquals(byteArrayOf(0x07, 0x44), bcd)
assertArrayEquals(byteArrayOf(0x07, 0x44), bcd)
}
@Test
fun encodeCtcssTone_141_3() {
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(141.3)
assertContentEquals(byteArrayOf(0x14, 0x13), bcd)
assertArrayEquals(byteArrayOf(0x14, 0x13), bcd)
}
@Test
@@ -87,13 +86,13 @@ class Ft817CatProtocolTest {
val cmd = Ft817CatProtocol.buildSetCtcssToneCommand(67.0)
assertEquals(5, cmd.size)
assertEquals(0x0B.toByte(), cmd[4])
assertContentEquals(byteArrayOf(0x06, 0x70, 0x00, 0x00, 0x0B), cmd)
assertArrayEquals(byteArrayOf(0x06, 0x70, 0x00, 0x00, 0x0B), cmd)
}
@Test
fun buildCtcssModeCommand_enable() {
val cmd = Ft817CatProtocol.buildCtcssModeCommand(true)
assertContentEquals(byteArrayOf(0x2A, 0x00, 0x00, 0x00, 0x0A), cmd)
assertArrayEquals(byteArrayOf(0x2A, 0x00, 0x00, 0x00, 0x0A), cmd)
}
@Test
@@ -108,6 +107,7 @@ class Ft817CatProtocolTest {
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01)
val result = Ft817CatProtocol.parseReadResponse(response)
assertNotNull(result)
result ?: return
assertEquals(145500000L, result.first)
assertEquals("USB", result.second)
}
@@ -117,6 +117,7 @@ class Ft817CatProtocolTest {
val response = byteArrayOf(0x14, 0x60, 0x00, 0x00, 0x08)
val result = Ft817CatProtocol.parseReadResponse(response)
assertNotNull(result)
result ?: return
assertEquals(146000000L, result.first)
assertEquals("FM", result.second)
}
@@ -135,7 +136,7 @@ class Ft817CatProtocolTest {
@Test
fun buildPttCommands() {
val on = Ft817CatProtocol.buildPttOnCommand()
assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x08), on)
assertArrayEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x08), on)
val off = Ft817CatProtocol.buildPttOffCommand()
assertEquals(0x88.toByte(), off[4])
@@ -144,6 +145,6 @@ class Ft817CatProtocolTest {
@Test
fun buildReadCommand() {
val cmd = Ft817CatProtocol.buildReadFreqModeCommand()
assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x03), cmd)
assertArrayEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x03), cmd)
}
}
@@ -0,0 +1,229 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.SatItem
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.utility.DataParser
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.InputStream
@OptIn(ExperimentalCoroutinesApi::class)
class DatabaseRepoTest {
private val dispatcher = StandardTestDispatcher()
private val dataParser = DataParser(dispatcher)
@Test
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
val uri = "content://look4sat/import/satellites"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
fileStreams[uri] = { validCsvStream() }
}
val settingsRepo = FakeSettingsRepo()
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateTLEFromFile(uri)
assertEquals(1, localSource.insertedEntries.size)
assertEquals(25544, localSource.insertedEntries.first().catnum)
assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0)
}
@Test
fun `manual satellite import keeps tle support`() = runTest(dispatcher) {
val uri = "content://look4sat/import/legacy"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
fileStreams[uri] = { validTleStream() }
}
val settingsRepo = FakeSettingsRepo()
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateTLEFromFile(uri)
assertEquals(1, localSource.insertedEntries.size)
assertEquals(25544, localSource.insertedEntries.first().catnum)
}
@Test
fun `custom data source imports omm csv from web`() = runTest(dispatcher) {
val customCsvUrl = "https://example.com/custom-omm.csv"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[customCsvUrl] = { validCsvStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(customCsvUrl),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
}
private fun validCsvStream(): InputStream = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream()
private fun validTleStream(): InputStream = """
ISS (ZARYA)
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream()
}
private class FakeRemoteSource : IRemoteSource {
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
override suspend fun getAmSatCatalog(): String? = null
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
}
private class FakeLocalSource : ILocalSource {
val insertedEntries = mutableListOf<OrbitalData>()
private val insertedRadios = mutableListOf<SatRadio>()
override suspend fun getEntriesTotal(): Int = insertedEntries.size
override suspend fun getEntriesList(): List<SatItem> = emptyList()
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun insertEntries(entries: List<OrbitalData>) {
insertedEntries += entries
}
override suspend fun deleteEntries() {
insertedEntries.clear()
}
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
override suspend fun getRadiosTotal(): Int = insertedRadios.size
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
override suspend fun insertRadios(radios: List<SatRadio>) {
insertedRadios += radios
}
override suspend fun deleteRadios() {
insertedRadios.clear()
}
}
private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo {
override val appVersionName: String = "test"
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedSatModes: StateFlow<List<String>> = MutableStateFlow(emptyList())
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
PassesSettings(hoursAhead = 24, minElevation = 0.0)
)
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
)
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
OtherSettings(false, false, false, false, false, false, false, false)
)
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
override fun setSelectedIds(ids: List<Int>) = Unit
override fun setSelectedSatModes(modes: List<String>) = Unit
override fun setPassesSettings(settings: PassesSettings) = Unit
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
override fun setStationPosition(): Boolean = true
override fun setStationPosition(locator: String): Boolean = true
override fun updateDatabaseState(state: DatabaseState) {
databaseState.value = state
}
override fun updateRCSettings(settings: RCSettings) = Unit
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
dataSourcesSettings.value = settings
}
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
}
private fun defaultDataSourcesSettings(): DataSourcesSettings {
return DataSourcesSettings(
satelliteUrls = emptyList(),
transceiversUrls = emptyList()
)
}
@@ -0,0 +1,177 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.SatItem
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SelectionRepoTest {
private val dispatcher = StandardTestDispatcher()
@Test
fun `unknown mode values do not crash and do not filter out entries`() = runTest(dispatcher) {
val localSource = FakeLocalSource(
entries = listOf(
SatItem(25544, "ISS (ZARYA)", false),
SatItem(40967, "TIANGONG", false)
)
)
val settingsRepo = FakeSettingsRepo(selectedModes = listOf("REMOVED_MODE"))
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
val flow = repository.getEntriesFlow()
repository.setModes(listOf("REMOVED_MODE"))
val items = flow.first()
assertEquals(listOf(25544, 40967), items.map { it.catnum })
assertEquals(listOf("REMOVED_MODE"), repository.getCurrentModes())
}
@Test
fun `selected satellites are shown first`() = runTest(dispatcher) {
val localSource = FakeLocalSource(
entries = listOf(
SatItem(44444, "Zeta", false),
SatItem(25544, "Alpha", false),
SatItem(40967, "Beta", false)
)
)
val settingsRepo = FakeSettingsRepo(selectedModes = emptyList())
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
val flow = repository.getEntriesFlow()
repository.setSelection(listOf(40967), true)
val items = flow.first()
assertEquals(listOf(40967, 25544, 44444), items.map { it.catnum })
assertEquals(listOf(true, false, false), items.map { it.isSelected })
}
private class FakeLocalSource(
private val entries: List<SatItem>
) : ILocalSource {
override suspend fun getEntriesTotal(): Int = entries.size
override suspend fun getEntriesList(): List<SatItem> = entries
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun insertEntries(entries: List<com.rtbishop.look4sat.core.domain.predict.OrbitalData>) = Unit
override suspend fun deleteEntries() = Unit
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
override suspend fun getRadiosTotal(): Int = 0
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
override suspend fun insertRadios(radios: List<SatRadio>) = Unit
override suspend fun deleteRadios() = Unit
}
private class FakeSettingsRepo(
selectedModes: List<String>
) : ISettingsRepo {
override val appVersionName: String = "test"
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedSatModes: MutableStateFlow<List<String>> = MutableStateFlow(selectedModes)
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
PassesSettings(hoursAhead = 24, minElevation = 0.0)
)
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
)
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
OtherSettings(false, false, false, false, false, false, false, false)
)
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
DataSourcesSettings(emptyList(), emptyList())
)
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
override fun setSelectedIds(ids: List<Int>) = Unit
override fun setSelectedSatModes(modes: List<String>) {
selectedSatModes.value = modes
}
override fun setPassesSettings(settings: PassesSettings) = Unit
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
override fun setStationPosition(): Boolean = true
override fun setStationPosition(locator: String): Boolean = true
override fun updateDatabaseState(state: DatabaseState) {
databaseState.value = state
}
override fun updateRCSettings(settings: RCSettings) = Unit
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
}
}
@@ -0,0 +1,23 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.model
object Constants {
const val FREQ_OFFSET_MIN_HZ = -50_000L
const val FREQ_OFFSET_MAX_HZ = 50_000L
}
@@ -0,0 +1,37 @@
package com.rtbishop.look4sat.core.domain.model
/** One satellite status report (AMSAT site tooltip data) */
data class SatReport(
val id: String, // 报告 ID(a885153)
val statusText: String, // Heard / Telemetry Only / Not Heard ...
val call: String, // 呼号
val grid: String, // 网格坐标(可为空)
val dateUtc: String, // 2026-08-04
val timeUtc: String // 2:46-:59 UTC
)
/** State of one 2-hour slot */
data class SatSlot(
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
val count: Int, // 报告数量(0 = 无)
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
)
/** One satellite day (12 two-hour slots) */
data class SatDay(
val dateLabel: String, // "Aug 4"
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
)
/** One satellite, 5 days of state */
data class SatStatus(
val name: String, // "AO-123_[FM]"
val days: List<SatDay> // 5 天(新→旧)
)
/** Overall page parse result */
data class SatStatusPage(
val fetchedAtUtcMs: Long,
val statuses: List<SatStatus>,
val reports: Map<String, SatReport> // id → 报告
)
@@ -27,7 +27,9 @@ data class PassesSettings(
val showDeepSpace: Boolean = true,
val hoursAhead: Int,
val minElevation: Double,
val selectedModes: List<String>
val aosStartMinute: Int = 0,
val aosEndMinute: Int = 23 * 60 + 59,
val invertAosTimeWindow: Boolean = false
)
data class RCSettings(
@@ -39,6 +41,7 @@ data class RCSettings(
val frequencyAddress: String,
val frequencyPort: String,
val frequencyFormat: String,
val frequencyOffsetHz: Long = 0L,
val bluetoothRotatorState: Boolean,
val bluetoothRotatorFormat: String,
val bluetoothRotatorName: String,
@@ -57,14 +60,16 @@ data class OtherSettings(
val stateOfNightMode: Boolean = false,
val shouldSeeWarning: Boolean,
val shouldSeeWhatsNew: Boolean,
val sstvMode: String = "Auto"
val sstvMode: String = "Auto",
val lowElevation: Double = 15.0,
val highElevation: Double = 45.0,
val radarCompassOffset: Float = 0f,
val radarCompassOffsetElev: Float = 0f
)
data class DataSourcesSettings(
val useCustomTLE: Boolean,
val useCustomTransceivers: Boolean,
val tleUrl: String,
val transceiversUrl: String
val satelliteUrls: List<String>,
val transceiversUrls: List<String>
)
data class RadioControlSettings(
@@ -74,12 +79,20 @@ data class RadioControlSettings(
val rxRadioAddress: String,
val txRadioName: String,
val rxRadioName: String,
val baudRate: Int
val baudRate: Int,
/** IC-705 only: use single-radio split-VFO mode instead of two radios. */
val splitMode: Boolean = false
) {
companion object {
val SUPPORTED_RADIOS = listOf(
"Yaesu FT-817/818",
"Yaesu FT-857/897"
)
const val MODEL_YAESU_FT817 = "Yaesu FT-817/818"
const val MODEL_YAESU_FT857 = "Yaesu FT-857/897"
const val MODEL_ICOM_IC705 = "Icom IC-705"
val SUPPORTED_RADIOS = listOf(MODEL_YAESU_FT817, MODEL_YAESU_FT857, MODEL_ICOM_IC705)
/** Baud rates available for Yaesu radios. */
val BAUD_RATES_YAESU = listOf(4800, 9600, 38400)
/** Baud rates available for Icom IC-705 (higher speeds supported via CI-V USB/BT). */
val BAUD_RATES_ICOM = listOf(4800, 9600, 19200, 38400, 57600, 115200)
}
}
@@ -0,0 +1,9 @@
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
/** AMSAT satellite status data source */
interface IAmSatRepository {
/** Fetch and parse the AMSAT status page; null on failure */
suspend fun fetchStatus(): SatStatusPage?
}
@@ -18,8 +18,8 @@
package com.rtbishop.look4sat.core.domain.repository
interface IDatabaseRepo {
suspend fun updateTLEFromFile(uri: String)
suspend fun updateTransceiversFromFile(uri: String)
suspend fun updateTLEFromFile(uri: String): Int
suspend fun updateTransceiversFromFile(uri: String): Int
suspend fun updateFromRemote()
suspend fun clearAllData()
}
@@ -29,6 +29,7 @@ interface IMainContainer {
val selectionRepo: ISelectionRepo
val satelliteRepo: ISatelliteRepo
val databaseRepo: IDatabaseRepo
val amSatRepo: IAmSatRepository
val radioTrackingService: IRadioTrackingService
fun provideAddToCalendar(): IAddToCalendar
fun provideShowToast(): IShowToast
@@ -39,6 +40,7 @@ interface IMainContainer {
fun provideRxRadioController(): IRadioController
fun provideAudioCapture(): IAudioCapture
fun provideSaveImage(): ISaveImage
fun providePairedBluetoothDevices(): List<Pair<String, String>>
}
interface IContainerProvider {
@@ -38,4 +38,51 @@ interface IRadioController {
suspend fun pttOn(): Boolean
suspend fun pttOff(): Boolean
// ── Extended operations (IC-705 / CI-V) ──────────────────────────────
/**
* Select the band matching [frequencyHz] via the band stacking register.
* Must be called before [setFrequency] and [setMode] when first tracking.
* Default: no-op (Yaesu radios auto-switch band via frequency).
*/
suspend fun setBand(frequencyHz: Long): Boolean = false
/**
* Select the active VFO.
* @param vfoA true → VFO-A (main/RX), false → VFO-B (sub/TX in split).
*/
suspend fun setVfo(vfoA: Boolean): Boolean = false
/**
* Enable or disable SPLIT mode (TX on sub-VFO, RX on main VFO).
* Default: not supported.
*/
suspend fun setSplitMode(enabled: Boolean): Boolean = false
/**
* Set the frequency of the currently active VFO (IC-705: CMD 0x25 sub 0x00).
* Default: delegates to [setFrequency].
*/
suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = setFrequency(frequencyHz)
/**
* Set the frequency of the inactive/TX VFO (IC-705: CMD 0x25 sub 0x01).
* Sent every tracking cycle alongside [setWorkingFrequency] in split mode.
* Default: delegates to [setWorkingFrequency].
*/
suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = setWorkingFrequency(frequencyHz)
/**
* Read the frequency of the currently active VFO (IC-705: CMD 0x25 sub 0x00).
* Default: delegates to [readFrequencyAndMode].
*/
suspend fun readWorkingFrequency(): Long? = readFrequencyAndMode()?.first
/**
* Read the frequency of the inactive/TX VFO (IC-705: CMD 0x25 sub 0x01).
* Used for tuning detection in split mode.
* Default: delegates to [readWorkingFrequency].
*/
suspend fun readTxVfoFrequency(): Long? = readWorkingFrequency()
}
@@ -44,7 +44,15 @@ interface ISatelliteRepo {
suspend fun initRepository()
/** Recalculate passes with the given filter parameters. */
suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>)
suspend fun calculatePasses(
time: Long,
hoursAhead: Int,
minElevation: Double,
aosStartMinute: Int,
aosEndMinute: Int,
invertAosTimeWindow: Boolean,
modes: List<String>
)
/** Get the current position of a single satellite. */
suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos
@@ -21,10 +21,10 @@ import com.rtbishop.look4sat.core.domain.model.SatItem
import kotlinx.coroutines.flow.Flow
interface ISelectionRepo {
fun getCurrentTypes(): List<String>
fun getTypesList(): List<String>
fun getCurrentModes(): List<String>
fun getModesList(): List<String>
suspend fun getEntriesFlow(): Flow<List<SatItem>>
suspend fun setTypes(types: List<String>)
suspend fun setModes(modes: List<String>)
suspend fun setQuery(query: String)
suspend fun setSelection(selectAll: Boolean)
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
@@ -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()
@@ -32,9 +32,9 @@ interface ISettingsRepo {
//region # Satellites selection settings
val selectedIds: StateFlow<List<Int>>
val selectedTypes: StateFlow<List<String>>
val selectedSatModes: StateFlow<List<String>>
fun setSelectedIds(ids: List<Int>)
fun setSelectedTypes(types: List<String>)
fun setSelectedSatModes(modes: List<String>)
//endregion
//region # Passes filter settings
@@ -51,8 +51,6 @@ interface ISettingsRepo {
//region # Database update settings
val databaseState: StateFlow<DatabaseState>
fun getSatelliteTypesIds(types: List<String>): List<Int>
fun setSatelliteTypeIds(type: String, ids: List<Int>)
fun updateDatabaseState(state: DatabaseState)
//endregion
@@ -77,4 +75,9 @@ interface ISettingsRepo {
val radioControlSettings: StateFlow<RadioControlSettings>
fun updateRadioControlSettings(settings: RadioControlSettings)
//endregion
//region # Per-satellite calculator offset settings
fun getSatelliteOffset(catnum: Int): String
fun setSatelliteOffset(catnum: Int, offset: String)
//endregion
}
@@ -22,4 +22,6 @@ import java.io.InputStream
interface IRemoteSource {
suspend fun getFileStream(uri: String): InputStream?
suspend fun getNetworkStream(url: String): InputStream?
suspend fun getAmSatCatalog(): String?
suspend fun getAmSatReports(hours: Int, limit: Int): String?
}
@@ -18,37 +18,25 @@
package com.rtbishop.look4sat.core.domain.source
object Sources {
val satelliteDataUrls = mapOf(
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
"Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv",
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
"Education" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=education&FORMAT=csv",
"Engineer" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=engineering&FORMAT=csv",
"Geostationary" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=geo&FORMAT=csv",
"Globalstar" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=globalstar&FORMAT=csv",
"GNSS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=gnss&FORMAT=csv",
"Intelsat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=intelsat&FORMAT=csv",
"Iridium" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=iridium-NEXT&FORMAT=csv",
"Military" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=military&FORMAT=csv",
"New" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=last-30-days&FORMAT=csv",
"OneWeb" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=oneweb&FORMAT=csv",
"Orbcomm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=orbcomm&FORMAT=csv",
"Resource" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=resource&FORMAT=csv",
"SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv",
"Science" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=science&FORMAT=csv",
"Spire" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=spire&FORMAT=csv",
"Starlink" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=csv",
"Swarm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=swarm&FORMAT=csv",
"Weather" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=csv",
"X-Comm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=x-comm&FORMAT=csv",
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
"R4UAB" to "https://r4uab.ru/satonline.txt",
"Other" to "" // key for sats filter
val satelliteDataUrls = listOf(
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"db.satnogs.org/api/tle/?format=3le",
"amsat.org/tle/current/nasabare.txt",
"mmccants.org/tles/classfd.zip",
"r4uab.ru/satonline.txt",
"live.ariss.org/iss.txt"
)
val transceiversDataUrls = mapOf(
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
val transceiversDataUrls = listOf(
"db.satnogs.org/api/transmitters/?format=json&status=active",
"r4uab.ru/transmitters.json"
)
val satelliteModes = listOf(
"4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK",
"BPSK PMT-A3", "CERTO", "CW", "DATV", "DBPSK", "DOKA", "DPSK", "DQPSK", "DSB", "DSTAR",
"DUV", "DVB-S2", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5", "FSK AX.100 Mode 6",
"FSK AX.25 G3RUH", "FT8", "GENESIS FSK", "GFSK", "GFSK Pkst", "GFSK Rktr", "GFSK/BPSK",
"GMSK", "GMSK USP", "HRPT", "LoRa", "LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5",
"MSK AX.100 Mode 6", "OFDM", "OQPSK", "PPM", "PSK", "PSK31", "PSK63", "QPSK", "QPSK31",
"QPSK63", "SIDLOC", "SQPSK", "SSDV", "SSTV", "UNKNOWN", "USB", "WSJT"
)
}
@@ -20,39 +20,94 @@ package com.rtbishop.look4sat.core.domain.sstv
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.round
import kotlin.math.sqrt
class SstvFrame(
val scopePixels: IntArray?,
val scopeWidth: Int,
val scopeHeight: Int,
val imagePixels: IntArray?,
val imageWidth: Int,
val imageHeight: Int,
val modeName: String
val modeName: String,
val imageComplete: Boolean,
val inputRms: Float,
val appliedGain: Float,
val syncHitRate: Float,
val predictedLineBursts: Int,
val maxPredictedStreak: Int,
val timingErrorSamples: Int
)
/**
* Decoder quality metrics for diagnostics and logging. Useful for profiling decode
* performance on noisy recordings.
*/
data class SstvQualityMetrics(
val syncHitRate: Float,
val predictedLineBursts: Int,
val maxPredictedStreak: Int,
val timingErrorSamples: Int
)
enum class LineRecoveryStrategy {
Look4SatLimited,
Robot36Compatible
}
class SstvDiagnosticsHandle internal constructor(
val enabled: Boolean,
val metrics: StateFlow<SstvQualityMetrics?>
)
class SstvDecoder(
sampleRate: Int = 44100,
scopeWidth: Int = 320,
scopeHeight: Int = 256,
private val channelSelect: Int = 0
private val channelSelect: Int = 0,
targetRmsLevel: Float = 0.25f,
private val includeScopeData: Boolean = false,
private val enableRmsNormalization: Boolean = true,
preFilterCutoffHz: Double = 500.0,
enablePreFilter: Boolean = true,
private val enableDiagnosticsHandle: Boolean = false,
lineRecoveryStrategy: LineRecoveryStrategy = LineRecoveryStrategy.Look4SatLimited
) {
private val scopeBuffer = PixelBuffer(scopeWidth, scopeHeight * 2)
private val imageBuffer = PixelBuffer(scopeWidth, scopeHeight)
private val decoder = DecoderEngine(scopeBuffer, imageBuffer, "Raw", sampleRate)
private val decoder = DecoderEngine(scopeBuffer, imageBuffer, "Raw", sampleRate, lineRecoveryStrategy)
private val _frames = MutableSharedFlow<SstvFrame>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
private var lastInputRms = 0f
private var lastAppliedGain = 1f
private val _qualityMetrics = MutableStateFlow<SstvQualityMetrics?>(null)
private val preFilter = if (enablePreFilter) HighPassFilter(preFilterCutoffHz, sampleRate.toDouble()) else null
private val diagnosticsHandle = SstvDiagnosticsHandle(enableDiagnosticsHandle, _qualityMetrics)
val frames: SharedFlow<SstvFrame> = _frames
val supportedModes: List<String> = decoder.allModes.map { it.name }
suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) {
// Normalize to a fixed RMS before processing so that both direct audio
// coupling and air-coupled microphone input decode with equal reliability.
normalise(samples)
// Optional pre-filtering: remove DC offset and subsonic noise that can mask
// weak signals and corrupt the RMS normalization baseline.
preFilter?.apply(samples)
// Optional RMS normalization: bring input to a consistent level so the
// FM demodulator operates in a predictable region. However, this amplifies
// noise proportionally. Aggressive RMS targets (e.g., 0.25) can hurt weak
// signals by boosting noise floor. Safer defaults: 0.35-0.50 for noisy inputs.
// Disable entirely for direct line-level inputs (e.g., receiver discriminator).
val gain = if (enableRmsNormalization) normalise(samples) else GainInfo(0f, 1f)
lastInputRms = gain.inputRms
lastAppliedGain = gain.appliedGain
val hasNewLines = decoder.process(samples, channelSelect)
if (hasNewLines) emitFrame()
}
@@ -62,36 +117,134 @@ class SstvDecoder(
fun clearPixels() {
imageBuffer.line = -1
imageBuffer.pixels.fill(0)
decoder.resetQuality()
if (enableDiagnosticsHandle) _qualityMetrics.value = null
}
fun getDiagnosticsHandle(): SstvDiagnosticsHandle? = diagnosticsHandle.takeIf { it.enabled }
/**
* Export quality metrics for logging/diagnostics. Useful for profiling decode
* performance on noisy recordings. Returns null before first frame is emitted.
*/
@Suppress("unused")
fun getQualityMetrics(): SstvQualityMetrics? {
if (enableDiagnosticsHandle) return _qualityMetrics.value
val q = decoder.quality()
return SstvQualityMetrics(
syncHitRate = q.syncHitRate,
predictedLineBursts = q.predictedLineBursts,
maxPredictedStreak = q.maxPredictedStreak,
timingErrorSamples = q.timingErrorSamples
)
}
private fun emitFrame() {
val imageWidth = imageBuffer.width
val imageHeight = imageBuffer.height
val quality = decoder.quality()
if (enableDiagnosticsHandle) {
_qualityMetrics.value = SstvQualityMetrics(
syncHitRate = quality.syncHitRate,
predictedLineBursts = quality.predictedLineBursts,
maxPredictedStreak = quality.maxPredictedStreak,
timingErrorSamples = quality.timingErrorSamples
)
}
val imageComplete = imageBuffer.line >= imageHeight && imageBuffer.line > 0
// Copy only the active image region — imageBuffer.pixels is pre-allocated
// at the maximum possible size (PD-290: 800×616), so we must not copyOf()
// the entire array and send padding pixels to the observer.
val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf(imageWidth * imageHeight) else null
val modeName = decoder.currentMode.name
_frames.tryEmit(SstvFrame(imagePixels, imageWidth, imageHeight, modeName))
val scopePixels = if (includeScopeData) scopeBuffer.pixels.copyOf() else null
val scopeWidth = if (includeScopeData) scopeBuffer.width else 0
val scopeHeight = if (includeScopeData) scopeBuffer.height else 0
_frames.tryEmit(
SstvFrame(
scopePixels = scopePixels,
scopeWidth = scopeWidth,
scopeHeight = scopeHeight,
imagePixels = imagePixels,
imageWidth = imageWidth,
imageHeight = imageHeight,
modeName = modeName,
imageComplete = imageComplete,
inputRms = lastInputRms,
appliedGain = lastAppliedGain,
syncHitRate = quality.syncHitRate,
predictedLineBursts = quality.predictedLineBursts,
maxPredictedStreak = quality.maxPredictedStreak,
timingErrorSamples = quality.timingErrorSamples
)
)
}
// Target RMS level for the normalizer. 0.25 leaves headroom while keeping the
// FM demodulator well above its noise floor regardless of input gain.
private val targetRms = 0.25f
// TUNING GUIDE:
// - 0.20-0.25: Aggressive, best for clean direct-coupled inputs, worst for mic noise
// - 0.35-0.40: Moderate, good balance for typical phone/mic inputs (RECOMMENDED)
// - 0.50-0.60: Conservative, best for noisy environments, reduces amplitude resolution
// Disable RMS normalization entirely if using a professional receiver discriminator output.
private val targetRms = targetRmsLevel.coerceIn(0.05f, 0.8f)
private class GainInfo(
val inputRms: Float,
val appliedGain: Float
)
// Bring the buffer to a fixed RMS so that microphone and direct-coupled inputs
// both decode reliably. The guard prevents amplifying pure silence into noise.
private fun normalise(buffer: FloatArray) {
private fun normalise(buffer: FloatArray): GainInfo {
var sumSq = 0f
for (s in buffer) sumSq += s * s
val rms = sqrt(sumSq / buffer.size)
if (rms > 1e-6f) {
val gain = targetRms / rms
for (i in buffer.indices) buffer[i] *= gain
return GainInfo(rms, gain)
}
return GainInfo(rms, 1f)
}
}
/**
* Simple high-pass filter to remove DC offset and subsonic interference before RMS
* normalization. Improves noise floor estimation and prevents low-freq noise from
* corrupting the gain calculation.
*
* Design: First-order butterworth (pole at cutoff frequency). Fast, minimal latency,
* suitable for real-time preprocessing.
*/
internal class HighPassFilter(cutoffHz: Double, sampleRateHz: Double) {
private val alpha: Float
private var prevInput = 0f
private var prevOutput = 0f
init {
// First-order pole placement: alpha = wc / (wc + ws) where wc = 2*pi*fc, ws = 2*pi*fs
val omega = 2f * PI.toFloat() * (cutoffHz / sampleRateHz).toFloat()
alpha = omega / (omega + 1f)
}
fun apply(buffer: FloatArray) {
for (i in buffer.indices) {
val input = buffer[i]
prevOutput = alpha * (prevOutput + input - prevInput)
buffer[i] = prevOutput
prevInput = input
}
}
}
internal class DecoderQuality(
val syncHitRate: Float,
val predictedLineBursts: Int,
val maxPredictedStreak: Int,
val timingErrorSamples: Int
)
internal enum class SyncPulseWidth { FiveMs, NineMs, TwentyMs }
internal class SyncPulseDetector(sampleRate: Int) {
@@ -197,7 +350,8 @@ internal class DecoderEngine(
private val scopeBuffer: PixelBuffer,
private val imageBuffer: PixelBuffer,
rawName: String,
sampleRate: Int
sampleRate: Int,
lineRecoveryStrategy: LineRecoveryStrategy
) {
private val pixelBuffer = PixelBuffer(800, 2)
private val detector = SyncPulseDetector(sampleRate)
@@ -237,6 +391,19 @@ internal class DecoderEngine(
private var lastSync = 0
private var curLineSamples: Int
private var lastOffset = 0f
private var syncChecks = 0
private var syncHits = 0
private var predictedLineBursts = 0
private var predictedStreak = 0
private var maxPredictedStreak = 0
private var timingErrorSamples = 0
// Look4Sat strategy limits synthetic lines to avoid visible vertical collapse.
// Robot36 strategy preserves legacy behavior by allowing unlimited synthesis.
private val maxConsecutivePredictedLines = when (lineRecoveryStrategy) {
LineRecoveryStrategy.Look4SatLimited -> 2
LineRecoveryStrategy.Robot36Compatible -> Int.MAX_VALUE
}
init {
imageBuffer.line = -1
@@ -286,6 +453,11 @@ internal class DecoderEngine(
fun process(recordBuffer: FloatArray, channelSelect: Int): Boolean {
var newLines = false
val detected = detector.process(recordBuffer, channelSelect)
syncChecks++
if (detected) {
syncHits++
predictedStreak = 0
}
var syncIdx = sample + detector.pulseOffset
val channels = if (channelSelect > 0) 2 else 1
for (j in 0 until recordBuffer.size / channels) {
@@ -308,20 +480,10 @@ internal class DecoderEngine(
}
}
} else if (handleHeader()) {
predictedStreak = 0
newLines = true
} else if (sample > lastSync + (curLineSamples * 5) / 4) {
copyLines(
currentMode.decodeScanLine(
pixelBuffer,
scratch,
scanLineBuffer,
scopeBuffer.width,
lastSync,
curLineSamples,
lastOffset
)
)
lastSync += curLineSamples; newLines = true
newLines = decodePredictedLine()
}
return newLines
}
@@ -337,6 +499,25 @@ internal class DecoderEngine(
lockMode = false
}
fun quality(): DecoderQuality {
val hitRate = if (syncChecks > 0) syncHits.toFloat() / syncChecks else 0f
return DecoderQuality(
syncHitRate = hitRate,
predictedLineBursts = predictedLineBursts,
maxPredictedStreak = maxPredictedStreak,
timingErrorSamples = timingErrorSamples
)
}
fun resetQuality() {
syncChecks = 0
syncHits = 0
predictedLineBursts = 0
predictedStreak = 0
maxPredictedStreak = 0
timingErrorSamples = 0
}
private fun mean(a: IntArray): Double = a.sumOf { it.toDouble() } / a.size
private fun stdDev(a: IntArray, m: Double): Double {
var s = 0.0; for (v in a) s += (v - m) * (v - m); return sqrt(s / a.size)
@@ -426,6 +607,29 @@ internal class DecoderEngine(
if (finish) drawLines(0xff000000.toInt(), 10)
}
private fun decodePredictedLine(): Boolean {
// Avoid long streaks of synthetic lines; once we exceed the cap we wait for
// real sync to reduce visible vertical compression on weak/noisy signals.
if (predictedStreak >= maxConsecutivePredictedLines) return false
val expectedSync = lastSync + curLineSamples
timingErrorSamples = sample - expectedSync
val decoded = currentMode.decodeScanLine(
pixelBuffer,
scratch,
scanLineBuffer,
scopeBuffer.width,
lastSync,
curLineSamples,
lastOffset
)
copyLines(decoded)
lastSync = expectedSync
predictedLineBursts++
predictedStreak++
if (predictedStreak > maxPredictedStreak) maxPredictedStreak = predictedStreak
return decoded
}
private fun drawLines(color: Int, count: Int) {
repeat(count) {
scopeBuffer.pixels.fill(
@@ -542,6 +746,7 @@ internal class DecoderEngine(
lineLen: IntArray,
latest: Int
): Boolean {
predictedStreak = 0
for (i in 1 until syncPulses.size) syncPulses[i - 1] = syncPulses[i]
syncPulses[syncPulses.size - 1] = latest
for (i in 1 until lineLen.size) lineLen[i - 1] = lineLen[i]
@@ -91,7 +91,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
val line1 = tle[1]
val line2 = tle[2]
OrbitalData(
name = tle[0].trim(),
name = tle[0].trim().removePrefix("0 "),
epoch = line1.substring(18, 32).toDouble(),
meanmo = line2.substring(52, 63).toDouble(),
eccn = line2.substring(26, 33).toDouble() / 1e7,
@@ -0,0 +1,141 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import java.util.Locale
/**
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
*
* For a linear (passband) transponder, uplink and downlink frequencies are
* related by a fixed passband offset. When the satellite moves, both are
* Doppler-shifted. Given one, this computes the other:
*
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler)
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
*/
object DopplerFrequencyCalculator {
/**
* Given a downlink frequency, compute the Doppler-corrected uplink frequency.
* Returns null if the transponder is not a linear passband type.
*/
fun computeUplinkFromDownlink(
downlinkHz: Long,
transponder: SatRadio,
orbitalPos: OrbitalPos
): Long? {
if (!isLinearTransponder(transponder)) return null
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz, transponder) ?: return null
return orbitalPos.getUplinkFreq(baseUplink)
}
/**
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
* with an offset applied to the downlink (in Hz).
* Returns null if the transponder is not a linear passband type.
*
* The user-entered downlink frequency already includes the offset, so subtract
* it before mapping the downlink passband position back to the uplink.
*/
fun computeUplinkFromDownlinkWithOffset(
downlinkHz: Long,
transponder: SatRadio,
orbitalPos: OrbitalPos,
offsetHz: Long
): Long? {
if (!isLinearTransponder(transponder)) return null
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
return orbitalPos.getUplinkFreq(baseUplink)
}
/**
* Given an uplink frequency, compute the Doppler-corrected downlink frequency.
* Returns null if the transponder is not a linear passband type.
*/
fun computeDownlinkFromUplink(
uplinkHz: Long,
transponder: SatRadio,
orbitalPos: OrbitalPos
): Long? {
if (!isLinearTransponder(transponder)) return null
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
return orbitalPos.getDownlinkFreq(baseDownlink)
}
/**
* Given an uplink frequency, compute the Doppler-corrected downlink frequency
* with an offset applied to the downlink (in Hz).
* Returns null if the transponder is not a linear passband type.
*/
fun computeDownlinkFromUplinkWithOffset(
uplinkHz: Long,
transponder: SatRadio,
orbitalPos: OrbitalPos,
offsetHz: Long
): Long? {
if (!isLinearTransponder(transponder)) return null
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
return orbitalPos.getDownlinkFreq(baseDownlink + offsetHz)
}
/** True if this transponder supports linear passband mapping. */
fun isLinearTransponder(transponder: SatRadio): Boolean {
val upLow = transponder.uplinkLow
val upHigh = transponder.uplinkHigh
val downLow = transponder.downlinkLow
val downHigh = transponder.downlinkHigh
return upLow != null && upHigh != null && downLow != null && downHigh != null
&& upLow != upHigh && downLow != downHigh
}
/**
* True for the radio entry that should drive the standalone Calculator page.
*
* A frequency range alone is not enough: some non-user-facing or drifting data entries
* can also have low/high frequencies. The calculator is meant for named linear
* transponders, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
*/
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
if (!isLinearTransponder(transponder)) return false
val info = transponder.info.lowercase(Locale.ENGLISH)
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
.joinToString(separator = " ")
.lowercase(Locale.ENGLISH)
val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
info.contains("xponder") || info.contains("xpdr")
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) ||
(hasLinearName && hasLinearMode)
}
/**
* Removes duplicate transponder entries that describe the same physical
* transponder with different mode labels (e.g. SatNOGS lists AO-7's Mode A
* as both "Lin SSB" and "Lin CW", and JO-97's U/V transponder as both
* "CW Transponder" and "SSB Transponder").
*
* Entries sharing the same uplink/downlink frequency range are considered
* the same transponder. The non-CW entry is preferred because its invert
* flag is more reliable (e.g. JO-97's CW entry wrongly has invert=false).
*/
fun deduplicateTransponders(radios: List<SatRadio>): List<SatRadio> {
return radios.groupBy { radio ->
listOf(radio.uplinkLow, radio.uplinkHigh, radio.downlinkLow, radio.downlinkHigh)
}.values.map { group ->
group.firstOrNull { it.downlinkMode?.equals("CW", ignoreCase = true) != true } ?: group.first()
}
}
}
@@ -41,6 +41,21 @@ fun Double.round(decimals: Int): Double {
return kotlin.math.round(this * multiplier) / multiplier
}
fun String.aprsPasscode(): Int {
val callsign = this.trim().uppercase().substringBefore('-') // commonly strip SSID
var hash = 0x73E2
var i = 0
while (i < callsign.length) {
hash = hash xor (callsign[i].code shl 8)
i++
if (i < callsign.length) {
hash = hash xor callsign[i].code
i++
}
}
return hash and 0x7FFF
}
//fun String.getHash(type: String = "SHA-256"): String {
// val hexChars = "0123456789ABCDEF"
// val bytes = MessageDigest.getInstance(type).digest(this.toByteArray())
@@ -17,7 +17,9 @@
*/
package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.DEG2RAD
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
import kotlin.math.acos
import kotlin.math.atan2
@@ -85,6 +87,13 @@ fun clipLon(longitude: Double): Double {
return clip(result, MIN_LONGITUDE, MAX_LONGITUDE)
}
fun OrbitalPos.toMapGeoPos(): GeoPos {
return GeoPos(
latitude = clipLat(latitude.toDegrees()),
longitude = clipLon(longitude.toDegrees())
)
}
private fun clip(currentValue: Double, minValue: Double, maxValue: Double): Double {
return min(max(currentValue, minValue), maxValue)
}
@@ -18,6 +18,7 @@
package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.utility.DataParser
import com.rtbishop.look4sat.core.domain.utility.aprsPasscode
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
@@ -239,4 +240,10 @@ class DataParserTest {
// Matches the CSV test data epoch: 2021-11-16 → day 320
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
}
@Test
fun `check APRS passcode calculation`() {
assert("M7LNB".aprsPasscode() == 12443)
assert("N0CALL".aprsPasscode() == 13023)
}
}
@@ -0,0 +1,273 @@
package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class DopplerFrequencyCalculatorTest {
private fun linearTransponder(
uuid: String = "linear",
upLow: Long = 145_000_000L,
upHigh: Long = 145_500_000L,
downLow: Long = 435_000_000L,
downHigh: Long? = 435_500_000L,
inverted: Boolean = false,
info: String = "Linear Transponder",
downlinkMode: String? = "USB",
uplinkMode: String? = "LSB"
) = SatRadio(
uuid = uuid, info = info, isAlive = true,
downlinkLow = downLow, downlinkHigh = downHigh,
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
)
private fun fmTransponder() = SatRadio(
uuid = "fm", info = "FM Repeater", isAlive = true,
downlinkLow = 435_600_000L, downlinkHigh = null,
downlinkMode = "FM", uplinkLow = 145_900_000L, uplinkHigh = null,
uplinkMode = "FM", isInverted = false, catnum = 99999
)
private fun pos(distanceRateKmS: Double = 0.0) = OrbitalPos().apply {
this.distanceRate = distanceRateKmS
}
@Test
fun isLinearTransponder_returnsTrueForLinear() {
assertTrue(DopplerFrequencyCalculator.isLinearTransponder(linearTransponder()))
}
@Test
fun isLinearTransponder_returnsFalseForFM() {
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(fmTransponder()))
}
@Test
fun isLinearTransponder_returnsFalseForNullDownlinkHigh() {
val xpdr = linearTransponder(downHigh = null)
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
}
@Test
fun isNamedLinearTransponder_returnsTrueForLinearTransponderName() {
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(linearTransponder()))
}
@Test
fun isNamedLinearTransponder_returnsTrueForSsbTransponderName() {
val xpdr = linearTransponder(info = "Mode V/U SSB Transponder", downlinkMode = "USB", uplinkMode = "LSB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(xpdr))
}
@Test
fun isNamedLinearTransponder_returnsFalseForRangeEntryWithoutTransponderName() {
val driftingRangeEntry = linearTransponder(info = "Upper side band (drifting)")
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
}
@Test
fun isNamedLinearTransponder_returnsTrueForAbbreviatedLinName() {
// AO-7 style: "Mode V/A (A) Lin SSB" — "Lin" abbreviation, no "transponder" word
val ao7Entry = linearTransponder(info = "Mode V/A (A) Lin SSB", downlinkMode = "USB", uplinkMode = "USB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7Entry))
val ao7CwEntry = linearTransponder(info = "Mode V/A (A) Lin CW", downlinkMode = "CW", uplinkMode = "CW")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7CwEntry))
val ao7ModeBEntry = linearTransponder(info = "Mode U/V (B) Lin", downlinkMode = "USB", uplinkMode = "LSB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7ModeBEntry))
}
@Test
fun isNamedLinearTransponder_returnsTrueForLinearWithoutTransponderWord() {
// AO-73 style: "Mode U/V Linear" — has "Linear" but no "transponder"
val ao73Entry = linearTransponder(info = "Mode U/V Linear", downlinkMode = "USB", uplinkMode = "LSB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao73Entry))
}
@Test
fun isNamedLinearTransponder_returnsFalseForDownlinkContainingLinInsideWord() {
// "Downlink" contains "lin" but is not a linear-transponder name
val downlinkEntry = linearTransponder(info = "Mode U Downlink", downlinkMode = "FM", uplinkMode = "FM")
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(downlinkEntry))
}
@Test
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
}
@Test
fun deduplicateTransponders_mergesSameFrequencyRange() {
// AO-7's Mode A: same range, SSB and CW entries
val ssb = linearTransponder(
uuid = "ssb-uuid", info = "Mode V/A (A) Lin SSB",
downlinkMode = "USB", uplinkMode = "USB"
)
val cw = linearTransponder(
uuid = "cw-uuid", info = "Mode V/A (A) Lin CW",
downlinkMode = "CW", uplinkMode = "CW"
)
val modeB = linearTransponder(
uuid = "modeb-uuid", info = "Mode U/V (B) Lin",
upLow = 432_125_000L, upHigh = 432_175_000L,
downLow = 145_925_000L, downHigh = 145_975_000L,
downlinkMode = "USB", uplinkMode = "LSB"
)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(ssb, cw, modeB))
assertEquals(2, result.size)
// SSB entry should be preferred over CW (same range)
assertEquals("ssb-uuid", result[0].uuid)
assertEquals("modeb-uuid", result[1].uuid)
}
@Test
fun deduplicateTransponders_prefersNonCwEntry() {
// JO-97: CW entry has invert=false (wrong), SSB has invert=true (correct)
val cw = linearTransponder(
uuid = "cw-uuid", info = "U/V CW Transponder",
downlinkMode = "CW", uplinkMode = "CW",
upLow = 435_100_000L, upHigh = 435_120_000L,
downLow = 145_855_000L, downHigh = 145_875_000L
)
val ssb = linearTransponder(
uuid = "ssb-uuid", info = "U/V SSB Transponder",
downlinkMode = "USB", uplinkMode = "LSB",
upLow = 435_100_000L, upHigh = 435_120_000L,
downLow = 145_855_000L, downHigh = 145_875_000L,
inverted = true
)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(cw, ssb))
assertEquals(1, result.size)
assertEquals("ssb-uuid", result[0].uuid)
// Verify the correct invert flag is preserved
assertTrue(result[0].isInverted)
}
@Test
fun deduplicateTransponders_preservesUniqueEntries() {
val t1 = linearTransponder(uuid = "t1", upLow = 145_000_000L, upHigh = 145_500_000L,
downLow = 435_000_000L, downHigh = 435_500_000L)
val t2 = linearTransponder(uuid = "t2", upLow = 435_000_000L, upHigh = 435_500_000L,
downLow = 145_000_000L, downHigh = 145_500_000L)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(t1, t2))
assertEquals(2, result.size)
}
@Test
fun computeUplinkFromDownlink_linear_noDoppler() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
assertNotNull(uplink)
assertEquals(145_200_000L, uplink)
}
@Test
fun computeDownlinkFromUplink_linear_noDoppler() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_200_000L, xpdr, orbitalPos)
assertNotNull(downlink)
assertEquals(435_200_000L, downlink)
}
@Test
fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() {
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate.
val xpdr = linearTransponder()
val orbitalPos = pos(7.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
assertNotNull(uplink)
assertTrue(uplink!! > 145_200_000L)
}
@Test
fun computeUplinkFromDownlink_fmTransponder_returnsNull() {
val orbitalPos = pos()
val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos)
assertNull(result)
}
@Test
fun computeDownlinkFromUplink_fmTransponder_returnsNull() {
val orbitalPos = pos()
val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos)
assertNull(result)
}
@Test
fun computeDownlinkFromUplink_withPositiveOffset_addsOffsetToDownlink() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
uplinkHz = 145_200_000L,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = 2_500L
)
assertEquals(435_202_500L, downlink)
}
@Test
fun computeUplinkFromDownlink_withPositiveOffset_subtractsOffsetBeforeMapping() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
downlinkHz = 435_202_500L,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = 2_500L
)
assertEquals(145_200_000L, uplink)
}
@Test
fun computeOffsetRoundTrip_handlesNegativeOffset() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
uplinkHz = 145_200_000L,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = -2_500L
)
assertEquals(435_197_500L, downlink)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
downlinkHz = downlink!!,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = -2_500L
)
assertEquals(145_200_000L, uplink)
}
@Test
fun computeUplinkFromDownlink_invertedTransponder() {
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
val orbitalPos = pos(0.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
assertNotNull(uplink)
assertEquals(145_300_000L, uplink)
}
@Test
fun computeUplinkFromDownlink_roundTrip() {
val xpdr = linearTransponder()
val orbitalPos = pos(3.5)
val originalDownlink = 435_250_000L
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(originalDownlink, xpdr, orbitalPos)
assertNotNull(uplink)
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
assertNotNull(roundTripDownlink)
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
assertTrue("Round-trip error too large: $error", error < 10000)
}
}
@@ -0,0 +1,424 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.presentation
import android.graphics.Paint
import android.graphics.LinearGradient
import android.graphics.RadialGradient
import android.graphics.Shader
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.nativeCanvas
import androidx.core.graphics.withRotation
import kotlinx.coroutines.isActive
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
import kotlin.math.sqrt
import kotlin.random.Random
@Immutable
data class BubblesStyle(
val bgColor: Color = Color.Black,
val bubbleCount: Int = 16,
val minBubbleCount: Int = 8,
val maxBubbleCount: Int = 16,
val adaptiveBubbleCount: Boolean = true,
val spawnIntervalMs: Long = 800L,
val bubbleRadiusFraction: Float = 0.24f,
val adaptiveSizing: Boolean = true,
val referenceMinSizePx: Float = 360f,
val referenceAreaPx: Float = 360f * 800f,
val minBubbleRadiusPx: Float = 16f,
val maxBubbleRadiusPx: Float = 256f,
val speedScale: Float = 0.99f,
val minVelocity: Float = 0.8f,
val maxVelocity: Float = 3.2f,
val hueRotationSpeedDps: Float = 60f, // degrees per second
)
@Composable
fun BubblesEffect(
modifier: Modifier = Modifier,
isRunning: Boolean = true,
style: BubblesStyle = BubblesStyle(),
) {
val renderer = remember { BubblesRenderer() }
var frameSignal by remember { mutableIntStateOf(0) }
// Animation loop
LaunchedEffect(isRunning, style) {
if (!isRunning) return@LaunchedEffect
var previousNanos = 0L
while (isActive) {
withFrameNanos { now ->
if (previousNanos == 0L) previousNanos = now
val deltaSec = ((now - previousNanos).coerceAtMost(MAX_STEP_NANOS)).toFloat() / NANOS_TO_SECONDS
previousNanos = now
renderer.update(deltaSec, style)
frameSignal++
}
}
}
Canvas(modifier = modifier) {
frameSignal
renderer.ensureLayout(size)
renderer.draw(this, style)
}
}
private const val MAX_STEP_NANOS = 16_666_667L // ~60 FPS
private const val NANOS_TO_SECONDS = 1_000_000_000f
private val SHELL_GRADIENT_STOPS = floatArrayOf(0f, 0.52f, 0.66f, 0.79f, 0.90f, 0.968f, 0.993f, 1f)
private val INNER_GRADIENT_STOPS = floatArrayOf(0f, 0.34f, 0.68f, 1f)
private data class Velocity(var x: Float, var y: Float)
private data class Bubble(
var x: Float,
var y: Float,
val radius: Float,
val velocity: Velocity,
val baseHue: Float, // 0-360, unique for each bubble
)
private class BubblesRenderer {
private var width = 0f
private var height = 0f
private val bubbles = mutableListOf<Bubble>()
private val random = Random(System.currentTimeMillis())
private var globalHueRotation = 0f // degrees, rotates all bubbles hues
private var spawnAccumulatorSec = 0f
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val shellColors = IntArray(8)
private val innerColors = IntArray(4)
fun ensureLayout(size: Size) {
val targetWidth = size.width
val targetHeight = size.height
if (targetWidth <= 0f || targetHeight <= 0f) return
val shouldRebuild = width != targetWidth || height != targetHeight
if (shouldRebuild) {
width = targetWidth
height = targetHeight
bubbles.clear()
spawnAccumulatorSec = 0f
}
}
fun spawnBubble(style: BubblesStyle): Boolean {
if (width <= 0f || height <= 0f) return false
val radius = resolveBubbleRadius(style)
val (velocityMin, velocityMax) = resolveVelocityRange(style)
val bubble = Bubble(
x = radius,
y = height - radius,
radius = radius,
velocity = Velocity(
x = randomInRange(velocityMin, velocityMax),
y = -randomInRange(velocityMin, velocityMax),
),
baseHue = random.nextFloat() * 360f, // Random starting hue for this bubble
)
bubbles.add(bubble)
return true
}
fun update(deltaSeconds: Float, style: BubblesStyle) {
if (width <= 0f || height <= 0f) return
spawnMissingBubbles(deltaSeconds, style)
// Rotate hue for all bubbles
globalHueRotation += style.hueRotationSpeedDps * deltaSeconds
if (globalHueRotation >= 360f) globalHueRotation -= 360f
val frameScale = deltaSeconds * 60f
for (i in bubbles.indices) {
val bubble = bubbles[i]
// Update position
bubble.x += bubble.velocity.x * frameScale
bubble.y += bubble.velocity.y * frameScale
// Bounce off walls
if (bubble.x > width - bubble.radius) {
bubble.x = width - bubble.radius
bubble.velocity.x *= -1
}
if (bubble.x < bubble.radius) {
bubble.x = bubble.radius
bubble.velocity.x *= -1
}
if (bubble.y > height - bubble.radius) {
bubble.y = height - bubble.radius
bubble.velocity.y *= -1
}
if (bubble.y < bubble.radius) {
bubble.y = bubble.radius
bubble.velocity.y *= -1
}
}
// Collision detection and resolution
for (i in bubbles.indices) {
for (j in (i + 1) until bubbles.size) {
val b1 = bubbles[i]
val b2 = bubbles[j]
if (isCollided(b1, b2)) resolveCollision(b1, b2)
}
}
}
private fun spawnMissingBubbles(deltaSeconds: Float, style: BubblesStyle) {
val targetCount = resolveTargetBubbleCount(style)
if (bubbles.size >= targetCount) return
val spawnIntervalSec = style.spawnIntervalMs.coerceAtLeast(1L) / 1_000f
spawnAccumulatorSec += deltaSeconds
while (bubbles.size < targetCount && spawnAccumulatorSec >= spawnIntervalSec) {
if (!spawnBubble(style)) break
spawnAccumulatorSec -= spawnIntervalSec
}
}
fun draw(scope: DrawScope, style: BubblesStyle) {
if (width <= 0f || height <= 0f) return
scope.drawRect(style.bgColor)
val canvas = scope.drawContext.canvas.nativeCanvas
for (i in bubbles.indices) {
drawBubbleWithGradient(canvas, bubbles[i])
}
}
private fun drawBubbleWithGradient(canvas: android.graphics.Canvas, bubble: Bubble) {
val hue = (bubble.baseHue + globalHueRotation) % 360f
val lit = 53.33f + maxOf(0f, (70f - kotlin.math.abs(244f - hue)) / 4f)
val rgb = hueToRgb(hue, lit)
val shellColor = mixWithWhite(rgb, 0.04f)
val innerColor = mixWithWhite(rgb, 0.10f)
val shimmerColor = mixWithWhite(rgb, 0.35f)
// Outer shell
val shellGradient = RadialGradient(
bubble.x, bubble.y, bubble.radius,
buildShellColors(shellColor),
SHELL_GRADIENT_STOPS,
Shader.TileMode.CLAMP
)
paint.style = Paint.Style.FILL
paint.shader = shellGradient
canvas.drawCircle(bubble.x, bubble.y, bubble.radius, paint)
// Subtle thin rim
paint.shader = null
paint.style = Paint.Style.STROKE
paint.strokeWidth = maxOf(1f, bubble.radius * 0.024f)
paint.color = colorWithAlpha(mixWithWhite(shellColor, 0.08f), 0.30f)
canvas.drawCircle(bubble.x, bubble.y, bubble.radius - paint.strokeWidth * 0.5f, paint)
// Top internal bubble
val innerTop = bubble.y - bubble.radius * 0.96f
val innerBottom = bubble.y + bubble.radius * 0.48f
val innerLeft = bubble.x - bubble.radius * 0.84f
val innerRight = bubble.x + bubble.radius * 0.84f
val innerGradient = LinearGradient(
bubble.x,
innerTop,
bubble.x,
innerBottom,
buildInnerColors(innerColor),
INNER_GRADIENT_STOPS,
Shader.TileMode.CLAMP
)
paint.style = Paint.Style.FILL
paint.shader = innerGradient
canvas.drawOval(
innerLeft,
innerTop,
innerRight,
innerBottom,
paint
)
// Top-left shimmer
paint.shader = null
paint.style = Paint.Style.FILL
paint.color = colorWithAlpha(shimmerColor, 0.95f)
val shimmerCx = bubble.x - bubble.radius * 0.40f
val shimmerCy = bubble.y - bubble.radius * 0.72f
val shimmerHalfWidth = bubble.radius * 0.06f
val shimmerHalfHeight = bubble.radius * 0.21f
canvas.withRotation(60f, shimmerCx, shimmerCy) {
drawOval(
shimmerCx - shimmerHalfWidth,
shimmerCy - shimmerHalfHeight,
shimmerCx + shimmerHalfWidth,
shimmerCy + shimmerHalfHeight,
paint
)
}
}
private fun isCollided(bubble1: Bubble, bubble2: Bubble): Boolean {
val dx = bubble1.x - bubble2.x
val dy = bubble1.y - bubble2.y
val radius = (bubble1.radius + bubble2.radius) * 0.9f
return dx * dx + dy * dy < radius * radius
}
private fun resolveCollision(particle: Bubble, otherParticle: Bubble) {
val xVelocityDiff = particle.velocity.x - otherParticle.velocity.x
val yVelocityDiff = particle.velocity.y - otherParticle.velocity.y
val xDist = otherParticle.x - particle.x
val yDist = otherParticle.y - particle.y
// Prevent accidental overlap
if (xVelocityDiff * xDist + yVelocityDiff * yDist >= 0) {
val angle = -atan2(otherParticle.y - particle.y, otherParticle.x - particle.x)
val m1 = 1f
val m2 = 1f
val u1 = rotate(particle.velocity, angle)
val u2 = rotate(otherParticle.velocity, angle)
val v1 = Velocity(
x = (u1.x * (m1 - m2)) / (m1 + m2) + (u2.x * 2 * m2) / (m1 + m2),
y = u1.y,
)
val v2 = Velocity(
x = (u2.x * (m1 - m2)) / (m1 + m2) + (u1.x * 2 * m2) / (m1 + m2),
y = u2.y,
)
val vFinal1 = rotate(v1, -angle)
val vFinal2 = rotate(v2, -angle)
particle.velocity.x = vFinal1.x
particle.velocity.y = vFinal1.y
otherParticle.velocity.x = vFinal2.x
otherParticle.velocity.y = vFinal2.y
}
}
private fun rotate(velocity: Velocity, angle: Float): Velocity {
return Velocity(
x = velocity.x * cos(angle) - velocity.y * sin(angle),
y = velocity.x * sin(angle) + velocity.y * cos(angle),
)
}
private fun buildShellColors(shellColor: Int): IntArray {
shellColors[0] = colorWithAlpha(shellColor, 0f)
shellColors[1] = colorWithAlpha(shellColor, 0f)
shellColors[2] = colorWithAlpha(shellColor, 0.04f)
shellColors[3] = colorWithAlpha(shellColor, 0.12f)
shellColors[4] = colorWithAlpha(shellColor, 0.28f)
shellColors[5] = colorWithAlpha(shellColor, 0.44f)
shellColors[6] = colorWithAlpha(shellColor, 0.58f)
shellColors[7] = colorWithAlpha(shellColor, 0.64f)
return shellColors
}
private fun buildInnerColors(innerColor: Int): IntArray {
innerColors[0] = colorWithAlpha(innerColor, 0.48f)
innerColors[1] = colorWithAlpha(innerColor, 0.36f)
innerColors[2] = colorWithAlpha(innerColor, 0.08f)
innerColors[3] = colorWithAlpha(innerColor, 0f)
return innerColors
}
private fun resolveBubbleRadius(style: BubblesStyle): Float {
val minDimension = min(width, height)
val baseRadius = minDimension * style.bubbleRadiusFraction
val minRadius = style.minBubbleRadiusPx.coerceAtLeast(1f)
val maxRadius = maxOf(minRadius, style.maxBubbleRadiusPx)
if (!style.adaptiveSizing || minDimension <= 0f) {
return baseRadius.coerceIn(minRadius, maxRadius)
}
val reference = style.referenceMinSizePx.coerceAtLeast(1f)
val dampening = sqrt((reference / minDimension).coerceAtMost(1f))
return (baseRadius * dampening).coerceIn(minRadius, maxRadius)
}
private fun resolveTargetBubbleCount(style: BubblesStyle): Int {
val baseCount = style.bubbleCount.coerceAtLeast(1)
if (!style.adaptiveBubbleCount) return baseCount
val area = width * height
val referenceArea = style.referenceAreaPx.coerceAtLeast(1f)
val areaScale = sqrt((area / referenceArea).coerceAtLeast(0.25f))
val scaledCount = (baseCount * areaScale).toInt()
val minCount = style.minBubbleCount.coerceAtLeast(1)
val maxCount = maxOf(minCount, style.maxBubbleCount.coerceAtLeast(1))
return scaledCount.coerceIn(minCount, maxCount)
}
private fun resolveVelocityRange(style: BubblesStyle): Pair<Float, Float> {
val referenceMaxVelocity = maxOf(height / 300f, 1f)
val velocityMin = (style.minVelocity * style.speedScale).coerceAtLeast(0.05f)
val velocityMax = maxOf(velocityMin, min(style.maxVelocity, referenceMaxVelocity) * style.speedScale)
return velocityMin to velocityMax
}
private fun randomInRange(min: Float, max: Float): Float {
return random.nextFloat() * (max - min) + min
}
}
private fun hueToRgb(h: Float, l: Float): Int {
val hNorm = h / 360f
val lNorm = l / 100f
val sNorm = 1f
val c = (1f - kotlin.math.abs(2f * lNorm - 1f)) * sNorm
val hp = hNorm * 6f
val x = c * (1f - kotlin.math.abs((hp % 2f) - 1f))
val m = lNorm - c / 2f
val (r, g, b) = when {
hp < 1f -> Triple(c, x, 0f)
hp < 2f -> Triple(x, c, 0f)
hp < 3f -> Triple(0f, c, x)
hp < 4f -> Triple(0f, x, c)
hp < 5f -> Triple(x, 0f, c)
else -> Triple(c, 0f, x)
}
val r8 = ((r + m) * 255).toInt().coerceIn(0, 255)
val g8 = ((g + m) * 255).toInt().coerceIn(0, 255)
val b8 = ((b + m) * 255).toInt().coerceIn(0, 255)
return (0xFF shl 24) or (r8 shl 16) or (g8 shl 8) or b8
}
private fun colorWithAlpha(color: Int, alpha: Float): Int {
val a = (alpha * 255).toInt().coerceIn(0, 255)
val r = (color shr 16) and 0xFF
val g = (color shr 8) and 0xFF
val b = color and 0xFF
return (a shl 24) or (r shl 16) or (g shl 8) or b
}
private fun mixWithWhite(color: Int, amount: Float): Int {
val t = amount.coerceIn(0f, 1f)
val r = (color shr 16) and 0xFF
val g = (color shr 8) and 0xFF
val b = color and 0xFF
val mixedR = (r + (255 - r) * t).toInt().coerceIn(0, 255)
val mixedG = (g + (255 - g) * t).toInt().coerceIn(0, 255)
val mixedB = (b + (255 - b) * t).toInt().coerceIn(0, 255)
return (0xFF shl 24) or (mixedR shl 16) or (mixedG shl 8) or mixedB
}
@@ -27,30 +27,43 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility
@@ -63,11 +76,14 @@ 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.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
@@ -134,7 +150,7 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
}
val sdfTime = remember(isUtc) {
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
}
ElevatedCard(
modifier = modifier
@@ -203,6 +219,11 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
}
}
private fun displayLocale(): Locale {
val locale = Locale.getDefault()
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
}
@Composable
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
ElevatedButton(
@@ -218,8 +239,13 @@ fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier)
}
@Composable
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier, enabled: Boolean = true) {
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action) {
fun IconCard(
action: () -> Unit, resId: Int, modifier: Modifier = Modifier,
enabled: Boolean = true, containerColor: Color = Color.Unspecified
) {
val colors = if (containerColor == Color.Unspecified) CardDefaults.elevatedCardColors()
else CardDefaults.elevatedCardColors(containerColor = containerColor)
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action, colors = colors) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
}
@@ -275,58 +301,124 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass(
)
@Composable
fun SharedDialog(
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
fun InfoDialog(
title: String,
onDismiss: () -> Unit,
onAccept: () -> Unit,
content: @Composable () -> Unit
) {
DialogShell(title = title, titleFontSize = 16, onDismissRequest = onCancel) {
content()
Row(modifier = Modifier.padding(start = it, bottom = it, end = it)) {
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
Spacer(modifier = Modifier.weight(1f))
DialogShell(onDismissRequest = onDismiss) { padding ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = padding, top = padding, end = padding)
) {
Text(
text = title,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
}
content()
}
}
@Composable
fun InfoDialog(title: String, text: String, onDismiss: () -> Unit) {
DialogShell(title = title, titleFontSize = 18, onDismissRequest = {}) {
fun ConfirmDialog(
title: String,
onCancel: () -> Unit,
onAccept: () -> Unit,
content: @Composable () -> Unit
) {
DialogShell(onDismissRequest = onCancel) { padding ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = padding, top = padding, end = padding)
) {
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
Text(
text = title,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
.padding(horizontal = padding)
)
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
}
content()
}
}
@Composable
fun WhatsNewDialog(onDismiss: () -> Unit) {
InfoDialog(
title = stringResource(R.string.pass_whatsnew_title),
onDismiss = onDismiss,
onAccept = onDismiss
) {
Text(
text = text,
text = stringResource(R.string.pass_whatsnew_message),
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = it)
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
Row(modifier = Modifier.padding(start = it, bottom = it, end = it)) {
Spacer(modifier = Modifier.weight(1f))
CardButton(onClick = onDismiss, text = stringResource(R.string.btn_accept))
}
Spacer(modifier = Modifier.height(0.dp))
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DialogShell(
title: String,
titleFontSize: Int,
onDismissRequest: () -> Unit,
content: @Composable (padding: androidx.compose.ui.unit.Dp) -> Unit
content: @Composable (padding: Dp) -> Unit
) {
val padding = LocalSpacing.current.large
Dialog(onDismissRequest = onDismissRequest) {
ElevatedCard {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(padding)
) {
Text(
text = title,
fontSize = titleFontSize.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = padding, top = padding, end = padding)
)
content(padding)
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val containerHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
val maxSheetHeight = containerHeight - statusBarHeight
val stopSheetFling = remember {
object : NestedScrollConnection {
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
return available
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset = Offset.Zero
}
}
ModalBottomSheet(
onDismissRequest = onDismissRequest,
sheetState = sheetState,
dragHandle = null,
shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
scrimColor = Color.Black.copy(alpha = 0.64f)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(padding),
modifier = Modifier
.fillMaxWidth()
.heightIn(max = maxSheetHeight)
.nestedScroll(stopSheetFling)
) {
content(padding)
}
}
}
@@ -391,13 +483,26 @@ fun TopBar(
@Composable
fun elevationColor(elevation: Double): Color {
val thresholds = LocalElevationThresholds.current
return when {
elevation < 15.0 -> Color(0xFFEF5350) // soft red for low elevation
elevation < 45.0 -> MaterialTheme.colorScheme.primary // accent yellow for normal
else -> Color(0xFF66BB6A) // soft green for high elevation
elevation < thresholds.low -> ElevationLowColor // soft red for low elevation
elevation < thresholds.high -> MaterialTheme.colorScheme.primary // accent yellow for normal
else -> ElevationHighColor // soft green for high elevation
}
}
/** User-configurable elevation highlight thresholds (in degrees). */
data class ElevationThresholds(val low: Double = 15.0, val high: Double = 45.0)
/** Provided at the app root from settings; defaults keep the original 15°/45° behavior. */
val LocalElevationThresholds = compositionLocalOf { ElevationThresholds() }
/** Soft red used for elevations below the low threshold. */
val ElevationLowColor = Color(0xFFEF5350)
/** Soft green used for elevations above the high threshold. */
val ElevationHighColor = Color(0xFF66BB6A)
@Composable
fun OutlinedText(
text: String,
@@ -79,6 +79,10 @@ private val lightScheme = lightColorScheme(
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFF1E1BB),
onSecondaryContainer = Color(0xFF221B04),
tertiary = Color(0xFF3C6FE0), // AMSAT Active (darker for light theme)
onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFE09800), // AMSAT Telemetry (darker)
onTertiaryContainer = Color(0xFF000000),
background = Color(0xFFFFF8F0),
onBackground = Color(0xFF1E1B13),
surface = Color(0xFFFFF8F0),
@@ -101,10 +105,10 @@ private val darkScheme = darkColorScheme(
onSecondary = Color(0xFF000000),
secondaryContainer = Color(0xFF404040), // navBar indicator,
onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon
// tertiary = Color(0xFF121212),
// onTertiary = Color(0xFF121212),
// tertiaryContainer = Color(0xFF121212),
// onTertiaryContainer = Color(0xFF121212),
tertiary = Color(0xFF648FFF), // AMSAT Active (from amsat.org/status)
onTertiary = Color(0xFF000000),
tertiaryContainer = Color(0xFFFFB000), // AMSAT Telemetry
onTertiaryContainer = Color(0xFF000000),
background = Color(0xFF121212),
onBackground = Color(0xFFE0E0E0),
surface = Color(0xFF202020), // card background
@@ -0,0 +1,260 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.presentation
import android.graphics.Paint
import android.graphics.Typeface
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.isActive
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.random.Random
@Immutable
data class MatrixStyle(
val bgColor: Color = Color.Black,
val bodyColor: Color = Color(0xFF29C94A),
val headColor: Color = Color(0xFFC2FFC6),
val tailAlphaFloor: Float = 0.14f, // 0.14f - 0.18f,
val fontSize: TextUnit = 14.sp, // 11.sp - 14.sp
val minStreamLength: Int = 8, // 6 - 8
val maxStreamLength: Int = 28, // 20 - 28
val minSpeedRps: Float = 10f, // 10f - 15f
val maxSpeedRps: Float = 38f, // 38f - 45f
val resetPauseSec: ClosedFloatingPointRange<Float> = 0.1f..1.0f,
)
@Composable
fun MatrixEffect(
modifier: Modifier = Modifier,
isRunning: Boolean = true,
style: MatrixStyle = MatrixStyle(),
symbols: String = DEFAULT_SYMBOLS,
) {
val density = LocalDensity.current
val renderer = remember { MatrixRenderer() }
var frameSignal by remember { mutableIntStateOf(0) }
LaunchedEffect(isRunning, style, symbols) {
if (!isRunning) return@LaunchedEffect
var previousNanos = 0L
while (isActive) {
withFrameNanos { now ->
if (previousNanos == 0L) previousNanos = now
val deltaSec = ((now - previousNanos).coerceAtMost(MAX_STEP_NANOS)).toFloat() / NANOS_TO_SECONDS
previousNanos = now
renderer.update(deltaSec, style)
frameSignal++
}
}
}
Canvas(modifier = modifier) {
frameSignal
renderer.ensureLayout(size, density.density, style, symbols)
renderer.draw(this, style)
}
}
private const val MAX_STEP_NANOS = 33_333_333L
private const val NANOS_TO_SECONDS = 1_000_000_000f
private const val DEFAULT_SYMBOLS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789<>=*+-~:;/[]{}()"
private fun Color.toArgb(): Int {
val a = (alpha.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
val r = (red.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
val g = (green.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
val b = (blue.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
return (a shl 24) or (r shl 16) or (g shl 8) or b
}
private class MatrixRenderer {
private var columns = 0
private var rows = 0
private var width = 0f
private var height = 0f
private var fontSizePx = 0f
private var charWidth = 0f
private var charHeight = 0f
private var baselineOffset = 0f
private var symbolSet = ""
private var symbols = charArrayOf()
private var glyphs = charArrayOf()
private var streams = emptyArray<StreamState>()
private val bodyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE }
private val headPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE }
private val charBuffer = CharArray(1)
private val random = Random(System.currentTimeMillis())
fun ensureLayout(size: Size, density: Float, style: MatrixStyle, symbols: String) {
val targetFontPx = style.fontSize.value * density
val targetWidth = size.width
val targetHeight = size.height
val targetSymbols = symbols.ifBlank { DEFAULT_SYMBOLS }
if (targetWidth <= 0f || targetHeight <= 0f) return
val shouldRebuild = width != targetWidth ||
height != targetHeight ||
fontSizePx != targetFontPx ||
symbolSet != targetSymbols
if (!shouldRebuild) return
width = targetWidth
height = targetHeight
fontSizePx = targetFontPx
symbolSet = targetSymbols
this.symbols = targetSymbols.toCharArray()
bodyPaint.textSize = targetFontPx
headPaint.textSize = targetFontPx
charWidth = max(bodyPaint.measureText("W"), 1f)
val metrics = bodyPaint.fontMetrics
charHeight = max(metrics.descent - metrics.ascent, 1f)
baselineOffset = -metrics.ascent
columns = max((width / charWidth).toInt(), 1)
rows = max((height / charHeight).toInt() + 2, 1)
glyphs = CharArray(columns * rows) { randomGlyph() }
streams = Array(columns) { StreamState.random(rows, style, random) }
}
fun update(deltaSeconds: Float, style: MatrixStyle) {
if (columns == 0 || rows == 0 || deltaSeconds <= 0f) return
for (column in streams.indices) {
val stream = streams[column]
stream.pauseSec -= deltaSeconds
if (stream.pauseSec > 0f) continue
val previousHead = floor(stream.headRow).toInt()
stream.headRow += stream.speedRps * deltaSeconds
val newHead = floor(stream.headRow).toInt()
if (newHead > previousHead) {
for (row in (previousHead + 1)..newHead) {
if (row in 0 until rows) {
glyphs[row * columns + column] = randomGlyph()
}
}
}
if (newHead - stream.length > rows) {
stream.reset(rows, style, random)
}
}
}
fun draw(scope: androidx.compose.ui.graphics.drawscope.DrawScope, style: MatrixStyle) {
if (columns == 0 || rows == 0) return
scope.drawRect(style.bgColor)
bodyPaint.color = style.bodyColor.toArgb()
headPaint.color = style.headColor.toArgb()
val tailAlphaFloor = style.tailAlphaFloor.coerceIn(0f, 1f)
val canvas = scope.drawContext.canvas.nativeCanvas
for (column in streams.indices) {
val stream = streams[column]
if (stream.pauseSec > 0f) continue
val head = floor(stream.headRow).toInt()
val startRow = max(0, head - stream.length + 1)
val endRow = min(rows - 1, head)
if (startRow > endRow) continue
for (row in endRow downTo startRow) {
val tailIndex = head - row
val paint = if (tailIndex == 0) headPaint else bodyPaint
if (tailIndex != 0) {
val normalized = ((stream.length - tailIndex).toFloat() / stream.length).coerceIn(0f, 1f)
val alpha = tailAlphaFloor + (1f - tailAlphaFloor) * normalized
paint.alpha = (alpha * 255).toInt()
} else {
paint.alpha = 255
}
val glyph = glyphs[row * columns + column]
charBuffer[0] = glyph
val x = column * charWidth
val y = row * charHeight + baselineOffset
canvas.drawText(charBuffer, 0, 1, x, y, paint)
}
}
}
private fun randomGlyph(): Char = symbols[random.nextInt(symbols.size)]
}
private data class StreamState(
var headRow: Float,
var length: Int,
var speedRps: Float,
var pauseSec: Float,
) {
fun reset(rows: Int, style: MatrixStyle, random: Random) {
val randomOffset = random.nextFloat() * rows
headRow = -randomOffset
length = random.nextInt(
from = style.minStreamLength.coerceAtLeast(2),
until = (style.maxStreamLength.coerceAtLeast(style.minStreamLength + 1) + 1),
)
speedRps = random.nextFloat() * (style.maxSpeedRps - style.minSpeedRps) + style.minSpeedRps
pauseSec = random.nextFloat() * (style.resetPauseSec.endInclusive - style.resetPauseSec.start) +
style.resetPauseSec.start
}
companion object {
fun random(rows: Int, style: MatrixStyle, random: Random): StreamState {
val length = random.nextInt(
from = style.minStreamLength.coerceAtLeast(2),
until = (style.maxStreamLength.coerceAtLeast(style.minStreamLength + 1) + 1),
)
return StreamState(
headRow = -random.nextFloat() * rows,
length = length,
speedRps = random.nextFloat() * (style.maxSpeedRps - style.minSpeedRps) + style.minSpeedRps,
pauseSec = random.nextFloat() * (style.resetPauseSec.endInclusive - style.resetPauseSec.start) +
style.resetPauseSec.start,
)
}
}
}
@@ -24,13 +24,13 @@ import kotlinx.serialization.Serializable
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
@Serializable
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat)
data object Satellites : Screen(R.drawable.ic_sputnik, R.string.nav_sat)
@Serializable
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
@Serializable
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
data object Status : Screen(R.drawable.ic_satellite, R.string.nav_status)
@Serializable
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="15"
android:viewportHeight="15">
<path
android:fillColor="@android:color/white"
android:pathData="M13.91,6.75c-1.17,2.25 -4.3,5.31 -6.07,6.94c-0.19,0.172 -0.48,0.172 -0.67,0C5.39,12.06 2.26,9 1.09,6.75C-1.48,1.8 5,-1.5 7.5,3.45C10,-1.5 16.48,1.8 13.91,6.75z" />
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M14,21c1.93,0 3.62,-1.17 4,-3l-1.75,-0.88C16,18.21 15.33,19 14,19l-4.9,0c0.83,-1 1.5,-2.34 1.5,-4c0,-0.35 -0.03,-0.69 -0.08,-1L14,14v-2l-4.18,0C9,10.42 8,9.6 8,8c0,-1.93 1.57,-3.5 3.5,-3.5c1.5,0 2.79,0.95 3.28,2.28L16.63,6c-0.8,-2.05 -2.79,-3.5 -5.13,-3.5C8.46,2.5 6,4.96 6,8c0,1.78 0.79,2.9 1.49,4L6,12v2l2.47,0c0.08,0.31 0.13,0.64 0.13,1c0,2.7 -2.6,4 -2.6,4v2H14z" />
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80L520,80L520,411Q538,422 549,439.5Q560,457 560,480Q560,513 536.5,536.5Q513,560 480,560Q447,560 423.5,536.5Q400,513 400,480Q400,457 411,439Q422,421 440,411L440,325Q388,339 354,381.5Q320,424 320,480Q320,546 367,593Q414,640 480,640Q546,640 593,593Q640,546 640,480Q640,444 625.5,413.5Q611,383 586,360L643,303Q678,336 699,381.5Q720,427 720,480Q720,580 650,650Q580,720 480,720Q380,720 310,650Q240,580 240,480Q240,390 297,323.5Q354,257 440,243L440,162Q321,177 240.5,267Q160,357 160,480Q160,614 253,707Q346,800 480,800Q614,800 707,707Q800,614 800,480Q800,411 773,351Q746,291 699,247L756,190Q813,245 846.5,319.5Q880,394 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
</vector>
@@ -0,0 +1,31 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="128"
android:viewportHeight="128">
<path
android:pathData="m105.14,114.12a2.86,2.86 90,0 0,1.7 -0.85l19.33,-19.33a2.86,2.86 90,0 0,-0 -4.03l-26.58,-26.62a2.86,2.86 90,0 0,-1.84 -0.81l-7.92,-0.63 5.77,-5.77a2.86,2.86 90,0 0,-0 -4.03l-19.6,-19.64a2.86,2.86 90,0 0,-4.07 -0l-5.77,5.77 -0.63,-7.92a2.86,2.86 90,0 0,-0.81 -1.83l-26.63,-26.58a2.86,2.86 90,0 0,-4.07 -0l-19.29,19.29a2.86,2.86 90,0 0,-0 4.07l26.58,26.62a2.86,2.86 90,0 0,1.83 0.81l7.92,0.63 -5.77,5.77a2.86,2.86 90,0 0,-0 4.07l19.6,19.6a2.86,2.86 90,0 0,4.07 -0l5.77,-5.77 0.63,7.92a2.86,2.86 90,0 0,0.81 1.83l26.63,26.58a2.86,2.86 90,0 0,2.06 0.85,2.86 2.86,90 0,0 0.27,-0zM104.82,107.23 L80.97,83.38 80.16,72.69 85.58,67.27 96.28,68.12 120.13,91.93zM55.33,47.85 L44.63,47 20.78,23.19 36.08,7.89 59.89,31.74 60.74,42.44z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
<path
android:pathData="m62.65,115.81a2.86,2.86 90,1 0,-0 -5.73c-11.46,-0 -22.88,-4.35 -31.63,-13.11 -8.74,-8.74 -13.1,-20.17 -13.1,-31.61a2.86,2.86 90,1 0,-5.73 -0c-0,12.9 4.94,25.83 14.77,35.66 9.85,9.85 22.77,14.78 35.68,14.78z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
<path
android:pathData="m62.65,127a2.86,2.86 90,1 0,-0 -5.73c-14.32,-0 -28.61,-5.44 -39.55,-16.37 -10.93,-10.93 -16.38,-25.23 -16.38,-39.54a2.86,2.86 90,1 0,-5.73 -0c-0,15.76 6.03,31.56 18.06,43.59 12.03,12.03 27.82,18.05 43.6,18.05z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
<path
android:pathData="m62.65,104.7a2.86,2.86 90,1 0,-0 -5.73c-8.62,-0 -17.2,-3.26 -23.78,-9.84 -6.58,-6.58 -9.86,-15.17 -9.86,-23.77a2.86,2.86 90,1 0,-5.73 -0c-0,10.06 3.86,20.15 11.53,27.82 7.67,7.67 17.76,11.52 27.83,11.52z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
<path
android:pathData="m62.65,93.48a2.86,2.86 90,1 0,-0 -5.73c-5.74,-0 -11.45,-2.17 -15.84,-6.56 -4.38,-4.38 -6.56,-10.1 -6.56,-15.83a2.86,2.86 90,1 0,-5.73 -0c-0,7.19 2.76,14.41 8.24,19.88 5.48,5.48 12.69,8.23 19.89,8.23z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M240,800L280,640L120,640L140,560L300,560L340,400L180,400L200,320L360,320L400,160L480,160L440,320L600,320L640,160L720,160L680,320L840,320L820,400L660,400L620,560L780,560L760,640L600,640L560,800L480,800L520,640L360,640L320,800L240,800ZM380,560L540,560L580,400L420,400L380,560Z" />
</vector>
@@ -13,12 +13,16 @@
<string name="nav_prefs">Ajustes</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Tipo: %s</string>
<string name="sat_type_title">Seleccionar tipo de satélite</string>
<string name="sat_type_hint">Modos: %s</string>
<string name="sat_type_title">Seleccionar modos</string>
<string name="sat_search_hint">Id - Nombre</string>
<string name="sat_search_clear">Vaciar</string>
<string name="sat_clear_all">Vaciar todo</string>
<string name="sat_select_all">Seleccionar todo</string>
<string name="sat_group_selected">Seleccionados</string>
<string name="sat_group_available">Disponibles</string>
<string name="sat_group_selected_count">Seleccionados (%1$d)</string>
<string name="sat_group_available_count">Disponibles (%1$d)</string>
<string name="sat_empty_list_message">
Asegúrese de que su consulta de búsqueda sea correcta y que la base de datos esté actualizada</string>
<string name="sat_warning_title">Alarma\!</string>
@@ -26,13 +30,16 @@
Esta aplicación incluye más de 9000 satélites.
No tiene sentido rastrearlos todos a la vez.
\n\nIntenta siempre limitar la lista a los que te
interesen mediante la búsqueda y el selector de tipos.</string>
interesen mediante la búsqueda y el selector de modos.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Filtrar pases</string>
<string name="pass_filter_elev">Elevación mínima</string>
<string name="pass_filter_hours">Horas por delante</string>
<string name="pass_modes_title">Tipo de modulación</string>
<string name="pass_filter_hours">Tiempo por delante</string>
<string name="pass_filter_aos_time">Ventana AOS</string>
<string name="pass_filter_invert_time">Invertir ventana AOS</string>
<string name="pass_filter_deep_space">DeepSpace (período &gt;225min)</string>
<string name="pass_modes_title">Seleccionar modos</string>
<string name="pass_elevation">Elevación: %.1f°</string>
<string name="pass_altitude">Altitud: %.0f km</string>
<string name="pass_empty_list_message">
@@ -93,11 +100,11 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Ubicación QTH no válida</string>
<string name="prefs_loc_success">Ubicación actualizada con éxito</string>
<string name="prefs_station_title">Ajustar ubicaicón de la estación</string>
<string name="prefs_station_lat_text">Latitud de tu estación terrestre</string>
<string name="prefs_station_lon_text">Longitud de tu estación terrestre</string>
<string name="prefs_locator_title">Ajustes ubicación QTH</string>
<string name="prefs_locator_text">Ajustar posición de la estación con tu ubicación</string>
<string name="prefs_station_title">Ubicación de la estación</string>
<string name="prefs_station_lat_text">Latitud de estación</string>
<string name="prefs_station_lon_text">Longitud de estación</string>
<string name="prefs_locator_title">Localizador QTH</string>
<string name="prefs_locator_text">Posición por localizador</string>
<string name="prefs_data_title">Datos satelitales</string>
<string name="prefs_data_entries">Satélites: %s</string>
@@ -107,37 +114,57 @@
<string name="prefs_data_clear">Vaciar</string>
<string name="prefs_data_clear_success">Datos eliminados con éxito</string>
<string name="prefs_data_update_success">Actualización completada con éxito</string>
<string name="prefs_data_import_satellites_error">No se importaron satélites. Usa un TLE/3LE (.txt) u OMM (.csv) válido.</string>
<string name="prefs_data_import_transceivers_error">No se importaron transceptores. Usa un SatNOGS (.json) válido.</string>
<string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_title">Fuentes personalizadas</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL transceptores</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
<string name="prefs_data_output_title">Salida de datos</string>
<string name="prefs_net_output">Red</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="prefs_net_title">Salida de datos de red</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="nav_radiocontrol">Control de radio</string>
<string name="rc_settings_title">Control CAT</string>
<string name="rc_radio_model">Modelo de radio</string>
<string name="rc_tx_device_hint">Dirección BT TX</string>
<string name="rc_rx_device_hint">Dirección BT RX</string>
<string name="rc_tx_name_hint">Nombre radio TX</string>
<string name="rc_rx_name_hint">Nombre radio RX</string>
<string name="rc_enable_switch">Activar CAT</string>
<string name="prefs_net_title">Salida de red</string>
<string name="prefs_net_rotator_switch">Activar salida de rotación</string>
<string name="prefs_net_rotator_address_hint">IP:Puerto</string>
<string name="prefs_net_rotator_format_hint">Formato de datos</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_frequency_switch">Activar salida de frecuencia</string>
<string name="prefs_net_frequency_address_hint">IP:Puerto</string>
<string name="prefs_net_frequency_format_hint">Formato de datos</string>
<string name="prefs_bt_title">Salida por Bluetooth</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">Id dispositivo</string>
<string name="prefs_bt_rotator_output_hint">Formato de datos</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">Id dispositivo</string>
<string name="prefs_bt_frequency_output_hint">Formato de datos</string>
<string name="prefs_bt_title">Salida Bluetooth</string>
<string name="prefs_bt_rotator_switch">Activar salida de rotación</string>
<string name="prefs_bt_rotator_device_hint">ID dispositivo</string>
<string name="prefs_bt_rotator_output_hint">Formato</string>
<string name="prefs_bt_frequency_switch">Activar salida de frecuencia</string>
<string name="prefs_bt_frequency_device_hint">ID dispositivo</string>
<string name="prefs_bt_frequency_output_hint">Formato</string>
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</string>
<string name="prefs_net_perm_error">Revisa los permisos de red</string>
<string name="prefs_other_title">Otros ajustes</string>
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
<string name="prefs_other_title">Otros</string>
<string name="prefs_other_switch_utc">Hora UTC</string>
<string name="prefs_other_switch_update">Actualización automática</string>
<string name="prefs_other_switch_sweep">Animación radar</string>
<string name="prefs_other_switch_sensors">Sensores</string>
<string name="prefs_other_switch_night_mode">Filtro nocturno</string>
<string name="prefs_highlight_title">Resaltado de elevación</string>
<string name="prefs_highlight_low">Baja &lt; %1$d°</string>
<string name="prefs_highlight_mid">Media %1$d-%2$d°</string>
<string name="prefs_highlight_high">Alta &gt; %1$d°</string>
<string name="prefs_outro_title">Me gustaría dar las gracias a:</string>
<string name="prefs_outro_license">La app viene sin garantías de ningún tipo.</string>
@@ -13,25 +13,32 @@
<string name="nav_prefs">Настройки</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Тип: %s</string>
<string name="sat_type_title">Выберите тип спутника</string>
<string name="sat_type_hint">Режимы: %s</string>
<string name="sat_type_title">Выберите режимы</string>
<string name="sat_search_hint">Id - Название</string>
<string name="sat_search_clear">Очистить</string>
<string name="sat_clear_all">Очистить</string>
<string name="sat_select_all">Выбрать</string>
<string name="sat_group_selected">Выбранные</string>
<string name="sat_group_available">Доступные</string>
<string name="sat_group_selected_count">Выбранные (%1$d)</string>
<string name="sat_group_available_count">Доступные (%1$d)</string>
<string name="sat_empty_list_message">Убедитесь, что ваш поисковый запрос верен и база данных обновлена</string>
<string name="sat_warning_title">Внимание\!</string>
<string name="sat_warning_message">
В этом приложении перечислено более 9000 спутников.
Отслеживать их все одновременно бессмысленно.
\n\nВсегда старайтесь сузить список до тех,
которые вас интересуют, используя поиск и селектор типов.</string>
которые вас интересуют, используя поиск и выбор режимов.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Фильтровать пролеты</string>
<string name="pass_filter_elev">Минимальная элевация</string>
<string name="pass_filter_hours">Количество часов</string>
<string name="pass_modes_title">Выберите тип модуляции</string>
<string name="pass_filter_hours">Период расчёта</string>
<string name="pass_filter_aos_time">Окно AOS</string>
<string name="pass_filter_invert_time">Инвертировать окно AOS</string>
<string name="pass_filter_deep_space">DeepSpace (период &gt;225мин)</string>
<string name="pass_modes_title">Выберите режимы</string>
<string name="pass_elevation">Элевация: %.1f°</string>
<string name="pass_altitude">Высота: %d км</string>
<string name="pass_empty_list_message">
@@ -92,11 +99,11 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Неправильный локатор QTH</string>
<string name="prefs_loc_success">Позиция успешно обновлена</string>
<string name="prefs_station_title">Настройки местоположения</string>
<string name="prefs_station_lat_text">Введите широту наземной станции</string>
<string name="prefs_station_lon_text">Введите долготу наземной станции</string>
<string name="prefs_locator_title">Настройки локатора QTH</string>
<string name="prefs_locator_text">Введите позицию через локатор</string>
<string name="prefs_station_title">Положение станции</string>
<string name="prefs_station_lat_text">Широта станции</string>
<string name="prefs_station_lon_text">Долгота станции</string>
<string name="prefs_locator_title">Локатор QTH</string>
<string name="prefs_locator_text">Позиция по локатору</string>
<string name="prefs_data_title">Данные спутников</string>
<string name="prefs_data_entries">Спутников: %s</string>
@@ -106,16 +113,30 @@
<string name="prefs_data_clear">Очистить</string>
<string name="prefs_data_clear_success">Очистка прошла успешно</string>
<string name="prefs_data_update_success">Обновление прошло успешно</string>
<string name="prefs_data_import_satellites_error">Спутники не импортированы. Нужен TLE/3LE (.txt) или OMM (.csv).</string>
<string name="prefs_data_import_transceivers_error">Трансиверы не импортированы. Нужен SatNOGS (.json).</string>
<string name="prefs_data_sources_title">Свои источники данных</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom Transceivers URL</string>
<string name="prefs_data_sources_title">Свои источники</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL трансиверов</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Вывод данных</string>
<string name="prefs_data_output_title">Вывод</string>
<string name="prefs_net_output">Сеть</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="prefs_net_title">Вывод сетевых данных</string>
<string name="nav_radiocontrol">Управление радио</string>
<string name="rc_settings_title">CAT управление</string>
<string name="rc_radio_model">Модель радио</string>
<string name="rc_tx_device_hint">BT адрес TX</string>
<string name="rc_rx_device_hint">BT адрес RX</string>
<string name="rc_tx_name_hint">Имя радио TX</string>
<string name="rc_rx_name_hint">Имя радио RX</string>
<string name="rc_enable_switch">Включить CAT</string>
<string name="prefs_net_title">Сетевой вывод</string>
<string name="prefs_net_rotator_switch">Включить вывод ротатора</string>
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
<string name="prefs_net_rotator_format_hint">Формат</string>
@@ -123,20 +144,27 @@
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
<string name="prefs_net_frequency_format_hint">Формат</string>
<string name="prefs_bt_title">Вывод данных Bluetooth</string>
<string name="prefs_bt_title">Вывод Bluetooth</string>
<string name="prefs_bt_rotator_switch">Включить вывод ротатора</string>
<string name="prefs_bt_rotator_device_hint">Id устройства</string>
<string name="prefs_bt_rotator_device_hint">ID устройства</string>
<string name="prefs_bt_rotator_output_hint">Формат</string>
<string name="prefs_bt_frequency_switch">Включить вывод частоты</string>
<string name="prefs_bt_frequency_device_hint">Id устройства</string>
<string name="prefs_bt_frequency_device_hint">ID устройства</string>
<string name="prefs_bt_frequency_output_hint">Формат</string>
<string name="prefs_bt_perm_error">Нет разрешения использовать bluetooth</string>
<string name="prefs_net_perm_error">Нет разрешения использовать сеть</string>
<string name="prefs_other_title">Другие настройки</string>
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
<string name="prefs_other_title">Прочее</string>
<string name="prefs_other_switch_utc">Время в UTC</string>
<string name="prefs_other_switch_update">Автообновление данных</string>
<string name="prefs_other_switch_sweep">Анимация радара</string>
<string name="prefs_other_switch_sensors">Сенсоры</string>
<string name="prefs_other_switch_night_mode">Ночной фильтр</string>
<string name="prefs_highlight_title">Подсветка высоты</string>
<string name="prefs_highlight_low">Низко &lt; %1$d°</string>
<string name="prefs_highlight_mid">Средне %1$d-%2$d°</string>
<string name="prefs_highlight_high">Высоко &gt; %1$d°</string>
<string name="prefs_outro_title">Я хотел бы сказать спасибо:</string>
<string name="prefs_outro_license">Это ПО поставляется без гарантий.</string>
@@ -13,26 +13,33 @@
<string name="nav_prefs">පසුතල</string>
<!-- Satellites screen -->
<string name="sat_type_hint">වර්ගය: %s</string>
<string name="sat_type_title">චන්ද්‍රිකා ආකාරය තෝරන්න</string>
<string name="sat_type_hint">මාදිලි: %s</string>
<string name="sat_type_title">මාදිලි තෝරන්න</string>
<string name="sat_search_hint">Id - නම</string>
<string name="sat_search_clear">පිරිසිදු ක°</string>
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
<string name="sat_select_all">සියල්ල තෝරන්න</string>
<string name="sat_group_selected">තෝරාගත්</string>
<string name="sat_group_available">තිබෙන</string>
<string name="sat_group_selected_count">තෝරාගත් (%1$d)</string>
<string name="sat_group_available_count">තිබෙන (%1$d)</string>
<string name="sat_empty_list_message">
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
<string name="sat_warning_title">අවවාදයයි\!</string>
<string name="sat_warning_message">
මෙම යෙදුමේ ලැයිස්තුගත කර ඇති චන්ද්‍රිකා 9000 කට වඩා තිබේ.
ඒවා සියල්ලම එකවර නිරීක්ෂණය කිරීම තේරුමක් නැති දෙයක්.
\n\nසෙවීම සහ වර්ග තේරීම හරහා ඔබ උනන්දුවක් දක්වන
\n\nසෙවීම සහ මාදිලි තේරීම හරහා ඔබ උනන්දුවක් දක්වන
ඒවාට පමණක් ලැයිස්තුව පටු කිරීමට සැමවිටම උත්සාහ කරන්න.</string>
<!-- Passes screen -->
<string name="pass_filter_title">පසුකිරිම් පිරිපහදුව</string>
<string name="pass_filter_elev">අවම උත්තෝලනය</string>
<string name="pass_filter_hours">ඉදිරි පැය</string>
<string name="pass_modes_title">මූර්ජන ආකාරය තෝරන්න</string>
<string name="pass_filter_hours">ඉදිරි කාලය</string>
<string name="pass_filter_aos_time">AOS කවුළුව</string>
<string name="pass_filter_invert_time">AOS කවුළුව ප්‍රතිවිරුද්ධ කරන්න</string>
<string name="pass_filter_deep_space">DeepSpace (කාලය &gt;225min)</string>
<string name="pass_modes_title">මූර්ජන තෝරන්න</string>
<string name="pass_elevation">උත්තෝලනය: %.1f°</string>
<string name="pass_altitude">උන්නතාශය: %d km</string>
<string name="pass_empty_list_message">
@@ -93,11 +100,11 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">QTH locator වලංගු නොවේ</string>
<string name="prefs_loc_success">පිහිටීම යාවත්කාලීනය සාර්ථකයි</string>
<string name="prefs_station_title">පොළෙහි පිහිටීම් පසුතල</string>
<string name="prefs_station_lat_text">ඔබගේ පොළෙහි අක්‍ෂාංශය සකසන්න</string>
<string name="prefs_station_lon_text">ඔබගේ පොළෙහි දේශාංශය සකසන්න</string>
<string name="prefs_locator_title">QTH locator පසුතල</string>
<string name="prefs_locator_text"> ඔබගේ locator භාවිතා කර පොළහි පිහිටීම සකසන්න</string>
<string name="prefs_station_title">පොළ පිහිටීම</string>
<string name="prefs_station_lat_text">පොළ අක්‍ෂාංශය</string>
<string name="prefs_station_lon_text">පොළ දේශාංශය</string>
<string name="prefs_locator_title">QTH locator</string>
<string name="prefs_locator_text">locator මඟින් පොළ පිහිටීම සකසන්න</string>
<string name="prefs_data_title">චන්ද්‍රිකා දත්ත</string>
<string name="prefs_data_entries">චන්ද්‍රිකා: %s</string>
@@ -107,37 +114,57 @@
<string name="prefs_data_clear">පිරිසිදු ක°</string>
<string name="prefs_data_clear_success">දත්ත ඉවත් කිරීම සාර්ථකයි</string>
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
<string name="prefs_data_import_satellites_error">චන්ද්‍රිකා ආයාත නොවීය. වලංගු TLE/3LE (.txt) හෝ OMM (.csv) ගොනුවක් තෝරන්න.</string>
<string name="prefs_data_import_transceivers_error">සම්ප්‍රේෂක ආයාත නොවීය. වලංගු SatNOGS (.json) ගොනුවක් තෝරන්න.</string>
<string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_title">අභිරුචි මූලාශ්‍ර</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
<string name="prefs_data_output_title">දත්ත ප්‍රතිදානය</string>
<string name="prefs_net_output">ජාලය</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="nav_radiocontrol">රේඩියෝ පාලනය</string>
<string name="rc_settings_title">CAT පාලනය</string>
<string name="rc_radio_model">රේඩියෝ මාදිලිය</string>
<string name="rc_tx_device_hint">TX BT ලිපිනය</string>
<string name="rc_rx_device_hint">RX BT ලිපිනය</string>
<string name="rc_tx_name_hint">TX රේඩියෝ නම</string>
<string name="rc_rx_name_hint">RX රේඩියෝ නම</string>
<string name="rc_enable_switch">CAT සබල කරන්න</string>
<string name="prefs_net_title">ජාල ප්‍රතිදානය</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="prefs_net_rotator_switch">Rotation ප්‍රතිදානය සබල කරන්න</string>
<string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">දත්ත අකාරය</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_rotator_format_hint">දත්ත ආකෘතිය</string>
<string name="prefs_net_frequency_switch">Frequency ප්‍රතිදානය සබල කරන්න</string>
<string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">දත්ත අකාරය</string>
<string name="prefs_net_frequency_format_hint">දත්ත ආකෘතිය</string>
<string name="prefs_bt_title">Bluetooth දත්ත ප්‍රතිදානය</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">උපාංග id</string>
<string name="prefs_bt_rotator_output_hint">දත්ත අකාරය</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">උපාංග id</string>
<string name="prefs_bt_frequency_output_hint">දත්ත අකාරය</string>
<string name="prefs_bt_title">Bluetooth ප්‍රතිදානය</string>
<string name="prefs_bt_rotator_switch">Rotation ප්‍රතිදානය සබල කරන්න</string>
<string name="prefs_bt_rotator_device_hint">උපාංග ID</string>
<string name="prefs_bt_rotator_output_hint">දත්ත ආකෘතිය</string>
<string name="prefs_bt_frequency_switch">Frequency ප්‍රතිදානය සබල කරන්න</string>
<string name="prefs_bt_frequency_device_hint">උපාංග ID</string>
<string name="prefs_bt_frequency_output_hint">දත්ත ආකෘතිය</string>
<string name="prefs_bt_perm_error">Bluetooth අවසර පරික්‍ෂා ක°</string>
<string name="prefs_net_perm_error">Network අවසර පරික්‍ෂා කරන්න</string>
<string name="prefs_other_title">වෙනත් සැකසුම්</string>
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
<string name="prefs_other_title">වෙනත්</string>
<string name="prefs_other_switch_utc">UTC වේලාව</string>
<string name="prefs_other_switch_update">දත්ත ස්වයං යාවත්කාලීන</string>
<string name="prefs_other_switch_sweep">Radar sweep</string>
<string name="prefs_other_switch_sensors">සංවේදක</string>
<string name="prefs_other_switch_night_mode">රාත්‍රී පෙරහන</string>
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
<string name="prefs_highlight_low">අඩු &lt; %1$d°</string>
<string name="prefs_highlight_mid">මධ්‍ය %1$d-%2$d°</string>
<string name="prefs_highlight_high">ඉහළ &gt; %1$d°</string>
<string name="prefs_outro_title">මම ස්තුති කිරීමට කැමති:</string>
<string name="prefs_outro_license">මෘදුකාංගය වගකීමක් සමග නොලැබේ</string>
@@ -14,26 +14,33 @@
<string name="nav_prefs">Ayarlar</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Uydu türü: %s</string>
<string name="sat_type_title">Uydu türü seçin</string>
<string name="sat_type_hint">Modlar: %s</string>
<string name="sat_type_title">Modları seçin</string>
<string name="sat_search_hint">Id - Ad</string>
<string name="sat_search_clear">Temizle</string>
<string name="sat_clear_all">Tümünü temizle</string>
<string name="sat_select_all">Tümünü seç</string>
<string name="sat_group_selected">Seçilenler</string>
<string name="sat_group_available">Kullanılabilir</string>
<string name="sat_group_selected_count">Seçilenler (%1$d)</string>
<string name="sat_group_available_count">Kullanılabilir (%1$d)</string>
<string name="sat_empty_list_message">"Arama sorgunuzun doğru olduğundan ve veritabanının güncellendiğinden emin olun"</string>
<string name="sat_warning_title">Uyarı\!</string>
<string name="sat_warning_message">
Bu uygulamada 9000\'den fazla uydu listelenmiştir.
Hepsini aynı anda takip etmek anlamsızdır.
\n\nListeyi yalnızca ilgilendiğiniz uydularla
daraltmak için arama ve tür seçiciyi kullanın.</string>
daraltmak için arama ve mod seçiciyi kullanın.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Geçişleri filtrele</string>
<string name="pass_filter_elev">Minimum yükseklik açısı</string>
<string name="pass_filter_hours">Gösterilecek saat aralığı</string>
<string name="pass_filter_hours">Gösterilecek zaman</string>
<string name="pass_filter_aos_time">AOS aralığı</string>
<string name="pass_filter_invert_time">AOS aralığını tersine çevir</string>
<string name="pass_filter_deep_space">DeepSpace (periyot &gt;225dk)</string>
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
<string name="pass_modes_title">Modulasyon türü seçin</string>
<string name="pass_modes_title">Modları seçin</string>
<string name="pass_satId" translatable="false">%05d</string>
<string name="pass_elevation">Yükseklik açısı: %.1f°</string>
<string name="pass_deep_space" translatable="false">DeepSpace</string>
@@ -124,11 +131,11 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Geçersiz QTH konumlandırıcısı</string>
<string name="prefs_loc_success">Konum başarıyla güncellendi</string>
<string name="prefs_station_title">İstasyon konumu ayarları</string>
<string name="prefs_station_lat_text">Yer istasyonunuzun enlemini girin</string>
<string name="prefs_station_lon_text">Yer istasyonunuzun boylamını girin</string>
<string name="prefs_locator_title">QTH konumlandırıcı ayarları</string>
<string name="prefs_locator_text">İstasyon konumunu konumlandırıcı ile ayarlayın</string>
<string name="prefs_station_title">İstasyon konumu</string>
<string name="prefs_station_lat_text">İstasyon enlemi</string>
<string name="prefs_station_lon_text">İstasyon boylamı</string>
<string name="prefs_locator_title">QTH konumlandırıcı</string>
<string name="prefs_locator_text">Konumu lokatörle ayarla</string>
<string name="prefs_data_title">Uydu verisi</string>
<string name="prefs_data_entries">Uydular: %s</string>
@@ -138,28 +145,30 @@
<string name="prefs_data_clear">Temizle</string>
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
<string name="prefs_data_import_satellites_error">Uydu içe aktarılmadı. Geçerli bir TLE/3LE (.txt) veya OMM (.csv) dosyası seçin.</string>
<string name="prefs_data_import_transceivers_error">Transceiver içe aktarılmadı. Geçerli bir SatNOGS (.json) dosyası seçin.</string>
<string name="prefs_data_sources_title">Özel veri kaynakları</string>
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Özel transceiver URL</string>
<string name="prefs_data_sources_title">Özel kaynaklar</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Transceiver URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Veri aktarımı</string>
<string name="prefs_data_output_title">Veri çıkışı</string>
<string name="prefs_net_output">Ağ</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<!-- Radio Control -->
<string name="nav_radiocontrol">Radyo Kontrolü</string>
<string name="rc_settings_title">CAT Radyo Kontrolü</string>
<string name="rc_settings_title">CAT kontrolü</string>
<string name="rc_radio_model">Radyo Modeli</string>
<string name="rc_tx_device_hint">TX Radyo BT Adresi</string>
<string name="rc_rx_device_hint">RX Radyo BT Adresi</string>
<string name="rc_tx_device_hint">TX BT adresi</string>
<string name="rc_rx_device_hint">RX BT adresi</string>
<string name="rc_tx_name_hint">TX Radyo Adı</string>
<string name="rc_rx_name_hint">RX Radyo Adı</string>
<string name="rc_enable_switch">CAT Kontrolünü Etkinleştir</string>
<string name="rc_enable_switch">CAT\'i etkinleştir</string>
<string name="prefs_net_title">Ağ veri çıkışı</string>
<string name="prefs_net_title">Ağ çıkışı</string>
<string name="prefs_net_rotator_switch">Döndürme çıkışını etkinleştir</string>
<string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">Biçim</string>
@@ -167,20 +176,27 @@
<string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">Biçim</string>
<string name="prefs_bt_title">Bluetooth veri çıkışı</string>
<string name="prefs_bt_title">Bluetooth çıkışı</string>
<string name="prefs_bt_rotator_switch">Döndürme çıkışını etkinleştir</string>
<string name="prefs_bt_rotator_device_hint">Cihaz kimliği</string>
<string name="prefs_bt_rotator_output_hint">Veri biçimi</string>
<string name="prefs_bt_rotator_output_hint">Biçim</string>
<string name="prefs_bt_frequency_switch">Frekans çıkışını etkinleştir</string>
<string name="prefs_bt_frequency_device_hint">Cihaz kimliği</string>
<string name="prefs_bt_frequency_output_hint">Veri biçimi</string>
<string name="prefs_bt_frequency_output_hint">Biçim</string>
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
<string name="prefs_other_title">Diğer ayarlar</string>
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
<string name="prefs_other_title">Diğer</string>
<string name="prefs_other_switch_utc">UTC saati</string>
<string name="prefs_other_switch_update">Veriyi otomatik güncelle</string>
<string name="prefs_other_switch_sweep">Radar taraması</string>
<string name="prefs_other_switch_sensors">Sensör</string>
<string name="prefs_other_switch_night_mode">Gece filtresi</string>
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
<string name="prefs_highlight_low">Düşük &lt; %1$d°</string>
<string name="prefs_highlight_mid">Orta %1$d-%2$d°</string>
<string name="prefs_highlight_high">Yüksek &gt; %1$d°</string>
<string name="prefs_outro_title">Teşekkürler</string>
<string name="prefs_outro_thanks" translatable="false">
@@ -13,12 +13,16 @@
<string name="nav_prefs">Налаштування</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Тип: %s</string>
<string name="sat_type_title">Виберіть тип супутника</string>
<string name="sat_type_hint">Режими: %s</string>
<string name="sat_type_title">Виберіть режими</string>
<string name="sat_search_hint">Id - Назва</string>
<string name="sat_search_clear">Очистити</string>
<string name="sat_clear_all">Очистити всі</string>
<string name="sat_select_all">Вибрати всі</string>
<string name="sat_group_selected">Обрані</string>
<string name="sat_group_available">Доступні</string>
<string name="sat_group_selected_count">Обрані (%1$d)</string>
<string name="sat_group_available_count">Доступні (%1$d)</string>
<string name="sat_empty_list_message">
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
<string name="sat_warning_title">Увага\!</string>
@@ -26,13 +30,16 @@
У цьому додатку перелічено понад 9000 супутників.
Немає сенсу відстежувати їх усі одночасно.
\n\nЗавжди намагайтеся звузити список лише до тих,
які вас цікавлять, за допомогою пошуку та селектора типів.</string>
які вас цікавлять, за допомогою пошуку та вибору режимів.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Фільтр прольотів</string>
<string name="pass_filter_elev">Мінімальне піднесення</string>
<string name="pass_filter_hours">Кількість годин</string>
<string name="pass_modes_title">Виберіть тип модуляції</string>
<string name="pass_filter_elev">Мінімальне піднесень</string>
<string name="pass_filter_hours">Час вперед</string>
<string name="pass_filter_aos_time">Вікно AOS</string>
<string name="pass_filter_invert_time">Інвертувати вікно AOS</string>
<string name="pass_filter_deep_space">DeepSpace (період &gt;225хв)</string>
<string name="pass_modes_title">Виберіть режими</string>
<string name="pass_elevation">Піднес.: %.1f°</string>
<string name="pass_altitude">Висота: %.0f км</string>
<string name="pass_empty_list_message">
@@ -66,7 +73,7 @@
<string name="map_prev">Попередній</string>
<string name="map_next">Наступний</string>
<string name="map_azimuth">Азимут: %.1f°</string>
<string name="map_elevation">Піднесення: %.1f°</string>
<string name="map_elevation">Піднесень: %.1f°</string>
<string name="map_altitude">Висота: %.0f км</string>
<string name="map_distance">Дистанція: %.0f км</string>
<string name="map_latitude">Широта: %.1f°</string>
@@ -93,11 +100,11 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Некоректний локатор QTH</string>
<string name="prefs_loc_success">Позицію успішно оновлено</string>
<string name="prefs_station_title">Налаштування місцезнаходження станції</string>
<string name="prefs_station_lat_text">Широта місцезнаходження станції</string>
<string name="prefs_station_lon_text">Довгота місцезнаходження станції</string>
<string name="prefs_locator_title">Налаштування QTH локатора</string>
<string name="prefs_locator_text">Задати позицію за QTH локатором</string>
<string name="prefs_station_title">Позиція станції</string>
<string name="prefs_station_lat_text">Широта станції</string>
<string name="prefs_station_lon_text">Довгота станції</string>
<string name="prefs_locator_title">Локатор QTH</string>
<string name="prefs_locator_text">Позиція за локатором</string>
<string name="prefs_data_title">Супутникові дані</string>
<string name="prefs_data_entries">Супутників: %s</string>
@@ -107,37 +114,57 @@
<string name="prefs_data_clear">Очистити</string>
<string name="prefs_data_clear_success">Дані успішно очищено</string>
<string name="prefs_data_update_success">Оновлення успішне</string>
<string name="prefs_data_import_satellites_error">Супутники не імпортовано. Потрібен TLE/3LE (.txt) або OMM (.csv).</string>
<string name="prefs_data_import_transceivers_error">Трансивери не імпортовано. Потрібен SatNOGS (.json).</string>
<string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_title">Власні джерела</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL трансиверів</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
<string name="prefs_data_output_title">Вивід даних</string>
<string name="prefs_net_output">Мережа</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="prefs_net_title">Вивід мережевих даних</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="nav_radiocontrol">Керування радіо</string>
<string name="rc_settings_title">CAT керування</string>
<string name="rc_radio_model">Модель радіо</string>
<string name="rc_tx_device_hint">BT адреса TX</string>
<string name="rc_rx_device_hint">BT адреса RX</string>
<string name="rc_tx_name_hint">Назва радіо TX</string>
<string name="rc_rx_name_hint">Назва радіо RX</string>
<string name="rc_enable_switch">Увімкнути CAT</string>
<string name="prefs_net_title">Мережевий вивід</string>
<string name="prefs_net_rotator_switch">Увімкнути вивід повороту</string>
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
<string name="prefs_net_rotator_format_hint">Формат даних</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_frequency_switch">Увімкнути вивід частоти</string>
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
<string name="prefs_net_frequency_format_hint">Формат даних</string>
<string name="prefs_bt_title">Вивід даних Bluetooth</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">Id пристрою</string>
<string name="prefs_bt_title">Вивід Bluetooth</string>
<string name="prefs_bt_rotator_switch">Увімкнути вивід повороту</string>
<string name="prefs_bt_rotator_device_hint">ID пристрою</string>
<string name="prefs_bt_rotator_output_hint">Формат даних</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">Id пристрою</string>
<string name="prefs_bt_frequency_switch">Увімкнути вивід частоти</string>
<string name="prefs_bt_frequency_device_hint">ID пристрою</string>
<string name="prefs_bt_frequency_output_hint">Формат даних</string>
<string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</string>
<string name="prefs_net_perm_error">Перевірте дозвіл мережі</string>
<string name="prefs_other_title">Інші налаштування</string>
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
<string name="prefs_other_title">Інше</string>
<string name="prefs_other_switch_utc">Час в UTC</string>
<string name="prefs_other_switch_update">Автооновлення даних</string>
<string name="prefs_other_switch_sweep">Анімація радара</string>
<string name="prefs_other_switch_sensors">Сенсори</string>
<string name="prefs_other_switch_night_mode">Нічний фільтр</string>
<string name="prefs_highlight_title">Підсвічування висоти</string>
<string name="prefs_highlight_low">Низько &lt; %1$d°</string>
<string name="prefs_highlight_mid">Середньо %1$d-%2$d°</string>
<string name="prefs_highlight_high">Високо &gt; %1$d°</string>
<string name="prefs_outro_title">Я хотів би подякувати:</string>
<string name="prefs_outro_license">Ця програма поставляється без жодних гарантій</string>
@@ -13,21 +13,28 @@
<string name="nav_prefs">设置</string>
<!-- Satellites screen -->
<string name="sat_type_hint">类型:%s</string>
<string name="sat_type_title">选择卫星类型</string>
<string name="sat_type_hint">模式:%s</string>
<string name="sat_type_title">选择模式</string>
<string name="sat_search_hint">Id - 名称</string>
<string name="sat_search_clear">清空</string>
<string name="sat_clear_all">全部清空</string>
<string name="sat_select_all">全选</string>
<string name="sat_group_selected">已选择</string>
<string name="sat_group_available">可用</string>
<string name="sat_group_selected_count">已选择(%1$d)</string>
<string name="sat_group_available_count">可用(%1$d)</string>
<string name="sat_empty_list_message">请确保您的搜索查询正确且数据库已更新</string>
<string name="sat_warning_title">警告\!</string>
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星</string>
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和模式筛选功能仅勾选您感兴趣的卫星</string>
<!-- Passes screen -->
<string name="pass_filter_title">筛选过境</string>
<string name="pass_filter_elev">仰角值</string>
<string name="pass_filter_hours">小时数</string>
<string name="pass_modes_title">选择调制类型</string>
<string name="pass_filter_hours">提前时间</string>
<string name="pass_filter_aos_time">AOS 时间段</string>
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
<string name="pass_filter_deep_space">DeepSpace(周期 &gt;225分钟)</string>
<string name="pass_modes_title">选择模式</string>
<string name="pass_elevation">仰角:%.1f°</string>
<string name="pass_altitude">高度:%d km</string>
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
@@ -53,6 +60,9 @@
<string name="radar_string_no">否</string>
<string name="radar_string_yes">是</string>
<string name="radar_visible">日照中</string>
<string name="radar_doppler_tx_hint">输入上行频率 (MHz)</string>
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
<string name="radar_doppler_offset_hint">偏移 (kHz)</string>
<!-- Map screen -->
<string name="map_prev">上一个</string>
@@ -77,7 +87,7 @@
<string name="prefs_updated_title">更新:%s</string>
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
<string name="prefs_loc_title">站位</string>
<string name="prefs_loc_title">站点位置</string>
<string name="prefs_loc_gps_title">GPS 定位</string>
<string name="prefs_loc_gps_error">检查您的位置权限</string>
<string name="prefs_loc_input_title">输入位置</string>
@@ -85,11 +95,11 @@
<string name="prefs_loc_qth_title">QTH 网格</string>
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
<string name="prefs_loc_success">位置更新成功</string>
<string name="prefs_station_title">站位设置</string>
<string name="prefs_station_lat_text">输入站位的纬度</string>
<string name="prefs_station_lon_text">输入站位的经度</string>
<string name="prefs_locator_title">QTH 网格</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站位</string>
<string name="prefs_station_title">站点位置</string>
<string name="prefs_station_lat_text">站点纬度</string>
<string name="prefs_station_lon_text">站点经度</string>
<string name="prefs_locator_title">QTH 定位</string>
<string name="prefs_locator_text">通过 QTH 设置站点位置</string>
<string name="prefs_data_title">卫星数据更新</string>
<string name="prefs_data_entries">卫星:%s</string>
@@ -99,16 +109,29 @@
<string name="prefs_data_clear">清空</string>
<string name="prefs_data_clear_success">数据清空成功</string>
<string name="prefs_data_update_success">更新成功</string>
<string name="prefs_data_import_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</string>
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
<string name="prefs_data_sources_title">自定义数据源</string>
<string name="prefs_data_sources_tle_switch">自定义TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string>
<string name="prefs_data_sources_title">自定义源</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">收发器 URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">数据输出</string>
<string name="prefs_net_output">网络</string>
<string name="prefs_bt_output">蓝牙</string>
<string name="prefs_cat_output">CAT</string>
<string name="prefs_net_title">网络数据输出</string>
<string name="nav_radiocontrol">电台控制</string>
<string name="rc_settings_title">CAT 控制</string>
<string name="rc_radio_model">电台型号</string>
<string name="rc_tx_device_hint">TX 电台蓝牙地址</string>
<string name="rc_rx_device_hint">RX 电台蓝牙地址</string>
<string name="rc_tx_name_hint">TX 电台名称</string>
<string name="rc_rx_name_hint">RX 电台名称</string>
<string name="rc_enable_switch">启用 CAT</string>
<string name="prefs_net_title">网络输出</string>
<string name="prefs_net_rotator_switch">启用方位俯仰角输出</string>
<string name="prefs_net_rotator_address_hint">IP:端口</string>
<string name="prefs_net_rotator_format_hint">数据格式</string>
@@ -116,7 +139,7 @@
<string name="prefs_net_frequency_address_hint">IP:端口</string>
<string name="prefs_net_frequency_format_hint">数据格式</string>
<string name="prefs_bt_title">蓝牙数据输出</string>
<string name="prefs_bt_title">蓝牙输出</string>
<string name="prefs_bt_rotator_switch">启用方位俯仰角输出</string>
<string name="prefs_bt_rotator_device_hint">蓝牙设备 ID</string>
<string name="prefs_bt_rotator_output_hint">数据格式</string>
@@ -124,13 +147,21 @@
<string name="prefs_bt_frequency_device_hint">蓝牙设备 ID</string>
<string name="prefs_bt_frequency_output_hint">数据格式</string>
<string name="prefs_bt_perm_error">请检查蓝牙权限</string>
<string name="prefs_net_perm_error">请检查网络权限</string>
<string name="prefs_other_title">其他设置</string>
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
<string name="prefs_other_title">其他</string>
<string name="prefs_other_switch_utc">UTC 时间</string>
<string name="prefs_other_switch_update">自动更新数据</string>
<string name="prefs_other_switch_sweep">雷达扫描动画</string>
<string name="prefs_other_switch_sensors">传感器控制雷达</string>
<string name="prefs_other_switch_night_mode">红色夜间模式</string>
<string name="prefs_other_compass_offset">雷达罗盘水平偏移量</string>
<string name="prefs_other_compass_offset_elev">雷达罗盘俯仰偏移量</string>
<string name="prefs_highlight_title">仰角高亮</string>
<string name="prefs_highlight_low">低 &lt; %1$d°</string>
<string name="prefs_highlight_mid">中 %1$d-%2$d°</string>
<string name="prefs_highlight_high">高 &gt; %1$d°</string>
<string name="prefs_outro_title">我要感谢:</string>
<string name="prefs_outro_thanks" translatable="false">
@@ -12,29 +12,36 @@
<string name="nav_radar">Radar</string>
<string name="nav_map">Map</string>
<string name="nav_prefs">Settings</string>
<string name="nav_status" translatable="false">AMSAT</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Type: %s</string>
<string name="sat_type_title">Select satellite type</string>
<string name="sat_type_hint">Modes: %s</string>
<string name="sat_type_title">Select modes</string>
<string name="sat_search_hint">Id - Name</string>
<string name="sat_search_clear">Clear</string>
<string name="sat_clear_all">Clear all</string>
<string name="sat_select_all">Select all</string>
<string name="sat_group_selected">Selected</string>
<string name="sat_group_available">Available</string>
<string name="sat_group_selected_count">Selected (%1$d)</string>
<string name="sat_group_available_count">Available (%1$d)</string>
<string name="sat_empty_list_message">"Make sure your search query is correct and the DB is updated"</string>
<string name="sat_warning_title">Warning\!</string>
<string name="sat_warning_message">
There are over 9000 satellites listed in this app.
It makes no sense to track them all at the same time.
\n\nAlways try to narrow down the list to only the ones
you\'re interested in via search and types selector.</string>
you\'re interested in via search and the modes selector.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Filter passes</string>
<string name="pass_filter_elev">Minimal elevation</string>
<string name="pass_filter_hours">Hours ahead</string>
<string name="pass_filter_hours">Time ahead</string>
<string name="pass_filter_aos_time">AOS window</string>
<string name="pass_filter_invert_time">Invert AOS window</string>
<string name="pass_filter_deep_space">DeepSpace (period >225min)</string>
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
<string name="pass_modes_title">Select modulation type</string>
<string name="pass_modes_title">Select modes</string>
<string name="pass_satId" translatable="false">%05d</string>
<string name="pass_elevation">Elevation: %.1f°</string>
<string name="pass_deep_space" translatable="false">DeepSpace</string>
@@ -49,11 +56,30 @@
\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">
* Enabled continuous SSTV decoding for manually selected types (Play button enables/disables decoding)
\n\n* Added selected transceiver\'s frequency display to SSTV page
\n\n* Fixed Reset/Save/Play buttons behavior and states on SSTV page
* Added AMSAT status tracking page by MCKero6423 (#234)
\n* Tweaked Radar track on reposition by atsunatsu (#235)
\n* Tweaked linear transponder calc by atsunatsu (#236)
\n* Added saving per-sat doppler offset by atsunatsu (#237)
\n* Added custom freq offset setting to network reporting
\n* Added ability to customize data sources via import
\n* Fixed star chart in README by PingouinFerreux (#240)
</string>
<!-- AMSAT Status screen -->
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT Satellite Status</string>
<string name="amsat_refresh">Refresh</string>
<string name="amsat_retry">Retry</string>
<string name="amsat_updated">Updated:</string>
<string name="amsat_active">Active</string>
<string name="amsat_tlm">TLM/Beacon</string>
<string name="amsat_not_heard">Not Heard</string>
<string name="amsat_conflict">Conflicting</string>
<string name="amsat_name">Name</string>
<string name="amsat_load_failed">Load failed - check network and retry</string>
<string name="amsat_no_reports">No reports for this period</string>
<string name="amsat_close">Close</string>
<!-- Radar screen -->
<string name="radar_back">Back</string>
<string name="radar_notify">Notify</string>
@@ -79,6 +105,9 @@
<string name="radar_string_no">No</string>
<string name="radar_string_yes">Yes</string>
<string name="radar_visible">Visible</string>
<string name="radar_doppler_tx_hint">Enter TX freq (MHz)</string>
<string name="radar_doppler_rx_hint">Enter RX freq (MHz)</string>
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
<!-- Map screen -->
<string name="map_prev">Prev</string>
@@ -123,11 +152,11 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Invalid QTH locator</string>
<string name="prefs_loc_success">Position updated successfully</string>
<string name="prefs_station_title">Station position settings</string>
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
<string name="prefs_locator_title">QTH locator settings</string>
<string name="prefs_locator_text">Set station\'s position using locator</string>
<string name="prefs_station_title">Station position</string>
<string name="prefs_station_lat_text">Station latitude</string>
<string name="prefs_station_lon_text">Station longitude</string>
<string name="prefs_locator_title">QTH locator</string>
<string name="prefs_locator_text">Set position via locator</string>
<string name="prefs_data_title">Satellite data</string>
<string name="prefs_data_entries">Satellites: %s</string>
@@ -137,11 +166,10 @@
<string name="prefs_data_clear">Clear</string>
<string name="prefs_data_clear_success">Data was cleared successfully</string>
<string name="prefs_data_update_success">Update completed successfully</string>
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_sources_title">Data sources</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
@@ -150,37 +178,47 @@
<!-- Radio Control -->
<string name="nav_radiocontrol">Radio Control</string>
<string name="rc_settings_title">CAT Radio Control</string>
<string name="rc_settings_title">CAT control</string>
<string name="rc_radio_model">Radio Model</string>
<string name="rc_tx_device_hint">TX Radio BT Address</string>
<string name="rc_rx_device_hint">RX Radio BT Address</string>
<string name="rc_tx_device_hint">TX BT address</string>
<string name="rc_rx_device_hint">RX BT address</string>
<string name="rc_tx_name_hint">TX Radio Name</string>
<string name="rc_rx_name_hint">RX Radio Name</string>
<string name="rc_enable_switch">Enable CAT Control</string>
<string name="rc_enable_switch">Enable CAT</string>
<string name="prefs_net_title">Network data output</string>
<string name="prefs_net_title">Network output</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">Format</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">Format</string>
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
<string name="prefs_bt_title">Bluetooth data output</string>
<string name="prefs_bt_title">Bluetooth output</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">Device id</string>
<string name="prefs_bt_rotator_output_hint">Data format</string>
<string name="prefs_bt_rotator_device_hint">Device ID</string>
<string name="prefs_bt_rotator_output_hint">Format</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">Device id</string>
<string name="prefs_bt_frequency_output_hint">Data format</string>
<string name="prefs_bt_frequency_device_hint">Device ID</string>
<string name="prefs_bt_frequency_output_hint">Format</string>
<string name="prefs_bt_perm_error">Check your bluetooth permission</string>
<string name="prefs_net_perm_error">Check your network permission</string>
<string name="prefs_other_title">Other settings</string>
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
<string name="prefs_other_switch_update">Enable automatic data update</string>
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
<string name="prefs_other_title">Other</string>
<string name="prefs_other_switch_utc">UTC time</string>
<string name="prefs_other_switch_update">Auto-update data</string>
<string name="prefs_other_switch_sweep">Radar sweep</string>
<string name="prefs_other_switch_sensors">Use sensors</string>
<string name="prefs_other_switch_night_mode">Night filter</string>
<string name="prefs_other_compass_offset">Radar compass offset</string>
<string name="prefs_other_compass_offset_elev">Radar compass offset (elev)</string>
<string name="prefs_highlight_title">Elevation highlight</string>
<string name="prefs_highlight_low">Low &lt; %1$d°</string>
<string name="prefs_highlight_mid">Mid %1$d–%2$d°</string>
<string name="prefs_highlight_high">High &gt; %1$d°</string>
<string name="prefs_outro_title">I would like to say thanks to</string>
<string name="prefs_outro_thanks" translatable="false">
@@ -1,3 +1,7 @@
* Enabled continuous SSTV decoding for manually selected types (Play button enables/disables decoding)
* Added selected transceiver\'s frequency display to SSTV page
* Fixed Reset/Save/Play buttons behavior and states on SSTV page
* Added AMSAT status tracking page by MCKero6423 (#234)
* Tweaked Radar track on reposition by atsunatsu (#235)
* Tweaked linear transponder calc by atsunatsu (#236)
* Added saving per-sat doppler offset by atsunatsu (#237)
* Added custom freq offset setting to network reporting
* Added ability to customize data sources via import
* Fixed star chart in README by PingouinFerreux (#240)
@@ -32,6 +32,7 @@ import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.clipLat
import com.rtbishop.look4sat.core.domain.utility.clipLon
import com.rtbishop.look4sat.core.domain.utility.positionToQth
import com.rtbishop.look4sat.core.domain.utility.toMapGeoPos
import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.domain.utility.toTimerString
import com.rtbishop.look4sat.core.presentation.getDefaultPass
@@ -166,9 +167,7 @@ class MapViewModel(
var localSelectedPos: OrbitalPos? = null
for (satellite in chunk) {
val satPos = satelliteRepo.getPosition(satellite, pos, date.time)
val osmLat = clipLat(satPos.latitude.toDegrees())
val osmLon = clipLon(satPos.longitude.toDegrees())
localPositions.add(satellite to GeoPos(osmLat, osmLon))
localPositions.add(satellite to satPos.toMapGeoPos())
if (satellite === selected) {
localSelectedPos = satPos
}
@@ -242,9 +241,7 @@ class MapViewModel(
}
val azimuth = satPos.azimuth.toDegrees()
val elevation = satPos.elevation.toDegrees()
val osmLat = clipLat(satPos.latitude.toDegrees())
val osmLon = clipLon(satPos.longitude.toDegrees())
val osmPos = GeoPos(osmLat, osmLon)
val osmPos = satPos.toMapGeoPos()
val qthLoc = positionToQth(osmPos.latitude, osmPos.longitude) ?: "-- --"
val velocity = satPos.getOrbitalVelocity()
val phase = satPos.phase.toDegrees()
@@ -274,18 +271,16 @@ class MapViewModel(
val endDate = Date(date.time + (orbitalObject.data.orbitalPeriod * 2.4 * 60000L).toLong())
var oldLongitude = 0.0
satelliteRepo.getTrack(orbitalObject, pos, date.time, endDate.time).forEach { satPos ->
val osmLat = clipLat(satPos.latitude.toDegrees())
val osmLon = clipLon(satPos.longitude.toDegrees())
val currentPosition = GeoPos(osmLat, osmLon)
val currentPosition = satPos.toMapGeoPos()
if (oldLongitude < -170.0 && currentPosition.longitude > 170.0) {
// adding left terminal position
currentTrack.add(GeoPos(osmLat, -180.0))
currentTrack.add(GeoPos(currentPosition.latitude, -180.0))
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
satTracks.add(finishedTrack)
currentTrack.clear()
} else if (oldLongitude > 170.0 && currentPosition.longitude < -170.0) {
// adding right terminal position
currentTrack.add(GeoPos(osmLat, 180.0))
currentTrack.add(GeoPos(currentPosition.latitude, 180.0))
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
satTracks.add(finishedTrack)
currentTrack.clear()
@@ -21,8 +21,11 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
@@ -31,6 +34,7 @@ import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RangeSlider
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -54,45 +58,89 @@ import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
import com.rtbishop.look4sat.core.presentation.ElevationHighColor
import com.rtbishop.look4sat.core.presentation.ElevationLowColor
import com.rtbishop.look4sat.core.presentation.elevationColor
private val allModes = listOf(
"AFSK", "AFSK S-Net", "AFSK SALSAT", "AHRPT", "AM", "APT", "BPSK", "BPSK PMT-A3",
"CERTO", "CW", "DQPSK", "DSTAR", "DUV", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5",
"FSK AX.100 Mode 6", "FSK AX.25 G3RUH", "GFSK", "GFSK Rktr", "GMSK", "HRPT", "LoRa",
"LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5", "MSK AX.100 Mode 6", "OFDM", "OQPSK",
"PSK", "PSK31", "PSK63", "QPSK", "QPSK31", "QPSK63", "SSTV", "USB", "WSJT"
)
import com.rtbishop.look4sat.core.domain.source.Sources
import kotlin.math.roundToInt
private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 192, 216, 240)
private const val dayMinutes = 24 * 60
private const val minuteStep = 15
private const val endOfDayMinute = dayMinutes - 1
private const val quarterHourSlots = dayMinutes / minuteStep
data class PassFilterParams(
val hours: Int,
val elevation: Double,
val lowElevation: Double,
val highElevation: Double,
val aosStartMinute: Int,
val aosEndMinute: Int,
val invertAosTimeWindow: Boolean,
val showDeepSpace: Boolean
)
@Preview
@Composable
private fun PassesDialogPreview() {
MainTheme { PassesDialog(24, 16.0, true, {}) { _, _, _ -> } }
MainTheme {
PassesFilterDialog(
hours = 24,
elevation = 16.0,
lowElevation = 16.0,
highElevation = 65.0,
aosStartMinute = 0,
aosEndMinute = 23 * 60 + 59,
invertAosTimeWindow = false,
showDeepSpace = true,
cancel = {},
accept = {}
)
}
}
@Composable
internal fun PassesDialog(
internal fun PassesFilterDialog(
hours: Int,
elevation: Double,
lowElevation: Double,
highElevation: Double,
aosStartMinute: Int,
aosEndMinute: Int,
invertAosTimeWindow: Boolean,
showDeepSpace: Boolean,
cancel: () -> Unit,
accept: (Int, Double, Boolean) -> Unit
accept: (PassFilterParams) -> Unit
) {
val hoursIndex = remember { mutableIntStateOf(hourSteps.indexOfFirst { it >= hours }.coerceAtLeast(0)) }
val elevationValueNew = remember { mutableDoubleStateOf(elevation) }
val highlightBounds = 0f..90f
var highlightRange by remember(lowElevation, highElevation) {
mutableStateOf(lowElevation.toFloat()..highElevation.toFloat())
}
var aosRangeValue by remember {
mutableStateOf(minuteToSlot(aosStartMinute).toFloat()..minuteToSlot(aosEndMinute).toFloat())
}
var invertedAosRange by remember { mutableStateOf(invertAosTimeWindow) }
var deepSpaceEnabled by remember { mutableStateOf(showDeepSpace) }
val onAccept = {
accept(hourSteps[hoursIndex.intValue], elevationValueNew.doubleValue, deepSpaceEnabled).also { cancel() }
}
SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
ToggleRow(
title = stringResource(R.string.pass_filter_deep_space),
checked = deepSpaceEnabled,
onCheckedChange = { deepSpaceEnabled = it }
accept(
PassFilterParams(
hours = hourSteps[hoursIndex.intValue],
elevation = elevationValueNew.doubleValue,
lowElevation = highlightRange.start.roundToInt().toDouble(),
highElevation = highlightRange.endInclusive.roundToInt().toDouble(),
aosStartMinute = slotToMinute(aosRangeValue.start),
aosEndMinute = slotToMinute(aosRangeValue.endInclusive),
invertAosTimeWindow = invertedAosRange,
showDeepSpace = deepSpaceEnabled
)
)
cancel()
}
ConfirmDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
SliderRow(
title = stringResource(R.string.pass_filter_elev),
value = elevationValueNew.doubleValue,
@@ -104,24 +152,74 @@ internal fun PassesDialog(
SliderRow(
title = stringResource(R.string.pass_filter_hours),
value = hoursIndex.intValue.toDouble(),
displayValue = "${hourSteps[hoursIndex.intValue]}h",
displayValue = formatHoursLabel(hourSteps[hoursIndex.intValue]),
valueResId = R.drawable.ic_clock,
valueRange = 0f..(hourSteps.size - 1).toFloat(),
steps = hourSteps.size - 2
) { hoursIndex.intValue = it.toInt().coerceIn(0, hourSteps.size - 1) }
ToggleRow(
title = stringResource(R.string.pass_filter_deep_space),
checked = deepSpaceEnabled,
onCheckedChange = { deepSpaceEnabled = it }
)
ElevationColorsRangeSliderRow(
title = stringResource(R.string.prefs_highlight_title),
range = highlightRange,
valueRange = highlightBounds
) { highlightRange = it }
TimeRangeSliderRow(
title = stringResource(R.string.pass_filter_aos_time),
range = aosRangeValue,
displayValue = formatTimeRange(aosRangeValue, invertedAosRange),
valueResId = R.drawable.ic_clock,
valueRange = 0f..quarterHourSlots.toFloat(),
) { aosRangeValue = it }
ToggleRow(
title = stringResource(R.string.pass_filter_invert_time),
checked = invertedAosRange,
onCheckedChange = { invertedAosRange = it }
)
Spacer(modifier = Modifier.height(0.dp))
}
}
private fun slotToMinute(value: Float): Int {
val slot = value.roundToInt().coerceIn(0, quarterHourSlots)
return if (slot == quarterHourSlots) endOfDayMinute else slot * minuteStep
}
private fun minuteToSlot(minute: Int): Int {
if (minute >= endOfDayMinute) return quarterHourSlots
return ((minute + minuteStep / 2) / minuteStep).coerceIn(0, quarterHourSlots)
}
private fun formatMinuteOfDay(minute: Int): String {
val hourPart = minute / 60
val minutePart = minute % 60
return "%02d:%02d".format(hourPart, minutePart)
}
private fun formatHoursLabel(hours: Int): String {
if (hours < 24) return "${hours}h"
val days = hours / 24
val remainder = hours % 24
return if (remainder == 0) "${days}d" else "${days}d ${remainder}h"
}
private fun formatTimeRange(
range: ClosedFloatingPointRange<Float>,
inverted: Boolean
): String {
val start = formatMinuteOfDay(slotToMinute(range.start))
val end = formatMinuteOfDay(slotToMinute(range.endInclusive))
return if (inverted) "$end - $start" else "$start - $end"
}
@Composable
private fun SliderRow(
private fun SliderSection(
title: String,
value: Double,
displayValue: String,
valueResId: Int,
valueRange: ClosedFloatingPointRange<Float>,
steps: Int = 0,
accentColor: Color = MaterialTheme.colorScheme.primary,
onChange: (Float) -> Unit
trailingContent: @Composable () -> Unit,
sliderContent: @Composable () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@@ -138,6 +236,26 @@ private fun SliderRow(
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onSurface
)
trailingContent()
}
sliderContent()
}
}
@Composable
private fun SliderRow(
title: String,
value: Double,
displayValue: String,
valueResId: Int,
valueRange: ClosedFloatingPointRange<Float>,
steps: Int = 0,
accentColor: Color = MaterialTheme.colorScheme.primary,
onChange: (Float) -> Unit
) {
SliderSection(
title = title,
trailingContent = {
Icon(
painter = painterResource(id = valueResId),
contentDescription = null,
@@ -151,10 +269,89 @@ private fun SliderRow(
color = accentColor
)
}
) {
Slider(value = value.toFloat(), onValueChange = onChange, valueRange = valueRange, steps = steps)
}
}
@Composable
private fun TimeRangeSliderRow(
title: String,
range: ClosedFloatingPointRange<Float>,
displayValue: String,
valueResId: Int,
valueRange: ClosedFloatingPointRange<Float>,
onChange: (ClosedFloatingPointRange<Float>) -> Unit
) {
SliderSection(
title = title,
trailingContent = {
Icon(
painter = painterResource(id = valueResId),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Text(
text = displayValue,
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
}
) {
RangeSlider(
value = range,
onValueChange = { onChange(it.start..it.endInclusive) },
valueRange = valueRange,
steps = 0,
modifier = Modifier.fillMaxWidth()
)
}
}
@Composable
private fun ElevationColorsRangeSliderRow(
title: String,
range: ClosedFloatingPointRange<Float>,
valueRange: ClosedFloatingPointRange<Float>,
onChange: (ClosedFloatingPointRange<Float>) -> Unit
) {
val low = range.start.roundToInt()
val high = range.endInclusive.roundToInt()
SliderSection(
title = title,
trailingContent = {
Text(
text = "$low°",
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = ElevationLowColor
)
Text(
text = "..",
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "$high°",
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = ElevationHighColor
)
}
) {
RangeSlider(
value = range,
onValueChange = { onChange(it.start..it.endInclusive) },
valueRange = valueRange,
steps = 0,
modifier = Modifier.fillMaxWidth()
)
}
}
@Preview(showBackground = true)
@Composable
private fun RadiosDialogPreview() {
@@ -168,7 +365,7 @@ internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
}
val onAccept = { accept(selected.value.toList()).also { cancel() } }
SharedDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) {
ConfirmDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) {
LazyVerticalGrid(
columns = GridCells.Adaptive(240.dp),
modifier = Modifier
@@ -177,7 +374,7 @@ internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List
horizontalArrangement = Arrangement.spacedBy(1.dp),
verticalArrangement = Arrangement.spacedBy(1.dp)
) {
itemsIndexed(allModes) { index, item ->
itemsIndexed(Sources.satelliteModes) { index, item ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -67,7 +67,6 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.InfoDialog
import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.NextPassRow
import com.rtbishop.look4sat.core.presentation.R
@@ -75,6 +74,7 @@ import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.SwipeableItem
import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
import com.rtbishop.look4sat.core.presentation.elevationColor
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
@@ -99,14 +99,31 @@ private fun PassesScreen(
navigateToRadar: (Int, Long) -> Unit
) {
if (uiState.isPassesDialogShown) {
PassesDialog(
PassesFilterDialog(
hours = uiState.hours,
elevation = uiState.elevation,
lowElevation = uiState.lowElevation,
highElevation = uiState.highElevation,
aosStartMinute = uiState.aosStartMinute,
aosEndMinute = uiState.aosEndMinute,
invertAosTimeWindow = uiState.invertAosTimeWindow,
showDeepSpace = uiState.showDeepSpace,
cancel = { onAction(PassesAction.TogglePassesDialog) }
) { hours, elevation, showDeepSpace ->
onAction(PassesAction.FilterPasses(hours, elevation, showDeepSpace))
}
cancel = { onAction(PassesAction.TogglePassesDialog) },
accept = { params ->
onAction(
PassesAction.FilterPasses(
hoursAhead = params.hours,
minElevation = params.elevation,
lowElevation = params.lowElevation,
highElevation = params.highElevation,
aosStartMinute = params.aosStartMinute,
aosEndMinute = params.aosEndMinute,
invertAosTimeWindow = params.invertAosTimeWindow,
showDeepSpace = params.showDeepSpace
)
)
}
)
}
if (uiState.isRadiosDialogShown) {
RadiosDialog(
@@ -117,19 +134,15 @@ private fun PassesScreen(
}
}
if (uiState.shouldSeeWhatsNew) {
InfoDialog(
title = stringResource(R.string.pass_whatsnew_title),
text = stringResource(R.string.pass_whatsnew_message)
) {
onAction(PassesAction.DismissWhatsNew)
}
val dismiss = { onAction(PassesAction.DismissWhatsNew) }
WhatsNewDialog(onDismiss = dismiss)
}
ScreenColumn(
topBar = { isVerticalLayout ->
TopBar(
isVerticalLayout = isVerticalLayout,
startAction = {
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
},
topInfo = {
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
@@ -138,7 +151,7 @@ private fun PassesScreen(
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
},
endAction = {
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
}
)
}
@@ -265,6 +278,11 @@ private fun StickyDateHeader(label: String, sunriseTime: String, sunsetTime: Str
}
}
private fun displayLocale(): Locale {
val locale = Locale.getDefault()
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
}
@Preview(showBackground = true)
@Composable
private fun DeepSpacePassPreview() {
@@ -297,7 +315,7 @@ private fun PassItem(
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
}
val sdfTime = remember(isUtc) {
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
}
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
@@ -29,6 +29,11 @@ data class PassesState(
val isNextTimeAos: Boolean = true,
val hours: Int = 24,
val elevation: Double = 16.0,
val lowElevation: Double = 16.0,
val highElevation: Double = 65.0,
val aosStartMinute: Int = 0,
val aosEndMinute: Int = 23 * 60 + 59,
val invertAosTimeWindow: Boolean = false,
val showDeepSpace: Boolean = true,
val modes: List<String> = emptyList(),
val itemsList: List<OrbitalPass> = emptyList(),
@@ -40,7 +45,16 @@ data class PassesState(
sealed interface PassesAction {
data object DismissWhatsNew : PassesAction
data class FilterPasses(val hoursAhead: Int, val minElevation: Double, val showDeepSpace: Boolean) : PassesAction
data class FilterPasses(
val hoursAhead: Int,
val minElevation: Double,
val lowElevation: Double,
val highElevation: Double,
val aosStartMinute: Int,
val aosEndMinute: Int,
val invertAosTimeWindow: Boolean,
val showDeepSpace: Boolean
) : PassesAction
data class FilterRadios(val modes: List<String>) : PassesAction
data object RefreshPasses : PassesAction
data object TogglePassesDialog : PassesAction
@@ -34,6 +34,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -41,6 +44,7 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kotlin.time.Duration.Companion.milliseconds
class PassesViewModel(
private val satelliteRepo: ISatelliteRepo,
@@ -54,8 +58,13 @@ class PassesViewModel(
nextPass = defaultPass,
hours = settingsRepo.passesSettings.value.hoursAhead,
elevation = settingsRepo.passesSettings.value.minElevation,
lowElevation = settingsRepo.otherSettings.value.lowElevation,
highElevation = settingsRepo.otherSettings.value.highElevation,
aosStartMinute = settingsRepo.passesSettings.value.aosStartMinute,
aosEndMinute = settingsRepo.passesSettings.value.aosEndMinute,
invertAosTimeWindow = settingsRepo.passesSettings.value.invertAosTimeWindow,
showDeepSpace = settingsRepo.passesSettings.value.showDeepSpace,
modes = settingsRepo.passesSettings.value.selectedModes,
modes = settingsRepo.selectedSatModes.value,
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew
)
)
@@ -71,45 +80,70 @@ class PassesViewModel(
// React to settings changes: update UTC flag and whatsNew
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update { it.copy(isUtc = settings.stateOfUtc, shouldSeeWhatsNew = settings.shouldSeeWhatsNew) }
}
}
// Main tick loop: reacts to new passes, then ticks every second
viewModelScope.launch {
satelliteRepo.passes.collectLatest { allPasses ->
while (isActive) {
val timeNow = System.currentTimeMillis()
val isUtc = _uiState.value.isUtc
val showDeepSpace = _uiState.value.showDeepSpace
val filtered = allPasses
.let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
val processed = computePassProgress(filtered, timeNow)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
val sunTimes = computeSunTimes(processed, isUtc)
val grouped = groupPasses(processed, isUtc)
_uiState.update {
it.copy(
itemsList = processed,
groupedPasses = grouped,
sunTimes = sunTimes,
nextPass = nextPass,
nextTime = nextTime,
isNextTimeAos = isAos
)
}
delay(1000)
_uiState.update {
it.copy(
isUtc = settings.stateOfUtc,
shouldSeeWhatsNew = settings.shouldSeeWhatsNew,
lowElevation = settings.lowElevation,
highElevation = settings.highElevation
)
}
}
}
viewModelScope.launch {
settingsRepo.selectedSatModes.collectLatest { modes ->
_uiState.update { it.copy(modes = modes) }
}
}
// Tick loop: restarts on passes change, UTC/DeepSpace changes. Grouping/sun-time
// computations run once per restart, progress/countdown are calculated every second.
viewModelScope.launch {
combine(
satelliteRepo.passes,
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged(),
settingsRepo.passesSettings.map { it.showDeepSpace }.distinctUntilChanged()
) { passes, isUtc, showDeepSpace -> Triple(passes, isUtc, showDeepSpace) }
.collectLatest { (allPasses, isUtc, showDeepSpace) ->
val filtered = if (showDeepSpace) allPasses
else allPasses.filter { !it.isDeepSpace }
// Expensive: recompute sun times once per items/UTC change, not every second
val sunTimes = computeSunTimes(filtered, isUtc)
_uiState.update { it.copy(sunTimes = sunTimes) }
while (isActive) {
val timeNow = System.currentTimeMillis()
val processed = computePassProgress(filtered, timeNow)
val grouped = groupPasses(processed, isUtc)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
_uiState.update {
it.copy(
itemsList = processed,
groupedPasses = grouped,
nextPass = nextPass,
nextTime = nextTime,
isNextTimeAos = isAos
)
}
delay(1000.milliseconds)
}
}
}
}
fun onAction(action: PassesAction) {
when (action) {
PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed()
is PassesAction.FilterPasses ->
applyFilter(action.hoursAhead, action.minElevation, action.showDeepSpace, _uiState.value.modes)
is PassesAction.FilterRadios ->
applyFilter(_uiState.value.hours, _uiState.value.elevation, _uiState.value.showDeepSpace, action.modes)
applyFilter(
hoursAhead = action.hoursAhead,
minElevation = action.minElevation,
lowElevation = action.lowElevation,
highElevation = action.highElevation,
aosStartMinute = action.aosStartMinute,
aosEndMinute = action.aosEndMinute,
invertAosTimeWindow = action.invertAosTimeWindow,
showDeepSpace = action.showDeepSpace
)
is PassesAction.FilterRadios -> setModesFilter(action.modes)
PassesAction.RefreshPasses -> refreshPasses()
PassesAction.TogglePassesDialog ->
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
@@ -120,12 +154,28 @@ class PassesViewModel(
}
}
private fun displayLocale(): Locale {
val locale = Locale.getDefault()
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
}
/** Returns the visible pass-date format. Keep non-Chinese locales identical to upstream. */
private fun dateFormat(tz: TimeZone): SimpleDateFormat {
val locale = displayLocale()
val pattern = if (locale.language == Locale.CHINESE.language) {
"yyyy'年'M'月'd'日' EEEE"
} else {
"EEE, dd MMM yyyy"
}
return SimpleDateFormat(pattern, locale).also { it.timeZone = tz }
}
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
val stationPos = settingsRepo.stationPosition.value
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
val sdfTime = SimpleDateFormat("HH:mm", Locale.ENGLISH).also { it.timeZone = tz }
val sdfDate = dateFormat(tz)
val sdfTime = SimpleDateFormat("HH:mm", displayLocale()).also { it.timeZone = tz }
val result = LinkedHashMap<String, Pair<String, String>>()
// DeepSpace group always shows today's sun times
if (passes.any { it.isDeepSpace }) {
@@ -148,7 +198,7 @@ class PassesViewModel(
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
val sdfDate = dateFormat(tz)
val ordered = LinkedHashMap<String, List<OrbitalPass>>()
val deepSpace = passes.filter { it.isDeepSpace }
if (deepSpace.isNotEmpty()) ordered["DeepSpace (period >225min)"] = deepSpace
@@ -198,19 +248,73 @@ class PassesViewModel(
private fun applyFilter(
hoursAhead: Int,
minElevation: Double,
showDeepSpace: Boolean,
modes: List<String>
lowElevation: Double,
highElevation: Double,
aosStartMinute: Int,
aosEndMinute: Int,
invertAosTimeWindow: Boolean,
showDeepSpace: Boolean
) = viewModelScope.launch {
settingsRepo.setPassesSettings(PassesSettings(showDeepSpace, hoursAhead, minElevation, modes))
settingsRepo.setPassesSettings(
PassesSettings(
showDeepSpace,
hoursAhead,
minElevation,
aosStartMinute,
aosEndMinute,
invertAosTimeWindow
)
)
settingsRepo.updateOtherSettings { it.copy(lowElevation = lowElevation, highElevation = highElevation) }
_uiState.update {
it.copy(hours = hoursAhead, elevation = minElevation, showDeepSpace = showDeepSpace, modes = modes)
it.copy(
hours = hoursAhead,
elevation = minElevation,
lowElevation = lowElevation,
highElevation = highElevation,
aosStartMinute = aosStartMinute,
aosEndMinute = aosEndMinute,
invertAosTimeWindow = invertAosTimeWindow,
showDeepSpace = showDeepSpace
)
}
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
val modes = settingsRepo.selectedSatModes.value
satelliteRepo.calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = hoursAhead,
minElevation = minElevation,
aosStartMinute = aosStartMinute,
aosEndMinute = aosEndMinute,
invertAosTimeWindow = invertAosTimeWindow,
modes = modes
)
}
private fun setModesFilter(modes: List<String>) = viewModelScope.launch {
settingsRepo.setSelectedSatModes(modes)
_uiState.update { it.copy(modes = modes) }
satelliteRepo.calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = _uiState.value.hours,
minElevation = _uiState.value.elevation,
aosStartMinute = _uiState.value.aosStartMinute,
aosEndMinute = _uiState.value.aosEndMinute,
invertAosTimeWindow = _uiState.value.invertAosTimeWindow,
modes = modes
)
}
private fun refreshPasses() = viewModelScope.launch {
val (_, hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
val settings = settingsRepo.passesSettings.value
satelliteRepo.calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = settings.hoursAhead,
minElevation = settings.minElevation,
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settingsRepo.selectedSatModes.value
)
}
companion object {
@@ -45,6 +45,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -59,6 +60,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard
@@ -74,6 +76,7 @@ import kotlinx.coroutines.launch
private enum class RadarPage(val title: String) {
Transceivers("Transceivers"),
Calculator("Calculator"),
Sstv("SSTV")
}
@@ -150,16 +153,31 @@ private fun PagerCard(
requestMicPermission: () -> Unit,
modifier: Modifier = Modifier
) {
val pages = RadarPage.entries
val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
}
val pages = remember(hasCalculatorPage) {
buildList {
add(RadarPage.Transceivers)
if (hasCalculatorPage) add(RadarPage.Calculator)
add(RadarPage.Sstv)
}
}
val pagerState = rememberPagerState(pageCount = { pages.size })
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(pages.size) {
val lastPage = pages.lastIndex
if (pagerState.currentPage > lastPage) pagerState.scrollToPage(lastPage)
}
ElevatedCard(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize()) {
PrimaryTabRow(selectedTabIndex = pagerState.currentPage) {
val selectedTabIndex = pagerState.currentPage.coerceIn(0, pages.lastIndex)
PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
pages.forEachIndexed { index, page ->
Tab(
selected = pagerState.currentPage == index,
selected = selectedTabIndex == index,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
)
@@ -176,6 +194,13 @@ private fun PagerCard(
radioControl = uiState.radioControl,
onAction = onAction
)
RadarPage.Calculator -> CalculatorPage(
transceivers = uiState.transceivers.transmitters,
selectedUuid = uiState.transceivers.selectedUuid,
orbitalPos = uiState.orbitalPos,
onAction = onAction,
calculatorOffsetKHz = uiState.calculatorOffsetKHz
)
RadarPage.Sstv -> SstvPage(
sstv = uiState.sstv,
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
@@ -225,6 +250,7 @@ private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
azimElev = uiState.orientationValues,
shouldShowSweep = uiState.shouldShowSweep,
shouldUseCompass = uiState.shouldUseCompass,
shouldFlipRadar = uiState.shouldFlipRadar,
modifier = Modifier.align(Alignment.Center),
sunPosition = uiState.sunPosition,
moonPosition = uiState.moonPosition,
@@ -22,6 +22,7 @@ import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.sstv.SstvFrame
import com.rtbishop.look4sat.core.domain.sstv.SstvQualityMetrics
data class RadioPanelState(
val label: String = "",
@@ -57,11 +58,13 @@ data class RadarState(
val satTrack: List<OrbitalPos> = emptyList(),
val shouldShowSweep: Boolean = false,
val shouldUseCompass: Boolean = false,
val shouldFlipRadar: Boolean = false,
val sunPosition: CelestialComputer.SunPosition? = null,
val moonPosition: CelestialComputer.MoonPosition? = null,
val transceivers: TransceiverSubState = TransceiverSubState(),
val radioControl: RadioControlSubState = RadioControlSubState(),
val sstv: SstvSubState = SstvSubState()
val sstv: SstvSubState = SstvSubState(),
val calculatorOffsetKHz: String = ""
)
enum class SstvStatus { Idle, Recording }
@@ -72,7 +75,8 @@ data class SstvSubState(
val hasPermission: Boolean = false,
val selectedMode: String = "Auto",
val supportedModes: List<String> = emptyList(),
val currentFrame: SstvFrame? = null
val currentFrame: SstvFrame? = null,
val diagnosticsMetrics: SstvQualityMetrics? = null
)
sealed interface RadarAction {
@@ -94,4 +98,7 @@ sealed interface RadarAction {
data object SstvReset : RadarAction
data class SstvSelectMode(val modeName: String) : RadarAction
data class SstvPermissionResult(val granted: Boolean) : RadarAction
// Calculator actions
data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction
}
@@ -74,6 +74,7 @@ fun RadarViewCompose(
azimElev: Pair<Float, Float>,
shouldShowSweep: Boolean,
shouldUseCompass: Boolean,
shouldFlipRadar: Boolean,
modifier: Modifier = Modifier,
sunPosition: CelestialComputer.SunPosition? = null,
moonPosition: CelestialComputer.MoonPosition? = null,
@@ -125,7 +126,9 @@ fun RadarViewCompose(
cachedSweepBrush = makeSweepBrush(center, primaryColor)
cachedSweepColor = primaryColor
}
rotate(if (shouldUseCompass) -azimElev.first else 0f) {
val baseRotation = if (shouldUseCompass) -azimElev.first else 0f
val rotation = if (shouldFlipRadar) baseRotation + 180f else baseRotation
rotate(rotation) {
if (shouldShowSweep) cachedSweepBrush?.let { drawSweep(center, sweepDegrees, radius, it) }
drawRadar(radius, radarColor)
drawElevationLabels(radius, primaryColor, measurer)
@@ -32,6 +32,7 @@ import com.rtbishop.look4sat.core.domain.repository.IReporter
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.sstv.LineRecoveryStrategy
import com.rtbishop.look4sat.core.domain.sstv.SstvDecoder
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
@@ -64,11 +65,14 @@ class RadarViewModel(
private val showToast: IShowToast
) : ViewModel() {
private val stationPos = settingsRepo.stationPosition.value
private val magDeclination = sensorsRepo.getMagDeclination(stationPos)
private var stationPos = settingsRepo.stationPosition.value
private var magDeclination = sensorsRepo.getMagDeclination(stationPos)
private var compassOffset = settingsRepo.otherSettings.value.radarCompassOffset
private var compassOffsetElev = settingsRepo.otherSettings.value.radarCompassOffsetElev
private var transponders: List<SatRadio> = emptyList()
private var sstvDecoder: SstvDecoder? = null
private var sstvRecordingJob: Job? = null
private var sensorCollectionJob: Job? = null
// Celestial positions change slowly, recompute at most once per minute
private var lastCelestialUpdateMs = 0L
@@ -78,7 +82,7 @@ class RadarViewModel(
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)
@@ -87,33 +91,67 @@ class RadarViewModel(
val uiState: StateFlow<RadarState> = _uiState
init {
collectCompassSensor()
collectSettingsChanges()
collectSettingsChanges() // also handles initial sensor subscription
collectStationPositionChanges()
collectPassAndStartTickLoop()
collectRadioTrackingState()
}
private fun collectCompassSensor() {
if (!settingsRepo.otherSettings.value.stateOfSensors) return
viewModelScope.launch {
// Starts sensor collection if not already running.
private fun startSensorCollection() {
if (sensorCollectionJob?.isActive == true) return
sensorCollectionJob = viewModelScope.launch {
sensorsRepo.enableSensor()
sensorsRepo.orientation.collect { data ->
val orientationValues = (data.first + magDeclination) to data.second
sensorsRepo.sensorData.collect { data ->
val orientationValues =
(data.first + magDeclination + compassOffset) to (data.second + compassOffsetElev)
_uiState.update { it.copy(orientationValues = orientationValues) }
}
}
}
// Stops sensor collection and disables the hardware sensor.
private fun stopSensorCollection() {
sensorCollectionJob?.cancel()
sensorCollectionJob = null
sensorsRepo.disableSensor()
}
private fun collectSettingsChanges() {
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
settingsRepo.otherSettings.collect { settings ->
compassOffset = settings.radarCompassOffset
compassOffsetElev = settings.radarCompassOffsetElev
_uiState.update {
it.copy(
isUtc = settings.stateOfUtc,
shouldShowSweep = settings.stateOfSweep,
shouldUseCompass = settings.stateOfSensors
shouldUseCompass = settings.stateOfSensors,
shouldFlipRadar = settings.radarCompassOffsetElev < 0f
)
}
// Reactively wire sensor hardware to the compass setting
when {
settings.stateOfSensors -> startSensorCollection()
else -> stopSensorCollection()
}
}
}
}
private fun collectStationPositionChanges() {
viewModelScope.launch {
settingsRepo.stationPosition.collect { newPos ->
if (stationPos != newPos) {
stationPos = newPos
magDeclination = sensorsRepo.getMagDeclination(stationPos)
lastCelestialUpdateMs = 0L // force celestial recompute on next tick
val pass = _uiState.value.currentPass ?: return@collect
if (!pass.isDeepSpace) {
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
_uiState.update { it.copy(satTrack = track) }
}
}
}
}
}
@@ -212,8 +250,7 @@ class RadarViewModel(
}
override fun onCleared() {
sensorsRepo.disableSensor()
super.onCleared()
stopSensorCollection()
}
fun onAction(action: RadarAction) {
@@ -223,7 +260,17 @@ class RadarViewModel(
// 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)) }
// Load the saved offset for the newly selected satellite
val offsetKHz = if (!isTogglingOff) {
transponders.find { it.uuid == action.uuid }
?.catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
} else ""
_uiState.update {
it.copy(
transceivers = it.transceivers.copy(selectedUuid = newUuid),
calculatorOffsetKHz = offsetKHz
)
}
// Only update the tracking service when selecting a different transponder to
// avoid resetting a user-adjusted TX base on re-expand
if (!isTogglingOff) {
@@ -272,6 +319,15 @@ class RadarViewModel(
sstvDecoder?.clearPixels()
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
}
is RadarAction.ChangeCalculatorOffset -> {
val catnum = _uiState.value.transceivers.selectedUuid?.let { uuid ->
transponders.find { it.uuid == uuid }?.catnum
}
if (catnum != null) {
settingsRepo.setSatelliteOffset(catnum, action.offsetKHz)
}
_uiState.update { it.copy(calculatorOffsetKHz = action.offsetKHz) }
}
}
}
@@ -320,10 +376,8 @@ 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 ->
val freq = if (isFreqEnabled && state.transceivers.selectedUuid != null) {
val freq = if (state.transceivers.selectedUuid != null) {
val selectedRadio = transmitters.firstOrNull { it.uuid == state.transceivers.selectedUuid }
selectedRadio?.let { radio ->
val low = radio.downlinkLow
@@ -346,13 +400,32 @@ class RadarViewModel(
private fun initSstvDecoder() {
if (sstvDecoder == null) {
val decoder = SstvDecoder(sampleRate = audioCapture.sampleRate)
val decoder = SstvDecoder(
sampleRate = audioCapture.sampleRate,
targetRmsLevel = SSTV_TARGET_RMS,
includeScopeData = SSTV_INCLUDE_SCOPE,
enableRmsNormalization = SSTV_ENABLE_RMS_NORMALIZATION,
preFilterCutoffHz = SSTV_PREFILTER_CUTOFF_HZ,
enablePreFilter = SSTV_ENABLE_PREFILTER,
enableDiagnosticsHandle = SSTV_ENABLE_DIAGNOSTICS_HANDLE,
lineRecoveryStrategy = SSTV_LINE_RECOVERY_STRATEGY
)
sstvDecoder = decoder
decoder.lockMode(_uiState.value.sstv.selectedMode)
_uiState.update { it.copy(sstv = it.sstv.copy(supportedModes = decoder.supportedModes)) }
viewModelScope.launch {
decoder.frames.collect { frame ->
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = frame)) }
val metrics = decoder.getQualityMetrics()
_uiState.update {
it.copy(sstv = it.sstv.copy(currentFrame = frame, diagnosticsMetrics = metrics))
}
}
}
decoder.getDiagnosticsHandle()?.let { handle ->
viewModelScope.launch {
handle.metrics.collectLatest { metrics ->
_uiState.update { it.copy(sstv = it.sstv.copy(diagnosticsMetrics = metrics)) }
}
}
}
}
@@ -376,6 +449,39 @@ class RadarViewModel(
}
companion object {
// SSTV Decoder Tuning Parameters
// ==============================
// SSTV_TARGET_RMS: Normalized signal level before FM demodulation.
// - 0.25: Aggressive (original default), amplifies noise; use only for clean inputs
// - 0.35-0.40: RECOMMENDED for typical phone/mic inputs
// - 0.50-0.60: Conservative, best for noisy environments
private const val SSTV_TARGET_RMS = 0.45f // Changed from 0.25 to safer default
// Enable/disable RMS normalization entirely. Set false for direct receiver outputs.
private const val SSTV_ENABLE_RMS_NORMALIZATION = true
// High-pass pre-filter to remove DC offset and subsonic noise before RMS normalization.
// Improves weak signal robustness by cleaning the noise floor estimate.
private const val SSTV_PREFILTER_CUTOFF_HZ = 500.0 // Removes everything below 500 Hz
// Enable pre-filtering. Set false to skip (minimal CPU cost if disabled).
private const val SSTV_ENABLE_PREFILTER = true
// Diagnostics handle can be enabled temporarily to capture field reports.
// Interpretation notes for noisy recordings:
// - syncHitRate < 0.30: weak/noisy signal path or mistuned gain
// - predictedLineBursts rising quickly: frequent sync loss and likely vertical collapse
// - maxPredictedStreak > 3 in Robot36 mode: noisy path and synthetic line flooding
private const val SSTV_ENABLE_DIAGNOSTICS_HANDLE = false
// Line recovery policy:
// - Look4SatLimited: caps predicted lines to reduce visible vertical collapse.
// - Robot36Compatible: unlimited predicted lines (legacy Robot36 behavior).
// Ask users to compare both if they report line-skipping/compression artifacts.
private val SSTV_LINE_RECOVERY_STRATEGY = LineRecoveryStrategy.Robot36Compatible
// Debug: Return scope visualization in frames (increases per-frame memory usage).
private const val SSTV_INCLUDE_SCOPE = false
val CTCSS_TONES = listOf(
67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5,
@@ -384,7 +490,6 @@ class RadarViewModel(
218.1, 225.7, 233.6, 241.8, 250.3
)
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
RadarViewModel(
@@ -53,11 +53,12 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
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
import kotlin.math.roundToInt
@Composable
internal fun SstvPage(
@@ -94,53 +95,44 @@ internal fun SstvPage(
if (showModeDialog.value) {
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
Dialog(onDismissRequest = { showModeDialog.value = false }) {
ElevatedCard {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(vertical = 16.dp)
) {
Text(
text = "SSTV Mode",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 12.dp)
)
LazyColumn(
val dismiss = { showModeDialog.value = false }
ConfirmDialog(
title = "SSTV Mode",
onAccept = dismiss,
onCancel = dismiss
) {
LazyColumn(
modifier = Modifier
.fillMaxHeight(0.69f)
.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.spacedBy(1.dp)
) {
items(allModes) { mode ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxHeight(0.5f)
.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.spacedBy(1.dp)
) {
items(allModes) { mode ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.clickable {
onAction(RadarAction.SstvSelectMode(mode))
showModeDialog.value = false
}
) {
Text(
text = mode,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
.padding(start = 16.dp)
)
RadioButton(
selected = mode == sstv.selectedMode,
onClick = null,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.clickable {
onAction(RadarAction.SstvSelectMode(mode))
showModeDialog.value = false
}
}
) {
Text(
text = mode,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
.padding(start = 16.dp)
)
RadioButton(
selected = mode == sstv.selectedMode,
onClick = null,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
)
}
}
}
@@ -181,6 +173,19 @@ internal fun SstvPage(
modifier = Modifier.align(Alignment.Center)
)
}
val qualityText = frame?.let {
val syncPct = (it.syncHitRate * 100f).roundToInt()
val gainX10 = (it.appliedGain * 10f).roundToInt() / 10f
val inputRmsX1000 = (it.inputRms * 1000f).roundToInt() / 1000f
val scope = if (it.scopePixels != null) " | Scope ${it.scopeWidth}x${it.scopeHeight}" else ""
val complete = if (it.imageComplete) " | Complete" else ""
"RMS $inputRmsX1000 | Gain x$gainX10 | Sync $syncPct% | Pred ${it.predictedLineBursts}/${it.maxPredictedStreak} | Err ${it.timingErrorSamples}$scope$complete"
} ?: sstv.diagnosticsMetrics?.let {
val syncPct = (it.syncHitRate * 100f).roundToInt()
"Sync $syncPct% | Pred ${it.predictedLineBursts}/${it.maxPredictedStreak} | Err ${it.timingErrorSamples}"
}
// Bottom control bar — dark, blends with the black canvas
Column(
modifier = Modifier
@@ -190,12 +195,19 @@ internal fun SstvPage(
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
// Doppler-corrected downlink frequency hint
if (dopplerFrequency != null) {
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)
)
if (qualityText != null) {
OutlinedText(
text = "RX: $dopplerFrequency Hz",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
fillColor = MaterialTheme.colorScheme.primary,
text = qualityText,
fontSize = 12.sp,
fillColor = MaterialTheme.colorScheme.onSurface,
outlineColor = MaterialTheme.colorScheme.background,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
@@ -21,14 +21,18 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -39,14 +43,23 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -54,12 +67,17 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.formatFrequency
@@ -104,6 +122,73 @@ fun TransceiversPage(
}
}
@Composable
fun CalculatorPage(
transceivers: List<SatRadio>,
selectedUuid: String?,
orbitalPos: OrbitalPos?,
onAction: (RadarAction) -> Unit,
calculatorOffsetKHz: String = "",
modifier: Modifier = Modifier
) {
val calculatorTransceivers = remember(transceivers) {
transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder)
.let(DopplerFrequencyCalculator::deduplicateTransponders)
}
val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) {
calculatorTransceivers.firstOrNull { it.uuid == selectedUuid }
?: calculatorTransceivers.firstOrNull()
}
if (selectedTransceiver == null) {
EmptyTransceiversContent(modifier)
return
}
LazyColumn(
modifier = modifier
.fillMaxSize()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
if (calculatorTransceivers.size > 1) {
item {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth()
) {
calculatorTransceivers.forEach { radio ->
FilterChip(
selected = radio.uuid == selectedTransceiver.uuid,
onClick = {
if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid))
},
label = {
Text(
text = transceiverTitle(radio),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
}
}
item {
DopplerFrequencyCalculator(
transponder = selectedTransceiver,
orbitalPos = orbitalPos,
offsetKHz = calculatorOffsetKHz,
onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) },
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
@@ -172,12 +257,8 @@ private fun TransceiverItem(
.rotate(if (isExpanded) 270f else 90f)
)
}
// Title with mode
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
val fullTitle = "$title ($mode)"
Text(
text = fullTitle,
text = transceiverTitle(radio),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
@@ -369,10 +450,11 @@ private fun ExpandedRadioControl(
)
Spacer(modifier = Modifier.height(4.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
maxItemsInEachRow = 5,
modifier = Modifier.fillMaxWidth()
) {
val chipModifier = Modifier.width(64.dp)
val chipModifier = Modifier.weight(1f)
FilterChip(
selected = radioControl.ctcssTone == null,
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
@@ -436,6 +518,362 @@ private fun ExpandedRadioControl(
}
}
private enum class EditedField { TX, PASSBAND, RX }
@Composable
private fun DopplerFrequencyCalculator(
transponder: SatRadio,
orbitalPos: OrbitalPos?,
offsetKHz: String = "",
onOffsetChange: (String) -> Unit = {},
modifier: Modifier = Modifier
) {
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
var lastEditedField by remember { mutableStateOf(EditedField.TX) }
var txFrequencyHz by remember { mutableStateOf(0L) }
var rxFrequencyHz by remember { mutableStateOf(0L) }
var passbandPosition by remember { mutableStateOf(0.5f) }
var stepSizeKHz by remember { mutableIntStateOf(1) }
val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
val txLow = transponder.uplinkLow ?: return
val txHigh = transponder.uplinkHigh ?: return
val rxLow = transponder.downlinkLow ?: return
val rxHigh = transponder.downlinkHigh ?: return
val txRange = txHigh - txLow
val rxRange = rxHigh - rxLow
if (txRange <= 0L || rxRange <= 0L) return
// 原始转发器值(逆转多普勒),用于 passband 映射计算
val rawTxLow = orbitalPos.getDownlinkFreq(txLow)
val rawTxHigh = orbitalPos.getDownlinkFreq(txHigh)
val rawRxLow = orbitalPos.getUplinkFreq(rxLow)
val rawRxHigh = orbitalPos.getUplinkFreq(rxHigh)
val rawTxRange = rawTxHigh - rawTxLow
val rawRxRange = rawRxHigh - rawRxLow
val rawTransponder = transponder.copy(
uplinkLow = rawTxLow, uplinkHigh = rawTxHigh,
downlinkLow = rawRxLow, downlinkHigh = rawRxHigh
)
// 初始化
LaunchedEffect(Unit) {
if (txFrequencyHz == 0L) {
txFrequencyHz = txLow + txRange / 2
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: (rxLow + rxRange / 2)
}
}
// 实时重算:卫星移动时,锚点侧不变,重算另一侧
LaunchedEffect(orbitalPos) {
if (lastEditedField == EditedField.TX) {
// TX 是锚点,不变;重算 RX
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
} else if (lastEditedField == EditedField.RX) {
// RX 是锚点,不变;重算 TX
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
} else if (lastEditedField == EditedField.PASSBAND) {
// 相对位置不变(passbandPosition),TX 用地面频率,RX 经 Transceiver 完整路径计算
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
}
val txSliderValue = if (lastEditedField == EditedField.PASSBAND) {
passbandPosition
} else {
((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
}
val rxSliderValue = if (lastEditedField == EditedField.PASSBAND) {
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
} else {
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
}
fun updateTx(newTxHz: Long) {
when (lastEditedField) {
EditedField.PASSBAND -> {
// Passband 模式:滑块位置 → passbandPosition → 地面频率,RX 经 Transceiver 计算
val pos = ((newTxHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
passbandPosition = pos
txFrequencyHz = txLow + (pos * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
else -> {
txFrequencyHz = newTxHz.coerceIn(txLow, txHigh)
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
}
}
fun updateRx(newRxHz: Long) {
when (lastEditedField) {
EditedField.PASSBAND -> {
// Passband 模式:从 RX 反推 TX 位置,TX 用地面频率
val pos = ((newRxHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
val txFromRx = TransponderMapper.mapDownlinkToUplink(newRxHz, rawTransponder)
passbandPosition = if (txFromRx != null) {
((txFromRx - rawTxLow).toFloat() / rawTxRange).coerceIn(0f, 1f)
} else pos
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
else -> {
rxFrequencyHz = newRxHz.coerceIn(rxLow, rxHigh)
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
}
}
}
Column(
modifier = modifier.fillMaxWidth().padding(top = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
// 左半边: TX + RX 按钮
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
FilterChip(
selected = lastEditedField == EditedField.TX,
onClick = {
when (lastEditedField) {
EditedField.TX -> {
passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
lastEditedField = EditedField.PASSBAND
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
EditedField.PASSBAND -> { lastEditedField = EditedField.TX }
else -> { lastEditedField = EditedField.TX }
}
},
label = {
Text("TX", fontSize = 13.sp, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth())
},
modifier = Modifier.weight(1f).fillMaxHeight()
)
FilterChip(
selected = lastEditedField == EditedField.RX,
onClick = {
when (lastEditedField) {
EditedField.RX -> {
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
lastEditedField = EditedField.PASSBAND
}
EditedField.PASSBAND -> {
lastEditedField = EditedField.RX
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
}
else -> { lastEditedField = EditedField.RX }
}
},
label = {
Text("RX", fontSize = 13.sp, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth())
},
modifier = Modifier.weight(1f).fillMaxHeight()
)
}
// 右半边: Offset 输入框(带细线边框)
Box(
modifier = Modifier
.weight(1f)
.height(48.dp)
.border(1.dp, MaterialTheme.colorScheme.outline, MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
BasicTextField(
value = offsetKHz,
onValueChange = onOffsetChange,
singleLine = true,
textStyle = TextStyle(
fontSize = 16.sp,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
),
decorationBox = { innerTextField ->
if (offsetKHz.isEmpty()) {
Text(
text = "Offset (kHz)",
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
innerTextField()
}
)
}
}
// TX 行
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(
onClick = { updateTx(txFrequencyHz - stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("−", fontSize = 14.sp) }
Slider(
value = txSliderValue,
onValueChange = { norm ->
val rawFreq = txLow + (txRange * norm).toLong()
val snapped = ((rawFreq + 500) / 1000L) * 1000L
updateTx(snapped.coerceIn(txLow, txHigh))
},
valueRange = 0f..1f,
modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp)
)
OutlinedButton(
onClick = { updateTx(txFrequencyHz + stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("+", fontSize = 14.sp) }
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = String.format(Locale.ENGLISH, "%.4f", txLow / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = String.format(Locale.ENGLISH, "%.6f", txFrequencyHz / 1_000_000.0),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = " MHz",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Text(
text = String.format(Locale.ENGLISH, "%.4f", txHigh / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End
)
}
}
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
// RX 行
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(
onClick = { updateRx(rxFrequencyHz - stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("−", fontSize = 14.sp) }
Slider(
value = rxSliderValue,
onValueChange = { norm ->
val rawFreq = rxLow + (rxRange * norm).toLong()
val snapped = ((rawFreq + 500) / 1000L) * 1000L
updateRx(snapped.coerceIn(rxLow, rxHigh))
},
valueRange = 0f..1f,
modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp)
)
OutlinedButton(
onClick = { updateRx(rxFrequencyHz + stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("+", fontSize = 14.sp) }
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = String.format(Locale.ENGLISH, "%.4f", rxLow / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = String.format(Locale.ENGLISH, "%.6f", rxFrequencyHz / 1_000_000.0),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = " MHz",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Text(
text = String.format(Locale.ENGLISH, "%.4f", rxHigh / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End
)
}
}
}
}
@Composable
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
val text = frequency?.let {
@@ -451,5 +889,12 @@ private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
)
}
private fun transceiverTitle(radio: SatRadio): String {
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
return "$title ($mode)"
}
private val FREQ_ADJUSTMENTS =
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
@@ -39,27 +39,27 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
@Preview(showBackground = true)
@Composable
private fun MultiTypesDialogPreview() {
val types = listOf("Amateur", "Geostationary", "Military", "Weather")
MainTheme { MultiTypesDialog(types, emptyList(), {}) {} }
private fun MultiModesDialogPreview() {
val modes = listOf("FM", "APT", "SSTV", "BPSK")
MainTheme { MultiModesDialog(modes, emptyList(), {}) {} }
}
@Composable
internal fun MultiTypesDialog(
allTypes: List<String>, types: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
internal fun MultiModesDialog(
allModes: List<String>, modes: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
) {
val selected = remember { mutableStateOf(types.toSet()) }
val toggle = { type: String ->
selected.value = if (type in selected.value) selected.value - type else selected.value + type
val selected = remember { mutableStateOf(modes.toSet()) }
val toggle = { mode: String ->
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
}
val onAccept = { accept(selected.value.toList()) }
SharedDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
ConfirmDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
LazyVerticalGrid(
columns = GridCells.Adaptive(240.dp),
modifier = Modifier
@@ -68,7 +68,7 @@ internal fun MultiTypesDialog(
horizontalArrangement = Arrangement.spacedBy(1.dp),
verticalArrangement = Arrangement.spacedBy(1.dp)
) {
itemsIndexed(allTypes) { index, item ->
itemsIndexed(allModes) { index, item ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -63,6 +63,7 @@ import com.rtbishop.look4sat.core.presentation.CardLoadingIndicator
import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.InfoDialog
import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
import com.rtbishop.look4sat.core.presentation.R
@@ -87,19 +88,27 @@ private fun SatellitesScreen(
navigateUp: () -> Unit
) {
if (uiState.isDialogShown) {
MultiTypesDialog(
allTypes = uiState.typesList,
types = uiState.currentTypes,
cancel = { onAction(SatellitesAction.ToggleTypesDialog) },
accept = { onAction(SatellitesAction.SelectTypes(it)) }
MultiModesDialog(
allModes = uiState.modesList,
modes = uiState.currentModes,
cancel = { onAction(SatellitesAction.ToggleModesDialog) },
accept = { onAction(SatellitesAction.SelectModes(it)) }
)
}
if (uiState.shouldSeeWarning) {
val dismiss = { onAction(SatellitesAction.DismissWarning) }
InfoDialog(
stringResource(R.string.sat_warning_title),
stringResource(R.string.sat_warning_message)
title = stringResource(R.string.sat_warning_title),
onDismiss = dismiss,
onAccept = dismiss
) {
onAction(SatellitesAction.DismissWarning)
Text(
text = stringResource(R.string.sat_warning_message),
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
Spacer(modifier = Modifier.height(0.dp))
}
}
@@ -114,9 +123,9 @@ private fun SatellitesScreen(
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (isVerticalLayout()) {
TopBar {
TypeCard(
types = uiState.currentTypes,
onClick = { onAction(SatellitesAction.ToggleTypesDialog) },
ModeCard(
modes = uiState.currentModes,
onClick = { onAction(SatellitesAction.ToggleModesDialog) },
modifier = Modifier.weight(1f)
)
PrimaryIconCard(
@@ -148,9 +157,9 @@ private fun SatellitesScreen(
resId = R.drawable.ic_done,
modifier = Modifier.semantics { contentDescription = primCardCd }
)
TypeCard(
types = uiState.currentTypes,
onClick = { onAction(SatellitesAction.ToggleTypesDialog) },
ModeCard(
modes = uiState.currentModes,
onClick = { onAction(SatellitesAction.ToggleModesDialog) },
modifier = Modifier.weight(1f)
)
SearchBar(
@@ -232,8 +241,8 @@ private fun SearchBar(onQueryChange: (String) -> Unit, modifier: Modifier = Modi
}
@Composable
private fun TypeCard(types: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
val typesText = if (types.isEmpty()) "All" else types.joinToString(", ")
private fun ModeCard(modes: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
val modesText = if (modes.isEmpty()) "All" else modes.joinToString(", ")
ElevatedCard(modifier = modifier) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -243,12 +252,12 @@ private fun TypeCard(types: List<String>, onClick: () -> Unit, modifier: Modifie
.clickable { onClick() }) {
Spacer(Modifier)
Icon(
painter = painterResource(id = R.drawable.ic_tags),
painter = painterResource(id = R.drawable.ic_radios),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.sat_type_hint, typesText),
text = stringResource(R.string.sat_type_hint, modesText),
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier
@@ -314,9 +323,54 @@ private fun SatellitesPreview() {
@Composable
private fun SatellitesCard(items: List<SatItem>, onSelected: (Int, Boolean) -> Unit) {
val selectedItems = items.filter { it.isSelected }
val availableItems = items.filterNot { it.isSelected }
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp)) {
items(items = items, key = { item -> item.catnum }) { entry ->
Satellite(entry, onSelected, Modifier.animateItem())
stickyHeader(key = "sat_summary_header") {
SummaryHeader(
selectedText = stringResource(R.string.sat_group_selected_count, selectedItems.size),
availableText = stringResource(R.string.sat_group_available_count, availableItems.size)
)
}
if (selectedItems.isNotEmpty()) {
items(items = selectedItems, key = { item -> item.catnum }) { entry ->
Satellite(entry, onSelected, Modifier.animateItem())
}
}
if (availableItems.isNotEmpty()) {
items(items = availableItems, key = { item -> item.catnum }) { entry ->
Satellite(entry, onSelected, Modifier.animateItem())
}
}
}
}
@Composable
private fun SummaryHeader(selectedText: String, availableText: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(horizontal = 12.dp, vertical = 4.dp)
) {
Text(
text = selectedText,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = availableText,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -24,8 +24,8 @@ data class SatellitesState(
val isLoading: Boolean = true,
val shouldSeeWarning: Boolean = false,
val itemsList: List<SatItem> = emptyList(),
val currentTypes: List<String> = emptyList(),
val typesList: List<String> = emptyList()
val currentModes: List<String> = emptyList(),
val modesList: List<String> = emptyList()
)
sealed interface SatellitesAction {
@@ -34,7 +34,7 @@ sealed interface SatellitesAction {
data class SearchFor(val query: String) : SatellitesAction
data object SelectAll : SatellitesAction
data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction
data class SelectTypes(val types: List<String>) : SatellitesAction
data object ToggleTypesDialog : SatellitesAction
data class SelectModes(val modes: List<String>) : SatellitesAction
data object ToggleModesDialog : SatellitesAction
data object UnselectAll : SatellitesAction
}
@@ -35,13 +35,13 @@ class SatellitesViewModel(
private val settingsRepo: ISettingsRepo
) : ViewModel() {
private val defaultTypes = selectionRepo.getCurrentTypes()
private val defaultModes = selectionRepo.getCurrentModes()
private val _uiState = MutableStateFlow(
SatellitesState(
isLoading = true,
shouldSeeWarning = settingsRepo.otherSettings.value.shouldSeeWarning,
currentTypes = defaultTypes,
typesList = selectionRepo.getTypesList()
currentModes = defaultModes,
modesList = selectionRepo.getModesList()
)
)
val uiState: StateFlow<SatellitesState> = _uiState
@@ -49,7 +49,7 @@ class SatellitesViewModel(
init {
viewModelScope.launch {
selectionRepo.setQuery(String())
selectionRepo.setTypes(defaultTypes)
selectionRepo.setModes(defaultModes)
selectionRepo.getEntriesFlow().collectLatest { items ->
_uiState.update { it.copy(isLoading = false, itemsList = items) }
}
@@ -59,6 +59,11 @@ class SatellitesViewModel(
_uiState.update { it.copy(shouldSeeWarning = settings.shouldSeeWarning) }
}
}
viewModelScope.launch {
settingsRepo.selectedSatModes.collectLatest { modes ->
_uiState.update { it.copy(currentModes = modes) }
}
}
}
fun onAction(action: SatellitesAction) {
@@ -68,8 +73,8 @@ class SatellitesViewModel(
is SatellitesAction.SearchFor -> searchFor(action.query)
SatellitesAction.SelectAll -> selectAll(true)
is SatellitesAction.SelectSingle -> selectSingle(action.id, action.isTicked)
is SatellitesAction.SelectTypes -> selectTypes(action.types)
SatellitesAction.ToggleTypesDialog -> toggleTypesDialog()
is SatellitesAction.SelectModes -> selectModes(action.modes)
SatellitesAction.ToggleModesDialog -> toggleModesDialog()
SatellitesAction.UnselectAll -> selectAll(false)
}
}
@@ -86,12 +91,12 @@ class SatellitesViewModel(
selectionRepo.setSelection(listOf(id), isTicked.not())
}
private fun selectTypes(types: List<String>) = viewModelScope.launch {
selectionRepo.setTypes(types)
_uiState.update { it.copy(currentTypes = types, isDialogShown = false) }
private fun selectModes(modes: List<String>) = viewModelScope.launch {
selectionRepo.setModes(modes)
_uiState.update { it.copy(currentModes = modes, isDialogShown = false) }
}
private fun toggleTypesDialog() {
private fun toggleModesDialog() {
_uiState.update { it.copy(isDialogShown = !it.isDialogShown) }
}
@@ -17,37 +17,55 @@
*/
package com.rtbishop.look4sat.feature.settings
import android.bluetooth.BluetoothManager
import android.content.Context
import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
@Preview(showBackground = true)
@Composable
@@ -61,17 +79,29 @@ fun PositionDialog(lat: Double, lon: Double, dismiss: () -> Unit, save: (Double,
val lonValue = rememberSaveable { mutableStateOf(lon.toString()) }
val titleText = stringResource(id = R.string.prefs_station_title)
val onAccept = { saveValues(latValue.value, lonValue.value, save).also { dismiss() } }
SharedDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
OutlinedTextField(
value = latValue.value,
onValueChange = { latValue.value = it },
label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) }
)
OutlinedTextField(
value = lonValue.value,
onValueChange = { lonValue.value = it },
label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) }
)
ConfirmDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = LocalSpacing.current.large)
) {
OutlinedTextField(
value = latValue.value,
onValueChange = { latValue.value = it },
label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) },
singleLine = true,
modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = lonValue.value,
onValueChange = { lonValue.value = it },
label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) },
singleLine = true,
modifier = Modifier.weight(1f),
)
}
Spacer(modifier = Modifier.height(0.dp))
}
}
@@ -93,12 +123,20 @@ private fun LocatorDialogPreview() {
fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Unit) {
val locator = rememberSaveable { mutableStateOf(qthLocator) }
val onAccept = { save(locator.value).also { dismiss() } }
SharedDialog(title = stringResource(R.string.prefs_locator_title), onCancel = dismiss, onAccept = onAccept) {
ConfirmDialog(
title = stringResource(R.string.prefs_locator_title),
onCancel = dismiss,
onAccept = onAccept
) {
OutlinedTextField(
value = locator.value,
onValueChange = { locator.value = it },
label = { Text(text = stringResource(id = R.string.prefs_locator_text)) }
label = { Text(text = stringResource(id = R.string.prefs_locator_text)) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = LocalSpacing.current.large),
)
Spacer(modifier = Modifier.height(0.dp))
}
}
@@ -107,107 +145,144 @@ fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Uni
private fun TransceiversDialogPreview() {
MainTheme {
DataSourcesDialog(
useCustomTle = true,
useCustomTransceivers = true,
tleUrl = "https://example.com/tle.txt",
transceiversUrl = "https://example.com/tx.json",
satelliteUrls = listOf(
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"amsat.org/tle/current/nasabare.txt"
),
transceiversUrls = listOf(
"db.satnogs.org/api/transmitters/?format=json&status=active"
),
onImportTle = {},
onImportTransceivers = {},
onDismiss = {},
onSave = { _, _, _, _ -> }
onSave = { _, _ -> }
)
}
}
@Composable
fun DataSourcesDialog(
useCustomTle: Boolean,
useCustomTransceivers: Boolean,
tleUrl: String,
transceiversUrl: String,
satelliteUrls: List<String>,
transceiversUrls: List<String>,
onImportTle: () -> Unit,
onImportTransceivers: () -> Unit,
onDismiss: () -> Unit,
onSave: (Boolean, Boolean, String, String) -> Unit
onSave: (List<String>, List<String>) -> Unit
) {
val padding = LocalSpacing.current.large
val isEnabledCustomTle = rememberSaveable { mutableStateOf(useCustomTle) }
val isEnabledCustomTransceivers = rememberSaveable { mutableStateOf(useCustomTransceivers) }
val urlTle = rememberSaveable { mutableStateOf(tleUrl) }
val urlTransceivers = rememberSaveable { mutableStateOf(transceiversUrl) }
val onAccept = {
onSave(isEnabledCustomTle.value, isEnabledCustomTransceivers.value, urlTle.value, urlTransceivers.value)
onDismiss()
// Use stable Long IDs to avoid key collisions (e.g. multiple empty "" entries).
val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) }
val satUrls = remember {
satelliteUrls.mapIndexed { i, url -> i.toLong() to url }.toMutableStateList()
}
val onCancel = { onDismiss() }
SharedDialog(
val txUrls = remember {
transceiversUrls.mapIndexed { i, url -> (satelliteUrls.size + i).toLong() to url }.toMutableStateList()
}
val onAccept = { onSave(satUrls.map { it.second }, txUrls.map { it.second }); onDismiss() }
ConfirmDialog(
title = stringResource(id = R.string.prefs_data_sources_title),
onCancel = onCancel,
onCancel = onDismiss,
onAccept = onAccept,
) {
Column(modifier = Modifier.padding(horizontal = padding)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
CardButton(
onClick = {
onImportTle()
onDismiss()
},
text = "TLE (3LE)\nR4UAB (.txt)",
modifier = Modifier.weight(1f)
)
CardButton(
onClick = {
onImportTransceivers()
onDismiss()
},
text = "Transceivers\nSatNOGS (.json)",
modifier = Modifier.weight(1f)
)
LazyColumn(
modifier = Modifier
.fillMaxHeight(0.84f)
.padding(horizontal = padding),
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = PaddingValues(vertical = 6.dp)
) {
item {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
CardButton(
onClick = { onImportTle(); onDismiss() },
text = "TLE/3LE (.txt)\nOMM (.csv)",
modifier = Modifier.weight(1f)
)
CardButton(
onClick = { onImportTransceivers(); onDismiss() },
text = "Transceivers\nSatNOGS (.json)",
modifier = Modifier.weight(1f)
)
}
}
Spacer(modifier = Modifier.height(6.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.prefs_data_sources_tle_switch))
Switch(
checked = isEnabledCustomTle.value,
onCheckedChange = { isEnabledCustomTle.value = it }
)
}
OutlinedTextField(
value = urlTle.value,
onValueChange = { urlTle.value = it },
label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
enabled = isEnabledCustomTle.value,
sourceSection(
sectionKey = "sat",
label = "Satellites data",
urls = satUrls,
onAdd = { satUrls.add(nextId.longValue++ to "") },
onMoveUp = { i -> if (i > 0) satUrls.add(i - 1, satUrls.removeAt(i)) },
onRemove = { i -> satUrls.removeAt(i) },
onUrlChange = { i, v -> satUrls[i] = satUrls[i].first to v }
)
Spacer(modifier = Modifier.height(6.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.prefs_data_sources_transceivers_switch))
Switch(
checked = isEnabledCustomTransceivers.value,
onCheckedChange = { isEnabledCustomTransceivers.value = it }
)
}
OutlinedTextField(
value = urlTransceivers.value,
onValueChange = { urlTransceivers.value = it },
label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
enabled = isEnabledCustomTransceivers.value,
sourceSection(
sectionKey = "tx",
label = "Transceivers data",
urls = txUrls,
onAdd = { txUrls.add(nextId.longValue++ to "") },
onMoveUp = { i -> if (i > 0) txUrls.add(i - 1, txUrls.removeAt(i)) },
onRemove = { i -> txUrls.removeAt(i) },
onUrlChange = { i, v -> txUrls[i] = txUrls[i].first to v }
)
}
}
}
private fun LazyListScope.sourceSection(
sectionKey: String,
label: String,
urls: List<Pair<Long, String>>,
onAdd: () -> Unit,
onMoveUp: (Int) -> Unit,
onRemove: (Int) -> Unit,
onUrlChange: (Int, String) -> Unit
) {
item {
Spacer(modifier = Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
IconCard(action = onAdd, resId = R.drawable.ic_add, containerColor = MaterialTheme.colorScheme.surfaceVariant)
}
}
itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (_, url) ->
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant
OutlinedTextField(
value = url,
onValueChange = { onUrlChange(index, it) },
label = { Text("Source URL") },
leadingIcon = {
IconButton(onClick = { onMoveUp(index) }) {
Icon(
painter = painterResource(R.drawable.ic_arrow),
contentDescription = null,
tint = if (index > 0) enabledTint else enabledTint.copy(alpha = 0.32f),
modifier = Modifier.rotate(270f)
)
}
},
trailingIcon = {
IconButton(onClick = { onRemove(index) }) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = null
)
}
},
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = spring(), fadeOutSpec = spring())
)
}
}
@Preview(showBackground = true)
@Composable
fun PreviewNetworkOutputDialog() {
@@ -222,6 +297,7 @@ fun PreviewNetworkOutputDialog() {
frequencyAddress = "127.0.0.1",
frequencyPort = "4532",
frequencyFormat = $$"F $FREQ",
frequencyOffsetHz = 0L,
bluetoothRotatorState = false,
bluetoothRotatorFormat = $$"P $AZ $EL",
bluetoothRotatorName = "Default",
@@ -231,7 +307,7 @@ fun PreviewNetworkOutputDialog() {
bluetoothFrequencyFormat = $$"F $FREQ"
),
onDismiss = {},
onSave = { _, _, _, _, _, _, _, _ -> }
onSave = { _, _, _, _, _, _, _, _, _ -> }
)
}
}
@@ -242,7 +318,7 @@ fun NetworkOutputDialog(
onDismiss: () -> Unit,
onSave: (
Boolean, String, String, String,
Boolean, String, String, String
Boolean, String, String, String, Long
) -> Unit
) {
val padding = LocalSpacing.current.large
@@ -256,16 +332,19 @@ fun NetworkOutputDialog(
mutableStateOf("${initialSettings.frequencyAddress}:${initialSettings.frequencyPort}")
}
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.frequencyFormat) }
val frequencyOffsetHz = rememberSaveable { mutableStateOf(initialSettings.frequencyOffsetHz.toString()) }
val onAccept = {
val (rotIp, rotPort) = splitAddress(rotatorAddress.value)
val (freqIp, freqPort) = splitAddress(frequencyAddress.value)
val offsetHz = (frequencyOffsetHz.value.trim().toLongOrNull() ?: 0L)
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ)
onSave(
rotatorState.value, rotIp, rotPort, rotatorFormat.value,
frequencyState.value, freqIp, freqPort, frequencyFormat.value
frequencyState.value, freqIp, freqPort, frequencyFormat.value, offsetHz
)
onDismiss()
}
SharedDialog(
ConfirmDialog(
title = stringResource(R.string.prefs_net_title),
onCancel = onDismiss,
onAccept = onAccept
@@ -294,6 +373,28 @@ fun NetworkOutputDialog(
onFormatChange = { frequencyFormat.value = it },
formatLabel = stringResource(R.string.prefs_net_frequency_format_hint)
)
Spacer(modifier = Modifier.height(6.dp))
OutlinedTextField(
value = frequencyOffsetHz.value,
onValueChange = { frequencyOffsetHz.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_net_frequency_offset_hint)) },
supportingText = { Text(stringResource(R.string.prefs_net_frequency_offset_help)) },
trailingIcon = {
IconButton(
onClick = { frequencyOffsetHz.value = "0" },
enabled = frequencyState.value && frequencyOffsetHz.value != "0"
) {
Icon(
painter = painterResource(R.drawable.ic_close),
contentDescription = null
)
}
},
modifier = Modifier.fillMaxWidth(),
enabled = frequencyState.value
)
Spacer(modifier = Modifier.height(12.dp))
}
}
}
@@ -321,6 +422,7 @@ fun PreviewBluetoothOutputDialog() {
frequencyAddress = "127.0.0.1",
frequencyPort = "4532",
frequencyFormat = $$"F $FREQ",
frequencyOffsetHz = 0L,
bluetoothRotatorState = false,
bluetoothRotatorFormat = $$"P $AZ $EL",
bluetoothRotatorName = "Default",
@@ -346,11 +448,15 @@ fun BluetoothOutputDialog(
) {
val padding = LocalSpacing.current.large
val rotatorState = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorState) }
val rotatorAddress = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorAddress) }
val rotatorAddress =
rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorAddress) }
val rotatorFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorFormat) }
val frequencyState = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyState) }
val frequencyAddress = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyAddress) }
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) }
val frequencyState =
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyState) }
val frequencyAddress =
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyAddress) }
val frequencyFormat =
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) }
val onAccept = {
onSave(
rotatorState.value, rotatorAddress.value, rotatorFormat.value,
@@ -358,7 +464,7 @@ fun BluetoothOutputDialog(
)
onDismiss()
}
SharedDialog(
ConfirmDialog(
title = stringResource(R.string.prefs_bt_title),
onCancel = onDismiss,
onAccept = onAccept
@@ -387,6 +493,7 @@ fun BluetoothOutputDialog(
onFormatChange = { frequencyFormat.value = it },
formatLabel = stringResource(R.string.prefs_bt_frequency_output_hint)
)
Spacer(modifier = Modifier.height(12.dp))
}
}
}
@@ -441,14 +548,14 @@ private fun OutputChannelSection(
@Composable
fun RadioControlDialog(
initialSettings: RadioControlSettings,
pairedBluetoothDevices: List<Pair<String, String>>,
onDismiss: () -> Unit,
onSave: (RadioControlSettings) -> Unit
) {
val context = androidx.compose.ui.platform.LocalContext.current
val padding = LocalSpacing.current.large
val baudRates = listOf(4800, 9600, 38400)
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) }
val splitMode = rememberSaveable { mutableStateOf(initialSettings.splitMode) }
val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) }
val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) }
val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) }
@@ -456,14 +563,17 @@ fun RadioControlDialog(
val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) }
val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or ""
val pairedDevices = remember {
try {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
manager.adapter?.bondedDevices?.map { Pair(it.name ?: "Unknown", it.address) } ?: emptyList()
} catch (_: SecurityException) {
emptyList()
}
}
val isIcom = radioModel.value == RadioControlSettings.MODEL_ICOM_IC705
val isSingleRadio = isIcom && splitMode.value
// Reset split mode when switching away from IC-705
if (!isIcom && splitMode.value) splitMode.value = false
val baudRates = if (isIcom) RadioControlSettings.BAUD_RATES_ICOM
else RadioControlSettings.BAUD_RATES_YAESU
// If current baud rate is not in the new list, default to the first available
if (baudRate.intValue !in baudRates) baudRate.intValue = baudRates.first()
val onAccept = {
onSave(
@@ -471,20 +581,23 @@ fun RadioControlDialog(
enabled = enabled.value,
radioModel = radioModel.value,
txRadioAddress = txAddress.value,
rxRadioAddress = rxAddress.value,
rxRadioAddress = if (isSingleRadio) "" else rxAddress.value,
txRadioName = txName.value,
rxRadioName = rxName.value,
baudRate = baudRate.intValue
rxRadioName = if (isSingleRadio) "" else rxName.value,
baudRate = baudRate.intValue,
splitMode = splitMode.value
)
)
onDismiss()
}
SharedDialog(
ConfirmDialog(
title = stringResource(R.string.rc_settings_title),
onCancel = onDismiss,
onAccept = onAccept
) {
Column(modifier = Modifier.padding(horizontal = padding)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
@@ -495,59 +608,98 @@ fun RadioControlDialog(
}
Spacer(modifier = Modifier.height(6.dp))
// Radio model selection
Text(
stringResource(R.string.rc_radio_model),
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
text = stringResource(R.string.rc_radio_model),
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.fillMaxWidth()
) {
RadioControlSettings.SUPPORTED_RADIOS.forEach { model ->
androidx.compose.material3.FilterChip(
FilterChip(
selected = radioModel.value == model,
onClick = { radioModel.value = model },
label = { Text(model, fontSize = 12.sp) },
enabled = enabled.value
label = {
Text(
text = compactRadioModelLabel(model),
fontSize = 12.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
},
enabled = enabled.value,
modifier = Modifier.weight(1f)
)
}
}
Spacer(modifier = Modifier.height(6.dp))
// TX Radio selection
Text("TX Radio (Uplink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium)
if (txAddress.value.isNotBlank()) {
Text("${txName.value} - ${txAddress.value}", fontSize = 13.sp)
val isSplitModeAvailable = enabled.value && isIcom
val splitModeLabelColor = if (isSplitModeAvailable) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.56f)
}
CardButton(
onClick = { selectingFor.value = "tx" },
text = "Select TX Device",
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
)
) {
Text(
text = "Split mode (IC-705 only)",
fontWeight = FontWeight.Medium,
color = splitModeLabelColor
)
Switch(
checked = splitMode.value,
onCheckedChange = { splitMode.value = it },
enabled = isSplitModeAvailable
)
}
Spacer(modifier = Modifier.height(6.dp))
// RX Radio selection
Text("RX Radio (Downlink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium)
if (rxAddress.value.isNotBlank()) {
Text("${rxName.value} - ${rxAddress.value}", fontSize = 13.sp)
}
CardButton(
onClick = { selectingFor.value = "rx" },
text = "Select RX Device",
Text("Radio devices", fontWeight = FontWeight.Medium)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
)
) {
CardButton(
onClick = { selectingFor.value = "tx" },
text = if (isSingleRadio) "Select Radio" else "Select TX",
modifier = Modifier.weight(1f)
)
CardButton(
onClick = { if (!isSingleRadio) selectingFor.value = "rx" },
text = if (isSingleRadio) "RX = TX" else "Select RX",
modifier = Modifier.weight(1f)
)
}
if (txAddress.value.isNotBlank()) {
Text("TX: ${txName.value} — ${txAddress.value}", fontSize = 13.sp)
}
if (isSingleRadio && txAddress.value.isNotBlank()) {
Text("RX: same as TX", fontSize = 13.sp)
} else if (rxAddress.value.isNotBlank()) {
Text("RX: ${rxName.value} — ${rxAddress.value}", fontSize = 13.sp)
}
Spacer(modifier = Modifier.height(6.dp))
// Paired devices list (shown when selecting)
if (selectingFor.value.isNotBlank()) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Paired Bluetooth Devices:",
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
if (pairedDevices.isEmpty()) {
Text("No paired devices found. Pair your BT adapter in Android Bluetooth settings first.")
Spacer(modifier = Modifier.height(2.dp))
if (pairedBluetoothDevices.isEmpty()) {
Text(
"No paired devices found. Pair your BT adapter in Android Bluetooth settings first.",
fontSize = 13.sp
)
} else {
pairedDevices.forEach { (name, address) ->
pairedBluetoothDevices.forEach { (name, address) ->
androidx.compose.material3.Surface(
modifier = Modifier
.fillMaxWidth()
@@ -555,6 +707,10 @@ fun RadioControlDialog(
if (selectingFor.value == "tx") {
txAddress.value = address
txName.value = name
if (isSingleRadio) {
rxAddress.value = address
rxName.value = name
}
} else {
rxAddress.value = address
rxName.value = name
@@ -573,25 +729,50 @@ fun RadioControlDialog(
}
}
}
Spacer(modifier = Modifier.height(6.dp))
}
Spacer(modifier = Modifier.height(6.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
Text("Baud Rate:", fontWeight = FontWeight.Medium)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(2.dp),
maxItemsInEachRow = 6,
modifier = Modifier.fillMaxWidth()
) {
Text("Baud Rate:")
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
baudRates.forEach { rate ->
CardButton(
onClick = { baudRate.intValue = rate },
text = if (rate == baudRate.intValue) "[$rate]" else rate.toString(),
modifier = Modifier
)
}
baudRates.forEach { rate ->
FilterChip(
selected = rate == baudRate.intValue,
onClick = { baudRate.intValue = rate },
label = {
Text(
text = baudLabel(rate),
fontSize = 12.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
},
enabled = enabled.value,
modifier = Modifier.weight(1f)
)
}
}
Spacer(modifier = Modifier.height(6.dp))
}
}
}
private fun compactRadioModelLabel(model: String): String = when (model) {
RadioControlSettings.MODEL_YAESU_FT817 -> "FT-817/818"
RadioControlSettings.MODEL_YAESU_FT857 -> "FT-857/897"
RadioControlSettings.MODEL_ICOM_IC705 -> "IC-705"
else -> model
}
private fun baudLabel(rate: Int): String = when (rate) {
4800 -> "4k"
9600 -> "9k"
19200 -> "19k"
38400 -> "38k"
57600 -> "57k"
115200 -> "115k"
else -> rate.toString()
}
@@ -25,10 +25,12 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
@@ -38,6 +40,7 @@ import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -61,6 +64,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
@@ -71,6 +75,7 @@ import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import java.text.SimpleDateFormat
@@ -91,7 +96,8 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
val dialogs = rememberDialogVisibility()
val permissions = rememberSettingsPermissions(
sendAction = onAction,
onBluetoothGranted = { dialogs.bluetooth = true }
onBluetoothGranted = { dialogs.bluetooth = true },
onNetworkGranted = { dialogs.network = true }
)
// Dialogs
@@ -112,23 +118,15 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
}
if (dialogs.dataSources) {
DataSourcesDialog(
useCustomTle = uiState.dataSourcesSettings.useCustomTLE,
useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers,
tleUrl = uiState.dataSourcesSettings.tleUrl,
transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
satelliteUrls = uiState.dataSourcesSettings.satelliteUrls,
transceiversUrls = uiState.dataSourcesSettings.transceiversUrls,
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
onDismiss = { dialogs.dataSources = false },
onSave = { useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl ->
val current = uiState.dataSourcesSettings
val newSettings = current.copy(
useCustomTLE = if (!useCustomTle || tleUrl.isNotBlank()) useCustomTle else current.useCustomTLE,
tleUrl = if (!useCustomTle || tleUrl.isNotBlank()) tleUrl else current.tleUrl,
useCustomTransceivers = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) useCustomTransceivers else current.useCustomTransceivers,
transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl
)
if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings))
if (useCustomTle || useCustomTransceivers) onAction(SettingsAction.UpdateFromWeb)
onSave = { satUrls, txUrls ->
val newSettings = DataSourcesSettings(satelliteUrls = satUrls, transceiversUrls = txUrls)
if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
onAction(SettingsAction.UpdateFromWeb)
}
)
}
@@ -136,14 +134,15 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
NetworkOutputDialog(
initialSettings = uiState.rcSettings,
onDismiss = { dialogs.network = false },
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt ->
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt, freqOffsetHz ->
onAction(
SettingsAction.UpdateRC(
uiState.rcSettings.copy(
rotatorState = rotState, rotatorAddress = rotAddr,
rotatorPort = rotPort, rotatorFormat = rotFmt,
frequencyState = freqState, frequencyAddress = freqAddr,
frequencyPort = freqPort, frequencyFormat = freqFmt
frequencyPort = freqPort, frequencyFormat = freqFmt,
frequencyOffsetHz = freqOffsetHz
)
)
)
@@ -170,10 +169,14 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
if (dialogs.radioControl) {
RadioControlDialog(
initialSettings = uiState.radioControlSettings,
pairedBluetoothDevices = uiState.pairedBluetoothDevices,
onDismiss = { dialogs.radioControl = false },
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
)
}
if (dialogs.whatsNew) {
WhatsNewDialog(onDismiss = { dialogs.whatsNew = false })
}
// URLs for top bar
val uriHandler = LocalUriHandler.current
@@ -194,11 +197,11 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
if (isVerticalLayout) {
TopBar {
TopCard(
onClick = { safeOpenUri(appUrl) },
onClick = { dialogs.whatsNew = true },
version = uiState.appVersionName,
modifier = Modifier.weight(1f)
)
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
}
TopBar {
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -220,9 +223,9 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
}
} else {
TopBar {
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
TopCard(
onClick = { safeOpenUri(appUrl) },
onClick = { dialogs.whatsNew = true },
version = uiState.appVersionName,
modifier = Modifier.weight(1f)
)
@@ -273,13 +276,27 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
}
item(span = { GridItemSpan(maxLineSpan) }) {
OutputCard(
onNetworkClick = { dialogs.network = true },
onNetworkClick = permissions.launchNetwork,
onBluetoothClick = permissions.launchBluetooth,
onRadioControlClick = { dialogs.radioControl = true }
)
}
item { OtherCard(uiState.otherSettings, onAction) }
item { CardCredits() }
item(span = { GridItemSpan(maxLineSpan) }) {
if (isVerticalLayout) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
OtherCard(uiState.otherSettings, onAction)
CardCredits()
}
} else {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)
) {
OtherCard(uiState.otherSettings, onAction, modifier = Modifier.weight(1f).fillMaxHeight())
CardCredits(modifier = Modifier.weight(1f).fillMaxHeight())
}
}
}
}
}
}
@@ -356,7 +373,7 @@ private fun LocationCard(
@Composable
private fun DataCardPreview() = MainTheme {
val settings = DataSettings(true, 5000, 2500, 0L)
DataCard(settings = settings, {}, {}, {})
DataCard(settings = settings, updateFromWeb = {}, clearAllData = {}, showDataSourcesDialog = {})
}
@Composable
@@ -458,40 +475,83 @@ private fun OtherCardPreview() = MainTheme {
shouldSeeWarning = false,
shouldSeeWhatsNew = false
)
OtherCard(settings = values) {}
OtherCard(settings = values, onAction = {})
}
@Composable
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.height(268.dp)
) {
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit, modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
text = stringResource(id = R.string.prefs_other_title),
color = MaterialTheme.colorScheme.primary
)
SwitchRow(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
onAction(SettingsAction.ToggleUtc(it))
Spacer(modifier = Modifier.height(4.dp))
// Display preferences: UTC clock + night filter
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SwitchTile(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
onAction(SettingsAction.ToggleUtc(it))
}
SwitchTile(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
onAction(SettingsAction.ToggleNightMode(it))
}
}
Spacer(modifier = Modifier.height(4.dp))
// Radar behavior: sweep animation + sensor control
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SwitchTile(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
onAction(SettingsAction.ToggleSweep(it))
}
SwitchTile(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
onAction(SettingsAction.ToggleSensor(it))
}
}
Spacer(modifier = Modifier.height(4.dp))
// Data management (full width)
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
onAction(SettingsAction.ToggleUpdate(it))
}
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
onAction(SettingsAction.ToggleSweep(it))
}
SwitchRow(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
onAction(SettingsAction.ToggleSensor(it))
}
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
onAction(SettingsAction.ToggleNightMode(it))
}
Spacer(modifier = Modifier.height(4.dp))
// Compass calibration sliders at the bottom
CompassOffsetRow(
labelResId = R.string.prefs_other_compass_offset,
value = settings.radarCompassOffset,
range = -180f..180f
) { onAction(SettingsAction.SetRadarCompassOffset(it)) }
Spacer(modifier = Modifier.height(4.dp))
CompassOffsetRow(
labelResId = R.string.prefs_other_compass_offset_elev,
value = settings.radarCompassOffsetElev,
range = -90f..90f
) { onAction(SettingsAction.SetRadarCompassOffsetElev(it)) }
Spacer(modifier = Modifier.height(4.dp))
}
}
}
@Composable
private fun CompassOffsetRow(
labelResId: Int,
value: Float,
range: ClosedFloatingPointRange<Float>,
onValueChange: (Float) -> Unit
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = labelResId))
Text(text = "${value.toInt()}°")
}
Spacer(modifier = Modifier.height(4.dp))
Slider(
value = value,
onValueChange = onValueChange,
valueRange = range
)
}
@Composable
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
@@ -504,6 +564,21 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole
}
}
@Composable
private fun RowScope.SwitchTile(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Text(
text = stringResource(id = labelResId),
modifier = Modifier.weight(1f)
)
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@Composable
private fun formatUpdateTime(updateTime: Long): String {
val timePattern = stringResource(id = R.string.prefs_updated_time)
@@ -538,7 +613,6 @@ private fun CardCredits(modifier: Modifier = Modifier) {
ElevatedCard(
modifier = modifier
.fillMaxWidth()
.height(268.dp)
) {
Column(
verticalArrangement = Arrangement.SpaceBetween,
@@ -572,7 +646,7 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version:
.clickable { onClick() }) {
Spacer(Modifier)
Icon(
painter = painterResource(id = R.drawable.ic_satellites),
painter = painterResource(id = R.drawable.ic_sputnik),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
@@ -623,6 +697,7 @@ private class DialogVisibility {
var network by mutableStateOf(false)
var bluetooth by mutableStateOf(false)
var radioControl by mutableStateOf(false)
var whatsNew by mutableStateOf(false)
}
@Composable
@@ -630,12 +705,12 @@ private fun rememberDialogVisibility(): DialogVisibility {
return rememberSaveable(saver = run {
androidx.compose.runtime.saveable.Saver(
save = {
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl)
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.whatsNew)
},
restore = {
DialogVisibility().apply {
position = it[0]; locator = it[1]; dataSources = it[2]
network = it[3]; bluetooth = it[4]; radioControl = it[5]
network = it[3]; bluetooth = it[4]; radioControl = it[5]; whatsNew = it[6]
}
}
)
@@ -651,13 +726,15 @@ private class SettingsPermissions(
val launchLocation: () -> Unit,
val launchTleImport: () -> Unit,
val launchTransceiverImport: () -> Unit,
val launchBluetooth: () -> Unit
val launchBluetooth: () -> Unit,
val launchNetwork: () -> Unit
)
@Composable
private fun rememberSettingsPermissions(
sendAction: (SettingsAction) -> Unit,
onBluetoothGranted: () -> Unit
onBluetoothGranted: () -> Unit,
onNetworkGranted: () -> Unit
): SettingsPermissions {
val locationError = stringResource(R.string.prefs_loc_gps_error)
val locationRequest = rememberLauncherForActivityResult(
@@ -669,12 +746,15 @@ private fun rememberSettingsPermissions(
else sendAction(SettingsAction.ShowToast(locationError))
}
val satellitesImportError = stringResource(R.string.prefs_data_import_satellites_error)
val transceiversImportError = stringResource(R.string.prefs_data_import_transceivers_error)
val tleRequest = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { sendAction(SettingsAction.UpdateTLEFromFile(it.toString())) }
uri?.let { sendAction(SettingsAction.UpdateTLEFromFile(it.toString(), satellitesImportError)) }
}
val transceiversRequest = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { sendAction(SettingsAction.UpdateTransceiversFromFile(it.toString())) }
uri?.let { sendAction(SettingsAction.UpdateTransceiversFromFile(it.toString(), transceiversImportError)) }
}
val bluetoothError = stringResource(R.string.prefs_bt_perm_error)
@@ -685,6 +765,12 @@ private fun rememberSettingsPermissions(
else sendAction(SettingsAction.ShowToast(bluetoothError))
}
val networkError = stringResource(R.string.prefs_net_perm_error)
val networkRequest = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) onNetworkGranted()
else sendAction(SettingsAction.ShowToast(networkError))
}
return remember {
SettingsPermissions(
launchLocation = {
@@ -694,7 +780,14 @@ private fun rememberSettingsPermissions(
},
launchTleImport = { tleRequest.launch("*/*") },
launchTransceiverImport = { transceiversRequest.launch("*/*") },
launchBluetooth = { bluetoothRequest.launch(bluetoothPerm) }
launchBluetooth = { bluetoothRequest.launch(bluetoothPerm) },
launchNetwork = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
networkRequest.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
} else {
onNetworkGranted()
}
}
)
}
}
@@ -28,7 +28,10 @@ data class PositionSettings(
)
data class DataSettings(
val isUpdating: Boolean, val entriesTotal: Int, val radiosTotal: Int, val timestamp: Long
val isUpdating: Boolean,
val entriesTotal: Int,
val radiosTotal: Int,
val timestamp: Long
)
data class SettingsState(
@@ -38,7 +41,8 @@ data class SettingsState(
val otherSettings: OtherSettings,
val rcSettings: RCSettings,
val radioControlSettings: RadioControlSettings,
val dataSourcesSettings: DataSourcesSettings
val dataSourcesSettings: DataSourcesSettings,
val pairedBluetoothDevices: List<Pair<String, String>> = emptyList()
)
sealed interface SettingsAction {
@@ -50,8 +54,8 @@ sealed interface SettingsAction {
// Data
data object UpdateFromWeb : SettingsAction
data class UpdateTLEFromFile(val uri: String) : SettingsAction
data class UpdateTransceiversFromFile(val uri: String) : SettingsAction
data class UpdateTLEFromFile(val uri: String, val invalidFileMessage: String) : SettingsAction
data class UpdateTransceiversFromFile(val uri: String, val invalidFileMessage: String) : SettingsAction
data object ClearAllData : SettingsAction
// Toggles
@@ -61,6 +65,8 @@ sealed interface SettingsAction {
data class ToggleSensor(val value: Boolean) : SettingsAction
data class ToggleLightTheme(val value: Boolean) : SettingsAction
data class ToggleNightMode(val value: Boolean) : SettingsAction
data class SetRadarCompassOffset(val value: Float) : SettingsAction
data class SetRadarCompassOffsetElev(val value: Float) : SettingsAction
// Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction
@@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class SettingsViewModel(
private val container: IMainContainer,
private val databaseRepo: IDatabaseRepo,
private val settingsRepo: ISettingsRepo,
private val showToast: IShowToast
@@ -47,7 +48,8 @@ class SettingsViewModel(
otherSettings = settingsRepo.otherSettings.value,
rcSettings = settingsRepo.rcSettings.value,
radioControlSettings = settingsRepo.radioControlSettings.value,
dataSourcesSettings = settingsRepo.dataSourcesSettings.value
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
pairedBluetoothDevices = container.providePairedBluetoothDevices()
)
)
@@ -65,11 +67,11 @@ class SettingsViewModel(
settingsRepo.databaseState.collect { state ->
_uiState.update {
it.copy(
dataSettings = DataSettings(
false,
state.numberOfSatellites,
state.numberOfRadios,
state.updateTimestamp
dataSettings = it.dataSettings.copy(
isUpdating = false,
entriesTotal = state.numberOfSatellites,
radiosTotal = state.numberOfRadios,
timestamp = state.updateTimestamp
)
)
}
@@ -107,11 +109,11 @@ class SettingsViewModel(
SettingsAction.DismissPosMessages -> dismissPosMessage()
// Data
SettingsAction.UpdateFromWeb -> runDataUpdate { databaseRepo.updateFromRemote() }
is SettingsAction.UpdateTLEFromFile -> runDataUpdate { databaseRepo.updateTLEFromFile(action.uri) }
is SettingsAction.UpdateTransceiversFromFile -> runDataUpdate {
databaseRepo.updateTransceiversFromFile(
action.uri
)
is SettingsAction.UpdateTLEFromFile -> runManualImport(action.invalidFileMessage) {
databaseRepo.updateTLEFromFile(action.uri)
}
is SettingsAction.UpdateTransceiversFromFile -> runManualImport(action.invalidFileMessage) {
databaseRepo.updateTransceiversFromFile(action.uri)
}
SettingsAction.ClearAllData -> viewModelScope.launch { databaseRepo.clearAllData() }
// Toggles
@@ -121,6 +123,8 @@ class SettingsViewModel(
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
is SettingsAction.SetRadarCompassOffset -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffset = action.value) }
is SettingsAction.SetRadarCompassOffsetElev -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffsetElev = action.value) }
// Remote control & data sources
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
@@ -184,12 +188,27 @@ class SettingsViewModel(
}
}
private fun runManualImport(importErrorMessage: String, block: suspend () -> Int) {
viewModelScope.launch {
try {
_uiState.update { it.copy(dataSettings = it.dataSettings.copy(isUpdating = true)) }
if (block() == 0) showToast(importErrorMessage)
} catch (exception: Exception) {
_uiState.update { it.copy(dataSettings = it.dataSettings.copy(isUpdating = false)) }
println(exception)
}
}
}
// endregion
companion object {
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
SettingsViewModel(
container = container,
databaseRepo = container.databaseRepo,
settingsRepo = container.settingsRepo,
showToast = container.provideShowToast()
+17
View File
@@ -0,0 +1,17 @@
import com.android.build.api.dsl.LibraryExtension
plugins {
alias(libs.plugins.convention.featurePlugin)
}
android {
namespace = "com.rtbishop.look4sat.feature.status"
compileOptions {
encoding = "UTF-8"
}
}
dependencies {
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
}
@@ -0,0 +1,327 @@
package com.rtbishop.look4sat.feature.status
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.SatDay
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.InfoDialog
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.layoutPadding
import java.util.Calendar
/** Fixed width per day tile — tablet-safe; name column absorbs remaining space. */
private val TILE_WIDTH: Dp = 64.dp
/**
* Map AMSAT status text to Material3 colorScheme colors.
* Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants.
*/
@Composable
private fun statusColorOf(statusText: String): Color {
return when {
statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) ->
MaterialTheme.colorScheme.tertiary
statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) ->
MaterialTheme.colorScheme.tertiaryContainer
statusText.contains("Not Heard", ignoreCase = true) ->
Color(0xFFDC267F)
else ->
MaterialTheme.colorScheme.error
}
}
@Composable
fun SatStatusDestination() {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SatStatusScreen(uiState) { viewModel.refresh() }
}
@Composable
private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh)
LegendRow()
when {
uiState.isLoading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
uiState.error != null && uiState.statuses.isEmpty() -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = stringResource(id = R.string.amsat_load_failed), color = MaterialTheme.colorScheme.error)
TextButton(onClick = refresh) { Text(text = stringResource(id = R.string.amsat_retry)) }
}
}
else -> {
HeaderRow(statuses = uiState.statuses)
HorizontalDivider(thickness = 1.dp)
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(uiState.statuses, key = { it.name }) { status ->
StatusRow(status = status, onClickDay = { day -> selectedDay = status to day })
}
}
}
}
}
selectedDay?.let { (status, day) ->
ReportDialog(statusName = status.name, day = day, reports = uiState.reports, onDismiss = { selectedDay = null })
}
}
/** Top: update time + refresh button (spinner while loading) */
@Composable
private fun StatusHeader(fetchedAtUtcMs: Long, isRefreshing: Boolean, onRefresh: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (fetchedAtUtcMs > 0)
stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs)
else
stringResource(id = R.string.amsat_title),
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onRefresh),
contentAlignment = Alignment.Center
) {
if (isRefreshing) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Image(
painter = painterResource(id = R.drawable.ic_refresh),
contentDescription = stringResource(id = R.string.amsat_refresh),
modifier = Modifier.size(16.dp),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary)
)
}
}
}
}
/** Legend: FlowRow of colored chips — wraps to two lines on narrow screens, stays one line when wide. */
@Composable
private fun LegendRow() {
val legend = listOf(
stringResource(id = R.string.amsat_active) to Color(0xFF648FFF),
stringResource(id = R.string.amsat_tlm) to Color(0xFFFFB000),
stringResource(id = R.string.amsat_not_heard) to Color(0xFFDC267F),
stringResource(id = R.string.amsat_conflict) to Color(0xFFFE6100)
)
FlowRow(
modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
legend.forEach { (label, color) ->
val alphaColor = color.copy(alpha = 0.25f)
Row(
modifier = Modifier
.clip(RoundedCornerShape(32.dp))
.background(alphaColor)
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(RoundedCornerShape(4.dp))
.background(color)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = label,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
/** Header: satellite name column + fixed-width date labels aligned to tiles. */
@Composable
private fun HeaderRow(statuses: List<SatStatus>) {
val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList()
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(id = R.string.amsat_name),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f).padding(start = 4.dp)
)
dates.forEach { date ->
Text(
text = date,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.width(TILE_WIDTH)
)
}
}
}
/** Satellite row: name takes remaining width; day tiles are fixed-width (tablet-safe). */
@Composable
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
val noReportGray = 0xFFC0C0C0L
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = status.name,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(end = 4.dp)
)
status.days.forEach { day ->
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
DayCell(
slot = slot,
modifier = Modifier.width(TILE_WIDTH).padding(horizontal = 2.dp),
onClick = { onClickDay(day) }
)
}
}
}
/** Day block: newest reported status among the day's 12 slots; gray when none. */
@Composable
private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
val color = Color(slot.statusColor)
Box(
modifier = modifier
.height(28.dp)
.clip(RoundedCornerShape(4.dp))
.background(color)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
if (slot.count > 0) {
Text(text = slot.count.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
}
}
/** Report detail dialog (callsign / date / time / grid) */
@Composable
private fun ReportDialog(statusName: String, day: SatDay, reports: Map<String, SatReport>, onDismiss: () -> Unit) {
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
InfoDialog(title = "$statusName · ${day.dateLabel}", onDismiss = onDismiss, onAccept = onDismiss) {
if (dayReports.isEmpty()) {
Text(stringResource(id = R.string.amsat_no_reports))
Spacer(modifier = Modifier.height(8.dp))
} else {
LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) {
items(dayReports) { report ->
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(statusColorOf(report.statusText))
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = report.statusText, fontSize = 14.sp, fontWeight = FontWeight.Bold)
}
Text(
text = "${report.call} ${report.dateUtc} ${report.timeUtc}" +
if (report.grid.isNotBlank() && report.grid != "-") " ${report.grid}" else "",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
HorizontalDivider(thickness = 0.5.dp)
}
}
}
}
}
private val MONTH_ABBR = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
private fun formatFetchedAt(utcMs: Long): String {
val cal = Calendar.getInstance()
cal.timeInMillis = utcMs
val day = cal.get(Calendar.DAY_OF_MONTH)
val month = MONTH_ABBR[cal.get(Calendar.MONTH)]
val year = cal.get(Calendar.YEAR)
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
val ss = cal.get(Calendar.SECOND).toString().padStart(2, '0')
return "$day$month $year - $hh:$mm:$ss"
}
@@ -0,0 +1,95 @@
package com.rtbishop.look4sat.feature.status
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class SatStatusUiState(
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val statuses: List<SatStatus> = emptyList(),
val reports: Map<String, SatReport> = emptyMap(),
val fetchedAtUtcMs: Long = 0L,
val error: String? = null
)
class SatStatusViewModel(
private val amSatRepo: IAmSatRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
val uiState: StateFlow<SatStatusUiState> = _uiState
init {
fetch()
}
fun fetch() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val page = amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) {
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs
)
}
} else {
_uiState.update {
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
}
}
} catch (exception: Exception) {
_uiState.update {
it.copy(isLoading = false, isRefreshing = false, error = exception.message ?: "fetch_failed")
}
}
}
}
fun refresh() {
if (_uiState.value.isRefreshing) return
_uiState.update { it.copy(isRefreshing = true, error = null) }
viewModelScope.launch {
try {
val page = amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) {
_uiState.update {
it.copy(
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs
)
}
} else {
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
}
} catch (exception: Exception) {
_uiState.update { it.copy(isRefreshing = false, error = exception.message ?: "fetch_failed") }
}
}
}
companion object {
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
SatStatusViewModel(container.amSatRepo)
}
}
}
}
+1
View File
@@ -2,4 +2,5 @@
org.gradle.caching=true
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.tooling.parallel=true
org.gradle.workers.max=4
+13 -13
View File
@@ -1,35 +1,35 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "441"
appVersionCode = "446"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.1"
#noinspection GradleDependency,UnusedVersionCatalogEntry
compileSdk = "36"
appVersionName = "4.4.6"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry
minSdk = "24"
#noinspection UnusedVersionCatalogEntry
jdkVersion = "17"
jdkVersion = "21"
#noinspection UnusedVersionCatalogEntry
packageName = "com.rtbishop.look4sat"
android-gradle-plugin = "9.2.1"
android-gradle-plugin = "9.3.1"
androidx-core-ktx = "1.18.0"
androidx-core-ktx = "1.19.0"
androidx-core-splashscreen = "1.2.0"
androidx-room = "2.8.4"
compose-bom = "2026.05.01"
compose-bom = "2026.06.01"
compose-activity = "1.13.0"
compose-lifecycle = "2.10.0"
compose-navigation3 = "1.1.2"
compose-lifecycle = "2.11.0"
compose-navigation3 = "1.1.5"
google-ksp = "2.3.7"
google-ksp = "2.3.10"
kotlin = "2.4.0"
kotlin = "2.4.10"
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
#Tue Aug 11 14:16:53 CEST 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.7.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+2 -1
View File
@@ -25,5 +25,6 @@ include(
":feature:passes",
":feature:radar",
":feature:satellites",
":feature:settings"
":feature:settings",
":feature:status"
)