mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 19:17:21 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8c48e33a0 | ||
|
|
f9806d7f7f | ||
|
|
2d3adfc6a6 | ||
|
|
f585372594 | ||
|
|
4585332b1a | ||
|
|
6d2b0ced49 | ||
|
|
e86bc2c700 | ||
|
|
9a52fdfde5 | ||
|
|
07504a2a95 | ||
|
|
81525b6dd3 | ||
|
|
c7089ab314 | ||
|
|
77dfb7bb05 | ||
|
|
58fb688593 | ||
|
|
dc596b4348 | ||
|
|
f9b1a97a37 | ||
|
|
d700432829 |
No files matched your search
@@ -4,3 +4,7 @@ updates:
|
|||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
- package-ecosystem: "bundler"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "never"
|
||||||
@@ -8,16 +8,15 @@ All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) po
|
|||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
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
|
satellites using Celestrak/SatNOGS orbital data, calculates positions via SGP4/SDP4, and predicts passes relative to
|
||||||
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
|
the user's location. Features include polar radar visualization, SSTV image decoding, and ground track mapping. No ads,
|
||||||
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
|
no tracking, no network required after initial data download.
|
||||||
data download.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture & Design
|
||||||
|
|
||||||
**MVI (Model-View-Intent)** with unidirectional data flow:
|
**MVI (Model-View-Intent)** with unidirectional data flow:
|
||||||
- `State` data class → exposed via `StateFlow` from ViewModel
|
- `State` data class (named `<Feature>State`) exposed via `StateFlow` from ViewModel
|
||||||
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
|
- `Action` sealed interface (named `<Feature>Action`) dispatched to ViewModel's `onAction()`
|
||||||
- Jetpack Compose UI observes state and recomposes reactively
|
- Jetpack Compose UI observes state and recomposes reactively
|
||||||
|
|
||||||
**Clean Architecture layers:**
|
**Clean Architecture layers:**
|
||||||
@@ -34,9 +33,11 @@ data download.
|
|||||||
| `feature:satellites` | Satellite list, filtering, selection |
|
| `feature:satellites` | Satellite list, filtering, selection |
|
||||||
| `feature:settings` | User preferences |
|
| `feature:settings` | User preferences |
|
||||||
|
|
||||||
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
|
**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 & Run
|
## Build & Platform
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
# Debug build
|
# Debug build
|
||||||
@@ -50,54 +51,55 @@ data download.
|
|||||||
```
|
```
|
||||||
|
|
||||||
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
||||||
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
|
- **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/`
|
||||||
|
|
||||||
## Key Libraries
|
## Tech Stack
|
||||||
|
|
||||||
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
||||||
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
|
- **Navigation3**: Type-safe navigation with `@Serializable` nav keys
|
||||||
- **Room** (KSP code generation) for local satellite/TLE storage
|
- **Room** (KSP code generation) for local satellite/orbital storage
|
||||||
- **OkHttp** 5.x for TLE downloads
|
- **OkHttp** 5.x for data downloads
|
||||||
- **OSMDroid** for map rendering
|
- **OSMDroid** for map rendering
|
||||||
- **Kotlin Serialization** for navigation args and data parsing
|
- **Kotlin Serialization** for navigation args and parsing
|
||||||
- **Coroutines** + `StateFlow` for async/reactive patterns
|
- **Coroutines** + `StateFlow` for async/reactive patterns
|
||||||
|
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh)
|
||||||
## 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
|
## Data Formats & Migration
|
||||||
|
|
||||||
**TLE vs. OMM/CSV format:**
|
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
|
||||||
|
|
||||||
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
|
- **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)
|
||||||
|
|
||||||
- **TLE format**: Traditional 3-line element format (deprecated). NORAD catalog numbers are 5-digit integers, which
|
## Engineering Heuristics (Lazy = Efficient)
|
||||||
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:**
|
- Treat "lazy" as efficient, not careless: the best code is the code never written.
|
||||||
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly
|
- First understand the task and trace the real flow end-to-end, then climb this ladder:
|
||||||
- TLE data is downloaded from configured sources and stored in Room database
|
1. Does this need to be built now? (YAGNI)
|
||||||
- When downloading satellite data, the app automatically detects format and parses accordingly
|
2. Does it already exist in this codebase? Reuse helpers/patterns before rewriting.
|
||||||
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching
|
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?"
|
||||||
|
|
||||||
**Migration path:**
|
## Bug-Fix Policy
|
||||||
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
|
- Fix root cause, not just the reported symptom.
|
||||||
|
- If touching a shared function, inspect callers and prefer one shared fix over per-caller patches.
|
||||||
- Prefer **short, focused functions** — single responsibility, easy to read.
|
- The smallest correct diff wins only after behavior is understood.
|
||||||
- **Exceptions**: Composable functions and math-heavy algorithms (SGP4/SDP4) may be longer.
|
|
||||||
- Strict code style — no dead code, no unused imports, consistent formatting.
|
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
@@ -105,12 +107,9 @@ transition to OMM/CSV automatically.
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Orbital math lives in `core:domain/predict/` — it's dense vector math (SGP4/SDP4). Tread carefully.
|
- Orbital math lives in `core:domain/predict/` — 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.
|
- 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.
|
- `build-logic/convention/` contains 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.
|
|
||||||
|
|
||||||
## Copilot Working Mode: Code-Only
|
## Copilot Working Mode: Code-Only
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round">
|
android:roundIcon="@mipmap/ic_launcher_round">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaul
|
|||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -70,6 +71,8 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
|||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
import com.rtbishop.look4sat.core.presentation.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.RadarDestination
|
||||||
import com.rtbishop.look4sat.core.presentation.Screen
|
import com.rtbishop.look4sat.core.presentation.Screen
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||||
@@ -127,120 +130,128 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||||
|
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
NavigationSuiteScaffold(
|
CompositionLocalProvider(
|
||||||
navigationSuiteItems = {
|
LocalElevationThresholds provides ElevationThresholds(
|
||||||
navItems.forEach { screen ->
|
low = otherSettings.lowElevation,
|
||||||
val isSelected = when (currentKey) {
|
high = otherSettings.highElevation
|
||||||
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
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
Column {
|
NavigationSuiteScaffold(
|
||||||
NavDisplay(
|
navigationSuiteItems = {
|
||||||
backStack = backStack,
|
navItems.forEach { screen ->
|
||||||
modifier = Modifier.weight(1f),
|
val isSelected = when (currentKey) {
|
||||||
onBack = navigateBack,
|
is Screen.Satellites -> screen is Screen.Satellites
|
||||||
transitionSpec = { fadeTransition },
|
is Screen.Passes -> screen is Screen.Passes
|
||||||
popTransitionSpec = { fadeTransition },
|
is Screen.Radar -> screen is Screen.Radar
|
||||||
predictivePopTransitionSpec = { fadeTransition },
|
is Screen.Map -> screen is Screen.Map
|
||||||
entryDecorators = listOf(
|
is Screen.Settings -> screen is Screen.Settings
|
||||||
// Required for saving Compose state per entry
|
else -> false
|
||||||
rememberSaveableStateHolderNavEntryDecorator(),
|
|
||||||
// Required for ViewModel scoping per entry
|
|
||||||
rememberViewModelStoreNavEntryDecorator()
|
|
||||||
),
|
|
||||||
entryProvider = entryProvider {
|
|
||||||
entry<Screen.Satellites> {
|
|
||||||
SatellitesDestination(navigateUp = navigateBack)
|
|
||||||
}
|
}
|
||||||
entry<Screen.Passes> {
|
item(
|
||||||
PassesDestination { catNum, aosTime ->
|
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
|
||||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
label = { Text(stringResource(screen.titleResId)) },
|
||||||
backStack.add(Screen.Radar)
|
selected = isSelected,
|
||||||
// navigateToRadar()
|
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
|
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
||||||
if (trackingState.isActive) {
|
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
),
|
||||||
val alpha by infiniteTransition.animateFloat(
|
layoutType = when {
|
||||||
initialValue = 1f, targetValue = 0.4f,
|
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
|
||||||
animationSpec = infiniteRepeatable(
|
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
|
||||||
animation = tween(1000, easing = LinearEasing),
|
else -> NavigationSuiteType.ShortNavigationBarMedium
|
||||||
repeatMode = RepeatMode.Reverse
|
}
|
||||||
), label = "pulseAlpha"
|
) {
|
||||||
)
|
Column {
|
||||||
Row(
|
NavDisplay(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
backStack = backStack,
|
||||||
modifier = Modifier
|
modifier = Modifier.weight(1f),
|
||||||
.fillMaxWidth()
|
onBack = navigateBack,
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
transitionSpec = { fadeTransition },
|
||||||
.clickable {
|
popTransitionSpec = { fadeTransition },
|
||||||
val pass = trackingState.currentPass
|
predictivePopTransitionSpec = { fadeTransition },
|
||||||
if (pass != null) {
|
entryDecorators = listOf(
|
||||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
// Required for saving Compose state per entry
|
||||||
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
|
// Required for ViewModel scoping per entry
|
||||||
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
|
),
|
||||||
|
entryProvider = entryProvider {
|
||||||
|
entry<Screen.Satellites> {
|
||||||
|
SatellitesDestination(navigateUp = navigateBack)
|
||||||
|
}
|
||||||
|
entry<Screen.Passes> {
|
||||||
|
PassesDestination { catNum, aosTime ->
|
||||||
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
backStack.add(Screen.Radar)
|
backStack.add(Screen.Radar)
|
||||||
|
// navigateToRadar()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
entry<Screen.Radar> {
|
||||||
) {
|
RadarDestination(navigateUp = navigateBack)
|
||||||
Box(
|
}
|
||||||
|
entry<Screen.Map> {
|
||||||
|
MapDestination()
|
||||||
|
}
|
||||||
|
entry<Screen.Settings> {
|
||||||
|
SettingsDestination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// Radio tracking status banner
|
||||||
|
if (trackingState.isActive) {
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
||||||
|
val alpha by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 1f, targetValue = 0.4f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(1000, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
), label = "pulseAlpha"
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(8.dp)
|
.fillMaxWidth()
|
||||||
.clip(CircleShape)
|
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
.clickable {
|
||||||
)
|
val pass = trackingState.currentPass
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
if (pass != null) {
|
||||||
Text(
|
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||||
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
|
backStack.add(Screen.Radar)
|
||||||
fontSize = 13.sp,
|
}
|
||||||
fontWeight = FontWeight.Medium,
|
}
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
modifier = Modifier.weight(1f)
|
) {
|
||||||
)
|
Box(
|
||||||
val txOk = if (trackingState.txConnected) "TX" else ""
|
modifier = Modifier
|
||||||
val rxOk = if (trackingState.rxConnected) "RX" else ""
|
.size(8.dp)
|
||||||
Text(
|
.clip(CircleShape)
|
||||||
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
|
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
||||||
fontSize = 12.sp,
|
)
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="true" />
|
||||||
|
</network-security-config>
|
||||||
@@ -45,7 +45,12 @@ interface Look4SatDao {
|
|||||||
@Query("DELETE FROM entries")
|
@Query("DELETE FROM entries")
|
||||||
suspend fun deleteEntries()
|
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>
|
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM radios")
|
@Query("SELECT COUNT(*) FROM radios")
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
+403
-204
@@ -19,8 +19,10 @@ package com.rtbishop.look4sat.core.data.framework
|
|||||||
|
|
||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
import android.util.Log
|
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.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
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.IRadioController
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
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 com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -44,6 +47,8 @@ class RadioTrackingService(
|
|||||||
) : IRadioTrackingService {
|
) : IRadioTrackingService {
|
||||||
|
|
||||||
private val tag = "RadioTracking"
|
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())
|
private val _state = MutableStateFlow(RadioTrackingState())
|
||||||
override val state: StateFlow<RadioTrackingState> = _state
|
override val state: StateFlow<RadioTrackingState> = _state
|
||||||
|
|
||||||
@@ -51,227 +56,427 @@ class RadioTrackingService(
|
|||||||
private var rxController: IRadioController? = null
|
private var rxController: IRadioController? = null
|
||||||
private var trackingJob: Job? = null
|
private var trackingJob: Job? = null
|
||||||
|
|
||||||
|
// ── Connection ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
override suspend fun connectRadios() {
|
override suspend fun connectRadios() {
|
||||||
// Disconnect old controllers if any
|
|
||||||
txController?.disconnect()
|
txController?.disconnect()
|
||||||
rxController?.disconnect()
|
rxController?.disconnect()
|
||||||
|
|
||||||
// Read current addresses from settings
|
|
||||||
val rcSettings = settingsRepo.radioControlSettings.value
|
val rcSettings = settingsRepo.radioControlSettings.value
|
||||||
val txAddr = rcSettings.txRadioAddress
|
val txAddr = rcSettings.txRadioAddress
|
||||||
val rxAddr = rcSettings.rxRadioAddress
|
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()) {
|
if (isSplit) {
|
||||||
_state.update { it.copy(errorMessage = "No radio addresses configured in Settings") }
|
// Single-radio split mode: only TX slot is used
|
||||||
return
|
if (txAddr.isBlank()) {
|
||||||
}
|
_state.update { it.copy(errorMessage = "No radio address configured in Settings") }
|
||||||
|
return
|
||||||
val tx = Ft817Controller(bluetoothManager, txAddr)
|
}
|
||||||
val rx = Ft817Controller(bluetoothManager, rxAddr)
|
val tx = makeController(isIcom, txAddr)
|
||||||
txController = tx
|
txController = tx
|
||||||
rxController = rx
|
rxController = null
|
||||||
|
_state.update { it.copy(errorMessage = null) }
|
||||||
_state.update { it.copy(errorMessage = null) }
|
val txOk = tx.connect()
|
||||||
val txOk = if (txAddr.isNotBlank()) tx.connect() else false
|
_state.update {
|
||||||
val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false
|
it.copy(
|
||||||
_state.update {
|
txConnected = txOk,
|
||||||
it.copy(
|
rxConnected = false,
|
||||||
txConnected = txOk,
|
errorMessage = if (!txOk) "Could not connect to radio ($txAddr)" else null
|
||||||
rxConnected = rxOk,
|
)
|
||||||
errorMessage = when {
|
}
|
||||||
!txOk && !rxOk -> "Could not connect to TX and RX radios"
|
Log.i(tag, "IC-705 split mode connected: txOk=$txOk")
|
||||||
!txOk -> "Could not connect to TX radio ($txAddr)"
|
} else {
|
||||||
!rxOk -> "Could not connect to RX radio ($rxAddr)"
|
if (txAddr.isBlank() && rxAddr.isBlank()) {
|
||||||
else -> null
|
_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() {
|
override suspend fun disconnectRadios() {
|
||||||
stopTracking()
|
stopTracking()
|
||||||
txController?.disconnect()
|
txController?.disconnect()
|
||||||
rxController?.disconnect()
|
rxController?.disconnect()
|
||||||
txController = null
|
txController = null
|
||||||
rxController = null
|
rxController = null
|
||||||
_state.update {
|
_state.update { it.copy(txConnected = false, rxConnected = false, isActive = false) }
|
||||||
it.copy(
|
|
||||||
txConnected = false,
|
|
||||||
rxConnected = false,
|
|
||||||
isActive = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tracking ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) {
|
override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) {
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isActive = true,
|
isActive = true,
|
||||||
currentPass = pass,
|
currentPass = pass,
|
||||||
selectedTransponder = transponder,
|
selectedTransponder = transponder,
|
||||||
txBaseFrequencyHz = txBaseFreqHz
|
txBaseFrequencyHz = txBaseFreqHz
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
trackingJob?.cancel()
|
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
|
val rcSettings = settingsRepo.radioControlSettings.value
|
||||||
var lastSetRxFreq = 0.0
|
val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705
|
||||||
var tuningRadio = "" // "", "tx", or "rx" - which radio the user is tuning
|
val isSplit = isIcom && rcSettings.splitMode
|
||||||
var lastReadFreq = 0L
|
|
||||||
var stableCount = 0
|
|
||||||
|
|
||||||
while (isActive) {
|
if (isSplit) {
|
||||||
val currentState = _state.value
|
trackingJob = appScope.launch { runSplitTracking(transponder, txBaseFreqHz) }
|
||||||
if (!currentState.isActive) break
|
} else {
|
||||||
|
trackingJob = appScope.launch { runDualRadioTracking(transponder, txBaseFreqHz) }
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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() {
|
override fun stopTracking() {
|
||||||
trackingJob?.cancel()
|
trackingJob?.cancel()
|
||||||
trackingJob = null
|
trackingJob = null
|
||||||
@@ -288,7 +493,6 @@ class RadioTrackingService(
|
|||||||
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||||
}
|
}
|
||||||
rxMode?.let { rx?.setMode(it) }
|
rxMode?.let { rx?.setMode(it) }
|
||||||
|
|
||||||
if (transponder.uplinkMode?.uppercase() == "FM") {
|
if (transponder.uplinkMode?.uppercase() == "FM") {
|
||||||
_state.value.ctcssTone?.let { tone ->
|
_state.value.ctcssTone?.let { tone ->
|
||||||
tx?.setCtcssTone(tone)
|
tx?.setCtcssTone(tone)
|
||||||
@@ -302,21 +506,17 @@ class RadioTrackingService(
|
|||||||
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
// Show nominal frequencies immediately
|
|
||||||
val rxNominal = if (txCenter != null) {
|
val rxNominal = if (txCenter != null) {
|
||||||
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
||||||
} else {
|
} else transponder.downlinkLow
|
||||||
// Downlink-only transponder (beacon etc.) - use downlink directly
|
|
||||||
transponder.downlinkLow
|
|
||||||
}
|
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
selectedTransponder = transponder,
|
selectedTransponder = transponder,
|
||||||
txBaseFrequencyHz = txCenter,
|
txBaseFrequencyHz = txCenter,
|
||||||
txFrequencyHz = txCenter,
|
txFrequencyHz = txCenter,
|
||||||
rxFrequencyHz = rxNominal,
|
rxFrequencyHz = rxNominal,
|
||||||
txMode = transponder.uplinkMode,
|
txMode = transponder.uplinkMode,
|
||||||
rxMode = transponder.downlinkMode
|
rxMode = transponder.downlinkMode
|
||||||
?: transponder.uplinkMode?.let { m ->
|
?: transponder.uplinkMode?.let { m ->
|
||||||
TransponderMapper.mapUplinkModeToDownlinkMode(m, transponder.isInverted)
|
TransponderMapper.mapUplinkModeToDownlinkMode(m, transponder.isInverted)
|
||||||
}
|
}
|
||||||
@@ -353,5 +553,4 @@ class RadioTrackingService(
|
|||||||
}
|
}
|
||||||
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -26,6 +26,7 @@ import androidx.room.Room
|
|||||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||||
|
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
||||||
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
||||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
||||||
@@ -39,6 +40,7 @@ import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
|||||||
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
||||||
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
||||||
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
@@ -106,15 +108,25 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun provideTxRadioController(): IRadioController {
|
override fun provideTxRadioController(): IRadioController {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
val address = settingsRepo.radioControlSettings.value.txRadioAddress
|
val settings = settingsRepo.radioControlSettings.value
|
||||||
return Ft817Controller(manager, address)
|
val address = settings.txRadioAddress
|
||||||
|
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
|
||||||
|
Ic705Controller(manager, address)
|
||||||
|
} else {
|
||||||
|
Ft817Controller(manager, address)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun provideRxRadioController(): IRadioController {
|
override fun provideRxRadioController(): IRadioController {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
val address = settingsRepo.radioControlSettings.value.rxRadioAddress
|
val settings = settingsRepo.radioControlSettings.value
|
||||||
return Ft817Controller(manager, address)
|
val address = settings.rxRadioAddress
|
||||||
|
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
|
||||||
|
Ic705Controller(manager, address)
|
||||||
|
} else {
|
||||||
|
Ft817Controller(manager, address)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun provideSensorsRepo(): ISensorsRepo {
|
override fun provideSensorsRepo(): ISensorsRepo {
|
||||||
@@ -123,6 +135,13 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
return SensorsRepo(manager, displayManager)
|
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 {
|
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||||
val dbDispatcher = Dispatchers.Default
|
val dbDispatcher = Dispatchers.Default
|
||||||
val dataParser = DataParser(dbDispatcher)
|
val dataParser = DataParser(dbDispatcher)
|
||||||
|
|||||||
+36
-12
@@ -42,21 +42,26 @@ class DatabaseRepo(
|
|||||||
|
|
||||||
private val customSourceType = "Other"
|
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 ->
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
val entries = dataParser.parseTLEStream(unwrapIfZipped(uri, stream))
|
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
|
||||||
localSource.insertEntries(entries)
|
localSource.insertEntries(entries)
|
||||||
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
|
importedCount = entries.size
|
||||||
}
|
}
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
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 ->
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
||||||
localSource.insertRadios(transceivers)
|
localSource.insertRadios(transceivers)
|
||||||
|
importedCount = transceivers.size
|
||||||
}
|
}
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
|
importedCount
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||||
@@ -72,12 +77,9 @@ class DatabaseRepo(
|
|||||||
// launch all network requests concurrently
|
// launch all network requests concurrently
|
||||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||||
val radioJobs = radioUrls.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
|
// parse fetched data concurrently
|
||||||
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
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()
|
||||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
|
||||||
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
||||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
||||||
@@ -94,9 +96,31 @@ class DatabaseRepo(
|
|||||||
setUpdateSuccessful(0L)
|
setUpdateSuccessful(0L)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> = when {
|
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
|
||||||
url.contains("FORMAT=csv", ignoreCase = true) -> dataParser.parseCSVStream(stream)
|
val bufferedStream = stream.buffered()
|
||||||
else -> dataParser.parseTLEStream(stream)
|
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) {
|
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
||||||
|
|||||||
+43
-4
@@ -36,6 +36,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
class SatelliteRepo(
|
class SatelliteRepo(
|
||||||
private val dispatcher: CoroutineDispatcher,
|
private val dispatcher: CoroutineDispatcher,
|
||||||
@@ -64,8 +65,16 @@ class SatelliteRepo(
|
|||||||
override suspend fun initRepository() = withContext(dispatcher) {
|
override suspend fun initRepository() = withContext(dispatcher) {
|
||||||
settingsRepo.selectedIds.collect { selectedIds ->
|
settingsRepo.selectedIds.collect { selectedIds ->
|
||||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||||
val (_, hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
|
val settings = settingsRepo.passesSettings.value
|
||||||
calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
|
calculatePasses(
|
||||||
|
time = System.currentTimeMillis(),
|
||||||
|
hoursAhead = settings.hoursAhead,
|
||||||
|
minElevation = settings.minElevation,
|
||||||
|
aosStartMinute = settings.aosStartMinute,
|
||||||
|
aosEndMinute = settings.aosEndMinute,
|
||||||
|
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||||
|
modes = settingsRepo.selectedSatModes.value
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +114,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
|
_isCalculating.value = true
|
||||||
// Normalize to the start of the current minute so that coarse 60-second stepping
|
// 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
|
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
|
||||||
@@ -130,7 +147,12 @@ class SatelliteRepo(
|
|||||||
val newPasses = ArrayList<OrbitalPass>()
|
val newPasses = ArrayList<OrbitalPass>()
|
||||||
for (list in passLists) {
|
for (list in passLists) {
|
||||||
for (pass in list) {
|
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.add(pass)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,6 +164,23 @@ class SatelliteRepo(
|
|||||||
_isCalculating.value = false
|
_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 >= aosStartMinute || aosMinute <= aosEndMinute
|
||||||
|
}
|
||||||
|
return if (invertAosTimeWindow) !inRange else inRange
|
||||||
|
}
|
||||||
|
|
||||||
private fun OrbitalObject.getPasses(pos: GeoPos, time: Long, hours: Int): List<OrbitalPass> {
|
private fun OrbitalObject.getPasses(pos: GeoPos, time: Long, hours: Int): List<OrbitalPass> {
|
||||||
val passes = mutableListOf<OrbitalPass>()
|
val passes = mutableListOf<OrbitalPass>()
|
||||||
val endDate = time + hours * 60L * 60L * 1000L
|
val endDate = time + hours * 60L * 60L * 1000L
|
||||||
|
|||||||
+17
-13
@@ -38,16 +38,16 @@ class SelectionRepo(
|
|||||||
) : ISelectionRepo {
|
) : ISelectionRepo {
|
||||||
|
|
||||||
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
||||||
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
|
private val currentModes = MutableStateFlow(settingsRepo.selectedSatModes.value)
|
||||||
private val currentQuery = MutableStateFlow("")
|
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.
|
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
||||||
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
private val itemsWithModes = currentModes.flatMapLatest { modes: List<String> ->
|
||||||
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
val catnumSet: Set<Int>? = if (modes.isEmpty()) {
|
||||||
null // null = no filtering
|
null // null = no filtering
|
||||||
} else {
|
} else {
|
||||||
val ids = settingsRepo.getSatelliteTypesIds(types)
|
val ids = localSource.getIdsWithModes(modes)
|
||||||
if (ids.isEmpty()) null else ids.toHashSet()
|
if (ids.isEmpty()) null else ids.toHashSet()
|
||||||
}
|
}
|
||||||
currentItems.map { items ->
|
currentItems.map { items ->
|
||||||
@@ -56,14 +56,18 @@ class SelectionRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
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() = currentModes.value
|
||||||
|
|
||||||
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
|
override fun getModesList() = Sources.satelliteModes
|
||||||
removeAt(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
||||||
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
|
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
|
||||||
@@ -73,9 +77,9 @@ class SelectionRepo(
|
|||||||
return@withContext itemsWithQuery
|
return@withContext itemsWithQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setTypes(types: List<String>) {
|
override suspend fun setModes(modes: List<String>) {
|
||||||
currentTypes.value = types
|
currentModes.value = modes
|
||||||
settingsRepo.setSelectedTypes(types)
|
settingsRepo.setSelectedSatModes(modes)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setQuery(query: String) {
|
override suspend fun setQuery(query: String) {
|
||||||
|
|||||||
+40
-38
@@ -54,6 +54,9 @@ class SettingsRepo(
|
|||||||
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
|
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
|
||||||
private val keyFilterHoursAhead = "filterHoursAhead"
|
private val keyFilterHoursAhead = "filterHoursAhead"
|
||||||
private val keyFilterMinElevation = "filterMinElevation"
|
private val keyFilterMinElevation = "filterMinElevation"
|
||||||
|
private val keyFilterAosStartMinute = "filterAosStartMinute"
|
||||||
|
private val keyFilterAosEndMinute = "filterAosEndMinute"
|
||||||
|
private val keyFilterAosInvert = "filterAosInvert"
|
||||||
private val keyNumberOfRadios = "numberOfRadios"
|
private val keyNumberOfRadios = "numberOfRadios"
|
||||||
private val keyNumberOfSatellites = "numberOfSatellites"
|
private val keyNumberOfSatellites = "numberOfSatellites"
|
||||||
private val keyRotatorAddress = "rotatorAddress"
|
private val keyRotatorAddress = "rotatorAddress"
|
||||||
@@ -65,8 +68,7 @@ class SettingsRepo(
|
|||||||
private val keyFrequencyPort = "frequencyPort"
|
private val keyFrequencyPort = "frequencyPort"
|
||||||
private val keyFrequencyFormat = "frequencyFormat"
|
private val keyFrequencyFormat = "frequencyFormat"
|
||||||
private val keySelectedIds = "selectedIds"
|
private val keySelectedIds = "selectedIds"
|
||||||
private val keySelectedTypes = "selectedTypes"
|
private val keySelectedSatModes = "selectedSatModes"
|
||||||
private val keySelectedModes = "selectedModes"
|
|
||||||
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
||||||
private val keyStateOfSensors = "stateOfSensors"
|
private val keyStateOfSensors = "stateOfSensors"
|
||||||
private val keyStateOfSweep = "stateOfSweep"
|
private val keyStateOfSweep = "stateOfSweep"
|
||||||
@@ -82,6 +84,8 @@ class SettingsRepo(
|
|||||||
private val keyShouldSeeWarning = "shouldSeeWarning"
|
private val keyShouldSeeWarning = "shouldSeeWarning"
|
||||||
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
||||||
private val keySstvMode = "sstvMode"
|
private val keySstvMode = "sstvMode"
|
||||||
|
private val keyLowElevation = "lowElevation"
|
||||||
|
private val keyHighElevation = "highElevation"
|
||||||
private val keyUseCustomTle = "useCustomTle"
|
private val keyUseCustomTle = "useCustomTle"
|
||||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||||
private val keyTleUrl = "tleUrl"
|
private val keyTleUrl = "tleUrl"
|
||||||
@@ -90,9 +94,9 @@ class SettingsRepo(
|
|||||||
|
|
||||||
//region # Satellites selection settings
|
//region # Satellites selection settings
|
||||||
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
||||||
private val _typesSelection = MutableStateFlow(getSelectedTypes())
|
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
|
||||||
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
|
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>) {
|
override fun setSelectedIds(ids: List<Int>) {
|
||||||
val selectionString = ids.joinToString(separatorComma)
|
val selectionString = ids.joinToString(separatorComma)
|
||||||
@@ -100,10 +104,10 @@ class SettingsRepo(
|
|||||||
_satelliteSelection.value = ids
|
_satelliteSelection.value = ids
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setSelectedTypes(types: List<String>) {
|
override fun setSelectedSatModes(modes: List<String>) {
|
||||||
val typesString = types.joinToString(separatorComma)
|
val modesString = modes.joinToString(separatorComma)
|
||||||
preferences.edit { putString(keySelectedTypes, typesString) }
|
preferences.edit { putString(keySelectedSatModes, modesString) }
|
||||||
_typesSelection.value = types
|
_satelliteModeSelection.value = modes
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSelectedIds(): List<Int> {
|
private fun getSelectedIds(): List<Int> {
|
||||||
@@ -112,10 +116,10 @@ class SettingsRepo(
|
|||||||
return selectionString.split(separatorComma).map { it.toInt() }
|
return selectionString.split(separatorComma).map { it.toInt() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSelectedTypes(): List<String> {
|
private fun getSelectedSatModes(): List<String> {
|
||||||
val typesString = preferences.getString(keySelectedTypes, "Amateur")
|
val modesString = preferences.getString(keySelectedSatModes, null)
|
||||||
if (typesString.isNullOrEmpty()) return emptyList()
|
if (modesString.isNullOrEmpty()) return emptyList()
|
||||||
return typesString.split(separatorComma)
|
return modesString.split(separatorComma).sorted()
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -127,7 +131,9 @@ class SettingsRepo(
|
|||||||
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
|
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
|
||||||
putInt(keyFilterHoursAhead, settings.hoursAhead)
|
putInt(keyFilterHoursAhead, settings.hoursAhead)
|
||||||
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
|
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
|
||||||
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
|
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
||||||
|
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
||||||
|
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
||||||
_passesSettings.value = settings
|
_passesSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,9 +141,17 @@ class SettingsRepo(
|
|||||||
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
|
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
|
||||||
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
|
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
|
||||||
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
|
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
|
||||||
val selectedModesString = preferences.getString(keySelectedModes, null)
|
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
||||||
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
|
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
||||||
return PassesSettings(showDeepSpace, hoursAhead, minElevation, selectedModes)
|
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
|
||||||
|
return PassesSettings(
|
||||||
|
showDeepSpace,
|
||||||
|
hoursAhead,
|
||||||
|
minElevation,
|
||||||
|
aosStartMinute,
|
||||||
|
aosEndMinute,
|
||||||
|
invertAosTimeWindow
|
||||||
|
)
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -216,25 +230,6 @@ class SettingsRepo(
|
|||||||
private val _databaseState = MutableStateFlow(getDatabaseState())
|
private val _databaseState = MutableStateFlow(getDatabaseState())
|
||||||
override val databaseState: StateFlow<DatabaseState> = _databaseState
|
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 {
|
override fun updateDatabaseState(state: DatabaseState) = preferences.edit {
|
||||||
putInt(keyNumberOfSatellites, state.numberOfSatellites)
|
putInt(keyNumberOfSatellites, state.numberOfSatellites)
|
||||||
@@ -335,6 +330,8 @@ class SettingsRepo(
|
|||||||
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
||||||
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
||||||
putString(keySstvMode, new.sstvMode)
|
putString(keySstvMode, new.sstvMode)
|
||||||
|
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
||||||
|
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||||
}
|
}
|
||||||
new
|
new
|
||||||
}
|
}
|
||||||
@@ -349,7 +346,9 @@ class SettingsRepo(
|
|||||||
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
||||||
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
||||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto"
|
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||||
|
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
||||||
|
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -383,6 +382,7 @@ class SettingsRepo(
|
|||||||
private val keyTxRadioName = "txRadioName"
|
private val keyTxRadioName = "txRadioName"
|
||||||
private val keyRxRadioName = "rxRadioName"
|
private val keyRxRadioName = "rxRadioName"
|
||||||
private val keyRadioBaudRate = "radioBaudRate"
|
private val keyRadioBaudRate = "radioBaudRate"
|
||||||
|
private val keyRadioSplitMode = "radioSplitMode"
|
||||||
|
|
||||||
private val _radioControlSettings = MutableStateFlow(getRadioControlSettings())
|
private val _radioControlSettings = MutableStateFlow(getRadioControlSettings())
|
||||||
override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings
|
override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings
|
||||||
@@ -396,18 +396,20 @@ class SettingsRepo(
|
|||||||
putString(keyTxRadioName, settings.txRadioName)
|
putString(keyTxRadioName, settings.txRadioName)
|
||||||
putString(keyRxRadioName, settings.rxRadioName)
|
putString(keyRxRadioName, settings.rxRadioName)
|
||||||
putInt(keyRadioBaudRate, settings.baudRate)
|
putInt(keyRadioBaudRate, settings.baudRate)
|
||||||
|
putBoolean(keyRadioSplitMode, settings.splitMode)
|
||||||
}
|
}
|
||||||
_radioControlSettings.value = settings
|
_radioControlSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings(
|
private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings(
|
||||||
enabled = preferences.getBoolean(keyRadioControlEnabled, false),
|
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) ?: "",
|
txRadioAddress = preferences.getString(keyTxRadioAddress, null) ?: "",
|
||||||
rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "",
|
rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "",
|
||||||
txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio",
|
txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio",
|
||||||
rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio",
|
rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio",
|
||||||
baudRate = preferences.getInt(keyRadioBaudRate, 4800)
|
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
|
||||||
|
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
}
|
}
|
||||||
+20
-19
@@ -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 org.junit.Test
|
||||||
import kotlin.test.assertContentEquals
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertNotNull
|
|
||||||
import kotlin.test.assertNull
|
|
||||||
|
|
||||||
class Ft817CatProtocolTest {
|
class Ft817CatProtocolTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeFrequencyBcd_145500000() {
|
fun encodeFrequencyBcd_145500000() {
|
||||||
val bcd = Ft817CatProtocol.encodeFrequencyBcd(145500000L)
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(145500000L)
|
||||||
assertContentEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00), bcd)
|
assertArrayEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeFrequencyBcd_435100000() {
|
fun encodeFrequencyBcd_435100000() {
|
||||||
val bcd = Ft817CatProtocol.encodeFrequencyBcd(435100000L)
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(435100000L)
|
||||||
assertContentEquals(byteArrayOf(0x43, 0x51, 0x00, 0x00), bcd)
|
assertArrayEquals(byteArrayOf(0x43, 0x51, 0x00, 0x00), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeFrequencyBcd_7074000() {
|
fun encodeFrequencyBcd_7074000() {
|
||||||
val bcd = Ft817CatProtocol.encodeFrequencyBcd(7074000L)
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(7074000L)
|
||||||
assertContentEquals(byteArrayOf(0x00, 0x70, 0x74, 0x00), bcd)
|
assertArrayEquals(byteArrayOf(0x00, 0x70, 0x74, 0x00), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -42,21 +41,21 @@ class Ft817CatProtocolTest {
|
|||||||
val cmd = Ft817CatProtocol.buildSetFreqCommand(145500000L)
|
val cmd = Ft817CatProtocol.buildSetFreqCommand(145500000L)
|
||||||
assertEquals(5, cmd.size)
|
assertEquals(5, cmd.size)
|
||||||
assertEquals(0x01.toByte(), cmd[4])
|
assertEquals(0x01.toByte(), cmd[4])
|
||||||
assertContentEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01), cmd)
|
assertArrayEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun buildSetModeCommand_usb() {
|
fun buildSetModeCommand_usb() {
|
||||||
val cmd = Ft817CatProtocol.buildSetModeCommand("USB")
|
val cmd = Ft817CatProtocol.buildSetModeCommand("USB")
|
||||||
assertNotNull(cmd)
|
assertNotNull(cmd)
|
||||||
assertContentEquals(byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x07), cmd)
|
assertArrayEquals(byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x07), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun buildSetModeCommand_fm() {
|
fun buildSetModeCommand_fm() {
|
||||||
val cmd = Ft817CatProtocol.buildSetModeCommand("FM")
|
val cmd = Ft817CatProtocol.buildSetModeCommand("FM")
|
||||||
assertNotNull(cmd)
|
assertNotNull(cmd)
|
||||||
assertContentEquals(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x07), cmd)
|
assertArrayEquals(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x07), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -67,19 +66,19 @@ class Ft817CatProtocolTest {
|
|||||||
@Test
|
@Test
|
||||||
fun encodeCtcssTone_67_0() {
|
fun encodeCtcssTone_67_0() {
|
||||||
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(67.0)
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(67.0)
|
||||||
assertContentEquals(byteArrayOf(0x06, 0x70), bcd)
|
assertArrayEquals(byteArrayOf(0x06, 0x70), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeCtcssTone_74_4() {
|
fun encodeCtcssTone_74_4() {
|
||||||
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(74.4)
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(74.4)
|
||||||
assertContentEquals(byteArrayOf(0x07, 0x44), bcd)
|
assertArrayEquals(byteArrayOf(0x07, 0x44), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeCtcssTone_141_3() {
|
fun encodeCtcssTone_141_3() {
|
||||||
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(141.3)
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(141.3)
|
||||||
assertContentEquals(byteArrayOf(0x14, 0x13), bcd)
|
assertArrayEquals(byteArrayOf(0x14, 0x13), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -87,13 +86,13 @@ class Ft817CatProtocolTest {
|
|||||||
val cmd = Ft817CatProtocol.buildSetCtcssToneCommand(67.0)
|
val cmd = Ft817CatProtocol.buildSetCtcssToneCommand(67.0)
|
||||||
assertEquals(5, cmd.size)
|
assertEquals(5, cmd.size)
|
||||||
assertEquals(0x0B.toByte(), cmd[4])
|
assertEquals(0x0B.toByte(), cmd[4])
|
||||||
assertContentEquals(byteArrayOf(0x06, 0x70, 0x00, 0x00, 0x0B), cmd)
|
assertArrayEquals(byteArrayOf(0x06, 0x70, 0x00, 0x00, 0x0B), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun buildCtcssModeCommand_enable() {
|
fun buildCtcssModeCommand_enable() {
|
||||||
val cmd = Ft817CatProtocol.buildCtcssModeCommand(true)
|
val cmd = Ft817CatProtocol.buildCtcssModeCommand(true)
|
||||||
assertContentEquals(byteArrayOf(0x2A, 0x00, 0x00, 0x00, 0x0A), cmd)
|
assertArrayEquals(byteArrayOf(0x2A, 0x00, 0x00, 0x00, 0x0A), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -108,6 +107,7 @@ class Ft817CatProtocolTest {
|
|||||||
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01)
|
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01)
|
||||||
val result = Ft817CatProtocol.parseReadResponse(response)
|
val result = Ft817CatProtocol.parseReadResponse(response)
|
||||||
assertNotNull(result)
|
assertNotNull(result)
|
||||||
|
result ?: return
|
||||||
assertEquals(145500000L, result.first)
|
assertEquals(145500000L, result.first)
|
||||||
assertEquals("USB", result.second)
|
assertEquals("USB", result.second)
|
||||||
}
|
}
|
||||||
@@ -117,6 +117,7 @@ class Ft817CatProtocolTest {
|
|||||||
val response = byteArrayOf(0x14, 0x60, 0x00, 0x00, 0x08)
|
val response = byteArrayOf(0x14, 0x60, 0x00, 0x00, 0x08)
|
||||||
val result = Ft817CatProtocol.parseReadResponse(response)
|
val result = Ft817CatProtocol.parseReadResponse(response)
|
||||||
assertNotNull(result)
|
assertNotNull(result)
|
||||||
|
result ?: return
|
||||||
assertEquals(146000000L, result.first)
|
assertEquals(146000000L, result.first)
|
||||||
assertEquals("FM", result.second)
|
assertEquals("FM", result.second)
|
||||||
}
|
}
|
||||||
@@ -135,7 +136,7 @@ class Ft817CatProtocolTest {
|
|||||||
@Test
|
@Test
|
||||||
fun buildPttCommands() {
|
fun buildPttCommands() {
|
||||||
val on = Ft817CatProtocol.buildPttOnCommand()
|
val on = Ft817CatProtocol.buildPttOnCommand()
|
||||||
assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x08), on)
|
assertArrayEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x08), on)
|
||||||
|
|
||||||
val off = Ft817CatProtocol.buildPttOffCommand()
|
val off = Ft817CatProtocol.buildPttOffCommand()
|
||||||
assertEquals(0x88.toByte(), off[4])
|
assertEquals(0x88.toByte(), off[4])
|
||||||
@@ -144,6 +145,6 @@ class Ft817CatProtocolTest {
|
|||||||
@Test
|
@Test
|
||||||
fun buildReadCommand() {
|
fun buildReadCommand() {
|
||||||
val cmd = Ft817CatProtocol.buildReadFreqModeCommand()
|
val cmd = Ft817CatProtocol.buildReadFreqModeCommand()
|
||||||
assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x03), cmd)
|
assertArrayEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x03), cmd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+225
@@ -0,0 +1,225 @@
|
|||||||
|
/*
|
||||||
|
* 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(
|
||||||
|
useCustomTLE = true,
|
||||||
|
useCustomTransceivers = false,
|
||||||
|
tleUrl = customCsvUrl,
|
||||||
|
transceiversUrl = ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "", "", "", 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
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||||
|
return DataSourcesSettings(
|
||||||
|
useCustomTLE = false,
|
||||||
|
useCustomTransceivers = false,
|
||||||
|
tleUrl = "",
|
||||||
|
transceiversUrl = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
/*
|
||||||
|
* 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, "", "", "", false, "", "", "", false, "", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||||
|
OtherSettings(false, false, false, false, false, false, false, false)
|
||||||
|
)
|
||||||
|
|
||||||
|
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
|
||||||
|
DataSourcesSettings(false, false, "", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -27,7 +27,9 @@ data class PassesSettings(
|
|||||||
val showDeepSpace: Boolean = true,
|
val showDeepSpace: Boolean = true,
|
||||||
val hoursAhead: Int,
|
val hoursAhead: Int,
|
||||||
val minElevation: Double,
|
val minElevation: Double,
|
||||||
val selectedModes: List<String>
|
val aosStartMinute: Int = 0,
|
||||||
|
val aosEndMinute: Int = 23 * 60 + 59,
|
||||||
|
val invertAosTimeWindow: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RCSettings(
|
data class RCSettings(
|
||||||
@@ -57,7 +59,9 @@ data class OtherSettings(
|
|||||||
val stateOfNightMode: Boolean = false,
|
val stateOfNightMode: Boolean = false,
|
||||||
val shouldSeeWarning: Boolean,
|
val shouldSeeWarning: Boolean,
|
||||||
val shouldSeeWhatsNew: Boolean,
|
val shouldSeeWhatsNew: Boolean,
|
||||||
val sstvMode: String = "Auto"
|
val sstvMode: String = "Auto",
|
||||||
|
val lowElevation: Double = 15.0,
|
||||||
|
val highElevation: Double = 45.0
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DataSourcesSettings(
|
data class DataSourcesSettings(
|
||||||
@@ -74,12 +78,20 @@ data class RadioControlSettings(
|
|||||||
val rxRadioAddress: String,
|
val rxRadioAddress: String,
|
||||||
val txRadioName: String,
|
val txRadioName: String,
|
||||||
val rxRadioName: 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 {
|
companion object {
|
||||||
val SUPPORTED_RADIOS = listOf(
|
const val MODEL_YAESU_FT817 = "Yaesu FT-817/818"
|
||||||
"Yaesu FT-817/818",
|
const val MODEL_YAESU_FT857 = "Yaesu FT-857/897"
|
||||||
"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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -18,8 +18,8 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.repository
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
interface IDatabaseRepo {
|
interface IDatabaseRepo {
|
||||||
suspend fun updateTLEFromFile(uri: String)
|
suspend fun updateTLEFromFile(uri: String): Int
|
||||||
suspend fun updateTransceiversFromFile(uri: String)
|
suspend fun updateTransceiversFromFile(uri: String): Int
|
||||||
suspend fun updateFromRemote()
|
suspend fun updateFromRemote()
|
||||||
suspend fun clearAllData()
|
suspend fun clearAllData()
|
||||||
}
|
}
|
||||||
+1
@@ -39,6 +39,7 @@ interface IMainContainer {
|
|||||||
fun provideRxRadioController(): IRadioController
|
fun provideRxRadioController(): IRadioController
|
||||||
fun provideAudioCapture(): IAudioCapture
|
fun provideAudioCapture(): IAudioCapture
|
||||||
fun provideSaveImage(): ISaveImage
|
fun provideSaveImage(): ISaveImage
|
||||||
|
fun providePairedBluetoothDevices(): List<Pair<String, String>>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IContainerProvider {
|
interface IContainerProvider {
|
||||||
|
|||||||
+47
@@ -38,4 +38,51 @@ interface IRadioController {
|
|||||||
suspend fun pttOn(): Boolean
|
suspend fun pttOn(): Boolean
|
||||||
|
|
||||||
suspend fun pttOff(): 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()
|
||||||
}
|
}
|
||||||
+9
-1
@@ -44,7 +44,15 @@ interface ISatelliteRepo {
|
|||||||
suspend fun initRepository()
|
suspend fun initRepository()
|
||||||
|
|
||||||
/** Recalculate passes with the given filter parameters. */
|
/** 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. */
|
/** Get the current position of a single satellite. */
|
||||||
suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos
|
suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos
|
||||||
|
|||||||
+3
-3
@@ -21,10 +21,10 @@ import com.rtbishop.look4sat.core.domain.model.SatItem
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface ISelectionRepo {
|
interface ISelectionRepo {
|
||||||
fun getCurrentTypes(): List<String>
|
fun getCurrentModes(): List<String>
|
||||||
fun getTypesList(): List<String>
|
fun getModesList(): List<String>
|
||||||
suspend fun getEntriesFlow(): Flow<List<SatItem>>
|
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 setQuery(query: String)
|
||||||
suspend fun setSelection(selectAll: Boolean)
|
suspend fun setSelection(selectAll: Boolean)
|
||||||
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
|
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
|
||||||
|
|||||||
+2
-4
@@ -32,9 +32,9 @@ interface ISettingsRepo {
|
|||||||
|
|
||||||
//region # Satellites selection settings
|
//region # Satellites selection settings
|
||||||
val selectedIds: StateFlow<List<Int>>
|
val selectedIds: StateFlow<List<Int>>
|
||||||
val selectedTypes: StateFlow<List<String>>
|
val selectedSatModes: StateFlow<List<String>>
|
||||||
fun setSelectedIds(ids: List<Int>)
|
fun setSelectedIds(ids: List<Int>)
|
||||||
fun setSelectedTypes(types: List<String>)
|
fun setSelectedSatModes(modes: List<String>)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Passes filter settings
|
//region # Passes filter settings
|
||||||
@@ -51,8 +51,6 @@ interface ISettingsRepo {
|
|||||||
|
|
||||||
//region # Database update settings
|
//region # Database update settings
|
||||||
val databaseState: StateFlow<DatabaseState>
|
val databaseState: StateFlow<DatabaseState>
|
||||||
fun getSatelliteTypesIds(types: List<String>): List<Int>
|
|
||||||
fun setSatelliteTypeIds(type: String, ids: List<Int>)
|
|
||||||
fun updateDatabaseState(state: DatabaseState)
|
fun updateDatabaseState(state: DatabaseState)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
|||||||
@@ -20,35 +20,24 @@ package com.rtbishop.look4sat.core.domain.source
|
|||||||
object Sources {
|
object Sources {
|
||||||
val satelliteDataUrls = mapOf(
|
val satelliteDataUrls = mapOf(
|
||||||
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||||
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
|
||||||
"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",
|
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
|
||||||
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
||||||
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
|
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
|
||||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||||
|
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
|
||||||
|
"ARISS" to "https://live.ariss.org/iss.txt",
|
||||||
"Other" to "" // key for sats filter
|
"Other" to "" // key for sats filter
|
||||||
)
|
)
|
||||||
val transceiversDataUrls = mapOf(
|
val transceiversDataUrls = mapOf(
|
||||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||||
)
|
)
|
||||||
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.math.PI
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.round
|
import kotlin.math.round
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
class SstvFrame(
|
class SstvFrame(
|
||||||
|
val scopePixels: IntArray?,
|
||||||
|
val scopeWidth: Int,
|
||||||
|
val scopeHeight: Int,
|
||||||
val imagePixels: IntArray?,
|
val imagePixels: IntArray?,
|
||||||
val imageWidth: Int,
|
val imageWidth: Int,
|
||||||
val imageHeight: 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(
|
class SstvDecoder(
|
||||||
sampleRate: Int = 44100,
|
sampleRate: Int = 44100,
|
||||||
scopeWidth: Int = 320,
|
scopeWidth: Int = 320,
|
||||||
scopeHeight: Int = 256,
|
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 scopeBuffer = PixelBuffer(scopeWidth, scopeHeight * 2)
|
||||||
private val imageBuffer = PixelBuffer(scopeWidth, scopeHeight)
|
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>(
|
private val _frames = MutableSharedFlow<SstvFrame>(
|
||||||
replay = 1,
|
replay = 1,
|
||||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
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 frames: SharedFlow<SstvFrame> = _frames
|
||||||
val supportedModes: List<String> = decoder.allModes.map { it.name }
|
val supportedModes: List<String> = decoder.allModes.map { it.name }
|
||||||
|
|
||||||
suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) {
|
suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) {
|
||||||
// Normalize to a fixed RMS before processing so that both direct audio
|
// Optional pre-filtering: remove DC offset and subsonic noise that can mask
|
||||||
// coupling and air-coupled microphone input decode with equal reliability.
|
// weak signals and corrupt the RMS normalization baseline.
|
||||||
normalise(samples)
|
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)
|
val hasNewLines = decoder.process(samples, channelSelect)
|
||||||
if (hasNewLines) emitFrame()
|
if (hasNewLines) emitFrame()
|
||||||
}
|
}
|
||||||
@@ -62,36 +117,134 @@ class SstvDecoder(
|
|||||||
fun clearPixels() {
|
fun clearPixels() {
|
||||||
imageBuffer.line = -1
|
imageBuffer.line = -1
|
||||||
imageBuffer.pixels.fill(0)
|
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() {
|
private fun emitFrame() {
|
||||||
val imageWidth = imageBuffer.width
|
val imageWidth = imageBuffer.width
|
||||||
val imageHeight = imageBuffer.height
|
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
|
// 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()
|
// at the maximum possible size (PD-290: 800×616), so we must not copyOf()
|
||||||
// the entire array and send padding pixels to the observer.
|
// the entire array and send padding pixels to the observer.
|
||||||
val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf(imageWidth * imageHeight) else null
|
val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf(imageWidth * imageHeight) else null
|
||||||
val modeName = decoder.currentMode.name
|
val modeName = decoder.currentMode.name
|
||||||
_frames.tryEmit(SstvFrame(imagePixels, imageWidth, imageHeight, modeName))
|
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
|
// Target RMS level for the normalizer. 0.25 leaves headroom while keeping the
|
||||||
// FM demodulator well above its noise floor regardless of input gain.
|
// 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
|
// 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.
|
// 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
|
var sumSq = 0f
|
||||||
for (s in buffer) sumSq += s * s
|
for (s in buffer) sumSq += s * s
|
||||||
val rms = sqrt(sumSq / buffer.size)
|
val rms = sqrt(sumSq / buffer.size)
|
||||||
if (rms > 1e-6f) {
|
if (rms > 1e-6f) {
|
||||||
val gain = targetRms / rms
|
val gain = targetRms / rms
|
||||||
for (i in buffer.indices) buffer[i] *= gain
|
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 enum class SyncPulseWidth { FiveMs, NineMs, TwentyMs }
|
||||||
|
|
||||||
internal class SyncPulseDetector(sampleRate: Int) {
|
internal class SyncPulseDetector(sampleRate: Int) {
|
||||||
@@ -197,7 +350,8 @@ internal class DecoderEngine(
|
|||||||
private val scopeBuffer: PixelBuffer,
|
private val scopeBuffer: PixelBuffer,
|
||||||
private val imageBuffer: PixelBuffer,
|
private val imageBuffer: PixelBuffer,
|
||||||
rawName: String,
|
rawName: String,
|
||||||
sampleRate: Int
|
sampleRate: Int,
|
||||||
|
lineRecoveryStrategy: LineRecoveryStrategy
|
||||||
) {
|
) {
|
||||||
private val pixelBuffer = PixelBuffer(800, 2)
|
private val pixelBuffer = PixelBuffer(800, 2)
|
||||||
private val detector = SyncPulseDetector(sampleRate)
|
private val detector = SyncPulseDetector(sampleRate)
|
||||||
@@ -237,6 +391,19 @@ internal class DecoderEngine(
|
|||||||
private var lastSync = 0
|
private var lastSync = 0
|
||||||
private var curLineSamples: Int
|
private var curLineSamples: Int
|
||||||
private var lastOffset = 0f
|
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 {
|
init {
|
||||||
imageBuffer.line = -1
|
imageBuffer.line = -1
|
||||||
@@ -286,6 +453,11 @@ internal class DecoderEngine(
|
|||||||
fun process(recordBuffer: FloatArray, channelSelect: Int): Boolean {
|
fun process(recordBuffer: FloatArray, channelSelect: Int): Boolean {
|
||||||
var newLines = false
|
var newLines = false
|
||||||
val detected = detector.process(recordBuffer, channelSelect)
|
val detected = detector.process(recordBuffer, channelSelect)
|
||||||
|
syncChecks++
|
||||||
|
if (detected) {
|
||||||
|
syncHits++
|
||||||
|
predictedStreak = 0
|
||||||
|
}
|
||||||
var syncIdx = sample + detector.pulseOffset
|
var syncIdx = sample + detector.pulseOffset
|
||||||
val channels = if (channelSelect > 0) 2 else 1
|
val channels = if (channelSelect > 0) 2 else 1
|
||||||
for (j in 0 until recordBuffer.size / channels) {
|
for (j in 0 until recordBuffer.size / channels) {
|
||||||
@@ -308,20 +480,10 @@ internal class DecoderEngine(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (handleHeader()) {
|
} else if (handleHeader()) {
|
||||||
|
predictedStreak = 0
|
||||||
newLines = true
|
newLines = true
|
||||||
} else if (sample > lastSync + (curLineSamples * 5) / 4) {
|
} else if (sample > lastSync + (curLineSamples * 5) / 4) {
|
||||||
copyLines(
|
newLines = decodePredictedLine()
|
||||||
currentMode.decodeScanLine(
|
|
||||||
pixelBuffer,
|
|
||||||
scratch,
|
|
||||||
scanLineBuffer,
|
|
||||||
scopeBuffer.width,
|
|
||||||
lastSync,
|
|
||||||
curLineSamples,
|
|
||||||
lastOffset
|
|
||||||
)
|
|
||||||
)
|
|
||||||
lastSync += curLineSamples; newLines = true
|
|
||||||
}
|
}
|
||||||
return newLines
|
return newLines
|
||||||
}
|
}
|
||||||
@@ -337,6 +499,25 @@ internal class DecoderEngine(
|
|||||||
lockMode = false
|
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 mean(a: IntArray): Double = a.sumOf { it.toDouble() } / a.size
|
||||||
private fun stdDev(a: IntArray, m: Double): Double {
|
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)
|
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)
|
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) {
|
private fun drawLines(color: Int, count: Int) {
|
||||||
repeat(count) {
|
repeat(count) {
|
||||||
scopeBuffer.pixels.fill(
|
scopeBuffer.pixels.fill(
|
||||||
@@ -542,6 +746,7 @@ internal class DecoderEngine(
|
|||||||
lineLen: IntArray,
|
lineLen: IntArray,
|
||||||
latest: Int
|
latest: Int
|
||||||
): Boolean {
|
): Boolean {
|
||||||
|
predictedStreak = 0
|
||||||
for (i in 1 until syncPulses.size) syncPulses[i - 1] = syncPulses[i]
|
for (i in 1 until syncPulses.size) syncPulses[i - 1] = syncPulses[i]
|
||||||
syncPulses[syncPulses.size - 1] = latest
|
syncPulses[syncPulses.size - 1] = latest
|
||||||
for (i in 1 until lineLen.size) lineLen[i - 1] = lineLen[i]
|
for (i in 1 until lineLen.size) lineLen[i - 1] = lineLen[i]
|
||||||
|
|||||||
+99
-39
@@ -34,23 +34,32 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ElevatedButton
|
import androidx.compose.material3.ElevatedButton
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
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.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.hideFromAccessibility
|
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||||
@@ -64,10 +73,11 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.TextUnit
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.ui.unit.Velocity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
|
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
@@ -134,7 +144,7 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
}
|
}
|
||||||
val sdfTime = remember(isUtc) {
|
val sdfTime = remember(isUtc) {
|
||||||
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
|
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
|
||||||
}
|
}
|
||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -203,6 +213,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
|
@Composable
|
||||||
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
@@ -278,55 +293,87 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass(
|
|||||||
fun SharedDialog(
|
fun SharedDialog(
|
||||||
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
DialogShell(title = title, titleFontSize = 16, onDismissRequest = onCancel) {
|
SharedDialog(title = title, onDismissRequest = onCancel, onCancel = onCancel, onAccept = onAccept) { _ ->
|
||||||
content()
|
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))
|
|
||||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InfoDialog(title: String, text: String, onDismiss: () -> Unit) {
|
fun SharedDialog(
|
||||||
DialogShell(title = title, titleFontSize = 18, onDismissRequest = {}) {
|
title: String,
|
||||||
Text(
|
onDismissRequest: () -> Unit,
|
||||||
text = text,
|
onCancel: (() -> Unit)? = null,
|
||||||
fontSize = 16.sp,
|
onAccept: (() -> Unit)? = null,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
titleFontSize: Int = 16,
|
||||||
modifier = Modifier.padding(horizontal = it)
|
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
|
||||||
)
|
content: @Composable (padding: Dp) -> Unit
|
||||||
Row(modifier = Modifier.padding(start = it, bottom = it, end = it)) {
|
) {
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
DialogShell(onDismissRequest = onDismissRequest) { padding ->
|
||||||
CardButton(onClick = onDismiss, text = stringResource(R.string.btn_accept))
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = padding, top = padding, end = padding)
|
||||||
|
) {
|
||||||
|
if (onCancel != null) {
|
||||||
|
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
fontSize = titleFontSize.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
textAlign = titleTextAlign,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp)
|
||||||
|
)
|
||||||
|
if (onAccept != null) {
|
||||||
|
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
content(padding)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun DialogShell(
|
private fun DialogShell(
|
||||||
title: String,
|
|
||||||
titleFontSize: Int,
|
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
content: @Composable (padding: androidx.compose.ui.unit.Dp) -> Unit
|
content: @Composable (padding: Dp) -> Unit
|
||||||
) {
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
val padding = LocalSpacing.current.large
|
||||||
Dialog(onDismissRequest = onDismissRequest) {
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
ElevatedCard {
|
val stopSheetFling = remember {
|
||||||
Column(
|
object : NestedScrollConnection {
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||||
verticalArrangement = Arrangement.spacedBy(padding)
|
return available
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
fontSize = titleFontSize.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(start = padding, top = padding, end = padding)
|
|
||||||
)
|
|
||||||
content(padding)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onPostScroll(
|
||||||
|
consumed: Offset,
|
||||||
|
available: Offset,
|
||||||
|
source: NestedScrollSource
|
||||||
|
): Offset = Offset.Zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
sheetState = sheetState,
|
||||||
|
dragHandle = null,
|
||||||
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||||
|
scrimColor = Color.Black.copy(alpha = 0.64f)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(padding),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.nestedScroll(stopSheetFling)
|
||||||
|
) {
|
||||||
|
content(padding)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,13 +438,26 @@ fun TopBar(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun elevationColor(elevation: Double): Color {
|
fun elevationColor(elevation: Double): Color {
|
||||||
|
val thresholds = LocalElevationThresholds.current
|
||||||
return when {
|
return when {
|
||||||
elevation < 15.0 -> Color(0xFFEF5350) // soft red for low elevation
|
elevation < thresholds.low -> ElevationLowColor // soft red for low elevation
|
||||||
elevation < 45.0 -> MaterialTheme.colorScheme.primary // accent yellow for normal
|
elevation < thresholds.high -> MaterialTheme.colorScheme.primary // accent yellow for normal
|
||||||
else -> Color(0xFF66BB6A) // soft green for high elevation
|
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
|
@Composable
|
||||||
fun OutlinedText(
|
fun OutlinedText(
|
||||||
text: String,
|
text: String,
|
||||||
|
|||||||
@@ -13,12 +13,16 @@
|
|||||||
<string name="nav_prefs">Ajustes</string>
|
<string name="nav_prefs">Ajustes</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Tipo: %s</string>
|
<string name="sat_type_hint">Modos: %s</string>
|
||||||
<string name="sat_type_title">Seleccionar tipo de satélite</string>
|
<string name="sat_type_title">Seleccionar modos</string>
|
||||||
<string name="sat_search_hint">Id - Nombre</string>
|
<string name="sat_search_hint">Id - Nombre</string>
|
||||||
<string name="sat_search_clear">Vaciar</string>
|
<string name="sat_search_clear">Vaciar</string>
|
||||||
<string name="sat_clear_all">Vaciar todo</string>
|
<string name="sat_clear_all">Vaciar todo</string>
|
||||||
<string name="sat_select_all">Seleccionar 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">
|
<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>
|
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>
|
<string name="sat_warning_title">Alarma\!</string>
|
||||||
@@ -26,13 +30,16 @@
|
|||||||
Esta aplicación incluye más de 9000 satélites.
|
Esta aplicación incluye más de 9000 satélites.
|
||||||
No tiene sentido rastrearlos todos a la vez.
|
No tiene sentido rastrearlos todos a la vez.
|
||||||
\n\nIntenta siempre limitar la lista a los que te
|
\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 -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Filtrar pases</string>
|
<string name="pass_filter_title">Filtrar pases</string>
|
||||||
<string name="pass_filter_elev">Elevación mínima</string>
|
<string name="pass_filter_elev">Elevación mínima</string>
|
||||||
<string name="pass_filter_hours">Horas por delante</string>
|
<string name="pass_filter_hours">Tiempo por delante</string>
|
||||||
<string name="pass_modes_title">Tipo de modulación</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 >225min)</string>
|
||||||
|
<string name="pass_modes_title">Seleccionar modos</string>
|
||||||
<string name="pass_elevation">Elevación: %.1f°</string>
|
<string name="pass_elevation">Elevación: %.1f°</string>
|
||||||
<string name="pass_altitude">Altitud: %.0f km</string>
|
<string name="pass_altitude">Altitud: %.0f km</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
@@ -93,11 +100,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<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_qth_error">Ubicación QTH no válida</string>
|
||||||
<string name="prefs_loc_success">Ubicación actualizada con éxito</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_title">Ubicación de la estación</string>
|
||||||
<string name="prefs_station_lat_text">Latitud de tu estación terrestre</string>
|
<string name="prefs_station_lat_text">Latitud de estación</string>
|
||||||
<string name="prefs_station_lon_text">Longitud de tu estación terrestre</string>
|
<string name="prefs_station_lon_text">Longitud de estación</string>
|
||||||
<string name="prefs_locator_title">Ajustes ubicación QTH</string>
|
<string name="prefs_locator_title">Localizador QTH</string>
|
||||||
<string name="prefs_locator_text">Ajustar posición de la estación con tu ubicación</string>
|
<string name="prefs_locator_text">Posición por localizador</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Datos satelitales</string>
|
<string name="prefs_data_title">Datos satelitales</string>
|
||||||
<string name="prefs_data_entries">Satélites: %s</string>
|
<string name="prefs_data_entries">Satélites: %s</string>
|
||||||
@@ -107,38 +114,57 @@
|
|||||||
<string name="prefs_data_clear">Vaciar</string>
|
<string name="prefs_data_clear">Vaciar</string>
|
||||||
<string name="prefs_data_clear_success">Datos eliminados con éxito</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_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_title">Fuentes personalizadas</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</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_data_output_title">Salida de datos</string>
|
||||||
<string name="prefs_net_output">Network</string>
|
<string name="prefs_net_output">Red</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</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="nav_radiocontrol">Control de radio</string>
|
||||||
<string name="prefs_net_rotator_switch">Enable rotation output</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_address_hint">IP:Puerto</string>
|
||||||
<string name="prefs_net_rotator_format_hint">Formato de datos</string>
|
<string name="prefs_net_rotator_format_hint">Formato de datos</string>
|
||||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
<string name="prefs_net_frequency_switch">Activar salida de frecuencia</string>
|
||||||
<string name="prefs_net_frequency_address_hint">IP:Puerto</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_net_frequency_format_hint">Formato de datos</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">Salida por Bluetooth</string>
|
<string name="prefs_bt_title">Salida Bluetooth</string>
|
||||||
<string name="prefs_bt_rotator_switch">Enable rotation output</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_device_hint">ID dispositivo</string>
|
||||||
<string name="prefs_bt_rotator_output_hint">Formato de datos</string>
|
<string name="prefs_bt_rotator_output_hint">Formato</string>
|
||||||
<string name="prefs_bt_frequency_switch">Enable frequency output</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_device_hint">ID dispositivo</string>
|
||||||
<string name="prefs_bt_frequency_output_hint">Formato de datos</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_bt_perm_error">Revisa los permisos de Bluetooth</string>
|
||||||
<string name="prefs_net_perm_error">Revisa los permisos de red</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_title">Otros</string>
|
||||||
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
|
<string name="prefs_other_switch_utc">Mostrar hora UTC</string>
|
||||||
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
|
<string name="prefs_other_switch_update">Actualización automática</string>
|
||||||
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
|
<string name="prefs_other_switch_sweep">Animación de radar</string>
|
||||||
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
|
<string name="prefs_other_switch_sensors">Sensores para radar</string>
|
||||||
|
<string name="prefs_other_switch_night_mode">Filtro nocturno rojo</string>
|
||||||
|
|
||||||
|
<string name="prefs_highlight_title">Resaltado de elevación</string>
|
||||||
|
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">Media %1$d-%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">Alta > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">Me gustaría dar las gracias a:</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>
|
<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>
|
<string name="nav_prefs">Настройки</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Тип: %s</string>
|
<string name="sat_type_hint">Режимы: %s</string>
|
||||||
<string name="sat_type_title">Выберите тип спутника</string>
|
<string name="sat_type_title">Выберите режимы</string>
|
||||||
<string name="sat_search_hint">Id - Название</string>
|
<string name="sat_search_hint">Id - Название</string>
|
||||||
<string name="sat_search_clear">Очистить</string>
|
<string name="sat_search_clear">Очистить</string>
|
||||||
<string name="sat_clear_all">Очистить</string>
|
<string name="sat_clear_all">Очистить</string>
|
||||||
<string name="sat_select_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_empty_list_message">Убедитесь, что ваш поисковый запрос верен и база данных обновлена</string>
|
||||||
<string name="sat_warning_title">Внимание\!</string>
|
<string name="sat_warning_title">Внимание\!</string>
|
||||||
<string name="sat_warning_message">
|
<string name="sat_warning_message">
|
||||||
В этом приложении перечислено более 9000 спутников.
|
В этом приложении перечислено более 9000 спутников.
|
||||||
Отслеживать их все одновременно бессмысленно.
|
Отслеживать их все одновременно бессмысленно.
|
||||||
\n\nВсегда старайтесь сузить список до тех,
|
\n\nВсегда старайтесь сузить список до тех,
|
||||||
которые вас интересуют, используя поиск и селектор типов.</string>
|
которые вас интересуют, используя поиск и выбор режимов.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Фильтровать пролеты</string>
|
<string name="pass_filter_title">Фильтровать пролеты</string>
|
||||||
<string name="pass_filter_elev">Минимальная элевация</string>
|
<string name="pass_filter_elev">Минимальная элевация</string>
|
||||||
<string name="pass_filter_hours">Количество часов</string>
|
<string name="pass_filter_hours">Период расчёта</string>
|
||||||
<string name="pass_modes_title">Выберите тип модуляции</string>
|
<string name="pass_filter_aos_time">Окно AOS</string>
|
||||||
|
<string name="pass_filter_invert_time">Инвертировать окно AOS</string>
|
||||||
|
<string name="pass_filter_deep_space">DeepSpace (период >225мин)</string>
|
||||||
|
<string name="pass_modes_title">Выберите режимы</string>
|
||||||
<string name="pass_elevation">Элевация: %.1f°</string>
|
<string name="pass_elevation">Элевация: %.1f°</string>
|
||||||
<string name="pass_altitude">Высота: %d км</string>
|
<string name="pass_altitude">Высота: %d км</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
@@ -92,11 +99,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">Неправильный локатор QTH</string>
|
<string name="prefs_loc_qth_error">Неправильный локатор QTH</string>
|
||||||
<string name="prefs_loc_success">Позиция успешно обновлена</string>
|
<string name="prefs_loc_success">Позиция успешно обновлена</string>
|
||||||
<string name="prefs_station_title">Настройки местоположения</string>
|
<string name="prefs_station_title">Положение станции</string>
|
||||||
<string name="prefs_station_lat_text">Введите широту наземной станции</string>
|
<string name="prefs_station_lat_text">Широта станции</string>
|
||||||
<string name="prefs_station_lon_text">Введите долготу наземной станции</string>
|
<string name="prefs_station_lon_text">Долгота станции</string>
|
||||||
<string name="prefs_locator_title">Настройки локатора QTH</string>
|
<string name="prefs_locator_title">Локатор QTH</string>
|
||||||
<string name="prefs_locator_text">Введите позицию через локатор</string>
|
<string name="prefs_locator_text">Позиция по локатору</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Данные спутников</string>
|
<string name="prefs_data_title">Данные спутников</string>
|
||||||
<string name="prefs_data_entries">Спутников: %s</string>
|
<string name="prefs_data_entries">Спутников: %s</string>
|
||||||
@@ -106,16 +113,30 @@
|
|||||||
<string name="prefs_data_clear">Очистить</string>
|
<string name="prefs_data_clear">Очистить</string>
|
||||||
<string name="prefs_data_clear_success">Очистка прошла успешно</string>
|
<string name="prefs_data_clear_success">Очистка прошла успешно</string>
|
||||||
<string name="prefs_data_update_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_title">Свои источники</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom Transceivers 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_data_output_title">Вывод</string>
|
||||||
<string name="prefs_net_output">Сеть</string>
|
<string name="prefs_net_output">Сеть</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</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_switch">Включить вывод ротатора</string>
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
|
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
|
||||||
<string name="prefs_net_rotator_format_hint">Формат</string>
|
<string name="prefs_net_rotator_format_hint">Формат</string>
|
||||||
@@ -123,21 +144,27 @@
|
|||||||
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
||||||
<string name="prefs_net_frequency_format_hint">Формат</string>
|
<string name="prefs_net_frequency_format_hint">Формат</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">Вывод данных Bluetooth</string>
|
<string name="prefs_bt_title">Вывод Bluetooth</string>
|
||||||
<string name="prefs_bt_rotator_switch">Включить вывод ротатора</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_rotator_output_hint">Формат</string>
|
||||||
<string name="prefs_bt_frequency_switch">Включить вывод частоты</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_frequency_output_hint">Формат</string>
|
||||||
<string name="prefs_bt_perm_error">Нет разрешения использовать bluetooth</string>
|
<string name="prefs_bt_perm_error">Нет разрешения использовать bluetooth</string>
|
||||||
<string name="prefs_net_perm_error">Нет разрешения использовать сеть</string>
|
<string name="prefs_net_perm_error">Нет разрешения использовать сеть</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Другие настройки</string>
|
<string name="prefs_other_title">Прочее</string>
|
||||||
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
|
<string name="prefs_other_switch_utc">Время в UTC</string>
|
||||||
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
|
<string name="prefs_other_switch_update">Автообновление данных</string>
|
||||||
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
|
<string name="prefs_other_switch_sweep">Анимация радара</string>
|
||||||
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
|
<string name="prefs_other_switch_sensors">Сенсоры для радара</string>
|
||||||
|
<string name="prefs_other_switch_night_mode">Красный ночной фильтр</string>
|
||||||
|
|
||||||
|
<string name="prefs_highlight_title">Подсветка высоты</string>
|
||||||
|
<string name="prefs_highlight_low">Низко < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">Средне %1$d-%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">Высоко > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">Я хотел бы сказать спасибо:</string>
|
<string name="prefs_outro_title">Я хотел бы сказать спасибо:</string>
|
||||||
<string name="prefs_outro_license">Это ПО поставляется без гарантий.</string>
|
<string name="prefs_outro_license">Это ПО поставляется без гарантий.</string>
|
||||||
|
|||||||
@@ -13,26 +13,33 @@
|
|||||||
<string name="nav_prefs">පසුතල</string>
|
<string name="nav_prefs">පසුතල</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">වර්ගය: %s</string>
|
<string name="sat_type_hint">මාදිලි: %s</string>
|
||||||
<string name="sat_type_title">චන්ද්රිකා ආකාරය තෝරන්න</string>
|
<string name="sat_type_title">මාදිලි තෝරන්න</string>
|
||||||
<string name="sat_search_hint">Id - නම</string>
|
<string name="sat_search_hint">Id - නම</string>
|
||||||
<string name="sat_search_clear">පිරිසිදු ක°</string>
|
<string name="sat_search_clear">පිරිසිදු ක°</string>
|
||||||
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
|
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
|
||||||
<string name="sat_select_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 name="sat_empty_list_message">
|
||||||
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
|
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
|
||||||
<string name="sat_warning_title">අවවාදයයි\!</string>
|
<string name="sat_warning_title">අවවාදයයි\!</string>
|
||||||
<string name="sat_warning_message">
|
<string name="sat_warning_message">
|
||||||
මෙම යෙදුමේ ලැයිස්තුගත කර ඇති චන්ද්රිකා 9000 කට වඩා තිබේ.
|
මෙම යෙදුමේ ලැයිස්තුගත කර ඇති චන්ද්රිකා 9000 කට වඩා තිබේ.
|
||||||
ඒවා සියල්ලම එකවර නිරීක්ෂණය කිරීම තේරුමක් නැති දෙයක්.
|
ඒවා සියල්ලම එකවර නිරීක්ෂණය කිරීම තේරුමක් නැති දෙයක්.
|
||||||
\n\nසෙවීම සහ වර්ග තේරීම හරහා ඔබ උනන්දුවක් දක්වන
|
\n\nසෙවීම සහ මාදිලි තේරීම හරහා ඔබ උනන්දුවක් දක්වන
|
||||||
ඒවාට පමණක් ලැයිස්තුව පටු කිරීමට සැමවිටම උත්සාහ කරන්න.</string>
|
ඒවාට පමණක් ලැයිස්තුව පටු කිරීමට සැමවිටම උත්සාහ කරන්න.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">පසුකිරිම් පිරිපහදුව</string>
|
<string name="pass_filter_title">පසුකිරිම් පිරිපහදුව</string>
|
||||||
<string name="pass_filter_elev">අවම උත්තෝලනය</string>
|
<string name="pass_filter_elev">අවම උත්තෝලනය</string>
|
||||||
<string name="pass_filter_hours">ඉදිරි පැය</string>
|
<string name="pass_filter_hours">ඉදිරි කාලය</string>
|
||||||
<string name="pass_modes_title">මූර්ජන ආකාරය තෝරන්න</string>
|
<string name="pass_filter_aos_time">AOS කවුළුව</string>
|
||||||
|
<string name="pass_filter_invert_time">AOS කවුළුව ප්රතිවිරුද්ධ කරන්න</string>
|
||||||
|
<string name="pass_filter_deep_space">DeepSpace (කාලය >225min)</string>
|
||||||
|
<string name="pass_modes_title">මූර්ජන තෝරන්න</string>
|
||||||
<string name="pass_elevation">උත්තෝලනය: %.1f°</string>
|
<string name="pass_elevation">උත්තෝලනය: %.1f°</string>
|
||||||
<string name="pass_altitude">උන්නතාශය: %d km</string>
|
<string name="pass_altitude">උන්නතාශය: %d km</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
@@ -93,11 +100,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">QTH locator වලංගු නොවේ</string>
|
<string name="prefs_loc_qth_error">QTH locator වලංගු නොවේ</string>
|
||||||
<string name="prefs_loc_success">පිහිටීම යාවත්කාලීනය සාර්ථකයි</string>
|
<string name="prefs_loc_success">පිහිටීම යාවත්කාලීනය සාර්ථකයි</string>
|
||||||
<string name="prefs_station_title">පොළෙහි පිහිටීම් පසුතල</string>
|
<string name="prefs_station_title">පොළ පිහිටීම</string>
|
||||||
<string name="prefs_station_lat_text">ඔබගේ පොළෙහි අක්ෂාංශය සකසන්න</string>
|
<string name="prefs_station_lat_text">පොළ අක්ෂාංශය</string>
|
||||||
<string name="prefs_station_lon_text">ඔබගේ පොළෙහි දේශාංශය සකසන්න</string>
|
<string name="prefs_station_lon_text">පොළ දේශාංශය</string>
|
||||||
<string name="prefs_locator_title">QTH locator පසුතල</string>
|
<string name="prefs_locator_title">QTH locator</string>
|
||||||
<string name="prefs_locator_text"> ඔබගේ locator භාවිතා කර පොළහි පිහිටීම සකසන්න</string>
|
<string name="prefs_locator_text">locator මඟින් පොළ පිහිටීම සකසන්න</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">චන්ද්රිකා දත්ත</string>
|
<string name="prefs_data_title">චන්ද්රිකා දත්ත</string>
|
||||||
<string name="prefs_data_entries">චන්ද්රිකා: %s</string>
|
<string name="prefs_data_entries">චන්ද්රිකා: %s</string>
|
||||||
@@ -107,38 +114,57 @@
|
|||||||
<string name="prefs_data_clear">පිරිසිදු ක°</string>
|
<string name="prefs_data_clear">පිරිසිදු ක°</string>
|
||||||
<string name="prefs_data_clear_success">දත්ත ඉවත් කිරීම සාර්ථකයි</string>
|
<string name="prefs_data_clear_success">දත්ත ඉවත් කිරීම සාර්ථකයි</string>
|
||||||
<string name="prefs_data_update_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_title">අභිරුචි මූලාශ්ර</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers 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_data_output_title">දත්ත ප්රතිදානය</string>
|
||||||
<string name="prefs_net_output">Network</string>
|
<string name="prefs_net_output">ජාලය</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</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_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_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_rotator_format_hint">දත්ත අකාරය</string>
|
<string name="prefs_net_rotator_format_hint">දත්ත ආකෘතිය</string>
|
||||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
<string name="prefs_net_frequency_switch">Frequency ප්රතිදානය සබල කරන්න</string>
|
||||||
<string name="prefs_net_frequency_address_hint">IP:Port</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_title">Bluetooth ප්රතිදානය</string>
|
||||||
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
<string name="prefs_bt_rotator_switch">Rotation ප්රතිදානය සබල කරන්න</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_rotator_output_hint">දත්ත ආකෘතිය</string>
|
||||||
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
<string name="prefs_bt_frequency_switch">Frequency ප්රතිදානය සබල කරන්න</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_frequency_output_hint">දත්ත ආකෘතිය</string>
|
||||||
<string name="prefs_bt_perm_error">Bluetooth අවසර පරික්ෂා ක°</string>
|
<string name="prefs_bt_perm_error">Bluetooth අවසර පරික්ෂා ක°</string>
|
||||||
<string name="prefs_net_perm_error">Network අවසර පරික්ෂා කරන්න</string>
|
<string name="prefs_net_perm_error">Network අවසර පරික්ෂා කරන්න</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">වෙනත් සැකසුම්</string>
|
<string name="prefs_other_title">වෙනත්</string>
|
||||||
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
|
<string name="prefs_other_switch_utc">UTC වේලාව පෙන්වන්න</string>
|
||||||
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
|
<string name="prefs_other_switch_update">දත්ත ස්වයං යාවත්කාලීන</string>
|
||||||
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
|
<string name="prefs_other_switch_sweep">Radar sweep සජීවිකරණය</string>
|
||||||
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
|
<string name="prefs_other_switch_sensors">Radar සඳහා සංවේදක භාවිතා කරන්න</string>
|
||||||
|
<string name="prefs_other_switch_night_mode">රතු රාත්රී පෙරහන</string>
|
||||||
|
|
||||||
|
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
|
||||||
|
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">මධ්ය %1$d-%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">ඉහළ > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">මම ස්තුති කිරීමට කැමති:</string>
|
<string name="prefs_outro_title">මම ස්තුති කිරීමට කැමති:</string>
|
||||||
<string name="prefs_outro_license">මෘදුකාංගය වගකීමක් සමග නොලැබේ</string>
|
<string name="prefs_outro_license">මෘදුකාංගය වගකීමක් සමග නොලැබේ</string>
|
||||||
|
|||||||
@@ -14,26 +14,33 @@
|
|||||||
<string name="nav_prefs">Ayarlar</string>
|
<string name="nav_prefs">Ayarlar</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Uydu türü: %s</string>
|
<string name="sat_type_hint">Modlar: %s</string>
|
||||||
<string name="sat_type_title">Uydu türü seçin</string>
|
<string name="sat_type_title">Modları seçin</string>
|
||||||
<string name="sat_search_hint">Id - Ad</string>
|
<string name="sat_search_hint">Id - Ad</string>
|
||||||
<string name="sat_search_clear">Temizle</string>
|
<string name="sat_search_clear">Temizle</string>
|
||||||
<string name="sat_clear_all">Tümünü 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_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_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_title">Uyarı\!</string>
|
||||||
<string name="sat_warning_message">
|
<string name="sat_warning_message">
|
||||||
Bu uygulamada 9000\'den fazla uydu listelenmiştir.
|
Bu uygulamada 9000\'den fazla uydu listelenmiştir.
|
||||||
Hepsini aynı anda takip etmek anlamsızdır.
|
Hepsini aynı anda takip etmek anlamsızdır.
|
||||||
\n\nListeyi yalnızca ilgilendiğiniz uydularla
|
\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 -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Geçişleri filtrele</string>
|
<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_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 >225dk)</string>
|
||||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </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_satId" translatable="false">%05d</string>
|
||||||
<string name="pass_elevation">Yükseklik açısı: %.1f°</string>
|
<string name="pass_elevation">Yükseklik açısı: %.1f°</string>
|
||||||
<string name="pass_deep_space" translatable="false">DeepSpace</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_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">Geçersiz QTH konumlandırıcısı</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_loc_success">Konum başarıyla güncellendi</string>
|
||||||
<string name="prefs_station_title">İstasyon konumu ayarları</string>
|
<string name="prefs_station_title">İstasyon konumu</string>
|
||||||
<string name="prefs_station_lat_text">Yer istasyonunuzun enlemini girin</string>
|
<string name="prefs_station_lat_text">İstasyon enlemi</string>
|
||||||
<string name="prefs_station_lon_text">Yer istasyonunuzun boylamını girin</string>
|
<string name="prefs_station_lon_text">İstasyon boylamı</string>
|
||||||
<string name="prefs_locator_title">QTH konumlandırıcı ayarları</string>
|
<string name="prefs_locator_title">QTH konumlandırıcı</string>
|
||||||
<string name="prefs_locator_text">İstasyon konumunu konumlandırıcı ile ayarlayın</string>
|
<string name="prefs_locator_text">Konumu lokatörle ayarla</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Uydu verisi</string>
|
<string name="prefs_data_title">Uydu verisi</string>
|
||||||
<string name="prefs_data_entries">Uydular: %s</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">Temizle</string>
|
||||||
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</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_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_title">Özel kaynaklar</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Özel transceiver 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_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_net_output">Ağ</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
<string name="prefs_cat_output">CAT</string>
|
<string name="prefs_cat_output">CAT</string>
|
||||||
|
|
||||||
<!-- Radio Control -->
|
<!-- Radio Control -->
|
||||||
<string name="nav_radiocontrol">Radyo Kontrolü</string>
|
<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_radio_model">Radyo Modeli</string>
|
||||||
<string name="rc_tx_device_hint">TX Radyo BT Adresi</string>
|
<string name="rc_tx_device_hint">TX BT adresi</string>
|
||||||
<string name="rc_rx_device_hint">RX Radyo 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_tx_name_hint">TX Radyo Adı</string>
|
||||||
<string name="rc_rx_name_hint">RX 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_switch">Döndürme çıkışını etkinleştir</string>
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_rotator_format_hint">Biçim</string>
|
<string name="prefs_net_rotator_format_hint">Biçim</string>
|
||||||
@@ -167,21 +176,27 @@
|
|||||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_frequency_format_hint">Biçim</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_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_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_switch">Frekans çıkışını etkinleştir</string>
|
||||||
<string name="prefs_bt_frequency_device_hint">Cihaz kimliği</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_bt_perm_error">Bluetooth izninizi kontrol edin</string>
|
||||||
<string name="prefs_net_perm_error">Ağ 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_title">Diğer</string>
|
||||||
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
|
<string name="prefs_other_switch_utc">UTC saatini göster</string>
|
||||||
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
|
<string name="prefs_other_switch_update">Veriyi otomatik güncelle</string>
|
||||||
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
|
<string name="prefs_other_switch_sweep">Radar tarama animasyonu</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_switch_sensors">Radar için sensör kullan</string>
|
||||||
|
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresi</string>
|
||||||
|
|
||||||
|
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
|
||||||
|
<string name="prefs_highlight_low">Düşük < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">Orta %1$d-%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">Yüksek > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">Teşekkürler</string>
|
<string name="prefs_outro_title">Teşekkürler</string>
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
|
|||||||
@@ -13,12 +13,16 @@
|
|||||||
<string name="nav_prefs">Налаштування</string>
|
<string name="nav_prefs">Налаштування</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Тип: %s</string>
|
<string name="sat_type_hint">Режими: %s</string>
|
||||||
<string name="sat_type_title">Виберіть тип супутника</string>
|
<string name="sat_type_title">Виберіть режими</string>
|
||||||
<string name="sat_search_hint">Id - Назва</string>
|
<string name="sat_search_hint">Id - Назва</string>
|
||||||
<string name="sat_search_clear">Очистити</string>
|
<string name="sat_search_clear">Очистити</string>
|
||||||
<string name="sat_clear_all">Очистити всі</string>
|
<string name="sat_clear_all">Очистити всі</string>
|
||||||
<string name="sat_select_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 name="sat_empty_list_message">
|
||||||
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
|
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
|
||||||
<string name="sat_warning_title">Увага\!</string>
|
<string name="sat_warning_title">Увага\!</string>
|
||||||
@@ -26,13 +30,16 @@
|
|||||||
У цьому додатку перелічено понад 9000 супутників.
|
У цьому додатку перелічено понад 9000 супутників.
|
||||||
Немає сенсу відстежувати їх усі одночасно.
|
Немає сенсу відстежувати їх усі одночасно.
|
||||||
\n\nЗавжди намагайтеся звузити список лише до тих,
|
\n\nЗавжди намагайтеся звузити список лише до тих,
|
||||||
які вас цікавлять, за допомогою пошуку та селектора типів.</string>
|
які вас цікавлять, за допомогою пошуку та вибору режимів.</string>
|
||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Фільтр прольотів</string>
|
<string name="pass_filter_title">Фільтр прольотів</string>
|
||||||
<string name="pass_filter_elev">Мінімальне піднесення</string>
|
<string name="pass_filter_elev">Мінімальне піднесень</string>
|
||||||
<string name="pass_filter_hours">Кількість годин</string>
|
<string name="pass_filter_hours">Час вперед</string>
|
||||||
<string name="pass_modes_title">Виберіть тип модуляції</string>
|
<string name="pass_filter_aos_time">Вікно AOS</string>
|
||||||
|
<string name="pass_filter_invert_time">Інвертувати вікно AOS</string>
|
||||||
|
<string name="pass_filter_deep_space">DeepSpace (період >225хв)</string>
|
||||||
|
<string name="pass_modes_title">Виберіть режими</string>
|
||||||
<string name="pass_elevation">Піднес.: %.1f°</string>
|
<string name="pass_elevation">Піднес.: %.1f°</string>
|
||||||
<string name="pass_altitude">Висота: %.0f км</string>
|
<string name="pass_altitude">Висота: %.0f км</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
@@ -66,7 +73,7 @@
|
|||||||
<string name="map_prev">Попередній</string>
|
<string name="map_prev">Попередній</string>
|
||||||
<string name="map_next">Наступний</string>
|
<string name="map_next">Наступний</string>
|
||||||
<string name="map_azimuth">Азимут: %.1f°</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_altitude">Висота: %.0f км</string>
|
||||||
<string name="map_distance">Дистанція: %.0f км</string>
|
<string name="map_distance">Дистанція: %.0f км</string>
|
||||||
<string name="map_latitude">Широта: %.1f°</string>
|
<string name="map_latitude">Широта: %.1f°</string>
|
||||||
@@ -93,11 +100,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">Некоректний локатор QTH</string>
|
<string name="prefs_loc_qth_error">Некоректний локатор QTH</string>
|
||||||
<string name="prefs_loc_success">Позицію успішно оновлено</string>
|
<string name="prefs_loc_success">Позицію успішно оновлено</string>
|
||||||
<string name="prefs_station_title">Налаштування місцезнаходження станції</string>
|
<string name="prefs_station_title">Позиція станції</string>
|
||||||
<string name="prefs_station_lat_text">Широта місцезнаходження станції</string>
|
<string name="prefs_station_lat_text">Широта станції</string>
|
||||||
<string name="prefs_station_lon_text">Довгота місцезнаходження станції</string>
|
<string name="prefs_station_lon_text">Довгота станції</string>
|
||||||
<string name="prefs_locator_title">Налаштування QTH локатора</string>
|
<string name="prefs_locator_title">Локатор QTH</string>
|
||||||
<string name="prefs_locator_text">Задати позицію за QTH локатором</string>
|
<string name="prefs_locator_text">Позиція за локатором</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Супутникові дані</string>
|
<string name="prefs_data_title">Супутникові дані</string>
|
||||||
<string name="prefs_data_entries">Супутників: %s</string>
|
<string name="prefs_data_entries">Супутників: %s</string>
|
||||||
@@ -107,38 +114,57 @@
|
|||||||
<string name="prefs_data_clear">Очистити</string>
|
<string name="prefs_data_clear">Очистити</string>
|
||||||
<string name="prefs_data_clear_success">Дані успішно очищено</string>
|
<string name="prefs_data_clear_success">Дані успішно очищено</string>
|
||||||
<string name="prefs_data_update_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_title">Власні джерела</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers 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">Data output</string>
|
<string name="prefs_data_output_title">Вивід даних</string>
|
||||||
<string name="prefs_net_output">Network</string>
|
<string name="prefs_net_output">Мережа</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</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="prefs_net_rotator_switch">Enable rotation output</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_address_hint">IP:Порт</string>
|
||||||
<string name="prefs_net_rotator_format_hint">Формат даних</string>
|
<string name="prefs_net_rotator_format_hint">Формат даних</string>
|
||||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
<string name="prefs_net_frequency_switch">Увімкнути вивід частоти</string>
|
||||||
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
||||||
<string name="prefs_net_frequency_format_hint">Формат даних</string>
|
<string name="prefs_net_frequency_format_hint">Формат даних</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">Вивід даних Bluetooth</string>
|
<string name="prefs_bt_title">Вивід Bluetooth</string>
|
||||||
<string name="prefs_bt_rotator_switch">Enable rotation output</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_rotator_output_hint">Формат даних</string>
|
||||||
<string name="prefs_bt_frequency_switch">Enable frequency output</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_frequency_output_hint">Формат даних</string>
|
||||||
<string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</string>
|
<string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</string>
|
||||||
<string name="prefs_net_perm_error">Перевірте дозвіл мережі</string>
|
<string name="prefs_net_perm_error">Перевірте дозвіл мережі</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Інші налаштування</string>
|
<string name="prefs_other_title">Інше</string>
|
||||||
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
|
<string name="prefs_other_switch_utc">Час в UTC</string>
|
||||||
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
|
<string name="prefs_other_switch_update">Автооновлення даних</string>
|
||||||
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
|
<string name="prefs_other_switch_sweep">Анімація радара</string>
|
||||||
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
|
<string name="prefs_other_switch_sensors">Сенсори для радара</string>
|
||||||
|
<string name="prefs_other_switch_night_mode">Червоний нічний фільтр</string>
|
||||||
|
|
||||||
|
<string name="prefs_highlight_title">Підсвічування висоти</string>
|
||||||
|
<string name="prefs_highlight_low">Низько < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">Середньо %1$d-%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">Високо > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">Я хотів би подякувати:</string>
|
<string name="prefs_outro_title">Я хотів би подякувати:</string>
|
||||||
<string name="prefs_outro_license">Ця програма поставляється без жодних гарантій</string>
|
<string name="prefs_outro_license">Ця програма поставляється без жодних гарантій</string>
|
||||||
|
|||||||
@@ -13,21 +13,28 @@
|
|||||||
<string name="nav_prefs">设置</string>
|
<string name="nav_prefs">设置</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">类型:%s</string>
|
<string name="sat_type_hint">模式:%s</string>
|
||||||
<string name="sat_type_title">选择卫星类型</string>
|
<string name="sat_type_title">选择模式</string>
|
||||||
<string name="sat_search_hint">Id - 名称</string>
|
<string name="sat_search_hint">Id - 名称</string>
|
||||||
<string name="sat_search_clear">清空</string>
|
<string name="sat_search_clear">清空</string>
|
||||||
<string name="sat_clear_all">全部清空</string>
|
<string name="sat_clear_all">全部清空</string>
|
||||||
<string name="sat_select_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_empty_list_message">请确保您的搜索查询正确且数据库已更新</string>
|
||||||
<string name="sat_warning_title">警告\!</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 -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">筛选过境</string>
|
<string name="pass_filter_title">筛选过境</string>
|
||||||
<string name="pass_filter_elev">仰角值</string>
|
<string name="pass_filter_elev">仰角值</string>
|
||||||
<string name="pass_filter_hours">小时数</string>
|
<string name="pass_filter_hours">提前时间</string>
|
||||||
<string name="pass_modes_title">选择调制类型</string>
|
<string name="pass_filter_aos_time">AOS 时间段</string>
|
||||||
|
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
|
||||||
|
<string name="pass_filter_deep_space">DeepSpace(周期 >225分钟)</string>
|
||||||
|
<string name="pass_modes_title">选择模式</string>
|
||||||
<string name="pass_elevation">仰角:%.1f°</string>
|
<string name="pass_elevation">仰角:%.1f°</string>
|
||||||
<string name="pass_altitude">高度:%d km</string>
|
<string name="pass_altitude">高度:%d km</string>
|
||||||
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
|
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
|
||||||
@@ -85,11 +92,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH 网格</string>
|
<string name="prefs_loc_qth_title">QTH 网格</string>
|
||||||
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
|
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
|
||||||
<string name="prefs_loc_success">位置更新成功</string>
|
<string name="prefs_loc_success">位置更新成功</string>
|
||||||
<string name="prefs_station_title">站位设置</string>
|
<string name="prefs_station_title">站位</string>
|
||||||
<string name="prefs_station_lat_text">输入站位的纬度</string>
|
<string name="prefs_station_lat_text">站位纬度</string>
|
||||||
<string name="prefs_station_lon_text">输入站位的经度</string>
|
<string name="prefs_station_lon_text">站位经度</string>
|
||||||
<string name="prefs_locator_title">QTH 网格</string>
|
<string name="prefs_locator_title">QTH 定位</string>
|
||||||
<string name="prefs_locator_text">使用梅登黑德网格设置站位</string>
|
<string name="prefs_locator_text">通过 QTH 设置站位</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">卫星数据更新</string>
|
<string name="prefs_data_title">卫星数据更新</string>
|
||||||
<string name="prefs_data_entries">卫星:%s</string>
|
<string name="prefs_data_entries">卫星:%s</string>
|
||||||
@@ -99,16 +106,29 @@
|
|||||||
<string name="prefs_data_clear">清空</string>
|
<string name="prefs_data_clear">清空</string>
|
||||||
<string name="prefs_data_clear_success">数据清空成功</string>
|
<string name="prefs_data_clear_success">数据清空成功</string>
|
||||||
<string name="prefs_data_update_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_title">自定义源</string>
|
||||||
<string name="prefs_data_sources_tle_switch">自定义TLE URL</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_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_net_output">网络</string>
|
||||||
<string name="prefs_bt_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_switch">启用方位俯仰角输出</string>
|
||||||
<string name="prefs_net_rotator_address_hint">IP:端口</string>
|
<string name="prefs_net_rotator_address_hint">IP:端口</string>
|
||||||
<string name="prefs_net_rotator_format_hint">数据格式</string>
|
<string name="prefs_net_rotator_format_hint">数据格式</string>
|
||||||
@@ -116,7 +136,7 @@
|
|||||||
<string name="prefs_net_frequency_address_hint">IP:端口</string>
|
<string name="prefs_net_frequency_address_hint">IP:端口</string>
|
||||||
<string name="prefs_net_frequency_format_hint">数据格式</string>
|
<string name="prefs_net_frequency_format_hint">数据格式</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">蓝牙数据输出</string>
|
<string name="prefs_bt_title">蓝牙输出</string>
|
||||||
<string name="prefs_bt_rotator_switch">启用方位俯仰角输出</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_rotator_output_hint">数据格式</string>
|
||||||
@@ -126,12 +146,17 @@
|
|||||||
<string name="prefs_bt_perm_error">请检查蓝牙权限</string>
|
<string name="prefs_bt_perm_error">请检查蓝牙权限</string>
|
||||||
<string name="prefs_net_perm_error">请检查网络权限</string>
|
<string name="prefs_net_perm_error">请检查网络权限</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">其他设置</string>
|
<string name="prefs_other_title">其他</string>
|
||||||
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
<string name="prefs_other_switch_utc">UTC 时间</string>
|
||||||
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
<string name="prefs_other_switch_update">自动更新数据</string>
|
||||||
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
|
<string name="prefs_other_switch_sweep">雷达扫描动画</string>
|
||||||
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
|
<string name="prefs_other_switch_sensors">传感器控制雷达</string>
|
||||||
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
|
<string name="prefs_other_switch_night_mode">红色夜间模式</string>
|
||||||
|
|
||||||
|
<string name="prefs_highlight_title">仰角高亮</string>
|
||||||
|
<string name="prefs_highlight_low">低 < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">中 %1$d-%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">高 > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">我要感谢:</string>
|
<string name="prefs_outro_title">我要感谢:</string>
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
|
|||||||
@@ -14,27 +14,33 @@
|
|||||||
<string name="nav_prefs">Settings</string>
|
<string name="nav_prefs">Settings</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Type: %s</string>
|
<string name="sat_type_hint">Modes: %s</string>
|
||||||
<string name="sat_type_title">Select satellite type</string>
|
<string name="sat_type_title">Select modes</string>
|
||||||
<string name="sat_search_hint">Id - Name</string>
|
<string name="sat_search_hint">Id - Name</string>
|
||||||
<string name="sat_search_clear">Clear</string>
|
<string name="sat_search_clear">Clear</string>
|
||||||
<string name="sat_clear_all">Clear all</string>
|
<string name="sat_clear_all">Clear all</string>
|
||||||
<string name="sat_select_all">Select 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_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_title">Warning\!</string>
|
||||||
<string name="sat_warning_message">
|
<string name="sat_warning_message">
|
||||||
There are over 9000 satellites listed in this app.
|
There are over 9000 satellites listed in this app.
|
||||||
It makes no sense to track them all at the same time.
|
It makes no sense to track them all at the same time.
|
||||||
\n\nAlways try to narrow down the list to only the ones
|
\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 -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Filter passes</string>
|
<string name="pass_filter_title">Filter passes</string>
|
||||||
<string name="pass_filter_elev">Minimal elevation</string>
|
<string name="pass_filter_elev">Minimal elevation</string>
|
||||||
<string name="pass_filter_hours">Hours ahead</string>
|
<string name="pass_filter_hours">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_filter_deep_space">DeepSpace (period >225min)</string>
|
||||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||||
<string name="pass_modes_title">Select modulation type</string>
|
<string name="pass_modes_title">Select modes</string>
|
||||||
<string name="pass_satId" translatable="false">%05d</string>
|
<string name="pass_satId" translatable="false">%05d</string>
|
||||||
<string name="pass_elevation">Elevation: %.1f°</string>
|
<string name="pass_elevation">Elevation: %.1f°</string>
|
||||||
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
||||||
@@ -122,11 +128,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
<string name="prefs_loc_qth_error">Invalid QTH locator</string>
|
<string name="prefs_loc_qth_error">Invalid QTH locator</string>
|
||||||
<string name="prefs_loc_success">Position updated successfully</string>
|
<string name="prefs_loc_success">Position updated successfully</string>
|
||||||
<string name="prefs_station_title">Station position settings</string>
|
<string name="prefs_station_title">Station position</string>
|
||||||
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
|
<string name="prefs_station_lat_text">Station latitude</string>
|
||||||
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
|
<string name="prefs_station_lon_text">Station longitude</string>
|
||||||
<string name="prefs_locator_title">QTH locator settings</string>
|
<string name="prefs_locator_title">QTH locator</string>
|
||||||
<string name="prefs_locator_text">Set station\'s position using locator</string>
|
<string name="prefs_locator_text">Set position via locator</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Satellite data</string>
|
<string name="prefs_data_title">Satellite data</string>
|
||||||
<string name="prefs_data_entries">Satellites: %s</string>
|
<string name="prefs_data_entries">Satellites: %s</string>
|
||||||
@@ -136,10 +142,12 @@
|
|||||||
<string name="prefs_data_clear">Clear</string>
|
<string name="prefs_data_clear">Clear</string>
|
||||||
<string name="prefs_data_clear_success">Data was cleared successfully</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_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_title">Custom sources</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers 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_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
|
|
||||||
<string name="prefs_data_output_title">Data output</string>
|
<string name="prefs_data_output_title">Data output</string>
|
||||||
@@ -149,15 +157,15 @@
|
|||||||
|
|
||||||
<!-- Radio Control -->
|
<!-- Radio Control -->
|
||||||
<string name="nav_radiocontrol">Radio Control</string>
|
<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_radio_model">Radio Model</string>
|
||||||
<string name="rc_tx_device_hint">TX Radio BT Address</string>
|
<string name="rc_tx_device_hint">TX BT address</string>
|
||||||
<string name="rc_rx_device_hint">RX Radio 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_tx_name_hint">TX Radio Name</string>
|
||||||
<string name="rc_rx_name_hint">RX 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_switch">Enable rotation output</string>
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_rotator_format_hint">Format</string>
|
<string name="prefs_net_rotator_format_hint">Format</string>
|
||||||
@@ -165,22 +173,27 @@
|
|||||||
<string name="prefs_net_frequency_address_hint">IP:Port</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_format_hint">Format</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_switch">Enable rotation output</string>
|
||||||
<string name="prefs_bt_rotator_device_hint">Device id</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_output_hint">Format</string>
|
||||||
<string name="prefs_bt_frequency_switch">Enable frequency output</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_device_hint">Device ID</string>
|
||||||
<string name="prefs_bt_frequency_output_hint">Data format</string>
|
<string name="prefs_bt_frequency_output_hint">Format</string>
|
||||||
<string name="prefs_bt_perm_error">Check your bluetooth permission</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_net_perm_error">Check your network permission</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Other settings</string>
|
<string name="prefs_other_title">Other</string>
|
||||||
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
|
<string name="prefs_other_switch_utc">Show UTC time</string>
|
||||||
<string name="prefs_other_switch_update">Enable automatic data update</string>
|
<string name="prefs_other_switch_update">Auto-update data</string>
|
||||||
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
|
<string name="prefs_other_switch_sweep">Radar sweep animation</string>
|
||||||
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
|
<string name="prefs_other_switch_sensors">Use sensors for radar</string>
|
||||||
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
|
<string name="prefs_other_switch_night_mode">Red night filter</string>
|
||||||
|
|
||||||
|
<string name="prefs_highlight_title">Elevation highlight</string>
|
||||||
|
<string name="prefs_highlight_low">Low < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">Mid %1$d–%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">High > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">I would like to say thanks to</string>
|
<string name="prefs_outro_title">I would like to say thanks to</string>
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
|
|||||||
+224
-27
@@ -21,8 +21,11 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
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.Checkbox
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RangeSlider
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -55,44 +59,88 @@ import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
|||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||||
|
import com.rtbishop.look4sat.core.presentation.ElevationHighColor
|
||||||
|
import com.rtbishop.look4sat.core.presentation.ElevationLowColor
|
||||||
import com.rtbishop.look4sat.core.presentation.elevationColor
|
import com.rtbishop.look4sat.core.presentation.elevationColor
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
private val allModes = listOf(
|
import kotlin.math.roundToInt
|
||||||
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 192, 216, 240)
|
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
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun PassesDialogPreview() {
|
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
|
@Composable
|
||||||
internal fun PassesDialog(
|
internal fun PassesFilterDialog(
|
||||||
hours: Int,
|
hours: Int,
|
||||||
elevation: Double,
|
elevation: Double,
|
||||||
|
lowElevation: Double,
|
||||||
|
highElevation: Double,
|
||||||
|
aosStartMinute: Int,
|
||||||
|
aosEndMinute: Int,
|
||||||
|
invertAosTimeWindow: Boolean,
|
||||||
showDeepSpace: Boolean,
|
showDeepSpace: Boolean,
|
||||||
cancel: () -> Unit,
|
cancel: () -> Unit,
|
||||||
accept: (Int, Double, Boolean) -> Unit
|
accept: (PassFilterParams) -> Unit
|
||||||
) {
|
) {
|
||||||
val hoursIndex = remember { mutableIntStateOf(hourSteps.indexOfFirst { it >= hours }.coerceAtLeast(0)) }
|
val hoursIndex = remember { mutableIntStateOf(hourSteps.indexOfFirst { it >= hours }.coerceAtLeast(0)) }
|
||||||
val elevationValueNew = remember { mutableDoubleStateOf(elevation) }
|
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) }
|
var deepSpaceEnabled by remember { mutableStateOf(showDeepSpace) }
|
||||||
val onAccept = {
|
val onAccept = {
|
||||||
accept(hourSteps[hoursIndex.intValue], elevationValueNew.doubleValue, deepSpaceEnabled).also { cancel() }
|
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()
|
||||||
}
|
}
|
||||||
SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
|
SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
|
||||||
ToggleRow(
|
|
||||||
title = stringResource(R.string.pass_filter_deep_space),
|
|
||||||
checked = deepSpaceEnabled,
|
|
||||||
onCheckedChange = { deepSpaceEnabled = it }
|
|
||||||
)
|
|
||||||
SliderRow(
|
SliderRow(
|
||||||
title = stringResource(R.string.pass_filter_elev),
|
title = stringResource(R.string.pass_filter_elev),
|
||||||
value = elevationValueNew.doubleValue,
|
value = elevationValueNew.doubleValue,
|
||||||
@@ -104,24 +152,74 @@ internal fun PassesDialog(
|
|||||||
SliderRow(
|
SliderRow(
|
||||||
title = stringResource(R.string.pass_filter_hours),
|
title = stringResource(R.string.pass_filter_hours),
|
||||||
value = hoursIndex.intValue.toDouble(),
|
value = hoursIndex.intValue.toDouble(),
|
||||||
displayValue = "${hourSteps[hoursIndex.intValue]}h",
|
displayValue = formatHoursLabel(hourSteps[hoursIndex.intValue]),
|
||||||
valueResId = R.drawable.ic_clock,
|
valueResId = R.drawable.ic_clock,
|
||||||
valueRange = 0f..(hourSteps.size - 1).toFloat(),
|
valueRange = 0f..(hourSteps.size - 1).toFloat(),
|
||||||
steps = hourSteps.size - 2
|
steps = hourSteps.size - 2
|
||||||
) { hoursIndex.intValue = it.toInt().coerceIn(0, hourSteps.size - 1) }
|
) { 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
|
@Composable
|
||||||
private fun SliderRow(
|
private fun SliderSection(
|
||||||
title: String,
|
title: String,
|
||||||
value: Double,
|
trailingContent: @Composable () -> Unit,
|
||||||
displayValue: String,
|
sliderContent: @Composable () -> Unit
|
||||||
valueResId: Int,
|
|
||||||
valueRange: ClosedFloatingPointRange<Float>,
|
|
||||||
steps: Int = 0,
|
|
||||||
accentColor: Color = MaterialTheme.colorScheme.primary,
|
|
||||||
onChange: (Float) -> Unit
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
@@ -138,6 +236,26 @@ private fun SliderRow(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
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(
|
Icon(
|
||||||
painter = painterResource(id = valueResId),
|
painter = painterResource(id = valueResId),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@@ -151,10 +269,89 @@ private fun SliderRow(
|
|||||||
color = accentColor
|
color = accentColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
) {
|
||||||
Slider(value = value.toFloat(), onValueChange = onChange, valueRange = valueRange, steps = steps)
|
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)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun RadiosDialogPreview() {
|
private fun RadiosDialogPreview() {
|
||||||
@@ -177,7 +374,7 @@ internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List
|
|||||||
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||||
) {
|
) {
|
||||||
itemsIndexed(allModes) { index, item ->
|
itemsIndexed(Sources.satelliteModes) { index, item ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
@@ -67,11 +68,11 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||||
|
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||||
import com.rtbishop.look4sat.core.presentation.SwipeableItem
|
import com.rtbishop.look4sat.core.presentation.SwipeableItem
|
||||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
@@ -99,14 +100,31 @@ private fun PassesScreen(
|
|||||||
navigateToRadar: (Int, Long) -> Unit
|
navigateToRadar: (Int, Long) -> Unit
|
||||||
) {
|
) {
|
||||||
if (uiState.isPassesDialogShown) {
|
if (uiState.isPassesDialogShown) {
|
||||||
PassesDialog(
|
PassesFilterDialog(
|
||||||
hours = uiState.hours,
|
hours = uiState.hours,
|
||||||
elevation = uiState.elevation,
|
elevation = uiState.elevation,
|
||||||
|
lowElevation = uiState.lowElevation,
|
||||||
|
highElevation = uiState.highElevation,
|
||||||
|
aosStartMinute = uiState.aosStartMinute,
|
||||||
|
aosEndMinute = uiState.aosEndMinute,
|
||||||
|
invertAosTimeWindow = uiState.invertAosTimeWindow,
|
||||||
showDeepSpace = uiState.showDeepSpace,
|
showDeepSpace = uiState.showDeepSpace,
|
||||||
cancel = { onAction(PassesAction.TogglePassesDialog) }
|
cancel = { onAction(PassesAction.TogglePassesDialog) },
|
||||||
) { hours, elevation, showDeepSpace ->
|
accept = { params ->
|
||||||
onAction(PassesAction.FilterPasses(hours, elevation, showDeepSpace))
|
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) {
|
if (uiState.isRadiosDialogShown) {
|
||||||
RadiosDialog(
|
RadiosDialog(
|
||||||
@@ -117,11 +135,20 @@ private fun PassesScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (uiState.shouldSeeWhatsNew) {
|
if (uiState.shouldSeeWhatsNew) {
|
||||||
InfoDialog(
|
val dismiss = { onAction(PassesAction.DismissWhatsNew) }
|
||||||
|
SharedDialog(
|
||||||
title = stringResource(R.string.pass_whatsnew_title),
|
title = stringResource(R.string.pass_whatsnew_title),
|
||||||
text = stringResource(R.string.pass_whatsnew_message)
|
onDismissRequest = dismiss,
|
||||||
) {
|
onAccept = dismiss,
|
||||||
onAction(PassesAction.DismissWhatsNew)
|
titleFontSize = 18
|
||||||
|
) { padding ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.pass_whatsnew_message),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(horizontal = padding)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(0.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ScreenColumn(
|
ScreenColumn(
|
||||||
@@ -265,6 +292,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)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeepSpacePassPreview() {
|
private fun DeepSpacePassPreview() {
|
||||||
@@ -297,7 +329,7 @@ private fun PassItem(
|
|||||||
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
}
|
}
|
||||||
val sdfTime = remember(isUtc) {
|
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 aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
|
||||||
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
|
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ data class PassesState(
|
|||||||
val isNextTimeAos: Boolean = true,
|
val isNextTimeAos: Boolean = true,
|
||||||
val hours: Int = 24,
|
val hours: Int = 24,
|
||||||
val elevation: Double = 16.0,
|
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 showDeepSpace: Boolean = true,
|
||||||
val modes: List<String> = emptyList(),
|
val modes: List<String> = emptyList(),
|
||||||
val itemsList: List<OrbitalPass> = emptyList(),
|
val itemsList: List<OrbitalPass> = emptyList(),
|
||||||
@@ -40,7 +45,16 @@ data class PassesState(
|
|||||||
|
|
||||||
sealed interface PassesAction {
|
sealed interface PassesAction {
|
||||||
data object DismissWhatsNew : 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 class FilterRadios(val modes: List<String>) : PassesAction
|
||||||
data object RefreshPasses : PassesAction
|
data object RefreshPasses : PassesAction
|
||||||
data object TogglePassesDialog : PassesAction
|
data object TogglePassesDialog : PassesAction
|
||||||
|
|||||||
+110
-15
@@ -54,8 +54,13 @@ class PassesViewModel(
|
|||||||
nextPass = defaultPass,
|
nextPass = defaultPass,
|
||||||
hours = settingsRepo.passesSettings.value.hoursAhead,
|
hours = settingsRepo.passesSettings.value.hoursAhead,
|
||||||
elevation = settingsRepo.passesSettings.value.minElevation,
|
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,
|
showDeepSpace = settingsRepo.passesSettings.value.showDeepSpace,
|
||||||
modes = settingsRepo.passesSettings.value.selectedModes,
|
modes = settingsRepo.selectedSatModes.value,
|
||||||
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew
|
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -71,7 +76,19 @@ class PassesViewModel(
|
|||||||
// React to settings changes: update UTC flag and whatsNew
|
// React to settings changes: update UTC flag and whatsNew
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepo.otherSettings.collectLatest { settings ->
|
settingsRepo.otherSettings.collectLatest { settings ->
|
||||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc, shouldSeeWhatsNew = settings.shouldSeeWhatsNew) }
|
_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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Main tick loop: reacts to new passes, then ticks every second
|
// Main tick loop: reacts to new passes, then ticks every second
|
||||||
@@ -107,9 +124,17 @@ class PassesViewModel(
|
|||||||
when (action) {
|
when (action) {
|
||||||
PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed()
|
PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed()
|
||||||
is PassesAction.FilterPasses ->
|
is PassesAction.FilterPasses ->
|
||||||
applyFilter(action.hoursAhead, action.minElevation, action.showDeepSpace, _uiState.value.modes)
|
applyFilter(
|
||||||
is PassesAction.FilterRadios ->
|
hoursAhead = action.hoursAhead,
|
||||||
applyFilter(_uiState.value.hours, _uiState.value.elevation, _uiState.value.showDeepSpace, action.modes)
|
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.RefreshPasses -> refreshPasses()
|
||||||
PassesAction.TogglePassesDialog ->
|
PassesAction.TogglePassesDialog ->
|
||||||
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
|
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
|
||||||
@@ -120,12 +145,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
|
// 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>> {
|
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
||||||
val stationPos = settingsRepo.stationPosition.value
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
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 sdfTime = SimpleDateFormat("HH:mm", Locale.ENGLISH).also { it.timeZone = tz }
|
val sdfTime = SimpleDateFormat("HH:mm", displayLocale()).also { it.timeZone = tz }
|
||||||
val result = LinkedHashMap<String, Pair<String, String>>()
|
val result = LinkedHashMap<String, Pair<String, String>>()
|
||||||
// DeepSpace group always shows today's sun times
|
// DeepSpace group always shows today's sun times
|
||||||
if (passes.any { it.isDeepSpace }) {
|
if (passes.any { it.isDeepSpace }) {
|
||||||
@@ -148,7 +189,7 @@ class PassesViewModel(
|
|||||||
|
|
||||||
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
|
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
|
||||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
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 ordered = LinkedHashMap<String, List<OrbitalPass>>()
|
||||||
val deepSpace = passes.filter { it.isDeepSpace }
|
val deepSpace = passes.filter { it.isDeepSpace }
|
||||||
if (deepSpace.isNotEmpty()) ordered["DeepSpace (period >225min)"] = deepSpace
|
if (deepSpace.isNotEmpty()) ordered["DeepSpace (period >225min)"] = deepSpace
|
||||||
@@ -198,19 +239,73 @@ class PassesViewModel(
|
|||||||
private fun applyFilter(
|
private fun applyFilter(
|
||||||
hoursAhead: Int,
|
hoursAhead: Int,
|
||||||
minElevation: Double,
|
minElevation: Double,
|
||||||
showDeepSpace: Boolean,
|
lowElevation: Double,
|
||||||
modes: List<String>
|
highElevation: Double,
|
||||||
|
aosStartMinute: Int,
|
||||||
|
aosEndMinute: Int,
|
||||||
|
invertAosTimeWindow: Boolean,
|
||||||
|
showDeepSpace: Boolean
|
||||||
) = viewModelScope.launch {
|
) = 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 {
|
_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 {
|
private fun refreshPasses() = viewModelScope.launch {
|
||||||
val (_, hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
|
val settings = settingsRepo.passesSettings.value
|
||||||
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
|
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 {
|
companion object {
|
||||||
|
|||||||
@@ -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.OrbitalPass
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.sstv.SstvFrame
|
import com.rtbishop.look4sat.core.domain.sstv.SstvFrame
|
||||||
|
import com.rtbishop.look4sat.core.domain.sstv.SstvQualityMetrics
|
||||||
|
|
||||||
data class RadioPanelState(
|
data class RadioPanelState(
|
||||||
val label: String = "",
|
val label: String = "",
|
||||||
@@ -72,7 +73,8 @@ data class SstvSubState(
|
|||||||
val hasPermission: Boolean = false,
|
val hasPermission: Boolean = false,
|
||||||
val selectedMode: String = "Auto",
|
val selectedMode: String = "Auto",
|
||||||
val supportedModes: List<String> = emptyList(),
|
val supportedModes: List<String> = emptyList(),
|
||||||
val currentFrame: SstvFrame? = null
|
val currentFrame: SstvFrame? = null,
|
||||||
|
val diagnosticsMetrics: SstvQualityMetrics? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface RadarAction {
|
sealed interface RadarAction {
|
||||||
|
|||||||
@@ -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.ISatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.sstv.LineRecoveryStrategy
|
||||||
import com.rtbishop.look4sat.core.domain.sstv.SstvDecoder
|
import com.rtbishop.look4sat.core.domain.sstv.SstvDecoder
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||||
@@ -213,7 +214,6 @@ class RadarViewModel(
|
|||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
sensorsRepo.disableSensor()
|
sensorsRepo.disableSensor()
|
||||||
super.onCleared()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onAction(action: RadarAction) {
|
fun onAction(action: RadarAction) {
|
||||||
@@ -344,13 +344,32 @@ class RadarViewModel(
|
|||||||
|
|
||||||
private fun initSstvDecoder() {
|
private fun initSstvDecoder() {
|
||||||
if (sstvDecoder == null) {
|
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
|
sstvDecoder = decoder
|
||||||
decoder.lockMode(_uiState.value.sstv.selectedMode)
|
decoder.lockMode(_uiState.value.sstv.selectedMode)
|
||||||
_uiState.update { it.copy(sstv = it.sstv.copy(supportedModes = decoder.supportedModes)) }
|
_uiState.update { it.copy(sstv = it.sstv.copy(supportedModes = decoder.supportedModes)) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
decoder.frames.collect { frame ->
|
decoder.frames.collect { frame ->
|
||||||
_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)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -374,6 +393,39 @@ class RadarViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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.40f // 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.Look4SatLimited
|
||||||
|
|
||||||
|
// Debug: Return scope visualization in frames (increases per-frame memory usage).
|
||||||
|
private const val SSTV_INCLUDE_SCOPE = false
|
||||||
|
|
||||||
val CTCSS_TONES = listOf(
|
val CTCSS_TONES = listOf(
|
||||||
67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5,
|
67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5,
|
||||||
|
|||||||
@@ -53,11 +53,12 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
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.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.OutlinedText
|
import com.rtbishop.look4sat.core.presentation.OutlinedText
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SstvPage(
|
internal fun SstvPage(
|
||||||
@@ -94,53 +95,44 @@ internal fun SstvPage(
|
|||||||
|
|
||||||
if (showModeDialog.value) {
|
if (showModeDialog.value) {
|
||||||
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
|
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
|
||||||
Dialog(onDismissRequest = { showModeDialog.value = false }) {
|
val dismiss = { showModeDialog.value = false }
|
||||||
ElevatedCard {
|
SharedDialog(
|
||||||
Column(
|
title = "SSTV Mode",
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
onDismissRequest = dismiss,
|
||||||
modifier = Modifier.padding(vertical = 16.dp)
|
onAccept = dismiss
|
||||||
) {
|
) { _ ->
|
||||||
Text(
|
LazyColumn(
|
||||||
text = "SSTV Mode",
|
modifier = Modifier
|
||||||
fontSize = 16.sp,
|
.fillMaxHeight(0.7f)
|
||||||
fontWeight = FontWeight.Medium,
|
.background(MaterialTheme.colorScheme.background),
|
||||||
color = MaterialTheme.colorScheme.primary,
|
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||||
modifier = Modifier.padding(bottom = 12.dp)
|
) {
|
||||||
)
|
items(allModes) { mode ->
|
||||||
LazyColumn(
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxHeight(0.5f)
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colorScheme.background),
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
.clickable {
|
||||||
) {
|
onAction(RadarAction.SstvSelectMode(mode))
|
||||||
items(allModes) { mode ->
|
showModeDialog.value = false
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
) {
|
||||||
|
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)
|
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
|
// Bottom control bar — dark, blends with the black canvas
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -198,6 +203,15 @@ internal fun SstvPage(
|
|||||||
outlineColor = MaterialTheme.colorScheme.background,
|
outlineColor = MaterialTheme.colorScheme.background,
|
||||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||||
)
|
)
|
||||||
|
if (qualityText != null) {
|
||||||
|
OutlinedText(
|
||||||
|
text = qualityText,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fillColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
outlineColor = MaterialTheme.colorScheme.background,
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||||
|
)
|
||||||
|
}
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
|||||||
@@ -369,10 +369,11 @@ private fun ExpandedRadioControl(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
FlowRow(
|
FlowRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
maxItemsInEachRow = 5,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
val chipModifier = Modifier.width(64.dp)
|
val chipModifier = Modifier.weight(1f)
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = radioControl.ctcssTone == null,
|
selected = radioControl.ctcssTone == null,
|
||||||
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
|
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
|
||||||
|
|||||||
+9
-9
@@ -45,18 +45,18 @@ import com.rtbishop.look4sat.core.presentation.SharedDialog
|
|||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun MultiTypesDialogPreview() {
|
private fun MultiModesDialogPreview() {
|
||||||
val types = listOf("Amateur", "Geostationary", "Military", "Weather")
|
val modes = listOf("FM", "APT", "SSTV", "BPSK")
|
||||||
MainTheme { MultiTypesDialog(types, emptyList(), {}) {} }
|
MainTheme { MultiModesDialog(modes, emptyList(), {}) {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MultiTypesDialog(
|
internal fun MultiModesDialog(
|
||||||
allTypes: List<String>, types: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
|
allModes: List<String>, modes: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
|
||||||
) {
|
) {
|
||||||
val selected = remember { mutableStateOf(types.toSet()) }
|
val selected = remember { mutableStateOf(modes.toSet()) }
|
||||||
val toggle = { type: String ->
|
val toggle = { mode: String ->
|
||||||
selected.value = if (type in selected.value) selected.value - type else selected.value + type
|
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
|
||||||
}
|
}
|
||||||
val onAccept = { accept(selected.value.toList()) }
|
val onAccept = { accept(selected.value.toList()) }
|
||||||
SharedDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
|
SharedDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
|
||||||
@@ -68,7 +68,7 @@ internal fun MultiTypesDialog(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||||
) {
|
) {
|
||||||
itemsIndexed(allTypes) { index, item ->
|
itemsIndexed(allModes) { index, item ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
+77
-23
@@ -62,10 +62,10 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
|||||||
import com.rtbishop.look4sat.core.presentation.CardLoadingIndicator
|
import com.rtbishop.look4sat.core.presentation.CardLoadingIndicator
|
||||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
|
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
@@ -87,19 +87,28 @@ private fun SatellitesScreen(
|
|||||||
navigateUp: () -> Unit
|
navigateUp: () -> Unit
|
||||||
) {
|
) {
|
||||||
if (uiState.isDialogShown) {
|
if (uiState.isDialogShown) {
|
||||||
MultiTypesDialog(
|
MultiModesDialog(
|
||||||
allTypes = uiState.typesList,
|
allModes = uiState.modesList,
|
||||||
types = uiState.currentTypes,
|
modes = uiState.currentModes,
|
||||||
cancel = { onAction(SatellitesAction.ToggleTypesDialog) },
|
cancel = { onAction(SatellitesAction.ToggleModesDialog) },
|
||||||
accept = { onAction(SatellitesAction.SelectTypes(it)) }
|
accept = { onAction(SatellitesAction.SelectModes(it)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (uiState.shouldSeeWarning) {
|
if (uiState.shouldSeeWarning) {
|
||||||
InfoDialog(
|
val dismiss = { onAction(SatellitesAction.DismissWarning) }
|
||||||
stringResource(R.string.sat_warning_title),
|
SharedDialog(
|
||||||
stringResource(R.string.sat_warning_message)
|
title = stringResource(R.string.sat_warning_title),
|
||||||
) {
|
onDismissRequest = dismiss,
|
||||||
onAction(SatellitesAction.DismissWarning)
|
onAccept = dismiss,
|
||||||
|
titleFontSize = 18
|
||||||
|
) { padding ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sat_warning_message),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(horizontal = padding)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(0.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,9 +123,9 @@ private fun SatellitesScreen(
|
|||||||
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
if (isVerticalLayout()) {
|
if (isVerticalLayout()) {
|
||||||
TopBar {
|
TopBar {
|
||||||
TypeCard(
|
ModeCard(
|
||||||
types = uiState.currentTypes,
|
modes = uiState.currentModes,
|
||||||
onClick = { onAction(SatellitesAction.ToggleTypesDialog) },
|
onClick = { onAction(SatellitesAction.ToggleModesDialog) },
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
PrimaryIconCard(
|
PrimaryIconCard(
|
||||||
@@ -148,9 +157,9 @@ private fun SatellitesScreen(
|
|||||||
resId = R.drawable.ic_done,
|
resId = R.drawable.ic_done,
|
||||||
modifier = Modifier.semantics { contentDescription = primCardCd }
|
modifier = Modifier.semantics { contentDescription = primCardCd }
|
||||||
)
|
)
|
||||||
TypeCard(
|
ModeCard(
|
||||||
types = uiState.currentTypes,
|
modes = uiState.currentModes,
|
||||||
onClick = { onAction(SatellitesAction.ToggleTypesDialog) },
|
onClick = { onAction(SatellitesAction.ToggleModesDialog) },
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
SearchBar(
|
SearchBar(
|
||||||
@@ -232,8 +241,8 @@ private fun SearchBar(onQueryChange: (String) -> Unit, modifier: Modifier = Modi
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TypeCard(types: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
private fun ModeCard(modes: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
val typesText = if (types.isEmpty()) "All" else types.joinToString(", ")
|
val modesText = if (modes.isEmpty()) "All" else modes.joinToString(", ")
|
||||||
ElevatedCard(modifier = modifier) {
|
ElevatedCard(modifier = modifier) {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
@@ -243,12 +252,12 @@ private fun TypeCard(types: List<String>, onClick: () -> Unit, modifier: Modifie
|
|||||||
.clickable { onClick() }) {
|
.clickable { onClick() }) {
|
||||||
Spacer(Modifier)
|
Spacer(Modifier)
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_tags),
|
painter = painterResource(id = R.drawable.ic_radios),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.sat_type_hint, typesText),
|
text = stringResource(R.string.sat_type_hint, modesText),
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -314,9 +323,54 @@ private fun SatellitesPreview() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SatellitesCard(items: List<SatItem>, onSelected: (Int, Boolean) -> Unit) {
|
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)) {
|
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp)) {
|
||||||
items(items = items, key = { item -> item.catnum }) { entry ->
|
stickyHeader(key = "sat_summary_header") {
|
||||||
Satellite(entry, onSelected, Modifier.animateItem())
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
+4
-4
@@ -24,8 +24,8 @@ data class SatellitesState(
|
|||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val shouldSeeWarning: Boolean = false,
|
val shouldSeeWarning: Boolean = false,
|
||||||
val itemsList: List<SatItem> = emptyList(),
|
val itemsList: List<SatItem> = emptyList(),
|
||||||
val currentTypes: List<String> = emptyList(),
|
val currentModes: List<String> = emptyList(),
|
||||||
val typesList: List<String> = emptyList()
|
val modesList: List<String> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface SatellitesAction {
|
sealed interface SatellitesAction {
|
||||||
@@ -34,7 +34,7 @@ sealed interface SatellitesAction {
|
|||||||
data class SearchFor(val query: String) : SatellitesAction
|
data class SearchFor(val query: String) : SatellitesAction
|
||||||
data object SelectAll : SatellitesAction
|
data object SelectAll : SatellitesAction
|
||||||
data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction
|
data class SelectSingle(val id: Int, val isTicked: Boolean) : SatellitesAction
|
||||||
data class SelectTypes(val types: List<String>) : SatellitesAction
|
data class SelectModes(val modes: List<String>) : SatellitesAction
|
||||||
data object ToggleTypesDialog : SatellitesAction
|
data object ToggleModesDialog : SatellitesAction
|
||||||
data object UnselectAll : SatellitesAction
|
data object UnselectAll : SatellitesAction
|
||||||
}
|
}
|
||||||
+10
-10
@@ -35,13 +35,13 @@ class SatellitesViewModel(
|
|||||||
private val settingsRepo: ISettingsRepo
|
private val settingsRepo: ISettingsRepo
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val defaultTypes = selectionRepo.getCurrentTypes()
|
private val defaultModes = selectionRepo.getCurrentModes()
|
||||||
private val _uiState = MutableStateFlow(
|
private val _uiState = MutableStateFlow(
|
||||||
SatellitesState(
|
SatellitesState(
|
||||||
isLoading = true,
|
isLoading = true,
|
||||||
shouldSeeWarning = settingsRepo.otherSettings.value.shouldSeeWarning,
|
shouldSeeWarning = settingsRepo.otherSettings.value.shouldSeeWarning,
|
||||||
currentTypes = defaultTypes,
|
currentModes = defaultModes,
|
||||||
typesList = selectionRepo.getTypesList()
|
modesList = selectionRepo.getModesList()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val uiState: StateFlow<SatellitesState> = _uiState
|
val uiState: StateFlow<SatellitesState> = _uiState
|
||||||
@@ -49,7 +49,7 @@ class SatellitesViewModel(
|
|||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
selectionRepo.setQuery(String())
|
selectionRepo.setQuery(String())
|
||||||
selectionRepo.setTypes(defaultTypes)
|
selectionRepo.setModes(defaultModes)
|
||||||
selectionRepo.getEntriesFlow().collectLatest { items ->
|
selectionRepo.getEntriesFlow().collectLatest { items ->
|
||||||
_uiState.update { it.copy(isLoading = false, itemsList = items) }
|
_uiState.update { it.copy(isLoading = false, itemsList = items) }
|
||||||
}
|
}
|
||||||
@@ -68,8 +68,8 @@ class SatellitesViewModel(
|
|||||||
is SatellitesAction.SearchFor -> searchFor(action.query)
|
is SatellitesAction.SearchFor -> searchFor(action.query)
|
||||||
SatellitesAction.SelectAll -> selectAll(true)
|
SatellitesAction.SelectAll -> selectAll(true)
|
||||||
is SatellitesAction.SelectSingle -> selectSingle(action.id, action.isTicked)
|
is SatellitesAction.SelectSingle -> selectSingle(action.id, action.isTicked)
|
||||||
is SatellitesAction.SelectTypes -> selectTypes(action.types)
|
is SatellitesAction.SelectModes -> selectModes(action.modes)
|
||||||
SatellitesAction.ToggleTypesDialog -> toggleTypesDialog()
|
SatellitesAction.ToggleModesDialog -> toggleModesDialog()
|
||||||
SatellitesAction.UnselectAll -> selectAll(false)
|
SatellitesAction.UnselectAll -> selectAll(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,12 +86,12 @@ class SatellitesViewModel(
|
|||||||
selectionRepo.setSelection(listOf(id), isTicked.not())
|
selectionRepo.setSelection(listOf(id), isTicked.not())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun selectTypes(types: List<String>) = viewModelScope.launch {
|
private fun selectModes(modes: List<String>) = viewModelScope.launch {
|
||||||
selectionRepo.setTypes(types)
|
selectionRepo.setModes(modes)
|
||||||
_uiState.update { it.copy(currentTypes = types, isDialogShown = false) }
|
_uiState.update { it.copy(currentModes = modes, isDialogShown = false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun toggleTypesDialog() {
|
private fun toggleModesDialog() {
|
||||||
_uiState.update { it.copy(isDialogShown = !it.isDialogShown) }
|
_uiState.update { it.copy(isDialogShown = !it.isDialogShown) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+209
-81
@@ -17,27 +17,29 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.feature.settings
|
package com.rtbishop.look4sat.feature.settings
|
||||||
|
|
||||||
import android.bluetooth.BluetoothManager
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
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.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -62,16 +64,28 @@ fun PositionDialog(lat: Double, lon: Double, dismiss: () -> Unit, save: (Double,
|
|||||||
val titleText = stringResource(id = R.string.prefs_station_title)
|
val titleText = stringResource(id = R.string.prefs_station_title)
|
||||||
val onAccept = { saveValues(latValue.value, lonValue.value, save).also { dismiss() } }
|
val onAccept = { saveValues(latValue.value, lonValue.value, save).also { dismiss() } }
|
||||||
SharedDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
|
SharedDialog(title = titleText, onCancel = dismiss, onAccept = onAccept) {
|
||||||
OutlinedTextField(
|
Row(
|
||||||
value = latValue.value,
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
onValueChange = { latValue.value = it },
|
modifier = Modifier
|
||||||
label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) }
|
.fillMaxWidth()
|
||||||
)
|
.padding(horizontal = LocalSpacing.current.large)
|
||||||
OutlinedTextField(
|
) {
|
||||||
value = lonValue.value,
|
OutlinedTextField(
|
||||||
onValueChange = { lonValue.value = it },
|
value = latValue.value,
|
||||||
label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) }
|
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 +107,20 @@ private fun LocatorDialogPreview() {
|
|||||||
fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Unit) {
|
fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Unit) {
|
||||||
val locator = rememberSaveable { mutableStateOf(qthLocator) }
|
val locator = rememberSaveable { mutableStateOf(qthLocator) }
|
||||||
val onAccept = { save(locator.value).also { dismiss() } }
|
val onAccept = { save(locator.value).also { dismiss() } }
|
||||||
SharedDialog(title = stringResource(R.string.prefs_locator_title), onCancel = dismiss, onAccept = onAccept) {
|
SharedDialog(
|
||||||
|
title = stringResource(R.string.prefs_locator_title),
|
||||||
|
onCancel = dismiss,
|
||||||
|
onAccept = onAccept
|
||||||
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = locator.value,
|
value = locator.value,
|
||||||
onValueChange = { locator.value = it },
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +133,7 @@ private fun TransceiversDialogPreview() {
|
|||||||
useCustomTransceivers = true,
|
useCustomTransceivers = true,
|
||||||
tleUrl = "https://example.com/tle.txt",
|
tleUrl = "https://example.com/tle.txt",
|
||||||
transceiversUrl = "https://example.com/tx.json",
|
transceiversUrl = "https://example.com/tx.json",
|
||||||
|
requestCustomSourcesPermission = { onGranted, _ -> onGranted() },
|
||||||
onImportTle = {},
|
onImportTle = {},
|
||||||
onImportTransceivers = {},
|
onImportTransceivers = {},
|
||||||
onDismiss = {},
|
onDismiss = {},
|
||||||
@@ -125,6 +148,7 @@ fun DataSourcesDialog(
|
|||||||
useCustomTransceivers: Boolean,
|
useCustomTransceivers: Boolean,
|
||||||
tleUrl: String,
|
tleUrl: String,
|
||||||
transceiversUrl: String,
|
transceiversUrl: String,
|
||||||
|
requestCustomSourcesPermission: (onGranted: () -> Unit, onDenied: () -> Unit) -> Unit,
|
||||||
onImportTle: () -> Unit,
|
onImportTle: () -> Unit,
|
||||||
onImportTransceivers: () -> Unit,
|
onImportTransceivers: () -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
@@ -136,7 +160,12 @@ fun DataSourcesDialog(
|
|||||||
val urlTle = rememberSaveable { mutableStateOf(tleUrl) }
|
val urlTle = rememberSaveable { mutableStateOf(tleUrl) }
|
||||||
val urlTransceivers = rememberSaveable { mutableStateOf(transceiversUrl) }
|
val urlTransceivers = rememberSaveable { mutableStateOf(transceiversUrl) }
|
||||||
val onAccept = {
|
val onAccept = {
|
||||||
onSave(isEnabledCustomTle.value, isEnabledCustomTransceivers.value, urlTle.value, urlTransceivers.value)
|
onSave(
|
||||||
|
isEnabledCustomTle.value,
|
||||||
|
isEnabledCustomTransceivers.value,
|
||||||
|
urlTle.value,
|
||||||
|
urlTransceivers.value
|
||||||
|
)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
val onCancel = { onDismiss() }
|
val onCancel = { onDismiss() }
|
||||||
@@ -152,7 +181,7 @@ fun DataSourcesDialog(
|
|||||||
onImportTle()
|
onImportTle()
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
text = "TLE (3LE)\nR4UAB (.txt)",
|
text = "TLE/3LE (.txt)\nOMM (.csv)",
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
CardButton(
|
CardButton(
|
||||||
@@ -173,7 +202,16 @@ fun DataSourcesDialog(
|
|||||||
Text(text = stringResource(id = R.string.prefs_data_sources_tle_switch))
|
Text(text = stringResource(id = R.string.prefs_data_sources_tle_switch))
|
||||||
Switch(
|
Switch(
|
||||||
checked = isEnabledCustomTle.value,
|
checked = isEnabledCustomTle.value,
|
||||||
onCheckedChange = { isEnabledCustomTle.value = it }
|
onCheckedChange = { enabled ->
|
||||||
|
if (!enabled) {
|
||||||
|
isEnabledCustomTle.value = false
|
||||||
|
} else {
|
||||||
|
requestCustomSourcesPermission(
|
||||||
|
{ isEnabledCustomTle.value = true },
|
||||||
|
{ isEnabledCustomTle.value = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -193,7 +231,16 @@ fun DataSourcesDialog(
|
|||||||
Text(text = stringResource(id = R.string.prefs_data_sources_transceivers_switch))
|
Text(text = stringResource(id = R.string.prefs_data_sources_transceivers_switch))
|
||||||
Switch(
|
Switch(
|
||||||
checked = isEnabledCustomTransceivers.value,
|
checked = isEnabledCustomTransceivers.value,
|
||||||
onCheckedChange = { isEnabledCustomTransceivers.value = it }
|
onCheckedChange = { enabled ->
|
||||||
|
if (!enabled) {
|
||||||
|
isEnabledCustomTransceivers.value = false
|
||||||
|
} else {
|
||||||
|
requestCustomSourcesPermission(
|
||||||
|
{ isEnabledCustomTransceivers.value = true },
|
||||||
|
{ isEnabledCustomTransceivers.value = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -204,6 +251,7 @@ fun DataSourcesDialog(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = isEnabledCustomTransceivers.value,
|
enabled = isEnabledCustomTransceivers.value,
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,6 +342,7 @@ fun NetworkOutputDialog(
|
|||||||
onFormatChange = { frequencyFormat.value = it },
|
onFormatChange = { frequencyFormat.value = it },
|
||||||
formatLabel = stringResource(R.string.prefs_net_frequency_format_hint)
|
formatLabel = stringResource(R.string.prefs_net_frequency_format_hint)
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -346,11 +395,15 @@ fun BluetoothOutputDialog(
|
|||||||
) {
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
val padding = LocalSpacing.current.large
|
||||||
val rotatorState = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorState) }
|
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 rotatorFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothRotatorFormat) }
|
||||||
val frequencyState = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyState) }
|
val frequencyState =
|
||||||
val frequencyAddress = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyAddress) }
|
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyState) }
|
||||||
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) }
|
val frequencyAddress =
|
||||||
|
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyAddress) }
|
||||||
|
val frequencyFormat =
|
||||||
|
rememberSaveable { mutableStateOf(initialSettings.bluetoothFrequencyFormat) }
|
||||||
val onAccept = {
|
val onAccept = {
|
||||||
onSave(
|
onSave(
|
||||||
rotatorState.value, rotatorAddress.value, rotatorFormat.value,
|
rotatorState.value, rotatorAddress.value, rotatorFormat.value,
|
||||||
@@ -387,6 +440,7 @@ fun BluetoothOutputDialog(
|
|||||||
onFormatChange = { frequencyFormat.value = it },
|
onFormatChange = { frequencyFormat.value = it },
|
||||||
formatLabel = stringResource(R.string.prefs_bt_frequency_output_hint)
|
formatLabel = stringResource(R.string.prefs_bt_frequency_output_hint)
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -441,14 +495,14 @@ private fun OutputChannelSection(
|
|||||||
@Composable
|
@Composable
|
||||||
fun RadioControlDialog(
|
fun RadioControlDialog(
|
||||||
initialSettings: RadioControlSettings,
|
initialSettings: RadioControlSettings,
|
||||||
|
pairedBluetoothDevices: List<Pair<String, String>>,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSave: (RadioControlSettings) -> Unit
|
onSave: (RadioControlSettings) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
|
||||||
val padding = LocalSpacing.current.large
|
val padding = LocalSpacing.current.large
|
||||||
val baudRates = listOf(4800, 9600, 38400)
|
|
||||||
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
|
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
|
||||||
val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) }
|
val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) }
|
||||||
|
val splitMode = rememberSaveable { mutableStateOf(initialSettings.splitMode) }
|
||||||
val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) }
|
val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) }
|
||||||
val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) }
|
val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) }
|
||||||
val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) }
|
val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) }
|
||||||
@@ -456,14 +510,17 @@ fun RadioControlDialog(
|
|||||||
val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) }
|
val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) }
|
||||||
val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or ""
|
val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or ""
|
||||||
|
|
||||||
val pairedDevices = remember {
|
val isIcom = radioModel.value == RadioControlSettings.MODEL_ICOM_IC705
|
||||||
try {
|
val isSingleRadio = isIcom && splitMode.value
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
|
||||||
manager.adapter?.bondedDevices?.map { Pair(it.name ?: "Unknown", it.address) } ?: emptyList()
|
// Reset split mode when switching away from IC-705
|
||||||
} catch (_: SecurityException) {
|
if (!isIcom && splitMode.value) splitMode.value = false
|
||||||
emptyList()
|
|
||||||
}
|
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 = {
|
val onAccept = {
|
||||||
onSave(
|
onSave(
|
||||||
@@ -471,20 +528,23 @@ fun RadioControlDialog(
|
|||||||
enabled = enabled.value,
|
enabled = enabled.value,
|
||||||
radioModel = radioModel.value,
|
radioModel = radioModel.value,
|
||||||
txRadioAddress = txAddress.value,
|
txRadioAddress = txAddress.value,
|
||||||
rxRadioAddress = rxAddress.value,
|
rxRadioAddress = if (isSingleRadio) "" else rxAddress.value,
|
||||||
txRadioName = txName.value,
|
txRadioName = txName.value,
|
||||||
rxRadioName = rxName.value,
|
rxRadioName = if (isSingleRadio) "" else rxName.value,
|
||||||
baudRate = baudRate.intValue
|
baudRate = baudRate.intValue,
|
||||||
|
splitMode = splitMode.value
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
SharedDialog(
|
SharedDialog(
|
||||||
title = stringResource(R.string.rc_settings_title),
|
title = stringResource(R.string.rc_settings_title),
|
||||||
onCancel = onDismiss,
|
onCancel = onDismiss,
|
||||||
onAccept = onAccept
|
onAccept = onAccept
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(horizontal = padding)) {
|
Column(modifier = Modifier.padding(horizontal = padding)) {
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -495,59 +555,98 @@ fun RadioControlDialog(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
// Radio model selection
|
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.rc_radio_model),
|
text = stringResource(R.string.rc_radio_model),
|
||||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
RadioControlSettings.SUPPORTED_RADIOS.forEach { model ->
|
RadioControlSettings.SUPPORTED_RADIOS.forEach { model ->
|
||||||
androidx.compose.material3.FilterChip(
|
FilterChip(
|
||||||
selected = radioModel.value == model,
|
selected = radioModel.value == model,
|
||||||
onClick = { radioModel.value = model },
|
onClick = { radioModel.value = model },
|
||||||
label = { Text(model, fontSize = 12.sp) },
|
label = {
|
||||||
enabled = enabled.value
|
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))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
// TX Radio selection
|
val isSplitModeAvailable = enabled.value && isIcom
|
||||||
Text("TX Radio (Uplink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium)
|
val splitModeLabelColor = if (isSplitModeAvailable) {
|
||||||
if (txAddress.value.isNotBlank()) {
|
MaterialTheme.colorScheme.onSurface
|
||||||
Text("${txName.value} - ${txAddress.value}", fontSize = 13.sp)
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.56f)
|
||||||
}
|
}
|
||||||
CardButton(
|
Row(
|
||||||
onClick = { selectingFor.value = "tx" },
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
text = "Select TX Device",
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth()
|
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))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
// RX Radio selection
|
Text("Radio devices", fontWeight = FontWeight.Medium)
|
||||||
Text("RX Radio (Downlink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium)
|
Row(
|
||||||
if (rxAddress.value.isNotBlank()) {
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
Text("${rxName.value} - ${rxAddress.value}", fontSize = 13.sp)
|
|
||||||
}
|
|
||||||
CardButton(
|
|
||||||
onClick = { selectingFor.value = "rx" },
|
|
||||||
text = "Select RX Device",
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
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()) {
|
if (selectingFor.value.isNotBlank()) {
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
Text(
|
Text(
|
||||||
text = "Paired Bluetooth Devices:",
|
text = "Paired Bluetooth Devices:",
|
||||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
if (pairedDevices.isEmpty()) {
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
Text("No paired devices found. Pair your BT adapter in Android Bluetooth settings first.")
|
if (pairedBluetoothDevices.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"No paired devices found. Pair your BT adapter in Android Bluetooth settings first.",
|
||||||
|
fontSize = 13.sp
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
pairedDevices.forEach { (name, address) ->
|
pairedBluetoothDevices.forEach { (name, address) ->
|
||||||
androidx.compose.material3.Surface(
|
androidx.compose.material3.Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -555,6 +654,10 @@ fun RadioControlDialog(
|
|||||||
if (selectingFor.value == "tx") {
|
if (selectingFor.value == "tx") {
|
||||||
txAddress.value = address
|
txAddress.value = address
|
||||||
txName.value = name
|
txName.value = name
|
||||||
|
if (isSingleRadio) {
|
||||||
|
rxAddress.value = address
|
||||||
|
rxName.value = name
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
rxAddress.value = address
|
rxAddress.value = address
|
||||||
rxName.value = name
|
rxName.value = name
|
||||||
@@ -573,25 +676,50 @@ fun RadioControlDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Text("Baud Rate:", fontWeight = FontWeight.Medium)
|
||||||
Row(
|
FlowRow(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
maxItemsInEachRow = 6,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text("Baud Rate:")
|
baudRates.forEach { rate ->
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
FilterChip(
|
||||||
baudRates.forEach { rate ->
|
selected = rate == baudRate.intValue,
|
||||||
CardButton(
|
onClick = { baudRate.intValue = rate },
|
||||||
onClick = { baudRate.intValue = rate },
|
label = {
|
||||||
text = if (rate == baudRate.intValue) "[$rate]" else rate.toString(),
|
Text(
|
||||||
modifier = Modifier
|
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()
|
||||||
|
}
|
||||||
+49
-7
@@ -89,10 +89,22 @@ fun SettingsDestination() {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
|
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
|
||||||
val dialogs = rememberDialogVisibility()
|
val dialogs = rememberDialogVisibility()
|
||||||
|
val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
|
val pendingCustomSourcesDeny = remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
val permissions = rememberSettingsPermissions(
|
val permissions = rememberSettingsPermissions(
|
||||||
sendAction = onAction,
|
sendAction = onAction,
|
||||||
onBluetoothGranted = { dialogs.bluetooth = true },
|
onBluetoothGranted = { dialogs.bluetooth = true },
|
||||||
onNetworkGranted = { dialogs.network = true }
|
onNetworkGranted = { dialogs.network = true },
|
||||||
|
onCustomSourcesPermissionGranted = {
|
||||||
|
pendingCustomSourcesGrant.value?.invoke()
|
||||||
|
pendingCustomSourcesGrant.value = null
|
||||||
|
pendingCustomSourcesDeny.value = null
|
||||||
|
},
|
||||||
|
onCustomSourcesPermissionDenied = {
|
||||||
|
pendingCustomSourcesDeny.value?.invoke()
|
||||||
|
pendingCustomSourcesGrant.value = null
|
||||||
|
pendingCustomSourcesDeny.value = null
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Dialogs
|
// Dialogs
|
||||||
@@ -117,6 +129,11 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers,
|
useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers,
|
||||||
tleUrl = uiState.dataSourcesSettings.tleUrl,
|
tleUrl = uiState.dataSourcesSettings.tleUrl,
|
||||||
transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
|
transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
|
||||||
|
requestCustomSourcesPermission = { onGranted, onDenied ->
|
||||||
|
pendingCustomSourcesGrant.value = onGranted
|
||||||
|
pendingCustomSourcesDeny.value = onDenied
|
||||||
|
permissions.launchCustomSourcesPermission()
|
||||||
|
},
|
||||||
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
|
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
|
||||||
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
|
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
|
||||||
onDismiss = { dialogs.dataSources = false },
|
onDismiss = { dialogs.dataSources = false },
|
||||||
@@ -129,7 +146,9 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl
|
transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl
|
||||||
)
|
)
|
||||||
if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings))
|
if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings))
|
||||||
if (useCustomTle || useCustomTransceivers) onAction(SettingsAction.UpdateFromWeb)
|
if (newSettings.useCustomTLE || newSettings.useCustomTransceivers) {
|
||||||
|
onAction(SettingsAction.UpdateFromWeb)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -171,6 +190,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
if (dialogs.radioControl) {
|
if (dialogs.radioControl) {
|
||||||
RadioControlDialog(
|
RadioControlDialog(
|
||||||
initialSettings = uiState.radioControlSettings,
|
initialSettings = uiState.radioControlSettings,
|
||||||
|
pairedBluetoothDevices = uiState.pairedBluetoothDevices,
|
||||||
onDismiss = { dialogs.radioControl = false },
|
onDismiss = { dialogs.radioControl = false },
|
||||||
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
|
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
|
||||||
)
|
)
|
||||||
@@ -357,7 +377,7 @@ private fun LocationCard(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DataCardPreview() = MainTheme {
|
private fun DataCardPreview() = MainTheme {
|
||||||
val settings = DataSettings(true, 5000, 2500, 0L)
|
val settings = DataSettings(true, 5000, 2500, 0L)
|
||||||
DataCard(settings = settings, {}, {}, {})
|
DataCard(settings = settings, updateFromWeb = {}, clearAllData = {}, showDataSourcesDialog = {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -653,14 +673,17 @@ private class SettingsPermissions(
|
|||||||
val launchTleImport: () -> Unit,
|
val launchTleImport: () -> Unit,
|
||||||
val launchTransceiverImport: () -> Unit,
|
val launchTransceiverImport: () -> Unit,
|
||||||
val launchBluetooth: () -> Unit,
|
val launchBluetooth: () -> Unit,
|
||||||
val launchNetwork: () -> Unit
|
val launchNetwork: () -> Unit,
|
||||||
|
val launchCustomSourcesPermission: () -> Unit
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun rememberSettingsPermissions(
|
private fun rememberSettingsPermissions(
|
||||||
sendAction: (SettingsAction) -> Unit,
|
sendAction: (SettingsAction) -> Unit,
|
||||||
onBluetoothGranted: () -> Unit,
|
onBluetoothGranted: () -> Unit,
|
||||||
onNetworkGranted: () -> Unit
|
onNetworkGranted: () -> Unit,
|
||||||
|
onCustomSourcesPermissionGranted: () -> Unit,
|
||||||
|
onCustomSourcesPermissionDenied: () -> Unit
|
||||||
): SettingsPermissions {
|
): SettingsPermissions {
|
||||||
val locationError = stringResource(R.string.prefs_loc_gps_error)
|
val locationError = stringResource(R.string.prefs_loc_gps_error)
|
||||||
val locationRequest = rememberLauncherForActivityResult(
|
val locationRequest = rememberLauncherForActivityResult(
|
||||||
@@ -672,12 +695,15 @@ private fun rememberSettingsPermissions(
|
|||||||
else sendAction(SettingsAction.ShowToast(locationError))
|
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 ->
|
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 ->
|
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)
|
val bluetoothError = stringResource(R.string.prefs_bt_perm_error)
|
||||||
@@ -694,6 +720,15 @@ private fun rememberSettingsPermissions(
|
|||||||
else sendAction(SettingsAction.ShowToast(networkError))
|
else sendAction(SettingsAction.ShowToast(networkError))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val customSourcesRequest = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
|
if (granted) {
|
||||||
|
onCustomSourcesPermissionGranted()
|
||||||
|
} else {
|
||||||
|
onCustomSourcesPermissionDenied()
|
||||||
|
sendAction(SettingsAction.ShowToast(networkError))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return remember {
|
return remember {
|
||||||
SettingsPermissions(
|
SettingsPermissions(
|
||||||
launchLocation = {
|
launchLocation = {
|
||||||
@@ -710,6 +745,13 @@ private fun rememberSettingsPermissions(
|
|||||||
} else {
|
} else {
|
||||||
onNetworkGranted()
|
onNetworkGranted()
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
launchCustomSourcesPermission = {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
|
||||||
|
customSourcesRequest.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||||
|
} else {
|
||||||
|
onCustomSourcesPermissionGranted()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-4
@@ -28,7 +28,10 @@ data class PositionSettings(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class DataSettings(
|
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(
|
data class SettingsState(
|
||||||
@@ -38,7 +41,8 @@ data class SettingsState(
|
|||||||
val otherSettings: OtherSettings,
|
val otherSettings: OtherSettings,
|
||||||
val rcSettings: RCSettings,
|
val rcSettings: RCSettings,
|
||||||
val radioControlSettings: RadioControlSettings,
|
val radioControlSettings: RadioControlSettings,
|
||||||
val dataSourcesSettings: DataSourcesSettings
|
val dataSourcesSettings: DataSourcesSettings,
|
||||||
|
val pairedBluetoothDevices: List<Pair<String, String>> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface SettingsAction {
|
sealed interface SettingsAction {
|
||||||
@@ -50,8 +54,8 @@ sealed interface SettingsAction {
|
|||||||
|
|
||||||
// Data
|
// Data
|
||||||
data object UpdateFromWeb : SettingsAction
|
data object UpdateFromWeb : SettingsAction
|
||||||
data class UpdateTLEFromFile(val uri: String) : SettingsAction
|
data class UpdateTLEFromFile(val uri: String, val invalidFileMessage: String) : SettingsAction
|
||||||
data class UpdateTransceiversFromFile(val uri: String) : SettingsAction
|
data class UpdateTransceiversFromFile(val uri: String, val invalidFileMessage: String) : SettingsAction
|
||||||
data object ClearAllData : SettingsAction
|
data object ClearAllData : SettingsAction
|
||||||
|
|
||||||
// Toggles
|
// Toggles
|
||||||
|
|||||||
+28
-11
@@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class SettingsViewModel(
|
class SettingsViewModel(
|
||||||
|
private val container: IMainContainer,
|
||||||
private val databaseRepo: IDatabaseRepo,
|
private val databaseRepo: IDatabaseRepo,
|
||||||
private val settingsRepo: ISettingsRepo,
|
private val settingsRepo: ISettingsRepo,
|
||||||
private val showToast: IShowToast
|
private val showToast: IShowToast
|
||||||
@@ -47,7 +48,8 @@ class SettingsViewModel(
|
|||||||
otherSettings = settingsRepo.otherSettings.value,
|
otherSettings = settingsRepo.otherSettings.value,
|
||||||
rcSettings = settingsRepo.rcSettings.value,
|
rcSettings = settingsRepo.rcSettings.value,
|
||||||
radioControlSettings = settingsRepo.radioControlSettings.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 ->
|
settingsRepo.databaseState.collect { state ->
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
dataSettings = DataSettings(
|
dataSettings = it.dataSettings.copy(
|
||||||
false,
|
isUpdating = false,
|
||||||
state.numberOfSatellites,
|
entriesTotal = state.numberOfSatellites,
|
||||||
state.numberOfRadios,
|
radiosTotal = state.numberOfRadios,
|
||||||
state.updateTimestamp
|
timestamp = state.updateTimestamp
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -107,11 +109,11 @@ class SettingsViewModel(
|
|||||||
SettingsAction.DismissPosMessages -> dismissPosMessage()
|
SettingsAction.DismissPosMessages -> dismissPosMessage()
|
||||||
// Data
|
// Data
|
||||||
SettingsAction.UpdateFromWeb -> runDataUpdate { databaseRepo.updateFromRemote() }
|
SettingsAction.UpdateFromWeb -> runDataUpdate { databaseRepo.updateFromRemote() }
|
||||||
is SettingsAction.UpdateTLEFromFile -> runDataUpdate { databaseRepo.updateTLEFromFile(action.uri) }
|
is SettingsAction.UpdateTLEFromFile -> runManualImport(action.invalidFileMessage) {
|
||||||
is SettingsAction.UpdateTransceiversFromFile -> runDataUpdate {
|
databaseRepo.updateTLEFromFile(action.uri)
|
||||||
databaseRepo.updateTransceiversFromFile(
|
}
|
||||||
action.uri
|
is SettingsAction.UpdateTransceiversFromFile -> runManualImport(action.invalidFileMessage) {
|
||||||
)
|
databaseRepo.updateTransceiversFromFile(action.uri)
|
||||||
}
|
}
|
||||||
SettingsAction.ClearAllData -> viewModelScope.launch { databaseRepo.clearAllData() }
|
SettingsAction.ClearAllData -> viewModelScope.launch { databaseRepo.clearAllData() }
|
||||||
// Toggles
|
// Toggles
|
||||||
@@ -184,12 +186,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
|
// endregion
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
SettingsViewModel(
|
SettingsViewModel(
|
||||||
|
container = container,
|
||||||
databaseRepo = container.databaseRepo,
|
databaseRepo = container.databaseRepo,
|
||||||
settingsRepo = container.settingsRepo,
|
settingsRepo = container.settingsRepo,
|
||||||
showToast = container.provideShowToast()
|
showToast = container.provideShowToast()
|
||||||
|
|||||||
@@ -12,20 +12,20 @@ jdkVersion = "21"
|
|||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
packageName = "com.rtbishop.look4sat"
|
packageName = "com.rtbishop.look4sat"
|
||||||
|
|
||||||
android-gradle-plugin = "9.2.1"
|
android-gradle-plugin = "9.3.1"
|
||||||
|
|
||||||
androidx-core-ktx = "1.19.0"
|
androidx-core-ktx = "1.19.0"
|
||||||
androidx-core-splashscreen = "1.2.0"
|
androidx-core-splashscreen = "1.2.0"
|
||||||
androidx-room = "2.8.4"
|
androidx-room = "2.8.4"
|
||||||
|
|
||||||
compose-bom = "2026.06.00"
|
compose-bom = "2026.06.01"
|
||||||
compose-activity = "1.13.0"
|
compose-activity = "1.13.0"
|
||||||
compose-lifecycle = "2.11.0"
|
compose-lifecycle = "2.11.0"
|
||||||
compose-navigation3 = "1.1.3"
|
compose-navigation3 = "1.1.5"
|
||||||
|
|
||||||
google-ksp = "2.3.8"
|
google-ksp = "2.3.8"
|
||||||
|
|
||||||
kotlin = "2.4.0"
|
kotlin = "2.4.10"
|
||||||
kotlin-coroutines = "1.11.0"
|
kotlin-coroutines = "1.11.0"
|
||||||
kotlin-serialization = "1.11.0"
|
kotlin-serialization = "1.11.0"
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user