Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3d7238721 | ||
|
|
40ba3fecdf | ||
|
|
57d6f9d7ed | ||
|
|
a654735337 | ||
|
|
a42a5f1f0d | ||
|
|
c547a126ee | ||
|
|
16c746f552 | ||
|
|
8f56ea05ec | ||
|
|
b749733289 | ||
|
|
b4cfb16159 | ||
|
|
7319cf8f5b | ||
|
|
baf2a7022d | ||
|
|
b95a86c97f | ||
|
|
ed1fe66892 | ||
|
|
1e24673632 | ||
|
|
ed0f5678b3 | ||
|
|
aac1fa0da5 | ||
|
|
c7bb253981 | ||
|
|
af96fe1cf0 | ||
|
|
27d41eb2ee | ||
|
|
2bac6655f3 | ||
|
|
2da7127fd3 | ||
|
|
fed9fe188e | ||
|
|
19ca5205fb | ||
|
|
a7a6d70d40 | ||
|
|
d5230b3bc6 | ||
|
|
7d7abeb31e | ||
|
|
828f720955 | ||
|
|
5f1f90067f | ||
|
|
f4e188261d | ||
|
|
e1233dceaa | ||
|
|
09e728fa1b | ||
|
|
c1895506e0 | ||
|
|
066fafbb81 | ||
|
|
aedb3fee19 | ||
|
|
77314e824e | ||
|
|
74902cddce | ||
|
|
8324903104 | ||
|
|
a757474b06 | ||
|
|
9cfa238e5c | ||
|
|
29ca4c29bb | ||
|
|
82c8112575 | ||
|
|
20e5b2f617 | ||
|
|
aa10d4170c | ||
|
|
aac3aee4bb | ||
|
|
d749c5ab48 | ||
|
|
082e0c5bff | ||
|
|
e69d6dee4a | ||
|
|
cf93ca9f71 | ||
|
|
7cadded6ca | ||
|
|
6fc2f560b7 | ||
|
|
8adf861ed7 | ||
|
|
f4f6ec7db5 | ||
|
|
5d89190348 | ||
|
|
f9fd7a2cfa | ||
|
|
21f14848da | ||
|
|
7cdc2952dc | ||
|
|
bd51044690 | ||
|
|
1982c2d3dc | ||
|
|
2f4e3f5802 | ||
|
|
24eebdef74 | ||
|
|
3f5b48f270 | ||
|
|
060fa2dfcd | ||
|
|
c42e1d7b4e | ||
|
|
09ebf1f39a | ||
|
|
8b5960282f | ||
|
|
ec40f29f28 | ||
|
|
5dd7a35a23 | ||
|
|
4b835bac5c | ||
|
|
10eb84690e | ||
|
|
bf25292bf8 | ||
|
|
8fbfcb3712 | ||
|
|
602b1553a2 | ||
|
|
bd0881fb4b | ||
|
|
a9dd3e6e55 | ||
|
|
905995ab44 | ||
|
|
f9806d7f7f | ||
|
|
2d3adfc6a6 |
No files matched your search
@@ -4,3 +4,7 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "bundler"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "never"
|
||||
@@ -70,3 +70,4 @@ fastlane/readme.md
|
||||
/app/release/output-metadata.json
|
||||
/app/release/
|
||||
/.kotlin/sessions/
|
||||
.hermes/
|
||||
@@ -8,16 +8,15 @@ All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) po
|
||||
## Project Overview
|
||||
|
||||
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
||||
satellites using TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays
|
||||
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
|
||||
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
|
||||
data download.
|
||||
satellites using Celestrak/SatNOGS orbital data, calculates positions via SGP4/SDP4, and predicts passes relative to
|
||||
the user's location. Features include polar radar visualization, SSTV image decoding, and ground track mapping. No ads,
|
||||
no tracking, no network required after initial data download.
|
||||
|
||||
## Architecture
|
||||
## Architecture & Design
|
||||
|
||||
**MVI (Model-View-Intent)** with unidirectional data flow:
|
||||
- `State` data class → exposed via `StateFlow` from ViewModel
|
||||
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
|
||||
- `State` data class (named `<Feature>State`) exposed via `StateFlow` from ViewModel
|
||||
- `Action` sealed interface (named `<Feature>Action`) dispatched to ViewModel's `onAction()`
|
||||
- Jetpack Compose UI observes state and recomposes reactively
|
||||
|
||||
**Clean Architecture layers:**
|
||||
@@ -34,9 +33,11 @@ data download.
|
||||
| `feature:satellites` | Satellite list, filtering, selection |
|
||||
| `feature:settings` | User preferences |
|
||||
|
||||
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
|
||||
**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
|
||||
# Debug build
|
||||
@@ -50,54 +51,55 @@ data download.
|
||||
```
|
||||
|
||||
- **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
|
||||
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
|
||||
- **Room** (KSP code generation) for local satellite/TLE storage
|
||||
- **OkHttp** 5.x for TLE downloads
|
||||
- **Navigation3**: Type-safe navigation with `@Serializable` nav keys
|
||||
- **Room** (KSP code generation) for local satellite/orbital storage
|
||||
- **OkHttp** 5.x for data downloads
|
||||
- **OSMDroid** for map rendering
|
||||
- **Kotlin Serialization** for navigation args and data parsing
|
||||
- **Kotlin Serialization** for navigation args and parsing
|
||||
- **Coroutines** + `StateFlow` for async/reactive patterns
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Minimal dependencies**: Avoid adding libraries when a simple manual solution exists. Fewer deps = less maintenance.
|
||||
- **DI**: Manual — ViewModels use companion `factory()` methods with `IMainContainer` interface.
|
||||
- **Navigation**: Type-safe Compose Navigation3 with `@Serializable` data classes as nav keys.
|
||||
- **State naming**: `<Feature>State` data class + `<Feature>Action` sealed interface per feature.
|
||||
- **No feature-to-feature deps**: All cross-feature communication goes through core layers.
|
||||
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh).
|
||||
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh)
|
||||
|
||||
## Data Formats & Migration
|
||||
|
||||
**TLE vs. OMM/CSV format:**
|
||||
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) 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
|
||||
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.
|
||||
## Engineering Heuristics (Lazy = Efficient)
|
||||
|
||||
**Current implementation:**
|
||||
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly
|
||||
- TLE data is downloaded from configured sources and stored in Room database
|
||||
- When downloading satellite data, the app automatically detects format and parses accordingly
|
||||
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching
|
||||
- Treat "lazy" as efficient, not careless: the best code is the code never written.
|
||||
- First understand the task and trace the real flow end-to-end, then climb this ladder:
|
||||
1. Does this need to be built now? (YAGNI)
|
||||
2. Does it already exist in this codebase? Reuse helpers/patterns before rewriting.
|
||||
3. Does Kotlin/Java stdlib already solve it?
|
||||
4. Does the Android/platform API already solve it?
|
||||
5. Does an already-installed dependency solve it?
|
||||
6. Can this be simpler (including one-liner simple)?
|
||||
7. Only then: write the minimum code that works.
|
||||
- Prefer deletion to addition, boring over clever, and the fewest touched files.
|
||||
- Avoid new abstractions, dependencies, and boilerplate unless explicitly requested.
|
||||
- Manual DI only: ViewModels use companion `factory()` methods with `IMainContainer`.
|
||||
- Release builds use ProGuard: avoid reflection-heavy libraries unless explicitly approved.
|
||||
- When two options are similar in size, choose the edge-case-correct one.
|
||||
- If you keep a deliberate simplification (for example O(n^2) scan or global lock), leave a short comment with the ceiling and upgrade path.
|
||||
- For complex asks, challenge scope when appropriate: "Do you need X, or does Y already cover it?"
|
||||
|
||||
**Migration path:**
|
||||
As NORAD catalog space becomes constrained, OMM/CSV will become the primary format. Look4Sat is already positioned
|
||||
to handle this transition without code changes — existing users can continue using TLE files while new sources
|
||||
transition to OMM/CSV automatically.
|
||||
## Bug-Fix Policy
|
||||
|
||||
## Code Style
|
||||
|
||||
- Prefer **short, focused functions** — single responsibility, easy to read.
|
||||
- **Exceptions**: Composable functions and math-heavy algorithms (SGP4/SDP4) may be longer.
|
||||
- Strict code style — no dead code, no unused imports, consistent formatting.
|
||||
- Fix root cause, not just the reported symptom.
|
||||
- If touching a shared function, inspect callers and prefer one shared fix over per-caller patches.
|
||||
- The smallest correct diff wins only after behavior is understood.
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -105,12 +107,9 @@ transition to OMM/CSV automatically.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Orbital math lives in `core:domain/predict/` — it's dense vector math (SGP4/SDP4). Tread carefully.
|
||||
- TLE/OMM data must be refreshed weekly for accurate predictions (satellite orbits decay). TLE format is legacy and
|
||||
will eventually be deprecated in favor of OMM/CSV as NORAD catalog numbers approach the 5-digit limit.
|
||||
- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully.
|
||||
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
||||
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
|
||||
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
|
||||
- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules.
|
||||
|
||||
## Copilot Working Mode: Code-Only
|
||||
|
||||
|
||||
@@ -1,27 +1,3 @@
|
||||
LOOK4SAT (MCKERO6423 FORK) — LICENSING NOTICE
|
||||
|
||||
This repository combines two separately-licensed components:
|
||||
|
||||
1. Look4Sat application code (all modules except the DeepCW model)
|
||||
— Copyright (C) 2019-2026 Arty Bishop (rt-bishop) and contributors
|
||||
— Licensed under the GNU General Public License v3.0 (GPL-3.0)
|
||||
|
||||
2. The DeepCW neural decoding model in feature/cw/src/main/assets/deepcw/
|
||||
— Copyright (C) e04 (https://github.com/e04/deepcw-engine)
|
||||
— Licensed under the GNU Affero General Public License v3.0 only
|
||||
(AGPL-3.0-only)
|
||||
|
||||
Because this combined work incorporates an AGPL-3.0 component, it is
|
||||
distributed under the GNU Affero General Public License v3.0. GPL-3.0
|
||||
Section 13 permits this combination; AGPL-3.0 Section 13 applies to the
|
||||
combined work as a whole.
|
||||
|
||||
See feature/cw/licenses/NOTICE.md for model provenance, attribution, and
|
||||
the applied int8 quantization. The original GPL-3.0 text for the Look4Sat
|
||||
application code is preserved at feature/cw/licenses/Look4Sat-GPL-3.0.txt.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
LOOK4SAT (MCKERO6423 FORK) — LICENSING NOTICE
|
||||
|
||||
This repository combines two separately-licensed components:
|
||||
|
||||
1. Look4Sat application code (all modules except the DeepCW model)
|
||||
— Copyright (C) 2019-2026 Arty Bishop (rt-bishop) and contributors
|
||||
— Licensed under the GNU General Public License v3.0 (GPL-3.0)
|
||||
|
||||
2. The DeepCW neural decoding model in feature/cw/src/main/assets/deepcw/
|
||||
— Copyright (C) e04 (https://github.com/e04/deepcw-engine)
|
||||
— Licensed under the GNU Affero General Public License v3.0 only
|
||||
(AGPL-3.0-only)
|
||||
|
||||
Because this combined work incorporates an AGPL-3.0 component, it is
|
||||
distributed under the GNU Affero General Public License v3.0. GPL-3.0
|
||||
Section 13 permits this combination; AGPL-3.0 Section 13 applies to the
|
||||
combined work as a whole.
|
||||
|
||||
See feature/cw/licenses/NOTICE.md for model provenance, attribution, and
|
||||
the applied int8 quantization. The original GPL-3.0 text for the Look4Sat
|
||||
application code is preserved at feature/cw/licenses/Look4Sat-GPL-3.0.txt.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
@@ -44,17 +44,17 @@ neural decoding model, licensed under the GNU Affero General Public License v3.0
|
||||
combined work is distributed under the
|
||||
[GNU Affero General Public License v3.0](LICENSE) — GPL-3.0 Section 13 permits the
|
||||
combination, and AGPL-3.0 Section 13 applies to the combined work as a whole.
|
||||
Model provenance, attribution and the applied int8 quantization are documented in
|
||||
Model provenance and attribution are documented in
|
||||
[`feature/cw/licenses/NOTICE.md`](feature/cw/licenses/NOTICE.md); the original GPL-3.0
|
||||
text is preserved at `feature/cw/licenses/Look4Sat-GPL-3.0.txt`. The CW model runs
|
||||
locally on-device and does not provide services over a network.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=rt-bishop%2FLook4Sat&type=timeline&legend=top-left">
|
||||
<a href="https://star-history.dera.page/#rt-bishop/Look4Sat&type=timeline&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
@@ -64,6 +64,16 @@ class AprsForegroundService : Service() {
|
||||
}
|
||||
|
||||
private fun startReporting() {
|
||||
// onStartCommand reaches here for every ACTION_START and for the null
|
||||
// intent that START_STICKY delivers on restart. Without this guard each
|
||||
// call built a fresh AprsReporter and overwrote the field, leaving the
|
||||
// previous one running with its own scope and timer: the server then
|
||||
// received one duplicate position report per leaked instance per cycle,
|
||||
// and ACTION_STOP could only ever stop the newest one.
|
||||
reporter?.let { existing ->
|
||||
if (existing.isRunning) return
|
||||
existing.stop()
|
||||
}
|
||||
val cfg = AprsStore.loadConfig(this)
|
||||
if (!cfg.enabled || cfg.callsign.isBlank()) {
|
||||
runCatching {
|
||||
|
||||
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
observeNightFilterState()
|
||||
setContent {
|
||||
MainTheme(isDarkTheme = true) { MainScreen() }
|
||||
MainTheme(isDarkTheme = true) { NavRoot() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,20 +20,18 @@ package com.rtbishop.look4sat
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
@@ -51,7 +49,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||
@@ -67,6 +65,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -83,6 +82,7 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||
import com.rtbishop.look4sat.core.domain.navigation.MenuLayout
|
||||
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
||||
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
||||
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
||||
@@ -97,41 +97,45 @@ import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||
import com.rtbishop.look4sat.feature.radar.WavelogLogScreen
|
||||
import com.rtbishop.look4sat.feature.status.SatStatusScreen
|
||||
import com.rtbishop.look4sat.feature.roaming.RoamingScreen
|
||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||
import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
||||
|
||||
@Composable
|
||||
fun NavRoot(deeplink: String? = null) {
|
||||
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||
val deeplinkResolver = DeeplinkResolver()
|
||||
LaunchedEffect(deeplink) {
|
||||
deeplink?.let {
|
||||
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
|
||||
rootBackStack.add(destination)
|
||||
}
|
||||
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
||||
}
|
||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
||||
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
||||
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
||||
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
|
||||
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
||||
slideOutHorizontally(tween(300)) { -it / 3 }
|
||||
// Reverse: outgoing slides out to the right, incoming drifts in from the left
|
||||
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
|
||||
slideOutHorizontally(tween(300)) { it }
|
||||
NavDisplay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
backStack = rootBackStack,
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { slideInTransition },
|
||||
popTransitionSpec = { slideOutTransition },
|
||||
predictivePopTransitionSpec = { slideOutTransition },
|
||||
transitionSpec = { pushTransition },
|
||||
popTransitionSpec = { popTransition },
|
||||
predictivePopTransitionSpec = { popTransition },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
|
||||
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
|
||||
entry<Screen.Passes> { MainScreen() }
|
||||
entry<RadarDestination> {
|
||||
Scaffold { innerPadding ->
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
innerPadding.calculateTopPadding()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +143,7 @@ fun NavRoot(deeplink: String? = null) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
fun MainScreen() {
|
||||
val backStack = rememberNavBackStack(Screen.Passes)
|
||||
val currentKey = backStack.lastOrNull()
|
||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||
@@ -148,27 +152,27 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||
// UI settings: sort by screenOrder (empty = default order), then filter by hiddenScreens (Settings always kept)
|
||||
val allNavItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.CwDecode, Screen.WavelogLog, Screen.AmSat, Screen.Map, Screen.Settings)
|
||||
.sortedBy { screen ->
|
||||
// Unknown pages (e.g. CwDecode not in old persisted order): use default-order position (Roaming<->Map), then fall back to last
|
||||
val idx = otherSettings.screenOrder.indexOf(screen.screenId)
|
||||
if (idx != -1) idx
|
||||
else com.rtbishop.look4sat.core.presentation.defaultScreenOrder.indexOf(screen.screenId).let {
|
||||
if (it != -1) it else Int.MAX_VALUE
|
||||
}
|
||||
}
|
||||
.filter { it.screenId !in otherSettings.hiddenScreens || it is Screen.Settings }
|
||||
// 4.5.1 foldable menu: main menu (5 bottom-bar slots) + More menu (overflow page)
|
||||
// Legacy migration: persisted subMenuOrder lacks new pages (WavelogLog) -> append to the sub-menu tail
|
||||
val subOrder = (otherSettings.subMenuOrder.ifEmpty { com.rtbishop.look4sat.core.presentation.defaultSubMenuOrder })
|
||||
.let { list -> if ("WavelogLog" in list) list else list + "WavelogLog" }
|
||||
.let { list -> if ("AMSAT" in list) list else list + "AMSAT" }
|
||||
val mainNavItems = remember(allNavItems, subOrder) {
|
||||
allNavItems.filter { it.screenId !in subOrder }.take(5)
|
||||
// Menu layout is resolved in core:domain so the bar and the settings editor
|
||||
// cannot disagree, and so Settings can never be pushed out of both menus.
|
||||
val allNavItems = listOf(
|
||||
Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming,
|
||||
Screen.CwDecode, Screen.WavelogLog, Screen.AmSat, Screen.Map, Screen.Settings
|
||||
)
|
||||
val menuLayout = remember(
|
||||
otherSettings.screenOrder, otherSettings.subMenuOrder, otherSettings.hiddenScreens
|
||||
) {
|
||||
MenuLayout.resolve(
|
||||
allScreenIds = allNavItems.map { it.screenId },
|
||||
screenOrder = otherSettings.screenOrder,
|
||||
subMenuOrder = otherSettings.subMenuOrder,
|
||||
hiddenScreenIds = otherSettings.hiddenScreens
|
||||
)
|
||||
}
|
||||
val moreNavItems = remember(allNavItems, subOrder) {
|
||||
subOrder.mapNotNull { id -> allNavItems.find { it.screenId == id } }
|
||||
val mainNavItems = remember(menuLayout) {
|
||||
menuLayout.mainIds.mapNotNull { id -> allNavItems.find { it.screenId == id } }
|
||||
}
|
||||
val moreNavItems = remember(menuLayout) {
|
||||
menuLayout.moreIds.mapNotNull { id -> allNavItems.find { it.screenId == id } }
|
||||
}
|
||||
var moreExpanded by remember { mutableStateOf(false) }
|
||||
// Intercept Back while the More menu is open: close the menu first
|
||||
@@ -188,20 +192,16 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
NavigationSuiteScaffold(
|
||||
navigationSuiteItems = {
|
||||
mainNavItems.forEach { screen ->
|
||||
val isSelected = when (currentKey) {
|
||||
is Screen.Satellites -> screen is Screen.Satellites
|
||||
is Screen.Passes -> screen is Screen.Passes
|
||||
is Screen.Radar -> screen is Screen.Radar
|
||||
is Screen.Mutual -> screen is Screen.Mutual
|
||||
is Screen.CwDecode -> screen is Screen.CwDecode
|
||||
is Screen.WavelogLog -> screen is Screen.WavelogLog
|
||||
is Screen.AmSat -> screen is Screen.AmSat
|
||||
is Screen.Map -> screen is Screen.Map
|
||||
is Screen.Settings -> screen is Screen.Settings
|
||||
else -> false
|
||||
}
|
||||
// Screen subclasses are data objects, so identity is enough and
|
||||
// newly added pages highlight without touching this call site.
|
||||
val isSelected = currentKey == screen
|
||||
item(
|
||||
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(screen.iconResId),
|
||||
contentDescription = stringResource(screen.titleResId)
|
||||
)
|
||||
},
|
||||
label = { Text(stringResource(screen.titleResId)) },
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
@@ -260,7 +260,6 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
container.setMutualPassData(MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
// navigateToRadar()
|
||||
}
|
||||
}
|
||||
entry<Screen.Radar> {
|
||||
@@ -287,7 +286,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
CwDecodeScreen()
|
||||
}
|
||||
entry<Screen.AmSat> {
|
||||
SatStatusScreen(container = container)
|
||||
SatStatusDestination()
|
||||
}
|
||||
entry<Screen.WavelogLog> {
|
||||
WavelogLogScreen(queue = container.wavelogQueue)
|
||||
@@ -346,14 +345,18 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// More-menu popup panel (overlays content above the bottom bar; spring bounce)
|
||||
// More-menu popup panel (slim strip anchored to the bottom-right corner)
|
||||
AnimatedVisibility(
|
||||
visible = moreExpanded,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
enter = expandVertically(
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
|
||||
) + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
enter = scaleIn(
|
||||
animationSpec = tween(150),
|
||||
transformOrigin = TransformOrigin(1f, 1f)
|
||||
) + fadeIn(animationSpec = tween(150)),
|
||||
exit = scaleOut(
|
||||
animationSpec = tween(120),
|
||||
transformOrigin = TransformOrigin(1f, 1f)
|
||||
) + fadeOut(animationSpec = tween(120))
|
||||
) {
|
||||
MoreMenuPopup(
|
||||
items = moreNavItems,
|
||||
@@ -369,4 +372,4 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
/*
|
||||
* MoreMenuPopup.kt - bottom-nav "More" second-level menu popup panel (4.5.1).
|
||||
*
|
||||
* Overlays the content area (above the bottom bar, right-aligned), vertical menu items (icon+text+arrow),
|
||||
* current page highlighted; tap the scrim to close, tap an item to navigate. Open/close animation is driven by the caller
|
||||
* (MainScreen's AnimatedVisibility + spring).
|
||||
* A slim right-aligned strip above the bottom bar, vertical menu items (icon+text+arrow),
|
||||
* current page highlighted; tap outside to close, tap an item to navigate. No dimming scrim, so the
|
||||
* page stays readable. Open/close animation is driven by the caller (MainScreen's AnimatedVisibility).
|
||||
*/
|
||||
package com.rtbishop.look4sat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@@ -45,31 +46,30 @@ fun MoreMenuPopup(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f))
|
||||
// Transparent catcher: taps outside the card dismiss the menu without
|
||||
// dimming the page behind it.
|
||||
.clickable(onClick = onDismiss)
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(12.dp),
|
||||
.padding(12.dp)
|
||||
.widthIn(max = 232.dp)
|
||||
// Swallow taps on the card so they do not reach the dismiss
|
||||
// catcher underneath.
|
||||
.clickable(enabled = false) {},
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
items.forEach { screen ->
|
||||
val isSelected = when (currentKey) {
|
||||
is Screen.Satellites -> screen is Screen.Satellites
|
||||
is Screen.Passes -> screen is Screen.Passes
|
||||
is Screen.Radar -> screen is Screen.Radar
|
||||
is Screen.Mutual -> screen is Screen.Mutual
|
||||
is Screen.CwDecode -> screen is Screen.CwDecode
|
||||
is Screen.WavelogLog -> screen is Screen.WavelogLog
|
||||
is Screen.Map -> screen is Screen.Map
|
||||
is Screen.Settings -> screen is Screen.Settings
|
||||
else -> false
|
||||
}
|
||||
// Screen subclasses are data objects, so identity is enough. The
|
||||
// old per-type when was missing AmSat and Roaming, leaving those
|
||||
// pages unhighlighted while open.
|
||||
val isSelected = currentKey == screen
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
|
||||
@@ -35,27 +35,36 @@ class AprsIsClient(
|
||||
fun connect() {
|
||||
disconnect()
|
||||
val s = Socket()
|
||||
s.connect(InetSocketAddress(host, port), 30_000)
|
||||
s.soTimeout = timeoutSec * 1000
|
||||
s.tcpNoDelay = true
|
||||
synchronized(lock) {
|
||||
socket = s
|
||||
writer = PrintWriter(OutputStreamWriter(s.getOutputStream(), Charsets.ISO_8859_1), true)
|
||||
reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.ISO_8859_1), 256)
|
||||
}
|
||||
// Login line
|
||||
val login = AprsPacket.formatLogin(callsign, ssid, passcode, version) + filter
|
||||
writer?.println(login)
|
||||
// Read the login response (aprsc replies # logresp ... verified/unverified)
|
||||
runCatching {
|
||||
s.soTimeout = 8000
|
||||
val resp = reader?.readLine()
|
||||
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
|
||||
resp.contains("unverified", ignoreCase = true))) {
|
||||
throw IllegalArgumentException(resp.trim())
|
||||
}
|
||||
// Restore timeout
|
||||
try {
|
||||
s.connect(InetSocketAddress(host, port), 30_000)
|
||||
s.soTimeout = timeoutSec * 1000
|
||||
s.tcpNoDelay = true
|
||||
synchronized(lock) {
|
||||
socket = s
|
||||
writer = PrintWriter(OutputStreamWriter(s.getOutputStream(), Charsets.ISO_8859_1), true)
|
||||
reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.ISO_8859_1), 256)
|
||||
}
|
||||
// Login line
|
||||
val login = AprsPacket.formatLogin(callsign, ssid, passcode, version) + filter
|
||||
writer?.println(login)
|
||||
// Read the login response (aprsc replies # logresp ... verified/unverified)
|
||||
runCatching {
|
||||
s.soTimeout = 8000
|
||||
val resp = reader?.readLine()
|
||||
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
|
||||
resp.contains("unverified", ignoreCase = true))) {
|
||||
throw IllegalArgumentException(resp.trim())
|
||||
}
|
||||
// Restore timeout
|
||||
s.soTimeout = timeoutSec * 1000
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Close the local socket before re-throwing, so it does not leak when
|
||||
// an exception is raised after s.connect() but before socket = s.
|
||||
// Otherwise periodic reconnect attempts (AprsReporter every 1–60 min)
|
||||
// accumulate leaked fds until the process cannot open any more files.
|
||||
runCatching { s.close() }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,24 +77,31 @@ class AprsIsClient(
|
||||
val w = writer ?: return null
|
||||
w.println(packetLine)
|
||||
if (w.checkError()) return Pair(false, "write failed")
|
||||
}
|
||||
// Try to read the server response (short 3 s timeout; APRS-IS replies with an error line on bad format)
|
||||
return runCatching {
|
||||
val s = socket ?: return@runCatching Pair(true, "OK")
|
||||
val oldTimeout = s.soTimeout
|
||||
s.soTimeout = 3000
|
||||
try {
|
||||
val resp = reader?.readLine()
|
||||
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
|
||||
resp.contains("error", ignoreCase = true))) {
|
||||
Pair(false, resp.trim())
|
||||
} else {
|
||||
Pair(true, if (resp.isNullOrBlank()) "OK" else resp.trim())
|
||||
// Read the server response inside the same lock: disconnect() (called
|
||||
// concurrently from stop()/reconnect on another thread) nulls
|
||||
// writer/reader/socket and closes them. Reading outside the lock raced
|
||||
// with that: the response read could hit a just-closed socket and the
|
||||
// swallowing runCatching reported Pair(true,"OK") for a packet that
|
||||
// never left, or read through a stale reference. Serialising keeps
|
||||
// the read on the connection this thread just wrote to. The 3 s read
|
||||
// timeout bounds how long a concurrent disconnect waits.
|
||||
return runCatching {
|
||||
val s = socket ?: return@runCatching Pair(true, "OK")
|
||||
val oldTimeout = s.soTimeout
|
||||
s.soTimeout = 3000
|
||||
try {
|
||||
val resp = reader?.readLine()
|
||||
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
|
||||
resp.contains("error", ignoreCase = true))) {
|
||||
Pair(false, resp.trim())
|
||||
} else {
|
||||
Pair(true, if (resp.isNullOrBlank()) "OK" else resp.trim())
|
||||
}
|
||||
} finally {
|
||||
s.soTimeout = oldTimeout
|
||||
}
|
||||
} finally {
|
||||
s.soTimeout = oldTimeout
|
||||
}
|
||||
}.getOrElse { Pair(true, "OK") }
|
||||
}.getOrElse { Pair(true, "OK") }
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one line (server response; throws on timeout) */
|
||||
|
||||
@@ -181,8 +181,22 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
|
||||
val overflow = buffer.drainOverflow()
|
||||
if (overflow.isNotEmpty()) {
|
||||
for (v in overflow) {
|
||||
if (archiveSize < archiveBuffer.size) archiveBuffer[archiveSize++] = v
|
||||
// Flush before appending when the buffer is full, so large batches
|
||||
// (e.g. 47999 samples already accumulated + 64000 new overflow)
|
||||
// do not silently drop audio that scrolled out of the live window.
|
||||
if (archiveSize >= archiveBuffer.size) {
|
||||
val audio = archiveBuffer.copyOf(archiveSize)
|
||||
archiveSize = 0
|
||||
try {
|
||||
archiveDecode(audio)
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
Log.e(TAG, "archive decode failed", t)
|
||||
}
|
||||
}
|
||||
archiveBuffer[archiveSize++] = v
|
||||
}
|
||||
// Final flush when threshold is reached (e.g. exactly 48000 accumulated).
|
||||
if (archiveSize >= ARCHIVE_THRESHOLD) {
|
||||
val audio = archiveBuffer.copyOf(archiveSize)
|
||||
archiveSize = 0
|
||||
|
||||
@@ -28,6 +28,12 @@ import android.util.Log
|
||||
*/
|
||||
internal object CwProbe {
|
||||
|
||||
/** Keep the diagnostic file bounded: the decoder writes two lines per
|
||||
* 1.5 s inference tick (~170 KB/hour), so without a cap it grows without
|
||||
* limit on every release build. Truncate instead of deleting so the
|
||||
* probe keeps the last diagnostics before a crash. */
|
||||
private const val MAX_FILE_BYTES = 1_048_576L // 1 MiB
|
||||
|
||||
private var dir: java.io.File? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
@@ -39,6 +45,7 @@ internal object CwProbe {
|
||||
runCatching {
|
||||
val line = "${System.currentTimeMillis()} $label"
|
||||
val file = java.io.File(target, "probe_cw.txt")
|
||||
if (file.length() > MAX_FILE_BYTES) file.delete()
|
||||
file.appendText("$line\n")
|
||||
Log.i("CwProbe", line)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,12 @@ interface Look4SatDao {
|
||||
@Query("DELETE FROM entries")
|
||||
suspend fun deleteEntries()
|
||||
|
||||
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
|
||||
@Query(
|
||||
"""
|
||||
SELECT DISTINCT catnum FROM radios WHERE isAlive = 1
|
||||
AND (downlinkMode IN (:modes) OR uplinkMode IN (:modes))
|
||||
"""
|
||||
)
|
||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM radios")
|
||||
|
||||
+14
@@ -76,10 +76,12 @@ class BluetoothReporter(
|
||||
private fun ensureRotatorConnected() {
|
||||
if (rotatorConnected || rotatorConnecting || rotatorDeviceId.isBlank()) return
|
||||
reporterScope.launch {
|
||||
var opened: android.bluetooth.BluetoothSocket? = null
|
||||
try {
|
||||
rotatorConnecting = true
|
||||
val device = bluetoothManager.adapter.getRemoteDevice(rotatorDeviceId)
|
||||
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||
opened = socket
|
||||
socket.connect()
|
||||
rotatorSocket = socket
|
||||
rotatorStream = socket.outputStream
|
||||
@@ -87,6 +89,11 @@ class BluetoothReporter(
|
||||
Log.i(tag, "Rotator connected to $rotatorDeviceId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Rotator connect error: ${e.message}")
|
||||
// Close the socket we opened, otherwise a failure after connect()
|
||||
// leaks it: nothing else holds a reference once this returns.
|
||||
runCatching { opened?.close() }
|
||||
rotatorSocket = null
|
||||
rotatorStream = null
|
||||
rotatorConnected = false
|
||||
} finally {
|
||||
rotatorConnecting = false
|
||||
@@ -97,10 +104,12 @@ class BluetoothReporter(
|
||||
private fun ensureFrequencyConnected() {
|
||||
if (frequencyConnected || frequencyConnecting || frequencyDeviceId.isBlank()) return
|
||||
reporterScope.launch {
|
||||
var opened: android.bluetooth.BluetoothSocket? = null
|
||||
try {
|
||||
frequencyConnecting = true
|
||||
val device = bluetoothManager.adapter.getRemoteDevice(frequencyDeviceId)
|
||||
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||
opened = socket
|
||||
socket.connect()
|
||||
frequencySocket = socket
|
||||
frequencyStream = socket.outputStream
|
||||
@@ -108,6 +117,11 @@ class BluetoothReporter(
|
||||
Log.i(tag, "Frequency connected to $frequencyDeviceId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Frequency connect error: ${e.message}")
|
||||
// Close the socket we opened, otherwise a failure after connect()
|
||||
// leaks it: nothing else holds a reference once this returns.
|
||||
runCatching { opened?.close() }
|
||||
frequencySocket = null
|
||||
frequencyStream = null
|
||||
frequencyConnected = false
|
||||
} finally {
|
||||
frequencyConnecting = false
|
||||
|
||||
@@ -42,6 +42,9 @@ class Ft817Controller(
|
||||
private val commandDelayMs = 200L
|
||||
private val maxAckReadFailures = 3
|
||||
|
||||
/** Largest frequency the 4-byte BCD / 10 Hz CAT field can represent. */
|
||||
private val maxFrequencyHz = 999_999_990L
|
||||
|
||||
private var socket: BluetoothSocket? = null
|
||||
private var outputStream: OutputStream? = null
|
||||
private var inputStream: InputStream? = null
|
||||
@@ -53,9 +56,11 @@ class Ft817Controller(
|
||||
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
|
||||
if (isConnected) return@withContext true
|
||||
if (deviceAddress.isBlank()) return@withContext false
|
||||
var opened: android.bluetooth.BluetoothSocket? = null
|
||||
try {
|
||||
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
|
||||
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||
opened = btSocket
|
||||
btSocket.connect()
|
||||
socket = btSocket
|
||||
outputStream = btSocket.outputStream
|
||||
@@ -66,6 +71,13 @@ class Ft817Controller(
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Connect error: ${e.message}")
|
||||
// Close the socket we opened. Without this a failure after connect()
|
||||
// (e.g. outputStream throwing) leaks the Bluetooth socket, because
|
||||
// disconnect() only closes what already reached the fields.
|
||||
runCatching { opened?.close() }
|
||||
socket = null
|
||||
outputStream = null
|
||||
inputStream = null
|
||||
isConnected = false
|
||||
false
|
||||
}
|
||||
@@ -91,6 +103,16 @@ class Ft817Controller(
|
||||
}
|
||||
|
||||
override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||
// The FT-817 CAT frequency field is 4 BCD bytes at 10 Hz resolution,
|
||||
// so the protocol cannot express anything above 999,999,990 Hz. Below
|
||||
// that the encoder is exact; above it, the %08d formatting silently
|
||||
// drops the leading digit and the radio receives a frequency ten
|
||||
// times lower (e.g. 1267.6 MHz becomes 126.76 MHz), and the tracking
|
||||
// loop's read-back then locks onto the wrong band. Reject instead.
|
||||
if (frequencyHz > maxFrequencyHz) {
|
||||
Log.e(tag, "setFrequency rejected: $frequencyHz Hz exceeds FT-817 CAT limit $maxFrequencyHz")
|
||||
return@withContext false
|
||||
}
|
||||
ioMutex.withLock {
|
||||
sendCommandWithAck(Ft817CatProtocol.buildSetFreqCommand(frequencyHz))
|
||||
}
|
||||
|
||||
@@ -67,9 +67,11 @@ class Ic705Controller(
|
||||
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
|
||||
if (isConnected) return@withContext true
|
||||
if (deviceAddress.isBlank()) return@withContext false
|
||||
var opened: android.bluetooth.BluetoothSocket? = null
|
||||
try {
|
||||
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
|
||||
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||
opened = btSocket
|
||||
btSocket.connect()
|
||||
socket = btSocket
|
||||
outputStream = btSocket.outputStream
|
||||
@@ -84,6 +86,13 @@ class Ic705Controller(
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Connect error: ${e.message}")
|
||||
// Close the socket we opened. Without this a failure after connect()
|
||||
// (e.g. outputStream throwing) leaks the Bluetooth socket, because
|
||||
// disconnect() only closes what already reached the fields.
|
||||
runCatching { opened?.close() }
|
||||
socket = null
|
||||
outputStream = null
|
||||
inputStream = null
|
||||
isConnected = false
|
||||
false
|
||||
}
|
||||
|
||||
+21
-2
@@ -71,14 +71,19 @@ class NetworkReporter(
|
||||
private fun ensureRotatorConnected() {
|
||||
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
|
||||
reporterScope.launch {
|
||||
var opened: SocketChannel? = null
|
||||
try {
|
||||
rotatorConnecting = true
|
||||
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
||||
opened = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
||||
rotatorSocket = opened
|
||||
rotatorConnected = true
|
||||
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter rotator connect error: ${e.message}")
|
||||
rotatorConnected = false
|
||||
// Close a socket that connected but failed during setup, so a
|
||||
// broken channel is never left referenced without a closer.
|
||||
opened?.close()
|
||||
} finally {
|
||||
rotatorConnecting = false
|
||||
}
|
||||
@@ -88,14 +93,17 @@ class NetworkReporter(
|
||||
private fun ensureFrequencyConnected() {
|
||||
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
|
||||
reporterScope.launch {
|
||||
var opened: SocketChannel? = null
|
||||
try {
|
||||
frequencyConnecting = true
|
||||
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
||||
opened = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
||||
frequencySocket = opened
|
||||
frequencyConnected = true
|
||||
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter frequency connect error: ${e.message}")
|
||||
frequencyConnected = false
|
||||
opened?.close()
|
||||
} finally {
|
||||
frequencyConnecting = false
|
||||
}
|
||||
@@ -111,6 +119,17 @@ class NetworkReporter(
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter write error: ${e.message}")
|
||||
onError()
|
||||
// The channel failed a write: drop it and its closure obligation.
|
||||
// Leaving it referenced lets the next connect overwrite the field
|
||||
// and leak the old channel. Only the field the caller passed is
|
||||
// cleared, matching the connected=false the onError sets.
|
||||
if (socket === rotatorSocket) {
|
||||
rotatorSocket?.close()
|
||||
rotatorSocket = null
|
||||
} else if (socket === frequencySocket) {
|
||||
frequencySocket?.close()
|
||||
frequencySocket = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-8
@@ -267,12 +267,18 @@ class RadioTrackingService(
|
||||
|
||||
if (tuningRadio.isEmpty()) {
|
||||
if (txNow != null && txNow.isConnected && txRadioFreq != null) {
|
||||
txNow.setFrequency(txRadioFreq)
|
||||
lastSetTxFreq = txRadioFreq.toDouble()
|
||||
// Only remember the frequency we actually wrote: if the radio
|
||||
// rejects it (FT-817 CAT limit) or the link dropped, keeping
|
||||
// lastSetTxFreq updated would make the manual-tuning detector
|
||||
// see a phantom dial change on the next read-back.
|
||||
if (txNow.setFrequency(txRadioFreq)) {
|
||||
lastSetTxFreq = txRadioFreq.toDouble()
|
||||
}
|
||||
}
|
||||
if (rxNow != null && rxNow.isConnected && rxRadioFreq != null) {
|
||||
rxNow.setFrequency(rxRadioFreq)
|
||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||
if (rxNow.setFrequency(rxRadioFreq)) {
|
||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,13 +456,15 @@ class RadioTrackingService(
|
||||
// 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 (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()
|
||||
if (radio.setTxVfoFrequency(txRadioFreq)) {
|
||||
lastSetTxFreq = txRadioFreq.toDouble()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,13 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
|
||||
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
||||
|
||||
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) {}
|
||||
}
|
||||
|
||||
override fun provideShowToast(): IShowToast = ShowToast(context)
|
||||
|
||||
override fun provideAudioCapture(): IAudioCapture = AudioCapture()
|
||||
|
||||
+13
-11
@@ -35,13 +35,15 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
|
||||
val reportsJson = remoteSource.getAmSatReports(hours = 168, limit = 500) ?: return@withContext null
|
||||
|
||||
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
|
||||
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
|
||||
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
|
||||
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return@withContext null
|
||||
val names = parseCatalog(catalogJson)
|
||||
val reports = parseReports(reportsJson)
|
||||
|
||||
|
||||
if (names.isEmpty() && reports.isEmpty()) return@withContext null
|
||||
|
||||
|
||||
val statuses = buildStatuses(names, reports, nowSec)
|
||||
val reportMap = reports.associate { it.id to toSatReport(it) }
|
||||
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
|
||||
@@ -52,7 +54,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
return try {
|
||||
val arr = JSONObject(json).getJSONArray("data")
|
||||
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
@@ -73,7 +75,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
reportedTimeUtcSec = parseIsoUtcSec(iso)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
@@ -82,22 +84,22 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
private fun parseIsoUtcSec(iso: String): Long {
|
||||
return try {
|
||||
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
/** Build one SatStatus (6 days x 12 slots) per catalog satellite, slotting reports by age. */
|
||||
/** Build one SatStatus (5 days x 12 slots) per catalog satellite, slotting reports by age. */
|
||||
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||
val byName = reports.groupBy { it.name }
|
||||
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
val labels = (0 until 6).map { d ->
|
||||
val labels = (0 until 3).map { d ->
|
||||
utc.timeInMillis = (nowSec - d * 86400L) * 1000
|
||||
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
|
||||
}
|
||||
return names.map { name ->
|
||||
val slots = (0 until 72).map { slotIdx ->
|
||||
val slots = (0 until 36).map { slotIdx ->
|
||||
val slotStart = nowSec - (slotIdx + 1) * 7200L
|
||||
val slotEnd = nowSec - slotIdx * 7200L
|
||||
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
|
||||
@@ -112,7 +114,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
)
|
||||
}
|
||||
}
|
||||
val days = (0 until 6).map { d ->
|
||||
val days = (0 until 3).map { d ->
|
||||
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
|
||||
}
|
||||
SatStatus(name = name, days = days)
|
||||
|
||||
+71
-49
@@ -34,9 +34,13 @@ import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.TimeZone
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class SatelliteRepo(
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
@@ -50,6 +54,10 @@ class SatelliteRepo(
|
||||
private val _isCalculating = MutableStateFlow(false)
|
||||
override val isCalculating: StateFlow<Boolean> = _isCalculating
|
||||
|
||||
// Serializes pass calculation. Callers queue instead of being dropped: a
|
||||
// dropped call would silently discard the filter the user just applied.
|
||||
private val calculationMutex = Mutex()
|
||||
|
||||
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
|
||||
override val satellites: StateFlow<List<OrbitalObject>> = _satellites
|
||||
|
||||
@@ -63,19 +71,23 @@ class SatelliteRepo(
|
||||
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||
|
||||
override suspend fun initRepository() = withContext(dispatcher) {
|
||||
settingsRepo.selectedIds.collect { selectedIds ->
|
||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||
val settings = settingsRepo.passesSettings.value
|
||||
calculatePasses(
|
||||
time = System.currentTimeMillis(),
|
||||
hoursAhead = settings.hoursAhead,
|
||||
minElevation = settings.minElevation,
|
||||
aosStartMinute = settings.aosStartMinute,
|
||||
aosEndMinute = settings.aosEndMinute,
|
||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||
modes = settings.selectedModes
|
||||
)
|
||||
}
|
||||
combine(
|
||||
settingsRepo.selectedIds,
|
||||
settingsRepo.stationPosition
|
||||
) { selectedIds, _ -> selectedIds }
|
||||
.collect { selectedIds ->
|
||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||
val settings = settingsRepo.passesSettings.value
|
||||
calculatePasses(
|
||||
time = System.currentTimeMillis(),
|
||||
hoursAhead = settings.hoursAhead,
|
||||
minElevation = settings.minElevation,
|
||||
aosStartMinute = settings.aosStartMinute,
|
||||
aosEndMinute = settings.aosEndMinute,
|
||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||
modes = settingsRepo.selectedSatModes.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
|
||||
@@ -117,45 +129,55 @@ class SatelliteRepo(
|
||||
invertAosTimeWindow: Boolean,
|
||||
modes: List<String>
|
||||
) {
|
||||
_isCalculating.value = true
|
||||
// Normalize to the start of the current minute so that coarse 60-second stepping
|
||||
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
|
||||
val normalizedTime = time / 60_000L * 60_000L
|
||||
val currentSatellites = _satellites.value
|
||||
withContext(dispatcher) {
|
||||
val idsWithModes = localStorage.getIdsWithModes(modes)
|
||||
val stationPos = settingsRepo.stationPosition.value
|
||||
val filteredSatellites = if (idsWithModes.isEmpty()) {
|
||||
currentSatellites
|
||||
} else {
|
||||
currentSatellites.filter { it.data.catnum in idsWithModes }
|
||||
}
|
||||
// Compute passes for each satellite in parallel
|
||||
val passLists = coroutineScope {
|
||||
filteredSatellites.map { satellite ->
|
||||
async { satellite.getPasses(stationPos, normalizedTime, hoursAhead) }
|
||||
}.awaitAll()
|
||||
}
|
||||
// Flatten and filter in a single pass
|
||||
val timeFuture = normalizedTime + (hoursAhead * 60L * 60L * 1000L)
|
||||
val newPasses = ArrayList<OrbitalPass>()
|
||||
for (list in passLists) {
|
||||
for (pass in list) {
|
||||
if (
|
||||
pass.losTime > time
|
||||
&& pass.aosTime < timeFuture
|
||||
&& pass.maxElevation > minElevation
|
||||
&& (pass.isDeepSpace || isAosInRange(pass.aosTime, aosStartMinute, aosEndMinute, invertAosTimeWindow))
|
||||
) {
|
||||
newPasses.add(pass)
|
||||
// Queue behind any in-flight calculation rather than dropping this call:
|
||||
// every invocation carries filter settings the user just chose, so a
|
||||
// dropped one leaves the list showing results for the previous filter.
|
||||
calculationMutex.withLock {
|
||||
_isCalculating.value = true
|
||||
try {
|
||||
// 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
|
||||
val normalizedTime = time / 60_000L * 60_000L
|
||||
val currentSatellites = _satellites.value
|
||||
withContext(dispatcher) {
|
||||
val idsWithModes = localStorage.getIdsWithModes(modes)
|
||||
val stationPos = settingsRepo.stationPosition.value
|
||||
val filteredSatellites = if (idsWithModes.isEmpty()) {
|
||||
currentSatellites
|
||||
} else {
|
||||
currentSatellites.filter { it.data.catnum in idsWithModes }
|
||||
}
|
||||
// Compute passes for each satellite in parallel
|
||||
val passLists = coroutineScope {
|
||||
filteredSatellites.map { satellite ->
|
||||
async { satellite.getPasses(stationPos, normalizedTime, hoursAhead) }
|
||||
}.awaitAll()
|
||||
}
|
||||
// Flatten and filter in a single pass
|
||||
val timeFuture = normalizedTime + (hoursAhead * 60L * 60L * 1000L)
|
||||
val newPasses = ArrayList<OrbitalPass>()
|
||||
for (list in passLists) {
|
||||
for (pass in list) {
|
||||
if (
|
||||
pass.losTime > time
|
||||
&& pass.aosTime < timeFuture
|
||||
&& pass.maxElevation > minElevation
|
||||
&& (pass.isDeepSpace || isAosInRange(pass.aosTime, aosStartMinute, aosEndMinute, invertAosTimeWindow))
|
||||
) {
|
||||
newPasses.add(pass)
|
||||
}
|
||||
}
|
||||
}
|
||||
newPasses.sortBy { it.aosTime }
|
||||
delay(1000) // Simulate loading time for better UX
|
||||
_passes.update { newPasses }
|
||||
}
|
||||
} finally {
|
||||
// finally: a thrown/cancelled calculation must not leave the
|
||||
// progress indicator spinning forever.
|
||||
_isCalculating.value = false
|
||||
}
|
||||
newPasses.sortBy { it.aosTime }
|
||||
delay(1000) // Simulate loading time for better UX
|
||||
_passes.update { newPasses }
|
||||
}
|
||||
_isCalculating.value = false
|
||||
}
|
||||
|
||||
private fun isAosInRange(
|
||||
@@ -170,7 +192,7 @@ class SatelliteRepo(
|
||||
val inRange = if (aosStartMinute <= aosEndMinute) {
|
||||
aosMinute in aosStartMinute..aosEndMinute
|
||||
} else {
|
||||
aosMinute >= aosStartMinute || aosMinute <= aosEndMinute
|
||||
aosMinute !in (aosEndMinute + 1)..<aosStartMinute
|
||||
}
|
||||
return if (invertAosTimeWindow) !inRange else inRange
|
||||
}
|
||||
|
||||
+16
-13
@@ -38,16 +38,16 @@ class SelectionRepo(
|
||||
) : ISelectionRepo {
|
||||
|
||||
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
||||
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
|
||||
private val currentQuery = MutableStateFlow("")
|
||||
|
||||
// Resolve type IDs once when types change, then filter items reactively.
|
||||
// Resolve sat IDs once when modes change, then filter items reactively.
|
||||
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
||||
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
||||
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
||||
// Directly observe settingsRepo.selectedSatModes to ensure real-time sync across screens.
|
||||
private val itemsWithModes = settingsRepo.selectedSatModes.flatMapLatest { list: List<String> ->
|
||||
val catnumSet: Set<Int>? = if (list.isEmpty()) {
|
||||
null // null = no filtering
|
||||
} else {
|
||||
val ids = settingsRepo.getSatelliteTypesIds(types)
|
||||
val ids = localSource.getIdsWithModes(list)
|
||||
if (ids.isEmpty()) null else ids.toHashSet()
|
||||
}
|
||||
currentItems.map { items ->
|
||||
@@ -56,14 +56,18 @@ class SelectionRepo(
|
||||
}
|
||||
|
||||
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
||||
itemsWithTypes.map { items -> filterByQuery(items, query) }
|
||||
itemsWithModes.map { items ->
|
||||
filterByQuery(items, query).sortedWith(
|
||||
compareByDescending<SatItem> { it.isSelected }
|
||||
.thenBy { it.name }
|
||||
.thenBy { it.catnum }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCurrentTypes() = currentTypes.value
|
||||
override fun getCurrentModes() = settingsRepo.selectedSatModes.value
|
||||
|
||||
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
|
||||
removeAt(0)
|
||||
}
|
||||
override fun getModesList() = Sources.satelliteModes
|
||||
|
||||
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
||||
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
|
||||
@@ -73,9 +77,8 @@ class SelectionRepo(
|
||||
return@withContext itemsWithQuery
|
||||
}
|
||||
|
||||
override suspend fun setTypes(types: List<String>) {
|
||||
currentTypes.value = types
|
||||
settingsRepo.setSelectedTypes(types)
|
||||
override suspend fun setModes(modes: List<String>) {
|
||||
settingsRepo.setSelectedSatModes(modes)
|
||||
}
|
||||
|
||||
override suspend fun setQuery(query: String) {
|
||||
|
||||
+63
-22
@@ -29,15 +29,18 @@ 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.source.Sources
|
||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||
import com.rtbishop.look4sat.core.domain.utility.round
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.json.JSONObject
|
||||
|
||||
class SettingsRepo(
|
||||
private val context: android.content.Context,
|
||||
@@ -69,9 +72,9 @@ class SettingsRepo(
|
||||
private val keyFrequencyAddress = "frequencyAddress"
|
||||
private val keyFrequencyPort = "frequencyPort"
|
||||
private val keyFrequencyFormat = "frequencyFormat"
|
||||
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
|
||||
private val keySelectedIds = "selectedIds"
|
||||
private val keySelectedTypes = "selectedTypes"
|
||||
private val keySelectedModes = "selectedModes"
|
||||
private val keySelectedSatModes = "selectedSatModes"
|
||||
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
||||
private val keyStateOfSensors = "stateOfSensors"
|
||||
private val keyStateOfSweep = "stateOfSweep"
|
||||
@@ -100,13 +103,16 @@ class SettingsRepo(
|
||||
private val keyWavelogApiKey = "wavelogApiKey"
|
||||
private val keyWavelogStationId = "wavelogStationId"
|
||||
private val keyWavelogAutoUpload = "wavelogAutoUpload"
|
||||
private val keyRadarCompassOffset = "radarCompassOffset"
|
||||
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
|
||||
|
||||
private val separatorComma = ","
|
||||
|
||||
//region # Satellites selection settings
|
||||
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
||||
private val _typesSelection = MutableStateFlow(getSelectedTypes())
|
||||
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
|
||||
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
|
||||
override val selectedTypes: StateFlow<List<String>> = _typesSelection
|
||||
override val selectedSatModes: StateFlow<List<String>> = _satelliteModeSelection
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) {
|
||||
val selectionString = ids.joinToString(separatorComma)
|
||||
@@ -114,10 +120,10 @@ class SettingsRepo(
|
||||
_satelliteSelection.value = ids
|
||||
}
|
||||
|
||||
override fun setSelectedTypes(types: List<String>) {
|
||||
val typesString = types.joinToString(separatorComma)
|
||||
preferences.edit { putString(keySelectedTypes, typesString) }
|
||||
_typesSelection.value = types
|
||||
override fun setSelectedSatModes(modes: List<String>) {
|
||||
val modesString = modes.joinToString(separatorComma)
|
||||
preferences.edit { putString(keySelectedSatModes, modesString) }
|
||||
_satelliteModeSelection.value = modes
|
||||
}
|
||||
|
||||
private fun getSelectedIds(): List<Int> {
|
||||
@@ -126,10 +132,10 @@ class SettingsRepo(
|
||||
return selectionString.split(separatorComma).map { it.toInt() }
|
||||
}
|
||||
|
||||
private fun getSelectedTypes(): List<String> {
|
||||
val typesString = preferences.getString(keySelectedTypes, "Amateur")
|
||||
if (typesString.isNullOrEmpty()) return emptyList()
|
||||
return typesString.split(separatorComma)
|
||||
private fun getSelectedSatModes(): List<String> {
|
||||
val modesString = preferences.getString(keySelectedSatModes, null)
|
||||
if (modesString.isNullOrEmpty()) return emptyList()
|
||||
return modesString.split(separatorComma).sorted()
|
||||
}
|
||||
//endregion
|
||||
|
||||
@@ -144,7 +150,6 @@ class SettingsRepo(
|
||||
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
||||
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
||||
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
||||
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
|
||||
_passesSettings.value = settings
|
||||
}
|
||||
|
||||
@@ -155,16 +160,13 @@ class SettingsRepo(
|
||||
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
||||
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
||||
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
|
||||
val selectedModesString = preferences.getString(keySelectedModes, null)
|
||||
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
|
||||
return PassesSettings(
|
||||
showDeepSpace,
|
||||
hoursAhead,
|
||||
minElevation,
|
||||
aosStartMinute,
|
||||
aosEndMinute,
|
||||
invertAosTimeWindow,
|
||||
selectedModes
|
||||
invertAosTimeWindow
|
||||
)
|
||||
}
|
||||
//endregion
|
||||
@@ -181,7 +183,10 @@ class SettingsRepo(
|
||||
}
|
||||
|
||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean {
|
||||
val newLongitude = if (longitude > 180.0) longitude - 180 else longitude
|
||||
// Wrap an out-of-range longitude into -180..180. Subtracting 180 (the
|
||||
// previous behaviour) mapped 270 to +90 instead of -90, i.e. the wrong
|
||||
// hemisphere, and 360 to +180 instead of 0.
|
||||
val newLongitude = ((longitude + 180.0).mod(360.0)) - 180.0
|
||||
val locator = positionToQth(latitude, newLongitude) ?: return false
|
||||
setStationPosition(latitude, newLongitude, altitude, locator)
|
||||
return true
|
||||
@@ -262,6 +267,7 @@ class SettingsRepo(
|
||||
private val _databaseState = MutableStateFlow(getDatabaseState())
|
||||
override val databaseState: StateFlow<DatabaseState> = _databaseState
|
||||
|
||||
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> {
|
||||
val idsSet = mutableSetOf<Int>()
|
||||
types.forEach { type ->
|
||||
@@ -325,6 +331,10 @@ class SettingsRepo(
|
||||
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
||||
|
||||
override fun updateRCSettings(settings: RCSettings) {
|
||||
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
|
||||
Constants.FREQ_OFFSET_MIN_HZ,
|
||||
Constants.FREQ_OFFSET_MAX_HZ
|
||||
)
|
||||
preferences.edit {
|
||||
putBoolean(keyRotatorState, settings.rotatorState)
|
||||
putString(keyRotatorAddress, settings.rotatorAddress)
|
||||
@@ -334,6 +344,7 @@ class SettingsRepo(
|
||||
putString(keyFrequencyAddress, settings.frequencyAddress)
|
||||
putString(keyFrequencyPort, settings.frequencyPort)
|
||||
putString(keyFrequencyFormat, settings.frequencyFormat)
|
||||
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
|
||||
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
||||
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
||||
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
||||
@@ -342,7 +353,7 @@ class SettingsRepo(
|
||||
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
||||
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
||||
}
|
||||
_rcSettings.value = settings
|
||||
_rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
|
||||
}
|
||||
|
||||
private fun getRCSettings(): RCSettings = RCSettings(
|
||||
@@ -354,6 +365,8 @@ class SettingsRepo(
|
||||
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
|
||||
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
|
||||
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
|
||||
frequencyOffsetHz = preferences.getLong(keyFrequencyOffsetHz, 0L)
|
||||
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ),
|
||||
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
|
||||
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
||||
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
||||
@@ -390,6 +403,9 @@ class SettingsRepo(
|
||||
putString(keyWavelogApiKey, new.wavelogApiKey)
|
||||
putString(keyWavelogStationId, new.wavelogStationId)
|
||||
putBoolean(keyWavelogAutoUpload, new.wavelogAutoUpload)
|
||||
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
|
||||
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
|
||||
|
||||
}
|
||||
new
|
||||
}
|
||||
@@ -413,7 +429,9 @@ class SettingsRepo(
|
||||
wavelogUrl = preferences.getString(keyWavelogUrl, null) ?: "",
|
||||
wavelogApiKey = preferences.getString(keyWavelogApiKey, null) ?: "",
|
||||
wavelogStationId = preferences.getString(keyWavelogStationId, null) ?: "",
|
||||
wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false)
|
||||
wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false),
|
||||
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
|
||||
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f)
|
||||
)
|
||||
//endregion
|
||||
|
||||
@@ -445,6 +463,7 @@ class SettingsRepo(
|
||||
transceiversUrl = txUrl
|
||||
)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
@@ -484,5 +503,27 @@ class SettingsRepo(
|
||||
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
|
||||
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
||||
)
|
||||
//endregion
|
||||
|
||||
private val keySatelliteOffsets = "satelliteOffsets"
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String {
|
||||
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||
return try {
|
||||
JSONObject(json).optString(catnum.toString(), "")
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) {
|
||||
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||
val updated = try {
|
||||
val obj = JSONObject(json)
|
||||
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
|
||||
obj.toString()
|
||||
} catch (_: Exception) {
|
||||
"""{"$catnum": "$offset"}"""
|
||||
}
|
||||
preferences.edit { putString(keySatelliteOffsets, updated) }
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package com.rtbishop.look4sat.core.data.source
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
import java.util.regex.Pattern
|
||||
|
||||
/**
|
||||
* AMSAT satellite status page parser (https://amsat.org/status/)
|
||||
*
|
||||
* Page structure (static HTML, verified 2026-08):
|
||||
* - Status table: 48 rows in <table>, header = Name + 6 days (each colspan=12)
|
||||
* Data row has 73 cells: [0]=satellite name, [1..72] = 6 days x 12 two-hour slots (new->old)
|
||||
* Each cell: <td width=9 bgcolor="color">count</td>; when reports exist the count carries a
|
||||
* docTips.show('id') link
|
||||
* - Report details live in the page's inline JS:
|
||||
* tips.a885153 = new Array(5,5,120,'status<br>callsign<br>grid<br>date<br>time span UTC')
|
||||
*
|
||||
* Status colors: #648fff=active, #ffb000=telemetry only, #dc267f=not heard, #fe6100=conflicting
|
||||
*/
|
||||
object AmSatParser {
|
||||
|
||||
private val STATUS_COLORS = mapOf(
|
||||
"#648fff" to 0xFF648FFF.toLong(),
|
||||
"#ffb000" to 0xFFFFB000.toLong(),
|
||||
"#dc267f" to 0xFFDC267F.toLong(),
|
||||
"#fe6100" to 0xFFFE6100.toLong()
|
||||
)
|
||||
private val GRAY = 0xFFC0C0C0.toLong()
|
||||
|
||||
private val rowRe = Pattern.compile("<tr>(.*?)</tr>", Pattern.DOTALL)
|
||||
private val cellRe = Pattern.compile("(<t[dh][^>]*>.*?</t[dh]>)", Pattern.DOTALL)
|
||||
private val bgRe = Pattern.compile("bgcolor=\"?(#[0-9a-fA-F]{6}|C0C0C0)\"?")
|
||||
private val linkRe = Pattern.compile("docTips\\.show\\('(a\\d+)'\\)")
|
||||
private val tipRe = Pattern.compile(
|
||||
"tips\\.(a\\d+)\\s*=\\s*new\\s+Array\\(\\s*\\d+,\\s*\\d+,\\s*\\d+,\\s*'([^']*)'"
|
||||
)
|
||||
|
||||
/** Parse the full page */
|
||||
fun parse(html: String, fetchedAtUtcMs: Long): SatStatusPage {
|
||||
val reports = parseReports(html)
|
||||
val statuses = parseStatusTable(html, reports)
|
||||
return SatStatusPage(fetchedAtUtcMs, statuses, reports)
|
||||
}
|
||||
|
||||
/** Extract all reports (tips.* JS arrays) */
|
||||
fun parseReports(html: String): Map<String, SatReport> {
|
||||
val map = mutableMapOf<String, SatReport>()
|
||||
val m = tipRe.matcher(html)
|
||||
while (m.find()) {
|
||||
val id = m.group(1)
|
||||
val parts = m.group(2).split("<br>")
|
||||
map[id] = SatReport(
|
||||
id = id,
|
||||
statusText = parts.getOrElse(0) { "" }.trim(),
|
||||
call = parts.getOrElse(1) { "" }.trim(),
|
||||
grid = parts.getOrElse(2) { "" }.trim(),
|
||||
dateUtc = parts.getOrElse(3) { "" }.trim(),
|
||||
timeUtc = parts.getOrElse(4) { "" }.trim()
|
||||
)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** Parse the status table (48 satellite rows) */
|
||||
fun parseStatusTable(html: String, reports: Map<String, SatReport>): List<SatStatus> {
|
||||
val result = mutableListOf<SatStatus>()
|
||||
val tables = extractTables(html)
|
||||
for (table in tables) {
|
||||
val rows = rowRe.matcher(table)
|
||||
val parsed = mutableListOf<SatStatus>()
|
||||
var rowIndex = 0
|
||||
var dayHeaders: List<String> = emptyList()
|
||||
while (rows.find()) {
|
||||
val rowHtml = rows.group(1)
|
||||
val cells = cellRe.matcher(rowHtml)
|
||||
val cellList = mutableListOf<String>()
|
||||
while (cells.find()) cellList.add(cells.group(1))
|
||||
if (rowIndex == 0) {
|
||||
// Header: Name + 6 days (each colspan=12)
|
||||
dayHeaders = cellList.drop(1).take(6).map { stripHtml(it) }
|
||||
rowIndex++
|
||||
continue
|
||||
}
|
||||
if (cellList.size < 7) { rowIndex++; continue }
|
||||
val name = stripHtml(cellList[0])
|
||||
if (name.isBlank()) { rowIndex++; continue }
|
||||
val days = mutableListOf<SatDay>()
|
||||
for (d in 0 until 6) {
|
||||
val start = 1 + d * 12
|
||||
val end = start + 12
|
||||
val slots = (start until end).mapNotNull { i ->
|
||||
cellList.getOrNull(i)?.let { cell ->
|
||||
val bg = bgRe.matcher(cell)
|
||||
val color = if (bg.find()) {
|
||||
STATUS_COLORS[bg.group(1).lowercase()] ?: GRAY
|
||||
} else GRAY
|
||||
val link = linkRe.matcher(cell)
|
||||
val ids = mutableListOf<String>()
|
||||
while (link.find()) ids.add(link.group(1))
|
||||
val hasReport = ids.isNotEmpty()
|
||||
val count = if (hasReport) {
|
||||
stripHtml(cell).trim().toIntOrNull() ?: ids.size
|
||||
} else 0
|
||||
SatSlot(
|
||||
statusColor = if (hasReport) color else GRAY,
|
||||
count = count,
|
||||
reportIds = ids
|
||||
)
|
||||
}
|
||||
}
|
||||
days.add(SatDay(dayHeaders.getOrElse(d) { "" }, slots))
|
||||
}
|
||||
parsed.add(SatStatus(name, days))
|
||||
rowIndex++
|
||||
}
|
||||
if (parsed.isNotEmpty()) {
|
||||
result.addAll(parsed)
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun extractTables(html: String): List<String> {
|
||||
val result = mutableListOf<String>()
|
||||
val m = Pattern.compile("<table.*?</table>", Pattern.DOTALL).matcher(html)
|
||||
while (m.find()) result.add(m.group())
|
||||
return result
|
||||
}
|
||||
|
||||
private fun stripHtml(s: String): String =
|
||||
s.replace(Regex("<[^>]+>"), "").trim()
|
||||
}
|
||||
@@ -20,11 +20,11 @@ package com.rtbishop.look4sat.core.data.source
|
||||
import android.content.ContentResolver
|
||||
import androidx.core.net.toUri
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
|
||||
class RemoteSource(
|
||||
@@ -37,35 +37,27 @@ class RemoteSource(
|
||||
try {
|
||||
val fileUri = uri.toUri()
|
||||
contentResolver.openInputStream(fileUri)?.buffered()
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource file stream exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getStatusHtml(): String? = withContext(dispatcher) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("https://amsat.org/status/")
|
||||
.header("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat/4.5")
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body?.string()
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource amsat status exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
||||
try {
|
||||
val networkRequest = Request.Builder().url(url).build()
|
||||
httpClient.newCall(networkRequest).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext null
|
||||
ByteArrayInputStream(response.body.bytes())
|
||||
val response = httpClient.newCall(networkRequest).execute()
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
return@withContext null
|
||||
}
|
||||
// Return the body stream directly as the caller is responsible for closing it
|
||||
// That returns the connection to OkHttp's pool
|
||||
response.body.byteStream().buffered()
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource network stream exception: $exception")
|
||||
null
|
||||
@@ -82,8 +74,11 @@ class RemoteSource(
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body?.string()
|
||||
}
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource amsat catalog exception: $exception")
|
||||
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -98,8 +93,11 @@ class RemoteSource(
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body?.string()
|
||||
}
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource amsat reports exception: $exception")
|
||||
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,8 +56,14 @@ class AudioCapture : IAudioCapture {
|
||||
if (read > 0) emit(if (read == chunkSize) buffer.copyOf() else buffer.copyOfRange(0, read))
|
||||
}
|
||||
} finally {
|
||||
recorder.stop()
|
||||
recorder.release()
|
||||
// stop() on a recorder that never started throws
|
||||
// IllegalStateException; wrapping each cleanup step separately
|
||||
// keeps the original error (e.g. a permission denial during
|
||||
// startRecording) intact and guarantees release() still runs.
|
||||
// Without this, a start failure masked the real cause AND leaked
|
||||
// the recorder because release() was skipped.
|
||||
runCatching { recorder.stop() }
|
||||
runCatching { recorder.release() }
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
+13
-5
@@ -125,6 +125,10 @@ private class FakeRemoteSource : IRemoteSource {
|
||||
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
||||
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
|
||||
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||
}
|
||||
|
||||
private class FakeLocalSource : ILocalSource {
|
||||
@@ -166,10 +170,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
|
||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||
|
||||
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||
override val selectedSatModes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||
|
||||
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
|
||||
PassesSettings(hoursAhead = 24, minElevation = 0.0)
|
||||
)
|
||||
|
||||
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||
@@ -177,7 +181,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||
|
||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
|
||||
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||
)
|
||||
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
@@ -194,13 +198,13 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||
|
||||
override fun setSelectedTypes(types: List<String>) = 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 suspend fun setStationPosition(): Boolean = true
|
||||
|
||||
override fun setStationPosition(locator: String): Boolean = true
|
||||
|
||||
@@ -223,6 +227,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
}
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
}
|
||||
|
||||
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.SatItem
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SelectionRepoTest {
|
||||
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
|
||||
@Test
|
||||
fun `unknown mode values do not crash and do not filter out entries`() = runTest(dispatcher) {
|
||||
val localSource = FakeLocalSource(
|
||||
entries = listOf(
|
||||
SatItem(25544, "ISS (ZARYA)", false),
|
||||
SatItem(40967, "TIANGONG", false)
|
||||
)
|
||||
)
|
||||
val settingsRepo = FakeSettingsRepo(selectedModes = listOf("REMOVED_MODE"))
|
||||
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
|
||||
|
||||
val flow = repository.getEntriesFlow()
|
||||
repository.setModes(listOf("REMOVED_MODE"))
|
||||
|
||||
val items = flow.first()
|
||||
|
||||
assertEquals(listOf(25544, 40967), items.map { it.catnum })
|
||||
assertEquals(listOf("REMOVED_MODE"), repository.getCurrentModes())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected satellites are shown first`() = runTest(dispatcher) {
|
||||
val localSource = FakeLocalSource(
|
||||
entries = listOf(
|
||||
SatItem(44444, "Zeta", false),
|
||||
SatItem(25544, "Alpha", false),
|
||||
SatItem(40967, "Beta", false)
|
||||
)
|
||||
)
|
||||
val settingsRepo = FakeSettingsRepo(selectedModes = emptyList())
|
||||
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
|
||||
|
||||
val flow = repository.getEntriesFlow()
|
||||
repository.setSelection(listOf(40967), true)
|
||||
|
||||
val items = flow.first()
|
||||
|
||||
assertEquals(listOf(40967, 25544, 44444), items.map { it.catnum })
|
||||
assertEquals(listOf(true, false, false), items.map { it.isSelected })
|
||||
}
|
||||
|
||||
private class FakeLocalSource(
|
||||
private val entries: List<SatItem>
|
||||
) : ILocalSource {
|
||||
override suspend fun getEntriesTotal(): Int = entries.size
|
||||
|
||||
override suspend fun getEntriesList(): List<SatItem> = entries
|
||||
|
||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||
|
||||
override suspend fun insertEntries(entries: List<com.rtbishop.look4sat.core.domain.predict.OrbitalData>) = Unit
|
||||
|
||||
override suspend fun deleteEntries() = Unit
|
||||
|
||||
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||
|
||||
override suspend fun getRadiosTotal(): Int = 0
|
||||
|
||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||
|
||||
override suspend fun insertRadios(radios: List<SatRadio>) = Unit
|
||||
|
||||
override suspend fun deleteRadios() = Unit
|
||||
}
|
||||
|
||||
private class FakeSettingsRepo(
|
||||
selectedModes: List<String>
|
||||
) : ISettingsRepo {
|
||||
|
||||
override val appVersionName: String = "test"
|
||||
|
||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||
|
||||
override val selectedSatModes: MutableStateFlow<List<String>> = MutableStateFlow(selectedModes)
|
||||
|
||||
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||
PassesSettings(hoursAhead = 24, minElevation = 0.0)
|
||||
)
|
||||
|
||||
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||
|
||||
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||
|
||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||
)
|
||||
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
OtherSettings(false, false, false, false, false, false, false, false)
|
||||
)
|
||||
|
||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
|
||||
DataSourcesSettings(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 suspend fun setStationPosition(): Boolean = true
|
||||
|
||||
override fun setStationPosition(locator: String): Boolean = true
|
||||
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
|
||||
|
||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = Unit
|
||||
|
||||
override fun updateDatabaseState(state: DatabaseState) {
|
||||
databaseState.value = state
|
||||
}
|
||||
|
||||
override fun updateRCSettings(settings: RCSettings) = Unit
|
||||
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
|
||||
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.rtbishop.look4sat.core.domain.aprs
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.round
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* APRS-IS protocol core (pure Kotlin, no Android dependencies).
|
||||
@@ -35,20 +36,31 @@ object AprsPacket {
|
||||
|
||||
/** Optional distance filter: filter r/lat/lon/dist */
|
||||
fun formatRangeFilter(latitude: Double, longitude: Double, distKm: Int): String {
|
||||
return String.format("r/%.3f/%.3f/%d", latitude, longitude, distKm)
|
||||
return String.format(Locale.ROOT, "r/%.3f/%.3f/%d", latitude, longitude, distKm)
|
||||
}
|
||||
|
||||
/** Altitude extension /A=00000 (feet) */
|
||||
/**
|
||||
* Altitude extension /A=000000 (feet). The field is a fixed six-digit
|
||||
* decimal, so a negative altitude (below sea level, or a bad GPS fix) must
|
||||
* be clamped: "%06d" of -164 yields "/A=-00164", which is not a valid
|
||||
* extension and corrupts the rest of the comment field.
|
||||
*/
|
||||
fun formatAltitude(altitudeMeters: Double?): String {
|
||||
if (altitudeMeters == null) return ""
|
||||
return String.format("/A=%06d", (altitudeMeters * 3.2808399).toInt())
|
||||
val feet = (altitudeMeters * 3.2808399).toInt().coerceIn(0, 999999)
|
||||
return String.format(Locale.ROOT, "/A=%06d", feet)
|
||||
}
|
||||
|
||||
/** Speed/course extension (knots/degrees) */
|
||||
/**
|
||||
* Speed/course extension /CCC/SSS (degrees/knots). Course wraps into
|
||||
* 0..359 and speed is clamped to three digits, because "%03d" of an
|
||||
* out-of-range value widens the field and breaks the fixed-width format.
|
||||
*/
|
||||
fun formatCourseSpeed(speedMps: Double?, bearing: Float?): String {
|
||||
if (speedMps == null || bearing == null) return ""
|
||||
val knots = (speedMps * 1.94384449).toInt()
|
||||
return String.format("/%03d/%03d", bearing.toInt(), knots)
|
||||
val knots = (speedMps * 1.94384449).toInt().coerceIn(0, 999)
|
||||
val course = ((bearing.toInt() % 360) + 360) % 360
|
||||
return String.format(Locale.ROOT, "/%03d/%03d", course, knots)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,17 +112,17 @@ class AprsPosition(
|
||||
val hundredths = iRound % 100
|
||||
val frac = when (positionAmbiguity) {
|
||||
1 -> " . "
|
||||
2 -> String.format("%d . ", minutes / 10)
|
||||
3 -> String.format("%02d. ", minutes)
|
||||
4 -> String.format("%02d.%d ", minutes, hundredths / 10)
|
||||
else -> String.format("%02d.%02d", minutes, hundredths)
|
||||
2 -> String.format(Locale.ROOT, "%d . ", minutes / 10)
|
||||
3 -> String.format(Locale.ROOT, "%02d. ", minutes)
|
||||
4 -> String.format(Locale.ROOT, "%02d.%d ", minutes, hundredths / 10)
|
||||
else -> String.format(Locale.ROOT, "%02d.%02d", minutes, hundredths)
|
||||
}
|
||||
return if (isLat) {
|
||||
val ns = if (value >= 0) 'N' else 'S'
|
||||
String.format("%02d%s%c", degrees, frac, ns)
|
||||
String.format(Locale.ROOT, "%02d%s%c", degrees, frac, ns)
|
||||
} else {
|
||||
val ew = if (value >= 0) 'E' else 'W'
|
||||
String.format("%03d%s%c", degrees, frac, ew)
|
||||
String.format(Locale.ROOT, "%03d%s%c", degrees, frac, ew)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
object Constants {
|
||||
const val FREQ_OFFSET_MIN_HZ = -50_000L
|
||||
const val FREQ_OFFSET_MAX_HZ = 50_000L
|
||||
}
|
||||
@@ -23,10 +23,10 @@ data class SatDay(
|
||||
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
||||
)
|
||||
|
||||
/** One satellite, 6 days of state */
|
||||
/** One satellite, 5 days of state */
|
||||
data class SatStatus(
|
||||
val name: String, // "AO-123_[FM]"
|
||||
val days: List<SatDay> // 6 天(新→旧)
|
||||
val days: List<SatDay> // 5 天(新→旧)
|
||||
)
|
||||
|
||||
/** Overall page parse result */
|
||||
|
||||
@@ -29,8 +29,7 @@ data class PassesSettings(
|
||||
val minElevation: Double,
|
||||
val aosStartMinute: Int = 0,
|
||||
val aosEndMinute: Int = 23 * 60 + 59,
|
||||
val invertAosTimeWindow: Boolean = false,
|
||||
val selectedModes: List<String>
|
||||
val invertAosTimeWindow: Boolean = false
|
||||
)
|
||||
|
||||
data class RCSettings(
|
||||
@@ -42,6 +41,7 @@ data class RCSettings(
|
||||
val frequencyAddress: String,
|
||||
val frequencyPort: String,
|
||||
val frequencyFormat: String,
|
||||
val frequencyOffsetHz: Long = 0L,
|
||||
val bluetoothRotatorState: Boolean,
|
||||
val bluetoothRotatorFormat: String,
|
||||
val bluetoothRotatorName: String,
|
||||
@@ -73,7 +73,10 @@ data class OtherSettings(
|
||||
val wavelogUrl: String = "",
|
||||
val wavelogApiKey: String = "",
|
||||
val wavelogStationId: String = "",
|
||||
val wavelogAutoUpload: Boolean = false
|
||||
val wavelogAutoUpload: Boolean = false,
|
||||
// Upstream radar compass offset (merged from rt-bishop)
|
||||
val radarCompassOffset: Float = 0f,
|
||||
val radarCompassOffsetElev: Float = 0f
|
||||
)
|
||||
|
||||
data class DataSourcesSettings(
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.navigation
|
||||
|
||||
/**
|
||||
* Single source of truth for the navigation menu layout.
|
||||
*
|
||||
* The bottom bar holds at most [MAIN_SLOTS] pages; the rest live behind the More
|
||||
* button. Both the bar and the settings editor resolve through here, so the list
|
||||
* the user edits is exactly the list they get.
|
||||
*
|
||||
* Lives in `core:domain` (pure Kotlin) so it is unit-testable and KMP-ready.
|
||||
*/
|
||||
object MenuLayout {
|
||||
|
||||
/** Bottom-bar capacity, including the Settings entry. */
|
||||
const val MAIN_SLOTS = 5
|
||||
|
||||
/** Must stay reachable from some menu, so the user cannot lock themselves out. */
|
||||
const val SETTINGS_ID = "Settings"
|
||||
|
||||
/** Bar contents for a fresh install. */
|
||||
val defaultMainOrder = listOf("Satellites", "Passes", "Radar", "Map", SETTINGS_ID)
|
||||
|
||||
/** More-menu contents for a fresh install. */
|
||||
val defaultMoreOrder = listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT")
|
||||
|
||||
/** What the bar and the More menu actually show. */
|
||||
data class Layout(val mainIds: List<String>, val moreIds: List<String>)
|
||||
|
||||
/** A menu assignment ready to be persisted to settings. */
|
||||
data class Assignment(val screenOrder: List<String>, val subMenuOrder: List<String>)
|
||||
|
||||
/**
|
||||
* Map persisted preferences onto the two menus.
|
||||
*
|
||||
* A page named by neither persisted list is new to this install and follows
|
||||
* the defaults, so upgrades never lose pages. Visible pages that overflow
|
||||
* [MAIN_SLOTS] fall through to the More menu instead of disappearing, and
|
||||
* [SETTINGS_ID] always survives the slot cut.
|
||||
*/
|
||||
fun resolve(
|
||||
allScreenIds: List<String>,
|
||||
screenOrder: List<String>,
|
||||
subMenuOrder: List<String>,
|
||||
hiddenScreenIds: List<String>
|
||||
): Layout {
|
||||
val visible = allScreenIds.filter { it !in hiddenScreenIds || it == SETTINGS_ID }
|
||||
val wantMain = ArrayList<String>()
|
||||
val wantMore = ArrayList<String>()
|
||||
for (id in visible) {
|
||||
when {
|
||||
id in screenOrder -> wantMain.add(id)
|
||||
id in subMenuOrder -> wantMore.add(id)
|
||||
id in defaultMainOrder -> wantMain.add(id)
|
||||
else -> wantMore.add(id)
|
||||
}
|
||||
}
|
||||
wantMain.sortBy { rank(it, screenOrder, defaultMainOrder) }
|
||||
wantMore.sortBy { rank(it, subMenuOrder, defaultMoreOrder) }
|
||||
|
||||
// Reserve the Settings slot before cutting so it cannot be truncated away.
|
||||
val settingsOnBar = SETTINGS_ID in wantMain
|
||||
val budget = if (settingsOnBar) MAIN_SLOTS - 1 else MAIN_SLOTS
|
||||
val main = ArrayList<String>(MAIN_SLOTS)
|
||||
for (id in wantMain) {
|
||||
if (id == SETTINGS_ID) continue
|
||||
if (main.size == budget) break
|
||||
main.add(id)
|
||||
}
|
||||
if (settingsOnBar) main.add(SETTINGS_ID)
|
||||
|
||||
val overflow = wantMain.filter { it !in main }
|
||||
return Layout(mainIds = main, moreIds = overflow + wantMore)
|
||||
}
|
||||
|
||||
/** Move [screenId] onto the bar, evicting the last movable page when full. */
|
||||
fun moveToMain(
|
||||
screenId: String,
|
||||
allScreenIds: List<String>,
|
||||
screenOrder: List<String>,
|
||||
subMenuOrder: List<String>
|
||||
): Assignment {
|
||||
val current = resolve(allScreenIds, screenOrder, subMenuOrder, emptyList())
|
||||
val main = current.mainIds.toMutableList()
|
||||
val more = current.moreIds.toMutableList()
|
||||
val wasInMore = screenId in more
|
||||
more.remove(screenId)
|
||||
if (screenId !in main) {
|
||||
val at = main.indexOf(SETTINGS_ID).let { if (it == -1) main.size else it }
|
||||
main.add(at, screenId)
|
||||
}
|
||||
// Only evict when we actually added a new page from More; internal reordering must not evict.
|
||||
if (wasInMore) {
|
||||
val movable = main.filter { it != SETTINGS_ID && it != screenId }
|
||||
if (main.size > MAIN_SLOTS && movable.isNotEmpty()) {
|
||||
val evicted = movable.last()
|
||||
main.remove(evicted)
|
||||
more.add(0, evicted)
|
||||
}
|
||||
}
|
||||
return Assignment(screenOrder = main, subMenuOrder = more)
|
||||
}
|
||||
|
||||
/** Move [screenId] off the bar; Settings is refused so it stays reachable. */
|
||||
fun moveToMore(
|
||||
screenId: String,
|
||||
allScreenIds: List<String>,
|
||||
screenOrder: List<String>,
|
||||
subMenuOrder: List<String>
|
||||
): Assignment {
|
||||
if (screenId == SETTINGS_ID) return Assignment(screenOrder, subMenuOrder)
|
||||
val current = resolve(allScreenIds, screenOrder, subMenuOrder, emptyList())
|
||||
val more = current.moreIds.toMutableList()
|
||||
if (screenId !in more) more.add(screenId)
|
||||
return Assignment(
|
||||
screenOrder = current.mainIds.filter { it != screenId },
|
||||
subMenuOrder = more
|
||||
)
|
||||
}
|
||||
|
||||
private fun rank(id: String, persisted: List<String>, fallback: List<String>): Int {
|
||||
val persistedIndex = persisted.indexOf(id)
|
||||
if (persistedIndex != -1) return persistedIndex
|
||||
val fallbackIndex = fallback.indexOf(id)
|
||||
return if (fallbackIndex != -1) fallbackIndex else Int.MAX_VALUE
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -424,8 +424,12 @@ object CelestialComputer {
|
||||
}
|
||||
if (sunrise == 0.0) sunrise = daynum
|
||||
|
||||
// Phase 4: fast-forward through the day until sun drops back below threshold
|
||||
daynum = sunrise
|
||||
// Phase 4: fast-forward through the day until sun drops back below threshold.
|
||||
// Start from just after sunrise (small offset) so the sun is clearly above
|
||||
// the threshold. This prevents a bug where Phase 3 converges to a point
|
||||
// slightly below -threshold, causing Phase 4 to skip and Phase 5 to converge
|
||||
// to the same time as sunrise, producing identical sunrise/sunset times.
|
||||
daynum = sunrise + 0.001
|
||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||
guard = 0
|
||||
while (sunPos.elevation > -threshold && guard++ < 500) {
|
||||
|
||||
+1
@@ -35,6 +35,7 @@ interface IMainContainer {
|
||||
val mutualPassData: StateFlow<MutualPassData>
|
||||
fun setMutualPassData(data: MutualPassData)
|
||||
fun provideAddToCalendar(): IAddToCalendar
|
||||
fun providePairedBluetoothDevices(): List<Pair<String, String>>
|
||||
fun provideShowToast(): IShowToast
|
||||
fun provideBluetoothReporter(): IReporter
|
||||
fun provideNetworkReporter(): IReporter
|
||||
|
||||
+3
-3
@@ -21,10 +21,10 @@ import com.rtbishop.look4sat.core.domain.model.SatItem
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface ISelectionRepo {
|
||||
fun getCurrentTypes(): List<String>
|
||||
fun getTypesList(): List<String>
|
||||
fun getCurrentModes(): List<String>
|
||||
fun getModesList(): List<String>
|
||||
suspend fun getEntriesFlow(): Flow<List<SatItem>>
|
||||
suspend fun setTypes(types: List<String>)
|
||||
suspend fun setModes(modes: List<String>)
|
||||
suspend fun setQuery(query: String)
|
||||
suspend fun setSelection(selectAll: Boolean)
|
||||
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
|
||||
|
||||
+7
-2
@@ -32,9 +32,9 @@ interface ISettingsRepo {
|
||||
|
||||
//region # Satellites selection settings
|
||||
val selectedIds: StateFlow<List<Int>>
|
||||
val selectedTypes: StateFlow<List<String>>
|
||||
val selectedSatModes: StateFlow<List<String>>
|
||||
fun setSelectedIds(ids: List<Int>)
|
||||
fun setSelectedTypes(types: List<String>)
|
||||
fun setSelectedSatModes(modes: List<String>)
|
||||
//endregion
|
||||
|
||||
//region # Passes filter settings
|
||||
@@ -78,4 +78,9 @@ interface ISettingsRepo {
|
||||
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||
//endregion
|
||||
|
||||
//region # Per-satellite calculator offset settings
|
||||
fun getSatelliteOffset(catnum: Int): String
|
||||
fun setSatelliteOffset(catnum: Int, offset: String)
|
||||
//endregion
|
||||
}
|
||||
@@ -23,9 +23,6 @@ interface IRemoteSource {
|
||||
suspend fun getFileStream(uri: String): InputStream?
|
||||
suspend fun getNetworkStream(url: String): InputStream?
|
||||
|
||||
/** Fetch AMSAT status page HTML (with UA; null = failure) */
|
||||
suspend fun getStatusHtml(): String?
|
||||
|
||||
/** Fetch AMSAT API catalog (JSON string; null on failure) */
|
||||
suspend fun getAmSatCatalog(): String?
|
||||
|
||||
|
||||
@@ -52,6 +52,15 @@ object Sources {
|
||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||
"Other" to "" // key for sats filter
|
||||
)
|
||||
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"
|
||||
)
|
||||
val transceiversDataUrls = mapOf(
|
||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||
)
|
||||
|
||||
@@ -70,8 +70,13 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
val min = timestamp.substring(14, 16).toInt() * 60000
|
||||
val sec = timestamp.substring(17, 19).toInt() * 1000
|
||||
val ms = timestamp.substring(20, 26).toInt() / 1000.0
|
||||
val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1)
|
||||
val epoch = "${year.substring(2)}$day$frac".toDouble()
|
||||
// Add the day fraction numerically. Building it by string surgery breaks
|
||||
// below 1e-3, where Double.toString() switches to scientific notation and
|
||||
// dropping the first character removes a significant digit instead of the
|
||||
// leading zero: 00:01:00 yielded "25001.944444444444445E-4" -> 2.50019,
|
||||
// a silently valid epoch about 26 years off.
|
||||
val dayFraction = (hour + min + sec + ms) / 86400000.0
|
||||
val epoch = "${year.substring(2)}$day".toDouble() + dayFraction
|
||||
OrbitalData(
|
||||
name = name,
|
||||
epoch = epoch,
|
||||
@@ -91,7 +96,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
val line1 = tle[1]
|
||||
val line2 = tle[2]
|
||||
OrbitalData(
|
||||
name = tle[0].trim(),
|
||||
name = tle[0].trim().removePrefix("0 "),
|
||||
epoch = line1.substring(18, 32).toDouble(),
|
||||
meanmo = line2.substring(52, 63).toDouble(),
|
||||
eccn = line2.substring(26, 33).toDouble() / 1e7,
|
||||
|
||||
+21
-2
@@ -114,11 +114,30 @@ object DopplerFrequencyCalculator {
|
||||
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
||||
.joinToString(separator = " ")
|
||||
.lowercase(Locale.ENGLISH)
|
||||
val hasLinearName = info.contains("linear")
|
||||
val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
|
||||
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
|
||||
info.contains("xponder") || info.contains("xpdr")
|
||||
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
|
||||
|
||||
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode)
|
||||
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) ||
|
||||
(hasLinearName && hasLinearMode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes duplicate transponder entries that describe the same physical
|
||||
* transponder with different mode labels (e.g. SatNOGS lists AO-7's Mode A
|
||||
* as both "Lin SSB" and "Lin CW", and JO-97's U/V transponder as both
|
||||
* "CW Transponder" and "SSB Transponder").
|
||||
*
|
||||
* Entries sharing the same uplink/downlink frequency range are considered
|
||||
* the same transponder. The non-CW entry is preferred because its invert
|
||||
* flag is more reliable (e.g. JO-97's CW entry wrongly has invert=false).
|
||||
*/
|
||||
fun deduplicateTransponders(radios: List<SatRadio>): List<SatRadio> {
|
||||
return radios.groupBy { radio ->
|
||||
listOf(radio.uplinkLow, radio.uplinkHigh, radio.downlinkLow, radio.downlinkHigh)
|
||||
}.values.map { group ->
|
||||
group.firstOrNull { it.downlinkMode?.equals("CW", ignoreCase = true) != true } ?: group.first()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,21 @@ fun Double.round(decimals: Int): Double {
|
||||
return kotlin.math.round(this * multiplier) / multiplier
|
||||
}
|
||||
|
||||
fun String.aprsPasscode(): Int {
|
||||
val callsign = this.trim().uppercase().substringBefore('-') // commonly strip SSID
|
||||
var hash = 0x73E2
|
||||
var i = 0
|
||||
while (i < callsign.length) {
|
||||
hash = hash xor (callsign[i].code shl 8)
|
||||
i++
|
||||
if (i < callsign.length) {
|
||||
hash = hash xor callsign[i].code
|
||||
i++
|
||||
}
|
||||
}
|
||||
return hash and 0x7FFF
|
||||
}
|
||||
|
||||
//fun String.getHash(type: String = "SHA-256"): String {
|
||||
// val hexChars = "0123456789ABCDEF"
|
||||
// val bytes = MessageDigest.getInstance(type).digest(this.toByteArray())
|
||||
|
||||
+11
-2
@@ -81,9 +81,18 @@ fun clipLat(latitude: Double): Double {
|
||||
}
|
||||
|
||||
fun clipLon(longitude: Double): Double {
|
||||
// Reduce with a modulo so a single pass always terminates. The previous
|
||||
// while-loop never returned for extreme inputs: Infinity stays Infinity
|
||||
// after subtracting 360, so the loop ran forever, and a ~1e12 degree value
|
||||
// took billions of iterations. NaN still passes through to clip() and is
|
||||
// returned as NaN, which is the same behaviour as before.
|
||||
if (!longitude.isFinite()) return longitude
|
||||
var result = longitude
|
||||
while (result < MIN_LONGITUDE) result += 360.0
|
||||
while (result > MAX_LONGITUDE) result -= 360.0
|
||||
result = ((result + 180.0) % 360.0 + 360.0) % 360.0 - 180.0
|
||||
// The closed interval [-180, 180] keeps +180 for a value that lands exactly
|
||||
// on the positive boundary (old loop: 180 stays 180, only > 180 wraps);
|
||||
// -180 is reserved for values that actually came from the west side.
|
||||
if (result == -180.0 && longitude > 0.0) result = 180.0
|
||||
return clip(result, MIN_LONGITUDE, MAX_LONGITUDE)
|
||||
}
|
||||
|
||||
|
||||
+12
-4
@@ -74,8 +74,14 @@ fun qthToPosition(locator: String): GeoPos? {
|
||||
*/
|
||||
fun positionToQth(latitude: Double, longitude: Double, precision: Int = 8): String? {
|
||||
if (!isValidPosition(latitude, longitude)) return null
|
||||
val newLongitude = longitude + 180
|
||||
val newLatitude = latitude + 90
|
||||
// The grid spans [0, 360) lon and [0, 180) lat once shifted. Clamping the
|
||||
// field index alone (coerceIn below) is not enough: at exactly +90 lat or
|
||||
// +180 lon the field saturates to R while the square/subsquare terms come
|
||||
// from a modulo that has already wrapped to 0, so the encoded locator
|
||||
// decoded back 10 degrees of latitude / 20 degrees of longitude away.
|
||||
// Nudge the upper bound into the last cell instead.
|
||||
val newLongitude = (longitude + 180).coerceIn(0.0, 360.0 - 1e-9)
|
||||
val newLatitude = (latitude + 90).coerceIn(0.0, 180.0 - 1e-9)
|
||||
val lonFirst = (65 + (newLongitude / 20).toInt().coerceIn(0, 17)).toChar()
|
||||
val latFirst = (65 + (newLatitude / 10).toInt().coerceIn(0, 17)).toChar()
|
||||
val lonSecond = ((newLongitude % 20) / 2).toInt()
|
||||
@@ -94,11 +100,13 @@ fun positionToQth(latitude: Double, longitude: Double, precision: Int = 8): Stri
|
||||
}
|
||||
|
||||
private fun isValidPosition(lat: Double, lon: Double): Boolean {
|
||||
return (lat >= -90.0 && lat <= 90.0) && (lon >= -180.0 && lon <= 360.0)
|
||||
return lat in -90.0..90.0 && lon in -180.0..180.0
|
||||
}
|
||||
|
||||
private fun isValidLocator(locator: String): Boolean {
|
||||
return locator.matches("[a-xA-X]{2}\\d{2}[a-xA-X]{2}(?:\\d{2}(?:[a-xA-X]{2})?)?".toRegex())
|
||||
// Maidenhead fields are A-R (18 x 18). Subsquare letters are A-X (24).
|
||||
// Accepting S-X in the first pair decodes to latitude >90 / longitude >180.
|
||||
return locator.matches("[a-rA-R]{2}\\d{2}[a-xA-X]{2}(?:\\d{2}(?:[a-xA-X]{2})?)?".toRegex())
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -106,6 +106,51 @@ object WaveLogApi {
|
||||
WavelogResult.Success("")
|
||||
}
|
||||
|
||||
/**
|
||||
* ADIF band code from a frequency in Hz. "SAT" is NOT a legal ADIF band
|
||||
* value (the Band enumeration is 160M/80M/.../2M/70CM/23CM...); a logger
|
||||
* that fails to parse an illegal band falls back to a default such as
|
||||
* 160m. Satellite QSOs must carry the real band of the TX frequency.
|
||||
*/
|
||||
fun bandFromHz(freqHz: Long): String = when {
|
||||
freqHz >= 1240_000_000 -> "23CM"
|
||||
freqHz >= 902_000_000 -> "33CM"
|
||||
freqHz >= 420_000_000 -> "70CM"
|
||||
freqHz >= 222_000_000 -> "1.25M"
|
||||
freqHz >= 144_000_000 -> "2M"
|
||||
freqHz >= 50_000_000 -> "6M"
|
||||
freqHz >= 28_000_000 -> "10M"
|
||||
freqHz >= 24_890_000 -> "12M"
|
||||
freqHz >= 21_000_000 -> "15M"
|
||||
freqHz >= 18_068_000 -> "17M"
|
||||
freqHz >= 14_000_000 -> "20M"
|
||||
freqHz >= 10_000_000 -> "30M"
|
||||
freqHz >= 7_000_000 -> "40M"
|
||||
freqHz >= 5_102_000 -> "60M"
|
||||
freqHz >= 3_500_000 -> "80M"
|
||||
freqHz >= 1_800_000 -> "160M"
|
||||
else -> "160M"
|
||||
}
|
||||
|
||||
/** Band class letter for satellite mode derivation: VHF=V, UHF=U, SHF=S. */
|
||||
private fun bandLetter(freqHz: Long): String = when {
|
||||
freqHz >= 1_240_000_000 -> "S"
|
||||
freqHz >= 420_000_000 -> "U"
|
||||
freqHz >= 144_000_000 -> "V"
|
||||
else -> "V"
|
||||
}
|
||||
|
||||
/**
|
||||
* ADIF SAT_MODE (free text, satellite convention): "V/U" = VHF up /
|
||||
* UHF down, "U/V", "V/S", "U/S"... Derived from the actual TX/RX bands.
|
||||
*/
|
||||
fun satModeFrom(txFreqHz: Long, rxFreqHz: Long): String {
|
||||
if (rxFreqHz <= 0) return ""
|
||||
val up = bandLetter(txFreqHz)
|
||||
val down = bandLetter(rxFreqHz)
|
||||
return if (up == down) "" else "$up/$down"
|
||||
}
|
||||
|
||||
/** LoTW-recognized satellite name: main name before parentheses, uppercased (ISS special case) */
|
||||
fun normalizeSatName(raw: String): String {
|
||||
val main = raw.substringBefore('(').trim()
|
||||
@@ -142,10 +187,11 @@ object WaveLogApi {
|
||||
val satName = normalizeSatName(qso.satName)
|
||||
|
||||
// v2: POST /index.php/api/v2/qso (JSON fields)
|
||||
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
|
||||
val v2Body = JSONObject().apply {
|
||||
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
|
||||
put("call", qso.call)
|
||||
put("band", "SAT")
|
||||
put("band", bandFromHz(qso.freqTxHz))
|
||||
put("mode", qso.mode)
|
||||
put("qso_date", utcDate(qso.timeUtcMs))
|
||||
put("time_on", utcTime(qso.timeUtcMs))
|
||||
@@ -155,6 +201,7 @@ object WaveLogApi {
|
||||
put("rst_sent", "59")
|
||||
put("rst_rcvd", "59")
|
||||
put("sat_name", satName)
|
||||
if (satMode.isNotBlank()) put("sat_mode", satMode)
|
||||
}
|
||||
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
|
||||
if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)")
|
||||
@@ -178,14 +225,15 @@ object WaveLogApi {
|
||||
}
|
||||
|
||||
/** v1 ADIF string (freq in MHz, length = UTF-8 byte count, sat_name normalized) */
|
||||
private fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String {
|
||||
internal fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String {
|
||||
fun field(name: String, value: String): String {
|
||||
val bytes = value.toByteArray(Charsets.UTF_8).size
|
||||
return "<$name:$bytes>$value"
|
||||
}
|
||||
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
|
||||
return buildString {
|
||||
append(field("call", qso.call))
|
||||
append(field("band", "SAT"))
|
||||
append(field("band", bandFromHz(qso.freqTxHz)))
|
||||
append(field("mode", qso.mode))
|
||||
append(field("freq", String.format(Locale.ENGLISH, "%.6f", qso.freqTxHz / 1_000_000.0)))
|
||||
if (qso.freqRxHz > 0) {
|
||||
@@ -195,9 +243,14 @@ object WaveLogApi {
|
||||
append(field("time_on", utcTimeCompact(qso.timeUtcMs)))
|
||||
append(field("rst_sent", "59"))
|
||||
append(field("rst_rcvd", "59"))
|
||||
if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare.take(4)))
|
||||
// Send the grid at full precision. Truncating to 4 characters threw
|
||||
// away the 6-character locator the QRZ lookup provides, coarsening the
|
||||
// stored position from ~4.6 km to ~100 km and making a QSO logged via
|
||||
// v1 disagree with the same QSO logged via v2 (which sends it whole).
|
||||
if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare))
|
||||
if (satName.isNotBlank()) {
|
||||
append(field("sat_name", satName))
|
||||
if (satMode.isNotBlank()) append(field("sat_mode", satMode))
|
||||
append(field("prop_mode", "SAT"))
|
||||
}
|
||||
append("<eor>")
|
||||
|
||||
@@ -81,6 +81,7 @@ class WavelogQueue(private val store: IWavelogQueueStore) {
|
||||
}
|
||||
|
||||
/** Update a QSO's counterpart grid (async backfill from the QRZ scraper, 4.5.5) */
|
||||
@Synchronized
|
||||
fun updateGridsquare(id: String, grid: String) {
|
||||
save(all().map { if (it.id == id) it.copy(gridsquare = grid) else it })
|
||||
}
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
package com.rtbishop.look4sat.core.domain
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||
import com.rtbishop.look4sat.core.domain.utility.aprsPasscode
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
@@ -111,6 +113,47 @@ class DataParserTest {
|
||||
assert(dataParser.parseCSVStream(invalidCSVStream).isEmpty())
|
||||
}
|
||||
|
||||
private fun csvWithEpoch(epoch: String) = """
|
||||
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,$epoch,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
@Test
|
||||
fun `Given CSV epoch one minute past midnight the day fraction is correct`() = runTest(testDispatcher) {
|
||||
// Regression: the day fraction used to be built by string surgery
|
||||
// (Double.toString().substring(1)), but toString switches to scientific
|
||||
// notation below 1e-3, so the leading significant digit was truncated.
|
||||
// 00:01:00 produced "25001.944444444444445E-4" -> 2.50019..., an epoch
|
||||
// roughly 26 years off, with no exception to reveal it.
|
||||
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:01:00.000000"))[0]
|
||||
assertEquals(25001.0 + 60.0 / 86400.0, sat.epoch, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given CSV epoch one second past midnight the day fraction is correct`() = runTest(testDispatcher) {
|
||||
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:00:01.000000"))[0]
|
||||
assertEquals(25001.0 + 1.0 / 86400.0, sat.epoch, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given CSV epoch exactly at midnight the day fraction is zero`() = runTest(testDispatcher) {
|
||||
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:00:00.000000"))[0]
|
||||
assertEquals(25001.0, sat.epoch, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given CSV epoch at midday the day fraction is one half`() = runTest(testDispatcher) {
|
||||
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T12:00:00.000000"))[0]
|
||||
assertEquals(25001.5, sat.epoch, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given CSV epoch late in the day the day fraction stays below one`() = runTest(testDispatcher) {
|
||||
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T23:59:59.999000"))[0]
|
||||
assert(sat.epoch > 25001.999) { "expected almost a full day, got ${sat.epoch}" }
|
||||
assert(sat.epoch < 25002.0) { "day fraction must not roll into the next day, got ${sat.epoch}" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) {
|
||||
val parsedList = dataParser.parseTLEStream(validTLEStream)
|
||||
@@ -239,4 +282,10 @@ class DataParserTest {
|
||||
// Matches the CSV test data epoch: 2021-11-16 → day 320
|
||||
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `check APRS passcode calculation`() {
|
||||
assert("M7LNB".aprsPasscode() == 12443)
|
||||
assert("N0CALL".aprsPasscode() == 13023)
|
||||
}
|
||||
}
|
||||
+93
-12
@@ -3,12 +3,17 @@ package com.rtbishop.look4sat.core.domain
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DopplerFrequencyCalculatorTest {
|
||||
|
||||
private fun linearTransponder(
|
||||
uuid: String = "linear",
|
||||
upLow: Long = 145_000_000L,
|
||||
upHigh: Long = 145_500_000L,
|
||||
downLow: Long = 435_000_000L,
|
||||
@@ -18,7 +23,7 @@ class DopplerFrequencyCalculatorTest {
|
||||
downlinkMode: String? = "USB",
|
||||
uplinkMode: String? = "LSB"
|
||||
) = SatRadio(
|
||||
uuid = "linear", info = info, isAlive = true,
|
||||
uuid = uuid, info = info, isAlive = true,
|
||||
downlinkLow = downLow, downlinkHigh = downHigh,
|
||||
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
|
||||
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
|
||||
@@ -68,19 +73,99 @@ class DopplerFrequencyCalculatorTest {
|
||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsTrueForAbbreviatedLinName() {
|
||||
// AO-7 style: "Mode V/A (A) Lin SSB" — "Lin" abbreviation, no "transponder" word
|
||||
val ao7Entry = linearTransponder(info = "Mode V/A (A) Lin SSB", downlinkMode = "USB", uplinkMode = "USB")
|
||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7Entry))
|
||||
val ao7CwEntry = linearTransponder(info = "Mode V/A (A) Lin CW", downlinkMode = "CW", uplinkMode = "CW")
|
||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7CwEntry))
|
||||
val ao7ModeBEntry = linearTransponder(info = "Mode U/V (B) Lin", downlinkMode = "USB", uplinkMode = "LSB")
|
||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7ModeBEntry))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsTrueForLinearWithoutTransponderWord() {
|
||||
// AO-73 style: "Mode U/V Linear" — has "Linear" but no "transponder"
|
||||
val ao73Entry = linearTransponder(info = "Mode U/V Linear", downlinkMode = "USB", uplinkMode = "LSB")
|
||||
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao73Entry))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsFalseForDownlinkContainingLinInsideWord() {
|
||||
// "Downlink" contains "lin" but is not a linear-transponder name
|
||||
val downlinkEntry = linearTransponder(info = "Mode U Downlink", downlinkMode = "FM", uplinkMode = "FM")
|
||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(downlinkEntry))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
|
||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deduplicateTransponders_mergesSameFrequencyRange() {
|
||||
// AO-7's Mode A: same range, SSB and CW entries
|
||||
val ssb = linearTransponder(
|
||||
uuid = "ssb-uuid", info = "Mode V/A (A) Lin SSB",
|
||||
downlinkMode = "USB", uplinkMode = "USB"
|
||||
)
|
||||
val cw = linearTransponder(
|
||||
uuid = "cw-uuid", info = "Mode V/A (A) Lin CW",
|
||||
downlinkMode = "CW", uplinkMode = "CW"
|
||||
)
|
||||
val modeB = linearTransponder(
|
||||
uuid = "modeb-uuid", info = "Mode U/V (B) Lin",
|
||||
upLow = 432_125_000L, upHigh = 432_175_000L,
|
||||
downLow = 145_925_000L, downHigh = 145_975_000L,
|
||||
downlinkMode = "USB", uplinkMode = "LSB"
|
||||
)
|
||||
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(ssb, cw, modeB))
|
||||
assertEquals(2, result.size)
|
||||
// SSB entry should be preferred over CW (same range)
|
||||
assertEquals("ssb-uuid", result[0].uuid)
|
||||
assertEquals("modeb-uuid", result[1].uuid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deduplicateTransponders_prefersNonCwEntry() {
|
||||
// JO-97: CW entry has invert=false (wrong), SSB has invert=true (correct)
|
||||
val cw = linearTransponder(
|
||||
uuid = "cw-uuid", info = "U/V CW Transponder",
|
||||
downlinkMode = "CW", uplinkMode = "CW",
|
||||
upLow = 435_100_000L, upHigh = 435_120_000L,
|
||||
downLow = 145_855_000L, downHigh = 145_875_000L
|
||||
)
|
||||
val ssb = linearTransponder(
|
||||
uuid = "ssb-uuid", info = "U/V SSB Transponder",
|
||||
downlinkMode = "USB", uplinkMode = "LSB",
|
||||
upLow = 435_100_000L, upHigh = 435_120_000L,
|
||||
downLow = 145_855_000L, downHigh = 145_875_000L,
|
||||
inverted = true
|
||||
)
|
||||
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(cw, ssb))
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("ssb-uuid", result[0].uuid)
|
||||
// Verify the correct invert flag is preserved
|
||||
assertTrue(result[0].isInverted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deduplicateTransponders_preservesUniqueEntries() {
|
||||
val t1 = linearTransponder(uuid = "t1", upLow = 145_000_000L, upHigh = 145_500_000L,
|
||||
downLow = 435_000_000L, downHigh = 435_500_000L)
|
||||
val t2 = linearTransponder(uuid = "t2", upLow = 435_000_000L, upHigh = 435_500_000L,
|
||||
downLow = 145_000_000L, downHigh = 145_500_000L)
|
||||
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(t1, t2))
|
||||
assertEquals(2, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_linear_noDoppler() {
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(0.0)
|
||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||
assertNotNull(uplink)
|
||||
assertTrue(uplink!! > 0)
|
||||
// With zero Doppler, result equals mapDownlinkToUplink output
|
||||
assertEquals(145_200_000L, uplink)
|
||||
}
|
||||
|
||||
@@ -95,24 +180,23 @@ class DopplerFrequencyCalculatorTest {
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() {
|
||||
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate
|
||||
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate.
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(7.0) // ~7 km/s receding
|
||||
val orbitalPos = pos(7.0)
|
||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||
assertNotNull(uplink)
|
||||
// Uplink freq should be Doppler shifted UP (compensating for receding)
|
||||
assertTrue(uplink!! > 145_200_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_fm_transponder_returnsNull() {
|
||||
fun computeUplinkFromDownlink_fmTransponder_returnsNull() {
|
||||
val orbitalPos = pos()
|
||||
val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos)
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeDownlinkFromUplink_fm_transponder_returnsNull() {
|
||||
fun computeDownlinkFromUplink_fmTransponder_returnsNull() {
|
||||
val orbitalPos = pos()
|
||||
val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos)
|
||||
assertNull(result)
|
||||
@@ -171,13 +255,11 @@ class DopplerFrequencyCalculatorTest {
|
||||
val orbitalPos = pos(0.0)
|
||||
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||
assertNotNull(uplink)
|
||||
// Inverted: offset from high end → maps to high end of uplink
|
||||
assertEquals(145_300_000L, uplink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun computeUplinkFromDownlink_roundTrip() {
|
||||
// downlink → uplink → downlink should round-trip
|
||||
val xpdr = linearTransponder()
|
||||
val orbitalPos = pos(3.5)
|
||||
val originalDownlink = 435_250_000L
|
||||
@@ -185,7 +267,6 @@ class DopplerFrequencyCalculatorTest {
|
||||
assertNotNull(uplink)
|
||||
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
|
||||
assertNotNull(roundTripDownlink)
|
||||
// Doppler round-trip: small residual due to freq-dependent Doppler
|
||||
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
|
||||
assertTrue("Round-trip error too large: $error", error < 10000)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,9 @@ class QthConverterTest {
|
||||
fun `Given boundary POS stays in valid grid`() {
|
||||
// antipodal / edge cases must not overflow the A-R / 0-9 / a-x alphabet
|
||||
assert(positionToQth(-90.0, -180.0, 8) == "AA00aa00")
|
||||
assert(positionToQth(90.0, 180.0, 8) == "RR00aa00")
|
||||
// Exact positive bounds belong to the final cell, not a modulo-wrapped
|
||||
// R-field/0-square combination that decodes 10°/20° away.
|
||||
assert(positionToQth(90.0, 180.0, 8) == "RR99xx99")
|
||||
assert(positionToQth(0.0, 0.0, 8) == "JJ00aa00")
|
||||
// roundtrip stability: 8-char roundtrip is stable across a sample of positions
|
||||
val positions = listOf(
|
||||
@@ -85,6 +87,55 @@ class QthConverterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Encoded locator always decodes back within one cell`() {
|
||||
// An 8-char cell is 30" lon x 15" lat, so a correct encode/decode pair
|
||||
// can never differ by more than that. Field clamping used to break this
|
||||
// near +90 / +180 and produced errors up to 10 deg lat / 20 deg lon.
|
||||
var worstLat = 0.0
|
||||
var worstLon = 0.0
|
||||
var worst = ""
|
||||
var lat = -90.0
|
||||
while (lat <= 90.0) {
|
||||
var lon = -180.0
|
||||
while (lon <= 180.0) {
|
||||
val qth = positionToQth(lat, lon, 8)
|
||||
?: error("valid position rejected: ($lat, $lon)")
|
||||
val pos = qthToPosition(qth) ?: error("own output rejected: $qth")
|
||||
val dLat = kotlin.math.abs(pos.latitude - lat)
|
||||
val dLon = kotlin.math.abs(pos.longitude - lon)
|
||||
if (dLat > worstLat || dLon > worstLon) {
|
||||
worstLat = maxOf(worstLat, dLat)
|
||||
worstLon = maxOf(worstLon, dLon)
|
||||
worst = "($lat, $lon) -> $qth -> (${pos.latitude}, ${pos.longitude})"
|
||||
}
|
||||
lon += 0.5
|
||||
}
|
||||
lat += 0.5
|
||||
}
|
||||
assert(worstLat <= 0.01 && worstLon <= 0.01) {
|
||||
"roundtrip drifted by (${worstLat}, ${worstLon}) deg, worst: $worst"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given out of range longitude returns null`() {
|
||||
// Maidenhead only covers -180..180; 181..360 used to be accepted and
|
||||
// encoded into a plausible-looking locator 20-200 deg away.
|
||||
assert(positionToQth(0.0, 181.0) == null)
|
||||
assert(positionToQth(0.0, 270.0) == null)
|
||||
assert(positionToQth(0.0, 360.0) == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given locator with out of range field returns null`() {
|
||||
// Fields run A-R; S-X in the first pair decoded past the poles.
|
||||
assert(qthToPosition("SS00aa") == null)
|
||||
assert(qthToPosition("XX99xx") == null)
|
||||
assert(qthToPosition("AS00aa") == null)
|
||||
assert(qthToPosition("AX99xx") == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given square returns correct 3x3 neighbors`() {
|
||||
// Reference grid from the QTH Locator screenshot: OL42
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.rtbishop.look4sat.core.domain.aprs
|
||||
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* APRS-IS is an ASCII line protocol. Formatting the position, altitude and
|
||||
* course/speed extensions with the JVM default locale produced Eastern Arabic
|
||||
* or Bengali digits on devices set to ar/fa/bn, and the server rejects those
|
||||
* packets.
|
||||
*
|
||||
* Regression guard: every formatted field must stay ASCII regardless of the
|
||||
* default locale.
|
||||
*/
|
||||
class AprsPacketLocaleTest {
|
||||
|
||||
private val original: Locale = Locale.getDefault()
|
||||
|
||||
@After
|
||||
fun restoreLocale() {
|
||||
Locale.setDefault(original)
|
||||
}
|
||||
|
||||
private val asciiPacket = Regex("^[\\x20-\\x7E]*$")
|
||||
|
||||
@Test
|
||||
fun position_staysAsciiUnderArabicLocale() {
|
||||
Locale.setDefault(Locale.forLanguageTag("ar-EG"))
|
||||
|
||||
val encoded = AprsPosition(39.9042, 116.4074, '/', '>').toUncompressedString()
|
||||
|
||||
assertTrue("not ASCII: $encoded", asciiPacket.matches(encoded))
|
||||
assertEquals("3954.25N/11624.44E>", encoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun position_staysAsciiUnderBengaliLocale() {
|
||||
Locale.setDefault(Locale.forLanguageTag("bn-BD"))
|
||||
|
||||
val encoded = AprsPosition(-33.8688, 151.2093, '/', '>').toUncompressedString()
|
||||
|
||||
assertTrue("not ASCII: $encoded", asciiPacket.matches(encoded))
|
||||
assertEquals("3352.13S/15112.56E>", encoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun altitudeAndCourseSpeed_stayAsciiUnderPersianLocale() {
|
||||
Locale.setDefault(Locale.forLanguageTag("fa-IR"))
|
||||
|
||||
val altitude = AprsPacket.formatAltitude(100.0)
|
||||
val courseSpeed = AprsPacket.formatCourseSpeed(10.0, 90f)
|
||||
val filter = AprsPacket.formatRangeFilter(39.9042, 116.4074, 100)
|
||||
|
||||
assertTrue("not ASCII: $altitude", asciiPacket.matches(altitude))
|
||||
assertTrue("not ASCII: $courseSpeed", asciiPacket.matches(courseSpeed))
|
||||
assertTrue("not ASCII: $filter", asciiPacket.matches(filter))
|
||||
assertEquals("/A=000328", altitude)
|
||||
assertEquals("/090/019", courseSpeed)
|
||||
assertEquals("r/39.904/116.407/100", filter)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun altitude_clampsNegativeToKeepSixDigitField() {
|
||||
// "%06d" of a negative value yields "/A=-00164": the '-' takes a digit
|
||||
// slot, so the extension is no longer a valid fixed-width field.
|
||||
assertEquals("/A=000000", AprsPacket.formatAltitude(-50.0))
|
||||
assertEquals("/A=000000", AprsPacket.formatAltitude(-1.0))
|
||||
assertEquals("/A=000328", AprsPacket.formatAltitude(100.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun courseSpeed_wrapsCourseIntoValidRange() {
|
||||
assertEquals("/000/019", AprsPacket.formatCourseSpeed(10.0, 360f))
|
||||
assertEquals("/359/019", AprsPacket.formatCourseSpeed(10.0, -1f))
|
||||
assertEquals("/090/019", AprsPacket.formatCourseSpeed(10.0, 90f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ambiguousPosition_staysAsciiUnderArabicLocale() {
|
||||
Locale.setDefault(Locale.forLanguageTag("ar-EG"))
|
||||
|
||||
for (ambiguity in 1..4) {
|
||||
val encoded = AprsPosition(39.9042, 116.4074, '/', '>', ambiguity)
|
||||
.toUncompressedString()
|
||||
assertTrue("ambiguity=$ambiguity not ASCII: $encoded", asciiPacket.matches(encoded))
|
||||
}
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.navigation
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Menu layout rules. Every case here is a bug that shipped at least once, so
|
||||
* treat a failure as a regression rather than a spec question.
|
||||
*/
|
||||
class MenuLayoutTest {
|
||||
|
||||
private val all = listOf(
|
||||
"Satellites", "Passes", "Radar", "Mutual", "Roaming",
|
||||
"CwDecode", "WavelogLog", "AMSAT", "Map", "Settings"
|
||||
)
|
||||
|
||||
private fun layout(
|
||||
screenOrder: List<String> = emptyList(),
|
||||
subMenuOrder: List<String> = emptyList(),
|
||||
hidden: List<String> = emptyList()
|
||||
) = MenuLayout.resolve(all, screenOrder, subMenuOrder, hidden)
|
||||
|
||||
@Test
|
||||
fun defaultsPutFivePagesOnTheBarAndTheRestBehindMore() {
|
||||
val l = layout()
|
||||
assertEquals(listOf("Satellites", "Passes", "Radar", "Map", "Settings"), l.mainIds)
|
||||
assertEquals(listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT"), l.moreIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsIsAlwaysReachable() {
|
||||
// Moving any page onto the bar used to push Settings out of BOTH menus,
|
||||
// permanently locking the user out of the settings page.
|
||||
for (page in listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT")) {
|
||||
val moved = MenuLayout.moveToMain(page, all, emptyList(), emptyList())
|
||||
val l = layout(moved.screenOrder, moved.subMenuOrder)
|
||||
assertTrue(
|
||||
"moving $page hid Settings: main=${l.mainIds} more=${l.moreIds}",
|
||||
"Settings" in l.mainIds || "Settings" in l.moreIds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingAmsatToMainActuallyTakesEffect() {
|
||||
// The legacy migration re-appended AMSAT to the sub-menu unconditionally,
|
||||
// silently undoing the user's choice.
|
||||
val moved = MenuLayout.moveToMain("AMSAT", all, emptyList(), emptyList())
|
||||
val l = layout(moved.screenOrder, moved.subMenuOrder)
|
||||
assertTrue("AMSAT missing from the bar: ${l.mainIds}", "AMSAT" in l.mainIds)
|
||||
assertTrue("AMSAT still behind More: ${l.moreIds}", "AMSAT" !in l.moreIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingWavelogLogToMainActuallyTakesEffect() {
|
||||
val moved = MenuLayout.moveToMain("WavelogLog", all, emptyList(), emptyList())
|
||||
val l = layout(moved.screenOrder, moved.subMenuOrder)
|
||||
assertTrue("WavelogLog missing from the bar: ${l.mainIds}", "WavelogLog" in l.mainIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newPagesUnknownToPersistedOrderDefaultToTheMoreMenu() {
|
||||
// Upgrading from a build that predates AMSAT and WavelogLog: neither list
|
||||
// mentions them, so both must land behind More rather than vanishing.
|
||||
val l = layout(
|
||||
screenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings"),
|
||||
subMenuOrder = listOf("Mutual", "Roaming", "CwDecode")
|
||||
)
|
||||
assertTrue("AMSAT should land behind More", "AMSAT" in l.moreIds)
|
||||
assertTrue("WavelogLog should land behind More", "WavelogLog" in l.moreIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyVisiblePageIsReachableFromSomeMenu() {
|
||||
// Pages beyond the five slots used to vanish instead of overflowing.
|
||||
val l = layout(
|
||||
screenOrder = listOf("Satellites", "Passes", "Radar", "Mutual", "Roaming", "Map", "Settings"),
|
||||
subMenuOrder = listOf("CwDecode", "WavelogLog", "AMSAT")
|
||||
)
|
||||
assertEquals("every page must be reachable", all.toSet(), (l.mainIds + l.moreIds).toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hiddenPagesAppearInNeitherMenu() {
|
||||
val l = layout(hidden = listOf("Radar", "Map"))
|
||||
assertTrue("Radar" !in l.mainIds && "Radar" !in l.moreIds)
|
||||
assertTrue("Map" !in l.mainIds && "Map" !in l.moreIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsCannotBeHidden() {
|
||||
val l = layout(hidden = listOf("Settings"))
|
||||
assertTrue("Settings" in l.mainIds || "Settings" in l.moreIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theBarNeverExceedsFiveSlots() {
|
||||
val l = layout(screenOrder = all)
|
||||
assertTrue("bar had ${l.mainIds.size} slots: ${l.mainIds}", l.mainIds.size <= MenuLayout.MAIN_SLOTS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingAPageOutOfTheBarPutsItBehindMore() {
|
||||
val moved = MenuLayout.moveToMore("Radar", all, emptyList(), emptyList())
|
||||
val l = layout(moved.screenOrder, moved.subMenuOrder)
|
||||
assertTrue("Radar" in l.moreIds)
|
||||
assertTrue("Radar" !in l.mainIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsCannotBeMovedOffTheBar() {
|
||||
val moved = MenuLayout.moveToMore("Settings", all, emptyList(), emptyList())
|
||||
val l = layout(moved.screenOrder, moved.subMenuOrder)
|
||||
assertTrue("Settings" in l.mainIds || "Settings" in l.moreIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveIsStableWhenAppliedTwice() {
|
||||
// Persisting what resolve() produced must not change the outcome, or the
|
||||
// settings list and the bar drift apart on the next recomposition.
|
||||
val first = layout()
|
||||
val second = layout(first.mainIds, first.moreIds)
|
||||
assertEquals(first.mainIds, second.mainIds)
|
||||
assertEquals(first.moreIds, second.moreIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingAPageOntoAFullBarEvictsAnotherPageIntoMore() {
|
||||
// The bar starts full (5 including Settings), so making room must push an
|
||||
// existing page into More instead of dropping it.
|
||||
val moved = MenuLayout.moveToMain("CwDecode", all, emptyList(), emptyList())
|
||||
val l = layout(moved.screenOrder, moved.subMenuOrder)
|
||||
assertTrue("CwDecode" in l.mainIds)
|
||||
assertEquals("nothing may be lost", all.toSet(), (l.mainIds + l.moreIds).toSet())
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.predict
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
import kotlin.math.abs
|
||||
|
||||
class CelestialComputerTest {
|
||||
|
||||
private val observer = GeoPos(22.314066, 108.706575)
|
||||
|
||||
private fun subLunarLongitude(gha: Double) = if (gha <= 180.0) -gha else 360.0 - gha
|
||||
|
||||
@Test
|
||||
fun `moon hour angle stays within one revolution`() {
|
||||
// Sample a full synodic month at 37-minute steps so the sweep crosses
|
||||
// every hour-angle quadrant many times over.
|
||||
var timeMillis = 1786060800_000L // 2026-08-07T00:00:00Z
|
||||
val stepMillis = 37 * 60 * 1000L
|
||||
val endMillis = timeMillis + 30L * 86400_000L
|
||||
var samples = 0
|
||||
while (timeMillis < endMillis) {
|
||||
val gha = CelestialComputer.getMoonPosition(observer, timeMillis).gha
|
||||
assertTrue("gha=$gha out of 0..360 at $timeMillis", gha >= 0.0 && gha < 360.0)
|
||||
val longitude = subLunarLongitude(gha)
|
||||
assertTrue(
|
||||
"sub-lunar longitude=$longitude out of -180..180 at $timeMillis",
|
||||
longitude >= -180.0 && longitude <= 180.0
|
||||
)
|
||||
samples++
|
||||
timeMillis += stepMillis
|
||||
}
|
||||
assertTrue("expected a meaningful sweep, got $samples samples", samples > 1000)
|
||||
}
|
||||
|
||||
private data class RiseSetCase(
|
||||
val name: String,
|
||||
val observer: GeoPos,
|
||||
val startIso: String
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `findSunRiseSet returns distinct sunrise and sunset for representative locations`() {
|
||||
val cases = listOf(
|
||||
RiseSetCase("Equator at March equinox", GeoPos(0.0, 0.0), "2026-03-20T00:00:00Z"),
|
||||
RiseSetCase("Equator at September equinox", GeoPos(0.0, 0.0), "2026-09-23T00:00:00Z"),
|
||||
RiseSetCase("Sydney winter", GeoPos(-33.8688, 151.2093), "2026-06-21T00:00:00Z"),
|
||||
RiseSetCase("Buenos Aires winter", GeoPos(-34.6037, -58.3816), "2026-06-21T00:00:00Z"),
|
||||
RiseSetCase("Cape Town winter", GeoPos(-33.9249, 18.4241), "2026-06-21T00:00:00Z"),
|
||||
RiseSetCase("London summer", GeoPos(51.5074, -0.1278), "2026-06-21T00:00:00Z")
|
||||
)
|
||||
|
||||
cases.forEach { testCase ->
|
||||
val result = CelestialComputer.findSunRiseSet(testCase.observer, testCase.startIso.toMillis())
|
||||
val daylightDuration = result.setTimeMillis - result.riseTimeMillis
|
||||
|
||||
assertTrue("${testCase.name}: sunrise should be non-zero", result.riseTimeMillis > 0L)
|
||||
assertTrue("${testCase.name}: sunset should be non-zero", result.setTimeMillis > 0L)
|
||||
assertTrue("${testCase.name}: sunset should be after sunrise", result.setTimeMillis > result.riseTimeMillis)
|
||||
assertTrue("${testCase.name}: daylight duration should be longer than 1 hour", daylightDuration > HOUR_MILLIS)
|
||||
assertTrue("${testCase.name}: daylight duration should be shorter than 24 hours", daylightDuration < DAY_MILLIS)
|
||||
|
||||
val riseElevation = CelestialComputer.getSunPosition(testCase.observer, result.riseTimeMillis).elevation
|
||||
val setElevation = CelestialComputer.getSunPosition(testCase.observer, result.setTimeMillis).elevation
|
||||
assertEquals("${testCase.name}: sunrise should converge near the standard threshold", SUNRISE_SET_THRESHOLD, riseElevation, 0.02)
|
||||
assertEquals("${testCase.name}: sunset should converge near the standard threshold", SUNRISE_SET_THRESHOLD, setElevation, 0.02)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findSunRiseSet does not return the same instant for equinox regression cases`() {
|
||||
listOf("2026-03-20T00:00:00Z", "2026-09-23T00:00:00Z").forEach { startIso ->
|
||||
val result = CelestialComputer.findSunRiseSet(GeoPos(0.0, 0.0), startIso.toMillis())
|
||||
val separationMillis = abs(result.setTimeMillis - result.riseTimeMillis)
|
||||
|
||||
assertTrue("$startIso: sunrise and sunset should be separated", separationMillis > HOUR_MILLIS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toMillis(): Long = Instant.parse(this).toEpochMilli()
|
||||
|
||||
private companion object {
|
||||
private const val SUNRISE_SET_THRESHOLD = -0.8333
|
||||
private const val HOUR_MILLIS = 60L * 60L * 1000L
|
||||
private const val DAY_MILLIS = 24L * HOUR_MILLIS
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* clipLon must reduce any longitude into [-180, 180] in bounded time. The old
|
||||
* while-loop never returned for extreme inputs: Infinity stays Infinity after
|
||||
* subtracting 360 (infinite loop), and ~1e12 degree values took billions of
|
||||
* iterations. The modulo rewrite must be bit-equivalent for all finite values.
|
||||
*/
|
||||
class ClipLonTest {
|
||||
|
||||
private fun reduced(v: Double) = ((v + 180.0) % 360.0 + 360.0) % 360.0 - 180.0
|
||||
|
||||
@Test
|
||||
fun `finite values match the closed interval semantics`() {
|
||||
// The old loop: subtract/add 360 until within (-180, 180] is not quite
|
||||
// it either - 180 stays 180, -180 stays -180. So the interval is closed
|
||||
// on both ends with +180 for positive-boundary hits.
|
||||
assertEquals(-180.0, clipLon(-180.0), 1e-12)
|
||||
assertEquals(180.0, clipLon(180.0), 1e-12)
|
||||
assertEquals(0.0, clipLon(360.0), 1e-12)
|
||||
assertEquals(180.0, clipLon(540.0), 1e-12)
|
||||
assertEquals(-180.0, clipLon(-540.0), 1e-12)
|
||||
assertEquals(-179.999, clipLon(180.001), 1e-9)
|
||||
assertEquals(179.999, clipLon(-180.001), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `modulo rewrite is equivalent to the loop across the domain`() {
|
||||
// Sweep the same range the old implementation covered in a probe:
|
||||
// every 0.01 degree from -10000 to 10000 must agree with the reference
|
||||
// reduction to within 1e-9.
|
||||
var v = -10000.0
|
||||
while (v <= 10000.0) {
|
||||
val reference = clipLonByLoop(v)
|
||||
assertEquals(reference, clipLon(v), 1e-9)
|
||||
v += 0.01
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extreme and non-finite inputs return immediately`() {
|
||||
// These used to hang the calling thread (Infinity loop) or take seconds.
|
||||
assertEquals(Double.POSITIVE_INFINITY, clipLon(Double.POSITIVE_INFINITY), 0.0)
|
||||
assertEquals(Double.NEGATIVE_INFINITY, clipLon(Double.NEGATIVE_INFINITY), 0.0)
|
||||
assertTrue(clipLon(Double.NaN).isNaN())
|
||||
assertFalse(clipLon(1e15).isNaN())
|
||||
assertTrue(clipLon(1e15) in -180.0..180.0)
|
||||
}
|
||||
|
||||
/** The old while-loop implementation, kept as the reference for equivalence. */
|
||||
private fun clipLonByLoop(longitude: Double): Double {
|
||||
var result = longitude
|
||||
while (result < -180.0) result += 360.0
|
||||
while (result > 180.0) result -= 360.0
|
||||
return minOf(maxOf(result, -180.0), 180.0)
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Verifies the WaveLog upload payload frequency/band fields.
|
||||
*
|
||||
* Regression: user reported QSOs landing in the 160m band. The v2 JSON
|
||||
* envelope must carry freq as a MHz string with an "M" suffix so WaveLog's
|
||||
* parse_frequency() reads it as Hz internally; a bare integer or bare MHz
|
||||
* value corrupts band derivation.
|
||||
*/
|
||||
class WaveLogApiPayloadTest {
|
||||
|
||||
// SO-50: uplink 145.850 MHz, downlink 436.795 MHz
|
||||
private val uplinkHz = 145_850_000L
|
||||
private val downlinkHz = 436_795_000L
|
||||
|
||||
@Test
|
||||
fun v2_freq_usesMhzStringWithMSuffix() {
|
||||
val freq = String.format(Locale.ENGLISH, "%.6fM", uplinkHz / 1_000_000.0)
|
||||
val freqRx = String.format(Locale.ENGLISH, "%.6fM", downlinkHz / 1_000_000.0)
|
||||
assertEquals("145.850000M", freq)
|
||||
assertEquals("436.795000M", freqRx)
|
||||
// WaveLog parse_frequency: "145.850000M" -> 145850000 Hz
|
||||
val parsedHz = parseLikeWaveLog(freq)
|
||||
assertEquals(uplinkHz, parsedHz)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v1_adif_freq_isBareMhzNumber() {
|
||||
// v1 ADIF <FREQ> is a bare MHz number per ADIF spec (no unit suffix)
|
||||
val freq = String.format(Locale.ENGLISH, "%.6f", uplinkHz / 1_000_000.0)
|
||||
assertEquals("145.850000", freq)
|
||||
val adifFreq = freq.toDouble() * 1_000_000
|
||||
assertEquals(uplinkHz.toDouble(), adifFreq, 1.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v1_adif_preservesSixCharacterGrid() {
|
||||
val qso = WavelogQso(
|
||||
id = "test",
|
||||
timeUtcMs = 0L,
|
||||
call = "BG7NTA",
|
||||
mode = "FM",
|
||||
freqTxHz = uplinkHz,
|
||||
freqRxHz = downlinkHz,
|
||||
satName = "SO-50"
|
||||
)
|
||||
|
||||
val adif = WaveLogApi.toAdif(qso, "OM89ab", "SO-50")
|
||||
|
||||
assertTrue(adif.contains("<gridsquare:6>OM89ab"))
|
||||
}
|
||||
|
||||
/** Mirrors WaveLog Logbook_model::parse_frequency: int = Hz, "12.3M" suffix = MHz. */
|
||||
private fun parseLikeWaveLog(raw: String): Long {
|
||||
val s = raw.trim()
|
||||
return if (s.endsWith("M", ignoreCase = true)) {
|
||||
(s.dropLast(1).toDouble() * 1_000_000).toLong()
|
||||
} else if (s.endsWith("k", ignoreCase = true)) {
|
||||
(s.dropLast(1).toDouble() * 1_000).toLong()
|
||||
} else {
|
||||
s.toLong()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun qsoFreqs_stayInSatelliteBands_afterDoppler() {
|
||||
// Doppler-corrected values must remain near the base frequency.
|
||||
// SPEED_OF_LIGHT = 299792458 m/s; distanceRate is km/s (x1000 -> m/s).
|
||||
val dopplerRate = 7.0 // km/s approaching
|
||||
val corrected = uplinkHz * (299_792_458.0 + dopplerRate * 1000.0) / 299_792_458.0
|
||||
assertTrue(
|
||||
"corrected within +-20kHz, got ${corrected - uplinkHz} Hz",
|
||||
kotlin.math.abs(corrected - uplinkHz) < 20_000
|
||||
)
|
||||
// band derivation: 145.x MHz -> 2m, never 160m (1.8-2.0 MHz)
|
||||
val mhz = corrected / 1_000_000.0
|
||||
assertTrue("145.x MHz stays in 2m, got $mhz MHz", mhz in 144.0..148.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun band_isRealBand_notSAT() {
|
||||
// SO-50: TX 145.850 MHz (VHF) -> band 2M, sat mode V/U
|
||||
assertEquals("2M", WaveLogApi.bandFromHz(145_850_000))
|
||||
assertEquals("V/U", WaveLogApi.satModeFrom(145_850_000, 436_795_000))
|
||||
// AO-73: TX 435.150 MHz (UHF up), RX 145.950 MHz (VHF down) -> band 70CM, U/V
|
||||
assertEquals("70CM", WaveLogApi.bandFromHz(435_150_000))
|
||||
assertEquals("U/V", WaveLogApi.satModeFrom(435_150_000, 145_950_000))
|
||||
// Same-band (e.g. simplex) -> empty sat mode
|
||||
assertEquals("", WaveLogApi.satModeFrom(145_850_000, 145_950_000))
|
||||
// Never 160m for satellite frequencies
|
||||
assertTrue(WaveLogApi.bandFromHz(145_850_000) != "160M")
|
||||
assertTrue(WaveLogApi.bandFromHz(436_795_000) != "160M")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun adif_containsRealBandAndSatMode() {
|
||||
// v2 payload fields (mirror postQso construction)
|
||||
val band = WaveLogApi.bandFromHz(uplinkHz)
|
||||
val satMode = WaveLogApi.satModeFrom(uplinkHz, downlinkHz)
|
||||
assertEquals("2M", band)
|
||||
assertEquals("V/U", satMode)
|
||||
}
|
||||
}
|
||||
+104
-4
@@ -27,13 +27,18 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -71,6 +76,8 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
@@ -143,7 +150,7 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
||||
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||
}
|
||||
val sdfTime = remember(isUtc) {
|
||||
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
|
||||
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
|
||||
}
|
||||
ElevatedCard(
|
||||
modifier = modifier
|
||||
@@ -209,6 +216,11 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayLocale(): Locale {
|
||||
val locale = Locale.getDefault()
|
||||
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
||||
ElevatedButton(
|
||||
@@ -224,8 +236,13 @@ fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier, enabled: Boolean = true) {
|
||||
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action) {
|
||||
fun IconCard(
|
||||
action: () -> Unit, resId: Int, modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true, containerColor: Color = Color.Unspecified
|
||||
) {
|
||||
val colors = if (containerColor == Color.Unspecified) CardDefaults.elevatedCardColors()
|
||||
else CardDefaults.elevatedCardColors(containerColor = containerColor)
|
||||
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action, colors = colors) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
|
||||
}
|
||||
@@ -280,6 +297,35 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass(
|
||||
altitude = 0, maxElevation = 0.0, orbitalObject = NearEarthObject(defaultOrbitalData), progress = 0f
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun InfoDialog(
|
||||
title: String,
|
||||
onDismiss: () -> Unit,
|
||||
onAccept: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
DialogShell(onDismissRequest = onDismiss) { padding ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = padding, top = padding, end = padding)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedDialog(
|
||||
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
||||
@@ -329,6 +375,56 @@ fun SharedDialog(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfirmDialog(
|
||||
title: String,
|
||||
onCancel: () -> Unit,
|
||||
onAccept: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
DialogShell(onDismissRequest = onCancel) { padding ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = padding, top = padding, end = padding)
|
||||
) {
|
||||
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = padding)
|
||||
)
|
||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WhatsNewDialog(onDismiss: () -> Unit) {
|
||||
InfoDialog(
|
||||
title = stringResource(R.string.pass_whatsnew_title),
|
||||
onDismiss = onDismiss,
|
||||
onAccept = onDismiss
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.pass_whatsnew_message),
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(0.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DialogShell(
|
||||
@@ -337,6 +433,9 @@ private fun DialogShell(
|
||||
) {
|
||||
val padding = LocalSpacing.current.large
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
val containerHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
|
||||
val maxSheetHeight = containerHeight - statusBarHeight
|
||||
val stopSheetFling = remember {
|
||||
object : NestedScrollConnection {
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
@@ -354,7 +453,7 @@ private fun DialogShell(
|
||||
onDismissRequest = onDismissRequest,
|
||||
sheetState = sheetState,
|
||||
dragHandle = null,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
|
||||
scrimColor = Color.Black.copy(alpha = 0.64f)
|
||||
) {
|
||||
Column(
|
||||
@@ -362,6 +461,7 @@ private fun DialogShell(
|
||||
verticalArrangement = Arrangement.spacedBy(padding),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = maxSheetHeight)
|
||||
.nestedScroll(stopSheetFling)
|
||||
) {
|
||||
content(padding)
|
||||
|
||||
+3
-9
@@ -24,13 +24,13 @@ import kotlinx.serialization.Serializable
|
||||
sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: String) : NavKey {
|
||||
|
||||
@Serializable
|
||||
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat, "Satellites")
|
||||
data object Satellites : Screen(R.drawable.ic_sputnik, R.string.nav_sat, "Satellites")
|
||||
|
||||
@Serializable
|
||||
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass, "Passes")
|
||||
|
||||
@Serializable
|
||||
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar, "Radar")
|
||||
data object Radar : Screen(R.drawable.ic_satellite, R.string.nav_radar, "Radar")
|
||||
|
||||
@Serializable
|
||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map, "Map")
|
||||
@@ -48,18 +48,12 @@ sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: Strin
|
||||
data object WavelogLog : Screen(R.drawable.ic_log, R.string.nav_log, "WavelogLog")
|
||||
|
||||
@Serializable
|
||||
data object AmSat : Screen(R.drawable.ic_satellites, R.string.nav_amsat, "AMSAT")
|
||||
data object AmSat : Screen(R.drawable.ic_satellite, R.string.nav_amsat, "AMSAT")
|
||||
|
||||
@Serializable
|
||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings")
|
||||
}
|
||||
|
||||
// UI settings: default main-menu page order (5 bottom-bar slots)
|
||||
val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings")
|
||||
|
||||
// UI settings: default More-menu order (pages behind "More")
|
||||
val defaultSubMenuOrder = listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT")
|
||||
|
||||
@Serializable
|
||||
data object RadarDestination : NavKey
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="15"
|
||||
android:viewportHeight="15">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M13.91,6.75c-1.17,2.25 -4.3,5.31 -6.07,6.94c-0.19,0.172 -0.48,0.172 -0.67,0C5.39,12.06 2.26,9 1.09,6.75C-1.48,1.8 5,-1.5 7.5,3.45C10,-1.5 16.48,1.8 13.91,6.75z" />
|
||||
</vector>
|
||||
@@ -1,9 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M14,21c1.93,0 3.62,-1.17 4,-3l-1.75,-0.88C16,18.21 15.33,19 14,19l-4.9,0c0.83,-1 1.5,-2.34 1.5,-4c0,-0.35 -0.03,-0.69 -0.08,-1L14,14v-2l-4.18,0C9,10.42 8,9.6 8,8c0,-1.93 1.57,-3.5 3.5,-3.5c1.5,0 2.79,0.95 3.28,2.28L16.63,6c-0.8,-2.05 -2.79,-3.5 -5.13,-3.5C8.46,2.5 6,4.96 6,8c0,1.78 0.79,2.9 1.49,4L6,12v2l2.47,0c0.08,0.31 0.13,0.64 0.13,1c0,2.7 -2.6,4 -2.6,4v2H14z" />
|
||||
</vector>
|
||||
@@ -1,9 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80L520,80L520,411Q538,422 549,439.5Q560,457 560,480Q560,513 536.5,536.5Q513,560 480,560Q447,560 423.5,536.5Q400,513 400,480Q400,457 411,439Q422,421 440,411L440,325Q388,339 354,381.5Q320,424 320,480Q320,546 367,593Q414,640 480,640Q546,640 593,593Q640,546 640,480Q640,444 625.5,413.5Q611,383 586,360L643,303Q678,336 699,381.5Q720,427 720,480Q720,580 650,650Q580,720 480,720Q380,720 310,650Q240,580 240,480Q240,390 297,323.5Q354,257 440,243L440,162Q321,177 240.5,267Q160,357 160,480Q160,614 253,707Q346,800 480,800Q614,800 707,707Q800,614 800,480Q800,411 773,351Q746,291 699,247L756,190Q813,245 846.5,319.5Q880,394 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Z" />
|
||||
</vector>
|
||||
+2
-4
@@ -1,12 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Standard Material refresh icon (24dp viewport), centered geometry -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
>
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,31 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="128"
|
||||
android:viewportHeight="128">
|
||||
<path
|
||||
android:pathData="m105.14,114.12a2.86,2.86 90,0 0,1.7 -0.85l19.33,-19.33a2.86,2.86 90,0 0,-0 -4.03l-26.58,-26.62a2.86,2.86 90,0 0,-1.84 -0.81l-7.92,-0.63 5.77,-5.77a2.86,2.86 90,0 0,-0 -4.03l-19.6,-19.64a2.86,2.86 90,0 0,-4.07 -0l-5.77,5.77 -0.63,-7.92a2.86,2.86 90,0 0,-0.81 -1.83l-26.63,-26.58a2.86,2.86 90,0 0,-4.07 -0l-19.29,19.29a2.86,2.86 90,0 0,-0 4.07l26.58,26.62a2.86,2.86 90,0 0,1.83 0.81l7.92,0.63 -5.77,5.77a2.86,2.86 90,0 0,-0 4.07l19.6,19.6a2.86,2.86 90,0 0,4.07 -0l5.77,-5.77 0.63,7.92a2.86,2.86 90,0 0,0.81 1.83l26.63,26.58a2.86,2.86 90,0 0,2.06 0.85,2.86 2.86,90 0,0 0.27,-0zM104.82,107.23 L80.97,83.38 80.16,72.69 85.58,67.27 96.28,68.12 120.13,91.93zM55.33,47.85 L44.63,47 20.78,23.19 36.08,7.89 59.89,31.74 60.74,42.44z"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="m62.65,115.81a2.86,2.86 90,1 0,-0 -5.73c-11.46,-0 -22.88,-4.35 -31.63,-13.11 -8.74,-8.74 -13.1,-20.17 -13.1,-31.61a2.86,2.86 90,1 0,-5.73 -0c-0,12.9 4.94,25.83 14.77,35.66 9.85,9.85 22.77,14.78 35.68,14.78z"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="m62.65,127a2.86,2.86 90,1 0,-0 -5.73c-14.32,-0 -28.61,-5.44 -39.55,-16.37 -10.93,-10.93 -16.38,-25.23 -16.38,-39.54a2.86,2.86 90,1 0,-5.73 -0c-0,15.76 6.03,31.56 18.06,43.59 12.03,12.03 27.82,18.05 43.6,18.05z"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="m62.65,104.7a2.86,2.86 90,1 0,-0 -5.73c-8.62,-0 -17.2,-3.26 -23.78,-9.84 -6.58,-6.58 -9.86,-15.17 -9.86,-23.77a2.86,2.86 90,1 0,-5.73 -0c-0,10.06 3.86,20.15 11.53,27.82 7.67,7.67 17.76,11.52 27.83,11.52z"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="m62.65,93.48a2.86,2.86 90,1 0,-0 -5.73c-5.74,-0 -11.45,-2.17 -15.84,-6.56 -4.38,-4.38 -6.56,-10.1 -6.56,-15.83a2.86,2.86 90,1 0,-5.73 -0c-0,7.19 2.76,14.41 8.24,19.88 5.48,5.48 12.69,8.23 19.89,8.23z"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
File renamed without changes.
@@ -1,9 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M240,800L280,640L120,640L140,560L300,560L340,400L180,400L200,320L360,320L400,160L480,160L440,320L600,320L640,160L720,160L680,320L840,320L820,400L660,400L620,560L780,560L760,640L600,640L560,800L480,800L520,640L360,640L320,800L240,800ZM380,560L540,560L580,400L420,400L380,560Z" />
|
||||
</vector>
|
||||
@@ -13,12 +13,16 @@
|
||||
<string name="nav_prefs">Ajustes</string>
|
||||
|
||||
<!-- Satellites screen -->
|
||||
<string name="sat_type_hint">Tipo: %s</string>
|
||||
<string name="sat_type_title">Seleccionar tipo de satélite</string>
|
||||
<string name="sat_type_hint">Modos: %s</string>
|
||||
<string name="sat_type_title">Seleccionar modos</string>
|
||||
<string name="sat_search_hint">Id - Nombre</string>
|
||||
<string name="sat_search_clear">Vaciar</string>
|
||||
<string name="sat_clear_all">Vaciar todo</string>
|
||||
<string name="sat_select_all">Seleccionar todo</string>
|
||||
<string name="sat_group_selected">Seleccionados</string>
|
||||
<string name="sat_group_available">Disponibles</string>
|
||||
<string name="sat_group_selected_count">Seleccionados (%1$d)</string>
|
||||
<string name="sat_group_available_count">Disponibles (%1$d)</string>
|
||||
<string name="sat_empty_list_message">
|
||||
Asegúrese de que su consulta de búsqueda sea correcta y que la base de datos esté actualizada</string>
|
||||
<string name="sat_warning_title">Alarma\!</string>
|
||||
@@ -26,7 +30,7 @@
|
||||
Esta aplicación incluye más de 9000 satélites.
|
||||
No tiene sentido rastrearlos todos a la vez.
|
||||
\n\nIntenta siempre limitar la lista a los que te
|
||||
interesen mediante la búsqueda y el selector de tipos.</string>
|
||||
interesen mediante la búsqueda y el selector de modos.</string>
|
||||
|
||||
<!-- Passes screen -->
|
||||
<string name="pass_filter_title">Filtrar pases</string>
|
||||
@@ -35,7 +39,7 @@
|
||||
<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">Tipo de modulación</string>
|
||||
<string name="pass_modes_title">Seleccionar modos</string>
|
||||
<string name="pass_elevation">Elevación: %.1f°</string>
|
||||
<string name="pass_altitude">Altitud: %.0f km</string>
|
||||
<string name="pass_empty_list_message">
|
||||
@@ -96,11 +100,11 @@
|
||||
<string name="prefs_loc_qth_title">QTH</string>
|
||||
<string name="prefs_loc_qth_error">Ubicación QTH no válida</string>
|
||||
<string name="prefs_loc_success">Ubicación actualizada con éxito</string>
|
||||
<string name="prefs_station_title">Ajustar ubicaicón de la estación</string>
|
||||
<string name="prefs_station_lat_text">Latitud de tu estación terrestre</string>
|
||||
<string name="prefs_station_lon_text">Longitud de tu estación terrestre</string>
|
||||
<string name="prefs_locator_title">Ajustes ubicación QTH</string>
|
||||
<string name="prefs_locator_text">Ajustar posición de la estación con tu ubicación</string>
|
||||
<string name="prefs_station_title">Ubicación de la estación</string>
|
||||
<string name="prefs_station_lat_text">Latitud de estación</string>
|
||||
<string name="prefs_station_lon_text">Longitud de estación</string>
|
||||
<string name="prefs_locator_title">Localizador QTH</string>
|
||||
<string name="prefs_locator_text">Posición por localizador</string>
|
||||
|
||||
<string name="prefs_data_title">Datos satelitales</string>
|
||||
<string name="prefs_data_entries">Satélites: %s</string>
|
||||
@@ -110,39 +114,52 @@
|
||||
<string name="prefs_data_clear">Vaciar</string>
|
||||
<string name="prefs_data_clear_success">Datos eliminados con éxito</string>
|
||||
<string name="prefs_data_update_success">Actualización completada con éxito</string>
|
||||
<string name="prefs_data_import_satellites_error">No se importaron satélites. Usa un TLE/3LE (.txt) u OMM (.csv) válido.</string>
|
||||
<string name="prefs_data_import_transceivers_error">No se importaron transceptores. Usa un SatNOGS (.json) válido.</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||
<string name="prefs_data_sources_title">Fuentes personalizadas</string>
|
||||
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">URL transceptores</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
|
||||
<string name="prefs_data_output_title">Data output</string>
|
||||
<string name="prefs_net_output">Network</string>
|
||||
<string name="prefs_data_output_title">Salida de datos</string>
|
||||
<string name="prefs_net_output">Red</string>
|
||||
<string name="prefs_bt_output">Bluetooth</string>
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
|
||||
<string name="prefs_net_title">Salida de datos de red</string>
|
||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
||||
<string name="nav_radiocontrol">Control de radio</string>
|
||||
<string name="rc_settings_title">Control CAT</string>
|
||||
<string name="rc_radio_model">Modelo de radio</string>
|
||||
<string name="rc_tx_device_hint">Dirección BT TX</string>
|
||||
<string name="rc_rx_device_hint">Dirección BT RX</string>
|
||||
<string name="rc_tx_name_hint">Nombre radio TX</string>
|
||||
<string name="rc_rx_name_hint">Nombre radio RX</string>
|
||||
<string name="rc_enable_switch">Activar CAT</string>
|
||||
|
||||
<string name="prefs_net_title">Salida de red</string>
|
||||
<string name="prefs_net_rotator_switch">Activar salida de rotación</string>
|
||||
<string name="prefs_net_rotator_address_hint">IP:Puerto</string>
|
||||
<string name="prefs_net_rotator_format_hint">Formato de datos</string>
|
||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||
<string name="prefs_net_frequency_switch">Activar salida de frecuencia</string>
|
||||
<string name="prefs_net_frequency_address_hint">IP:Puerto</string>
|
||||
<string name="prefs_net_frequency_format_hint">Formato de datos</string>
|
||||
|
||||
<string name="prefs_bt_title">Salida por Bluetooth</string>
|
||||
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
||||
<string name="prefs_bt_rotator_device_hint">Id dispositivo</string>
|
||||
<string name="prefs_bt_rotator_output_hint">Formato de datos</string>
|
||||
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
||||
<string name="prefs_bt_frequency_device_hint">Id dispositivo</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Formato de datos</string>
|
||||
<string name="prefs_bt_title">Salida Bluetooth</string>
|
||||
<string name="prefs_bt_rotator_switch">Activar salida de rotación</string>
|
||||
<string name="prefs_bt_rotator_device_hint">ID dispositivo</string>
|
||||
<string name="prefs_bt_rotator_output_hint">Formato</string>
|
||||
<string name="prefs_bt_frequency_switch">Activar salida de frecuencia</string>
|
||||
<string name="prefs_bt_frequency_device_hint">ID dispositivo</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Formato</string>
|
||||
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</string>
|
||||
<string name="prefs_net_perm_error">Revisa los permisos de red</string>
|
||||
|
||||
<string name="prefs_other_title">Otros ajustes</string>
|
||||
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
|
||||
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
|
||||
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
|
||||
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
|
||||
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
|
||||
<string name="prefs_other_title">Otros</string>
|
||||
<string name="prefs_other_switch_utc">Hora UTC</string>
|
||||
<string name="prefs_other_switch_update">Actualización automática</string>
|
||||
<string name="prefs_other_switch_sweep">Animación radar</string>
|
||||
<string name="prefs_other_switch_sensors">Sensores</string>
|
||||
<string name="prefs_other_switch_night_mode">Filtro nocturno</string>
|
||||
|
||||
<string name="prefs_highlight_title">Resaltado de elevación</string>
|
||||
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
||||
|
||||
@@ -94,10 +94,21 @@
|
||||
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* BARU: Decoder CW kini memakai jaringan saraf DeepCW — jauh lebih baik menafsir Morse sinyal lemah
|
||||
* BARU: menyertakan model fp32 penuh untuk akurasi tertinggi
|
||||
* BARU: Riwayat CW tersimpan permanen (teks tidak lagi hilang)
|
||||
* Waterfall CW: skema warna inferno baru dengan gradasi halus
|
||||
* Menu Lainnya: panel ramping di kanan, tanpa lapisan gelap
|
||||
* Halaman CW: tombol kembali yang tidak berfungsi dan baris status yang menyesatkan dihapus
|
||||
* Dukungan 64-bit (arm64) dipulihkan
|
||||
* Perbaikan: jeda/lanjut tidak lagi menampilkan galat "gagal menafsir" yang keliru
|
||||
* Perbaikan: memindahkan halaman ke menu utama tidak lagi menyembunyikan Pengaturan secara permanen
|
||||
* Perbaikan: AMSAT/WavelogLog kini dapat dipindahkan ke menu utama
|
||||
* Perbaikan: penguraian epoch satelit untuk waktu dalam 86 detik pertama tengah malam UTC
|
||||
* Perbaikan: halaman radar kini otomatis beralih ke pass berikutnya setelah pass saat ini berakhir
|
||||
* Perbaikan: perhitungan Doppler radar kini menggunakan nilai slider offset langsung
|
||||
* Perbaikan: perhitungan kemajuan pass dijaga terhadap pembagian dengan nol
|
||||
* Perbaikan: panggilan calculatePasses bersamaan tidak lagi memicu perhitungan duplikat
|
||||
* Perbaikan: pengiriman QSO duplikat WaveLog dan kondisi balapan pembaruan grid square
|
||||
</string>
|
||||
<string name="radar_back">Kembali</string>
|
||||
<string name="radar_notify">Beri tahu</string>
|
||||
|
||||
@@ -93,10 +93,21 @@
|
||||
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* BARU: Decoder CW kini memakai jaringan saraf DeepCW — jauh lebih baik menafsir Morse sinyal lemah
|
||||
* BARU: menyertakan model fp32 penuh untuk akurasi tertinggi
|
||||
* BARU: Riwayat CW tersimpan permanen (teks tidak lagi hilang)
|
||||
* Waterfall CW: skema warna inferno baru dengan gradasi halus
|
||||
* Menu Lainnya: panel ramping di kanan, tanpa lapisan gelap
|
||||
* Halaman CW: tombol kembali yang tidak berfungsi dan baris status yang menyesatkan dihapus
|
||||
* Dukungan 64-bit (arm64) dipulihkan
|
||||
* Perbaikan: jeda/lanjut tidak lagi menampilkan galat "gagal menafsir" yang keliru
|
||||
* Perbaikan: memindahkan halaman ke menu utama tidak lagi menyembunyikan Pengaturan secara permanen
|
||||
* Perbaikan: AMSAT/WavelogLog kini dapat dipindahkan ke menu utama
|
||||
* Perbaikan: penguraian epoch satelit untuk waktu dalam 86 detik pertama tengah malam UTC
|
||||
* Perbaikan: halaman radar kini otomatis beralih ke pass berikutnya setelah pass saat ini berakhir
|
||||
* Perbaikan: perhitungan Doppler radar kini menggunakan nilai slider offset langsung
|
||||
* Perbaikan: perhitungan kemajuan pass dijaga terhadap pembagian dengan nol
|
||||
* Perbaikan: panggilan calculatePasses bersamaan tidak lagi memicu perhitungan duplikat
|
||||
* Perbaikan: pengiriman QSO duplikat WaveLog dan kondisi balapan pembaruan grid square
|
||||
</string>
|
||||
<string name="radar_back">Kembali</string>
|
||||
<string name="radar_notify">Beri tahu</string>
|
||||
|
||||
@@ -13,19 +13,23 @@
|
||||
<string name="nav_prefs">Настройки</string>
|
||||
|
||||
<!-- Satellites screen -->
|
||||
<string name="sat_type_hint">Тип: %s</string>
|
||||
<string name="sat_type_title">Выберите тип спутника</string>
|
||||
<string name="sat_type_hint">Режимы: %s</string>
|
||||
<string name="sat_type_title">Выберите режимы</string>
|
||||
<string name="sat_search_hint">Id - Название</string>
|
||||
<string name="sat_search_clear">Очистить</string>
|
||||
<string name="sat_clear_all">Очистить</string>
|
||||
<string name="sat_select_all">Выбрать</string>
|
||||
<string name="sat_group_selected">Выбранные</string>
|
||||
<string name="sat_group_available">Доступные</string>
|
||||
<string name="sat_group_selected_count">Выбранные (%1$d)</string>
|
||||
<string name="sat_group_available_count">Доступные (%1$d)</string>
|
||||
<string name="sat_empty_list_message">Убедитесь, что ваш поисковый запрос верен и база данных обновлена</string>
|
||||
<string name="sat_warning_title">Внимание\!</string>
|
||||
<string name="sat_warning_message">
|
||||
В этом приложении перечислено более 9000 спутников.
|
||||
Отслеживать их все одновременно бессмысленно.
|
||||
\n\nВсегда старайтесь сузить список до тех,
|
||||
которые вас интересуют, используя поиск и селектор типов.</string>
|
||||
которые вас интересуют, используя поиск и выбор режимов.</string>
|
||||
|
||||
<!-- Passes screen -->
|
||||
<string name="pass_filter_title">Фильтровать пролеты</string>
|
||||
@@ -34,7 +38,7 @@
|
||||
<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_modes_title">Выберите режимы</string>
|
||||
<string name="pass_elevation">Элевация: %.1f°</string>
|
||||
<string name="pass_altitude">Высота: %d км</string>
|
||||
<string name="pass_empty_list_message">
|
||||
@@ -95,11 +99,11 @@
|
||||
<string name="prefs_loc_qth_title">QTH</string>
|
||||
<string name="prefs_loc_qth_error">Неправильный локатор QTH</string>
|
||||
<string name="prefs_loc_success">Позиция успешно обновлена</string>
|
||||
<string name="prefs_station_title">Настройки местоположения</string>
|
||||
<string name="prefs_station_lat_text">Введите широту наземной станции</string>
|
||||
<string name="prefs_station_lon_text">Введите долготу наземной станции</string>
|
||||
<string name="prefs_locator_title">Настройки локатора QTH</string>
|
||||
<string name="prefs_locator_text">Введите позицию через локатор</string>
|
||||
<string name="prefs_station_title">Положение станции</string>
|
||||
<string name="prefs_station_lat_text">Широта станции</string>
|
||||
<string name="prefs_station_lon_text">Долгота станции</string>
|
||||
<string name="prefs_locator_title">Локатор QTH</string>
|
||||
<string name="prefs_locator_text">Позиция по локатору</string>
|
||||
|
||||
<string name="prefs_data_title">Данные спутников</string>
|
||||
<string name="prefs_data_entries">Спутников: %s</string>
|
||||
@@ -109,16 +113,30 @@
|
||||
<string name="prefs_data_clear">Очистить</string>
|
||||
<string name="prefs_data_clear_success">Очистка прошла успешно</string>
|
||||
<string name="prefs_data_update_success">Обновление прошло успешно</string>
|
||||
<string name="prefs_data_import_satellites_error">Спутники не импортированы. Нужен TLE/3LE (.txt) или OMM (.csv).</string>
|
||||
<string name="prefs_data_import_transceivers_error">Трансиверы не импортированы. Нужен SatNOGS (.json).</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Свои источники данных</string>
|
||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Custom Transceivers URL</string>
|
||||
<string name="prefs_data_sources_title">Свои источники</string>
|
||||
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">URL трансиверов</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
|
||||
<string name="prefs_data_output_title">Вывод данных</string>
|
||||
<string name="prefs_data_output_title">Вывод</string>
|
||||
<string name="prefs_net_output">Сеть</string>
|
||||
<string name="prefs_bt_output">Bluetooth</string>
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
|
||||
<string name="prefs_net_title">Вывод сетевых данных</string>
|
||||
<string name="nav_radiocontrol">Управление радио</string>
|
||||
|
||||
<string name="rc_settings_title">CAT управление</string>
|
||||
<string name="rc_radio_model">Модель радио</string>
|
||||
<string name="rc_tx_device_hint">BT адрес TX</string>
|
||||
<string name="rc_rx_device_hint">BT адрес RX</string>
|
||||
<string name="rc_tx_name_hint">Имя радио TX</string>
|
||||
<string name="rc_rx_name_hint">Имя радио RX</string>
|
||||
<string name="rc_enable_switch">Включить CAT</string>
|
||||
|
||||
<string name="prefs_net_title">Сетевой вывод</string>
|
||||
<string name="prefs_net_rotator_switch">Включить вывод ротатора</string>
|
||||
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
|
||||
<string name="prefs_net_rotator_format_hint">Формат</string>
|
||||
@@ -126,22 +144,22 @@
|
||||
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
||||
<string name="prefs_net_frequency_format_hint">Формат</string>
|
||||
|
||||
<string name="prefs_bt_title">Вывод данных Bluetooth</string>
|
||||
<string name="prefs_bt_title">Вывод Bluetooth</string>
|
||||
<string name="prefs_bt_rotator_switch">Включить вывод ротатора</string>
|
||||
<string name="prefs_bt_rotator_device_hint">Id устройства</string>
|
||||
<string name="prefs_bt_rotator_device_hint">ID устройства</string>
|
||||
<string name="prefs_bt_rotator_output_hint">Формат</string>
|
||||
<string name="prefs_bt_frequency_switch">Включить вывод частоты</string>
|
||||
<string name="prefs_bt_frequency_device_hint">Id устройства</string>
|
||||
<string name="prefs_bt_frequency_device_hint">ID устройства</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Формат</string>
|
||||
<string name="prefs_bt_perm_error">Нет разрешения использовать bluetooth</string>
|
||||
<string name="prefs_net_perm_error">Нет разрешения использовать сеть</string>
|
||||
|
||||
<string name="prefs_other_title">Другие настройки</string>
|
||||
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
|
||||
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
|
||||
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
|
||||
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
|
||||
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
|
||||
<string name="prefs_other_title">Прочее</string>
|
||||
<string name="prefs_other_switch_utc">Время в UTC</string>
|
||||
<string name="prefs_other_switch_update">Автообновление данных</string>
|
||||
<string name="prefs_other_switch_sweep">Анимация радара</string>
|
||||
<string name="prefs_other_switch_sensors">Сенсоры</string>
|
||||
<string name="prefs_other_switch_night_mode">Ночной фильтр</string>
|
||||
|
||||
<string name="prefs_highlight_title">Подсветка высоты</string>
|
||||
<string name="prefs_highlight_low">Низко < %1$d°</string>
|
||||
|
||||
@@ -13,19 +13,23 @@
|
||||
<string name="nav_prefs">පසුතල</string>
|
||||
|
||||
<!-- Satellites screen -->
|
||||
<string name="sat_type_hint">වර්ගය: %s</string>
|
||||
<string name="sat_type_title">චන්ද්රිකා ආකාරය තෝරන්න</string>
|
||||
<string name="sat_type_hint">මාදිලි: %s</string>
|
||||
<string name="sat_type_title">මාදිලි තෝරන්න</string>
|
||||
<string name="sat_search_hint">Id - නම</string>
|
||||
<string name="sat_search_clear">පිරිසිදු ක°</string>
|
||||
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
|
||||
<string name="sat_select_all">සියල්ල තෝරන්න</string>
|
||||
<string name="sat_group_selected">තෝරාගත්</string>
|
||||
<string name="sat_group_available">තිබෙන</string>
|
||||
<string name="sat_group_selected_count">තෝරාගත් (%1$d)</string>
|
||||
<string name="sat_group_available_count">තිබෙන (%1$d)</string>
|
||||
<string name="sat_empty_list_message">
|
||||
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
|
||||
<string name="sat_warning_title">අවවාදයයි\!</string>
|
||||
<string name="sat_warning_message">
|
||||
මෙම යෙදුමේ ලැයිස්තුගත කර ඇති චන්ද්රිකා 9000 කට වඩා තිබේ.
|
||||
ඒවා සියල්ලම එකවර නිරීක්ෂණය කිරීම තේරුමක් නැති දෙයක්.
|
||||
\n\nසෙවීම සහ වර්ග තේරීම හරහා ඔබ උනන්දුවක් දක්වන
|
||||
\n\nසෙවීම සහ මාදිලි තේරීම හරහා ඔබ උනන්දුවක් දක්වන
|
||||
ඒවාට පමණක් ලැයිස්තුව පටු කිරීමට සැමවිටම උත්සාහ කරන්න.</string>
|
||||
|
||||
<!-- Passes screen -->
|
||||
@@ -35,7 +39,7 @@
|
||||
<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_modes_title">මූර්ජන තෝරන්න</string>
|
||||
<string name="pass_elevation">උත්තෝලනය: %.1f°</string>
|
||||
<string name="pass_altitude">උන්නතාශය: %d km</string>
|
||||
<string name="pass_empty_list_message">
|
||||
@@ -96,11 +100,11 @@
|
||||
<string name="prefs_loc_qth_title">QTH</string>
|
||||
<string name="prefs_loc_qth_error">QTH locator වලංගු නොවේ</string>
|
||||
<string name="prefs_loc_success">පිහිටීම යාවත්කාලීනය සාර්ථකයි</string>
|
||||
<string name="prefs_station_title">පොළෙහි පිහිටීම් පසුතල</string>
|
||||
<string name="prefs_station_lat_text">ඔබගේ පොළෙහි අක්ෂාංශය සකසන්න</string>
|
||||
<string name="prefs_station_lon_text">ඔබගේ පොළෙහි දේශාංශය සකසන්න</string>
|
||||
<string name="prefs_locator_title">QTH locator පසුතල</string>
|
||||
<string name="prefs_locator_text"> ඔබගේ locator භාවිතා කර පොළහි පිහිටීම සකසන්න</string>
|
||||
<string name="prefs_station_title">පොළ පිහිටීම</string>
|
||||
<string name="prefs_station_lat_text">පොළ අක්ෂාංශය</string>
|
||||
<string name="prefs_station_lon_text">පොළ දේශාංශය</string>
|
||||
<string name="prefs_locator_title">QTH locator</string>
|
||||
<string name="prefs_locator_text">locator මඟින් පොළ පිහිටීම සකසන්න</string>
|
||||
|
||||
<string name="prefs_data_title">චන්ද්රිකා දත්ත</string>
|
||||
<string name="prefs_data_entries">චන්ද්රිකා: %s</string>
|
||||
@@ -110,39 +114,52 @@
|
||||
<string name="prefs_data_clear">පිරිසිදු ක°</string>
|
||||
<string name="prefs_data_clear_success">දත්ත ඉවත් කිරීම සාර්ථකයි</string>
|
||||
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
|
||||
<string name="prefs_data_import_satellites_error">චන්ද්රිකා ආයාත නොවීය. වලංගු TLE/3LE (.txt) හෝ OMM (.csv) ගොනුවක් තෝරන්න.</string>
|
||||
<string name="prefs_data_import_transceivers_error">සම්ප්රේෂක ආයාත නොවීය. වලංගු SatNOGS (.json) ගොනුවක් තෝරන්න.</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||
<string name="prefs_data_sources_title">අභිරුචි මූලාශ්ර</string>
|
||||
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
|
||||
<string name="prefs_data_output_title">Data output</string>
|
||||
<string name="prefs_net_output">Network</string>
|
||||
<string name="prefs_data_output_title">දත්ත ප්රතිදානය</string>
|
||||
<string name="prefs_net_output">ජාලය</string>
|
||||
<string name="prefs_bt_output">Bluetooth</string>
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
|
||||
<string name="nav_radiocontrol">රේඩියෝ පාලනය</string>
|
||||
<string name="rc_settings_title">CAT පාලනය</string>
|
||||
<string name="rc_radio_model">රේඩියෝ මාදිලිය</string>
|
||||
<string name="rc_tx_device_hint">TX BT ලිපිනය</string>
|
||||
<string name="rc_rx_device_hint">RX BT ලිපිනය</string>
|
||||
<string name="rc_tx_name_hint">TX රේඩියෝ නම</string>
|
||||
<string name="rc_rx_name_hint">RX රේඩියෝ නම</string>
|
||||
<string name="rc_enable_switch">CAT සබල කරන්න</string>
|
||||
|
||||
<string name="prefs_net_title">ජාල ප්රතිදානය</string>
|
||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
||||
<string name="prefs_net_rotator_switch">Rotation ප්රතිදානය සබල කරන්න</string>
|
||||
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||
<string name="prefs_net_rotator_format_hint">දත්ත අකාරය</string>
|
||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||
<string name="prefs_net_rotator_format_hint">දත්ත ආකෘතිය</string>
|
||||
<string name="prefs_net_frequency_switch">Frequency ප්රතිදානය සබල කරන්න</string>
|
||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||
<string name="prefs_net_frequency_format_hint">දත්ත අකාරය</string>
|
||||
<string name="prefs_net_frequency_format_hint">දත්ත ආකෘතිය</string>
|
||||
|
||||
<string name="prefs_bt_title">Bluetooth දත්ත ප්රතිදානය</string>
|
||||
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
||||
<string name="prefs_bt_rotator_device_hint">උපාංග id</string>
|
||||
<string name="prefs_bt_rotator_output_hint">දත්ත අකාරය</string>
|
||||
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
||||
<string name="prefs_bt_frequency_device_hint">උපාංග id</string>
|
||||
<string name="prefs_bt_frequency_output_hint">දත්ත අකාරය</string>
|
||||
<string name="prefs_bt_title">Bluetooth ප්රතිදානය</string>
|
||||
<string name="prefs_bt_rotator_switch">Rotation ප්රතිදානය සබල කරන්න</string>
|
||||
<string name="prefs_bt_rotator_device_hint">උපාංග ID</string>
|
||||
<string name="prefs_bt_rotator_output_hint">දත්ත ආකෘතිය</string>
|
||||
<string name="prefs_bt_frequency_switch">Frequency ප්රතිදානය සබල කරන්න</string>
|
||||
<string name="prefs_bt_frequency_device_hint">උපාංග ID</string>
|
||||
<string name="prefs_bt_frequency_output_hint">දත්ත ආකෘතිය</string>
|
||||
<string name="prefs_bt_perm_error">Bluetooth අවසර පරික්ෂා ක°</string>
|
||||
<string name="prefs_net_perm_error">Network අවසර පරික්ෂා කරන්න</string>
|
||||
|
||||
<string name="prefs_other_title">වෙනත් සැකසුම්</string>
|
||||
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
|
||||
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
|
||||
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
|
||||
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
|
||||
<string name="prefs_other_switch_night_mode">රතු රාත්රී පෙරහන සබල කරන්න</string>
|
||||
<string name="prefs_other_title">වෙනත්</string>
|
||||
<string name="prefs_other_switch_utc">UTC වේලාව</string>
|
||||
<string name="prefs_other_switch_update">දත්ත ස්වයං යාවත්කාලීන</string>
|
||||
<string name="prefs_other_switch_sweep">Radar sweep</string>
|
||||
<string name="prefs_other_switch_sensors">සංවේදක</string>
|
||||
<string name="prefs_other_switch_night_mode">රාත්රී පෙරහන</string>
|
||||
|
||||
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
|
||||
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
||||
|
||||
@@ -98,10 +98,21 @@
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* YENİ: CW çözücü artık DeepCW sinir ağını kullanıyor — zayıf sinyal Morse çözümü çok daha iyi
|
||||
* YENİ: en yüksek doğruluk için tam fp32 DeepCW modeli dahil
|
||||
* YENİ: CW çözüm geçmişi kalıcı olarak saklanır (metin artık kaybolmaz)
|
||||
* CW şelale: yeni inferno renk şeması ve yumuşak geçişler
|
||||
* Daha Fazla menüsü: sağda ince panel, karartma yok
|
||||
* CW sayfası: işlevsiz geri düğmesi ve yanıltıcı durum satırı kaldırıldı
|
||||
* 64-bit (arm64) desteği geri geldi
|
||||
* Düzeltme: duraklat/devam artık hatalı "çözümleme başarısız" mesajı göstermiyor
|
||||
* Düzeltme: sayfaları ana menüye taşımak artık Ayarlar\'ı kalıcı olarak gizlemiyor
|
||||
* Düzeltme: AMSAT/WavelogLog artık ana menüye taşınabiliyor
|
||||
* Düzeltme: UTC gece yarısından sonraki ilk 86 saniye içinde uydu epoch ayrıştırma hatası
|
||||
* Düzeltme: radar sayfası mevcut geçiş sona erdiğinde otomatik olarak sonraki geçişe geçiyor
|
||||
* Düzeltme: radar Doppler hesaplaması artık canlı kayma kaydırıcı değerini kullanıyor
|
||||
* Düzeltme: geçiş ilerleme hesaplaması sıfıra bölme durumuna karşı korumalı
|
||||
* Düzeltme: eşzamanlı calculatePasses çağrıları artık yinelenen hesaplamaları tetiklemiyor
|
||||
* Düzeltme: WaveLog yinelenen QSO gönderimleri ve ızgara kare güncellemesi yarış durumları
|
||||
</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
|
||||
@@ -13,12 +13,16 @@
|
||||
<string name="nav_prefs">Налаштування</string>
|
||||
|
||||
<!-- Satellites screen -->
|
||||
<string name="sat_type_hint">Тип: %s</string>
|
||||
<string name="sat_type_title">Виберіть тип супутника</string>
|
||||
<string name="sat_type_hint">Режими: %s</string>
|
||||
<string name="sat_type_title">Виберіть режими</string>
|
||||
<string name="sat_search_hint">Id - Назва</string>
|
||||
<string name="sat_search_clear">Очистити</string>
|
||||
<string name="sat_clear_all">Очистити всі</string>
|
||||
<string name="sat_select_all">Вибрати всі</string>
|
||||
<string name="sat_group_selected">Обрані</string>
|
||||
<string name="sat_group_available">Доступні</string>
|
||||
<string name="sat_group_selected_count">Обрані (%1$d)</string>
|
||||
<string name="sat_group_available_count">Доступні (%1$d)</string>
|
||||
<string name="sat_empty_list_message">
|
||||
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
|
||||
<string name="sat_warning_title">Увага\!</string>
|
||||
@@ -26,7 +30,7 @@
|
||||
У цьому додатку перелічено понад 9000 супутників.
|
||||
Немає сенсу відстежувати їх усі одночасно.
|
||||
\n\nЗавжди намагайтеся звузити список лише до тих,
|
||||
які вас цікавлять, за допомогою пошуку та селектора типів.</string>
|
||||
які вас цікавлять, за допомогою пошуку та вибору режимів.</string>
|
||||
|
||||
<!-- Passes screen -->
|
||||
<string name="pass_filter_title">Фільтр прольотів</string>
|
||||
@@ -35,7 +39,7 @@
|
||||
<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_modes_title">Виберіть режими</string>
|
||||
<string name="pass_elevation">Піднес.: %.1f°</string>
|
||||
<string name="pass_altitude">Висота: %.0f км</string>
|
||||
<string name="pass_empty_list_message">
|
||||
@@ -96,11 +100,11 @@
|
||||
<string name="prefs_loc_qth_title">QTH</string>
|
||||
<string name="prefs_loc_qth_error">Некоректний локатор QTH</string>
|
||||
<string name="prefs_loc_success">Позицію успішно оновлено</string>
|
||||
<string name="prefs_station_title">Налаштування місцезнаходження станції</string>
|
||||
<string name="prefs_station_lat_text">Широта місцезнаходження станції</string>
|
||||
<string name="prefs_station_lon_text">Довгота місцезнаходження станції</string>
|
||||
<string name="prefs_locator_title">Налаштування QTH локатора</string>
|
||||
<string name="prefs_locator_text">Задати позицію за QTH локатором</string>
|
||||
<string name="prefs_station_title">Позиція станції</string>
|
||||
<string name="prefs_station_lat_text">Широта станції</string>
|
||||
<string name="prefs_station_lon_text">Довгота станції</string>
|
||||
<string name="prefs_locator_title">Локатор QTH</string>
|
||||
<string name="prefs_locator_text">Позиція за локатором</string>
|
||||
|
||||
<string name="prefs_data_title">Супутникові дані</string>
|
||||
<string name="prefs_data_entries">Супутників: %s</string>
|
||||
@@ -110,39 +114,52 @@
|
||||
<string name="prefs_data_clear">Очистити</string>
|
||||
<string name="prefs_data_clear_success">Дані успішно очищено</string>
|
||||
<string name="prefs_data_update_success">Оновлення успішне</string>
|
||||
<string name="prefs_data_import_satellites_error">Супутники не імпортовано. Потрібен TLE/3LE (.txt) або OMM (.csv).</string>
|
||||
<string name="prefs_data_import_transceivers_error">Трансивери не імпортовано. Потрібен SatNOGS (.json).</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||
<string name="prefs_data_sources_title">Власні джерела</string>
|
||||
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">URL трансиверів</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
|
||||
<string name="prefs_data_output_title">Data output</string>
|
||||
<string name="prefs_net_output">Network</string>
|
||||
<string name="prefs_data_output_title">Вивід даних</string>
|
||||
<string name="prefs_net_output">Мережа</string>
|
||||
<string name="prefs_bt_output">Bluetooth</string>
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
|
||||
<string name="prefs_net_title">Вивід мережевих даних</string>
|
||||
<string name="prefs_net_rotator_switch">Enable rotation output</string>
|
||||
<string name="nav_radiocontrol">Керування радіо</string>
|
||||
<string name="rc_settings_title">CAT керування</string>
|
||||
<string name="rc_radio_model">Модель радіо</string>
|
||||
<string name="rc_tx_device_hint">BT адреса TX</string>
|
||||
<string name="rc_rx_device_hint">BT адреса RX</string>
|
||||
<string name="rc_tx_name_hint">Назва радіо TX</string>
|
||||
<string name="rc_rx_name_hint">Назва радіо RX</string>
|
||||
<string name="rc_enable_switch">Увімкнути CAT</string>
|
||||
|
||||
<string name="prefs_net_title">Мережевий вивід</string>
|
||||
<string name="prefs_net_rotator_switch">Увімкнути вивід повороту</string>
|
||||
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
|
||||
<string name="prefs_net_rotator_format_hint">Формат даних</string>
|
||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||
<string name="prefs_net_frequency_switch">Увімкнути вивід частоти</string>
|
||||
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
|
||||
<string name="prefs_net_frequency_format_hint">Формат даних</string>
|
||||
|
||||
<string name="prefs_bt_title">Вивід даних Bluetooth</string>
|
||||
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
||||
<string name="prefs_bt_rotator_device_hint">Id пристрою</string>
|
||||
<string name="prefs_bt_title">Вивід Bluetooth</string>
|
||||
<string name="prefs_bt_rotator_switch">Увімкнути вивід повороту</string>
|
||||
<string name="prefs_bt_rotator_device_hint">ID пристрою</string>
|
||||
<string name="prefs_bt_rotator_output_hint">Формат даних</string>
|
||||
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
|
||||
<string name="prefs_bt_frequency_device_hint">Id пристрою</string>
|
||||
<string name="prefs_bt_frequency_switch">Увімкнути вивід частоти</string>
|
||||
<string name="prefs_bt_frequency_device_hint">ID пристрою</string>
|
||||
<string name="prefs_bt_frequency_output_hint">Формат даних</string>
|
||||
<string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</string>
|
||||
<string name="prefs_net_perm_error">Перевірте дозвіл мережі</string>
|
||||
|
||||
<string name="prefs_other_title">Інші налаштування</string>
|
||||
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
|
||||
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
|
||||
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
|
||||
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
|
||||
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
|
||||
<string name="prefs_other_title">Інше</string>
|
||||
<string name="prefs_other_switch_utc">Час в UTC</string>
|
||||
<string name="prefs_other_switch_update">Автооновлення даних</string>
|
||||
<string name="prefs_other_switch_sweep">Анімація радара</string>
|
||||
<string name="prefs_other_switch_sensors">Сенсори</string>
|
||||
<string name="prefs_other_switch_night_mode">Нічний фільтр</string>
|
||||
|
||||
<string name="prefs_highlight_title">Підсвічування висоти</string>
|
||||
<string name="prefs_highlight_low">Низько < %1$d°</string>
|
||||
|
||||
@@ -67,6 +67,8 @@
|
||||
<string name="sat_search_clear">清空</string>
|
||||
<string name="sat_clear_all">全部清空</string>
|
||||
<string name="sat_select_all">全选</string>
|
||||
<string name="sat_group_selected_count">已选 (%1$d)</string>
|
||||
<string name="sat_group_available_count">可选 (%1$d)</string>
|
||||
<string name="sat_empty_list_message">请确保您的搜索查询正确且数据库已更新</string>
|
||||
<string name="sat_warning_title">警告\!</string>
|
||||
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星</string>
|
||||
@@ -88,10 +90,21 @@
|
||||
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* 新: CW 解码改用 DeepCW 神经网络,弱信号摩尔斯码解码率大幅提升
|
||||
* 新: 内置完整版 fp32 模型,解码精度最高
|
||||
* 新: CW 解码历史永久保留(文字不再消失)
|
||||
* CW 瀑布图: 全新 inferno 配色与平滑渐变
|
||||
* 更多菜单: 改为靠右窄面板,不再压暗页面
|
||||
* CW 页面: 移除无效的返回键与误导性状态提示
|
||||
* 恢复 64 位(arm64)支持
|
||||
* 修复: 暂停/恢复不再误报"解码失败"
|
||||
* 修复: 移动页面到主菜单不再导致设置入口永久消失
|
||||
* 修复: AMSAT/WavelogLog 现在可以正常移到主菜单
|
||||
* 修复: UTC 午夜后 86 秒内的卫星历元解析错误
|
||||
* 修复: 雷达页面在当前过境结束后自动切换到下一个过境
|
||||
* 修复: 雷达多普勒计算现在使用实时的偏移滑块值
|
||||
* 修复: 过境进度计算防除零崩溃
|
||||
* 修复: 并发 calculatePasses 调用不再触发重复计算
|
||||
* 修复: WaveLog 重复提交 QSO 与网格更新竞态
|
||||
</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
@@ -283,4 +296,8 @@
|
||||
\n• BG7NTA</string>
|
||||
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
||||
|
||||
<string name="prefs_net_frequency_offset_hint" translatable="false">频率偏移 (Hz)</string>
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">范围: -50000 到 50000 Hz。正值提高上报频率; 负值降低。</string>
|
||||
<string name="prefs_other_compass_offset">雷达罗盘偏移</string>
|
||||
<string name="prefs_other_compass_offset_elev">雷达罗盘偏移 (仰角)</string>
|
||||
</resources>
|
||||
@@ -68,6 +68,8 @@
|
||||
<string name="sat_search_clear">Clear</string>
|
||||
<string name="sat_clear_all">Clear all</string>
|
||||
<string name="sat_select_all">Select all</string>
|
||||
<string name="sat_group_selected_count">Selected (%1$d)</string>
|
||||
<string name="sat_group_available_count">Available (%1$d)</string>
|
||||
<string name="sat_empty_list_message">"Make sure your search query is correct and the DB is updated"</string>
|
||||
<string name="sat_warning_title">Warning\!</string>
|
||||
<string name="sat_warning_message">
|
||||
@@ -100,10 +102,21 @@
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* NEW: CW decoder now uses the DeepCW neural network — far better weak-signal Morse decoding
|
||||
* NEW: ships the full fp32 DeepCW model for maximum decode accuracy
|
||||
* NEW: CW decode history is kept permanently (text no longer disappears)
|
||||
* CW waterfall: new inferno colour scheme with smooth gradients
|
||||
* More menu: slim right-hand panel, no dimming overlay
|
||||
* CW page: removed the dead back button and the misleading status line
|
||||
* Restored 64-bit (arm64) support
|
||||
* Fixed: pause/resume no longer shows a spurious "decode failed" error
|
||||
* Fixed: moving pages to main menu no longer hides Settings permanently
|
||||
* Fixed: AMSAT/WavelogLog can now be moved to main menu
|
||||
* Fixed: satellite epoch parsing for times within first 86 seconds of UTC midnight
|
||||
* Fixed: radar page now auto-switches to next pass after current pass ends
|
||||
* Fixed: radar Doppler calculation now uses live offset slider value
|
||||
* Fixed: pass progress calculation guards against division by zero
|
||||
* Fixed: concurrent calculatePasses calls no longer trigger duplicate calculations
|
||||
* Fixed: WaveLog duplicate QSO submissions and grid square update race conditions
|
||||
</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
@@ -316,4 +329,8 @@
|
||||
<string name="prefs_outro_license">The app comes with no warranty</string>
|
||||
<string name="prefs_outro_deepcw" translatable="false">CW decoding uses the DeepCW model by e04, licensed under AGPL-3.0-only.\nhttps://github.com/e04/deepcw-engine</string>
|
||||
|
||||
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||
<string name="prefs_other_compass_offset">Radar compass offset</string>
|
||||
<string name="prefs_other_compass_offset_elev">Radar compass offset (elev)</string>
|
||||
</resources>
|
||||
@@ -1,2 +1,7 @@
|
||||
* Added required tweaks to support Android 17 (API 37)
|
||||
* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
||||
* Added AMSAT status tracking page by MCKero6423 (#234)
|
||||
* Tweaked Radar track on reposition by atsunatsu (#235)
|
||||
* Tweaked linear transponder calc by atsunatsu (#236)
|
||||
* Added saving per-sat doppler offset by atsunatsu (#237)
|
||||
* Added custom freq offset setting to network reporting
|
||||
* Added ability to customize data sources via import
|
||||
* Fixed star chart in README by PingouinFerreux (#240)
|
||||
+11
-12
@@ -40,19 +40,18 @@ CPU; timed per-window on device and reported via `lastInferenceMs`).
|
||||
|
||||
## Model
|
||||
|
||||
| | fp32 (original) | int8 (shipped) |
|
||||
|---|---|---|
|
||||
| Size | 15,139,839 bytes | 4,248,808 bytes |
|
||||
| Derivation | — | `quantize_dynamic` (weights → QUInt8, activations float32) |
|
||||
| Input | `spectrogram` [1,1,T,65] float32 | unchanged |
|
||||
| Output | `log_probs` [1,T,42] float32 | unchanged |
|
||||
| In APK | no (available as a release asset) | yes (`assets/deepcw/model.onnx`) |
|
||||
| | Value |
|
||||
|---|---|
|
||||
| File | `assets/deepcw/model.onnx` (fp32, as published upstream) |
|
||||
| Size | 15,139,839 bytes |
|
||||
| Modifications | none — vendored byte-for-byte |
|
||||
| Input | `spectrogram` [1,1,T,65] float32 |
|
||||
| Output | `log_probs` [1,T,42] float32 |
|
||||
|
||||
The int8 model ships inside the APK: it is ~4× smaller and measurably identical
|
||||
to fp32 on synthetic CW at SNR ≥ −4 dB (both degrade together below that). The
|
||||
fp32 model is published as a separate release asset for anyone who wants the
|
||||
highest-fidelity reference. Both are AGPL-3.0-only — see
|
||||
[`licenses/NOTICE.md`](licenses/NOTICE.md) for provenance, commit SHA and hashes.
|
||||
The full fp32 model ships inside the APK for maximum decode fidelity. An int8
|
||||
`quantize_dynamic` build was trialled earlier (~4× smaller, measurably identical
|
||||
at SNR ≥ −4 dB) but the shipped artifact is now the unmodified fp32 model. See
|
||||
[`licenses/NOTICE.md`](licenses/NOTICE.md) for provenance, commit SHA and hash.
|
||||
|
||||
Audio must be packaged **uncompressed** (`noCompress += "onnx"` in the app
|
||||
module): ONNX Runtime mmap's assets and refuses compressed ones.
|
||||
|
||||
@@ -14,11 +14,9 @@ network model obtained from the DeepCW project.
|
||||
| **License** | GNU Affero General Public License v3.0 only (AGPL-3.0-only) |
|
||||
| **License text** | [`DeepCW-AGPL-3.0.txt`](DeepCW-AGPL-3.0.txt) |
|
||||
| **Obtained at commit** | `8e264d243bbd4467bd19f3f28292219405b47e0e` |
|
||||
| **Original file size** | 15,139,839 bytes |
|
||||
| **Original SHA-256** | `ef120799457bca042d4690944f0faf93268eb4654e7f50f28784ad63bdc1fe02` |
|
||||
| **Derived file size** | 4,248,808 bytes |
|
||||
| **Derived SHA-256** | `cd48259be0ea8c30ecbfff4a718644f361cb27b9228b030771b0c94756dcab98` |
|
||||
| **Derivation** | Dynamic int8 quantization (weights → QUInt8, activations stay float32) via `onnxruntime.quantization.quantize_dynamic`. Input/output names, shapes and dtypes are unchanged. Measured CER on synthetic CW audio is identical to the fp32 model at SNR >= -4 dB; at -6/-8 dB both models degrade similarly. |
|
||||
| **File size** | 15,139,839 bytes |
|
||||
| **SHA-256** | `ef120799457bca042d4690944f0faf93268eb4654e7f50f28784ad63bdc1fe02` |
|
||||
| **Modifications** | None. The full fp32 model is vendored byte-for-byte as published upstream. |
|
||||
|
||||
Related upstream repositories by the same author (not vendored here):
|
||||
|
||||
@@ -52,7 +50,6 @@ the repository hosting this file.
|
||||
### Reproducing the vendored files
|
||||
|
||||
```bash
|
||||
# 1) Fetch the original fp32 model
|
||||
SHA=8e264d243bbd4467bd19f3f28292219405b47e0e
|
||||
curl -sLO https://raw.githubusercontent.com/e04/deepcw-engine/$SHA/model.onnx
|
||||
curl -sLO https://raw.githubusercontent.com/e04/deepcw-engine/$SHA/model.onnx.json
|
||||
@@ -60,15 +57,7 @@ curl -sL -o DeepCW-AGPL-3.0.txt \
|
||||
https://raw.githubusercontent.com/e04/deepcw-engine/$SHA/LICENSE
|
||||
sha256sum model.onnx
|
||||
# expected: ef120799457bca042d4690944f0faf93268eb4654e7f50f28784ad63bdc1fe02
|
||||
|
||||
# 2) Reproduce the int8 quantization this repository ships
|
||||
python - <<'PY'
|
||||
from onnxruntime.quantization import quantize_dynamic, QuantType
|
||||
quantize_dynamic("model.onnx", "model_int8.onnx", weight_type=QuantType.QUInt8)
|
||||
PY
|
||||
sha256sum model_int8.onnx
|
||||
# expected: cd48259be0ea8c30ecbfff4a718644f361cb27b9228b030771b0c94756dcab98
|
||||
# then copy model_int8.onnx over assets/deepcw/model.onnx
|
||||
# copy model.onnx and model.onnx.json into assets/deepcw/ unchanged
|
||||
```
|
||||
|
||||
## ONNX Runtime
|
||||
|
||||
Binary file not shown.
@@ -77,7 +77,7 @@ import com.rtbishop.look4sat.core.presentation.R as CoreR
|
||||
* 400-1200 Hz window and tracks speed on its own, so there is nothing to tune.
|
||||
*/
|
||||
@Composable
|
||||
fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
|
||||
fun CwDecodeScreen() {
|
||||
val context = LocalContext.current
|
||||
val container = remember { (context.applicationContext as IContainerProvider).getMainContainer() }
|
||||
val decoder = remember { container.provideCwDecoder() }
|
||||
@@ -95,9 +95,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
|
||||
|
||||
val decodedText by decoder.decodedText.collectAsState()
|
||||
val historyText by decoder.historyText.collectAsState()
|
||||
val estimatedPitch by decoder.estimatedPitch.collectAsState()
|
||||
val signalStrength by decoder.signalStrength.collectAsState()
|
||||
val inferenceMs by decoder.lastInferenceMs.collectAsState()
|
||||
val errorMessage by decoder.errorMessage.collectAsState()
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
@@ -139,27 +137,13 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = { isListening = false; navigateUp() }) {
|
||||
Icon(
|
||||
painter = painterResource(CoreR.drawable.ic_back),
|
||||
contentDescription = stringResource(R.string.cw_back)
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(CoreR.string.nav_cw),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = if (estimatedPitch != null) {
|
||||
stringResource(R.string.cw_status_tone, estimatedPitch!!.toInt(), inferenceMs)
|
||||
} else {
|
||||
stringResource(R.string.cw_status_listening)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(CoreR.string.nav_cw),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 12.dp)
|
||||
)
|
||||
IconButton(onClick = { isListening = !isListening }) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
|
||||
@@ -31,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* Rolling spectrogram history for the waterfall display.
|
||||
@@ -46,6 +47,10 @@ class CwWaterfallState(private val historyRows: Int = 96) {
|
||||
private val lock = Any()
|
||||
private val rows = ArrayDeque<FloatArray>(historyRows)
|
||||
private val pending = ArrayList<Float>(CwDeepSpectrogram.SAMPLE_RATE)
|
||||
// Incremented by clear(). A pushSamples call records the generation before
|
||||
// doing FFT outside the lock and discards its result if a clear occurred in
|
||||
// the meantime, otherwise pre-clear audio would reappear after the button tap.
|
||||
private var generation = 0L
|
||||
|
||||
/**
|
||||
* Bumped on every change so Compose knows to redraw.
|
||||
@@ -66,6 +71,7 @@ class CwWaterfallState(private val historyRows: Int = 96) {
|
||||
chunk, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
||||
)
|
||||
val audio: FloatArray
|
||||
val generationAtStart: Long
|
||||
synchronized(lock) {
|
||||
pending.ensureCapacity(pending.size + resampled.size)
|
||||
for (sample in resampled) pending.add(sample)
|
||||
@@ -73,25 +79,34 @@ class CwWaterfallState(private val historyRows: Int = 96) {
|
||||
if (pending.size < CwDeepSpectrogram.FFT_LENGTH) return
|
||||
audio = FloatArray(pending.size) { pending[it] }
|
||||
pending.clear()
|
||||
generationAtStart = generation
|
||||
}
|
||||
|
||||
// FFT outside the lock; only the append below needs exclusivity.
|
||||
val computed = CwDeepSpectrogram.compute(audio)
|
||||
synchronized(lock) {
|
||||
// Drop the result when the user cleared the display while this FFT
|
||||
// was running: those samples belong to the discarded history.
|
||||
if (generation != generationAtStart) return
|
||||
for (row in computed) {
|
||||
if (rows.size >= historyRows) rows.removeFirst()
|
||||
rows.addLast(row)
|
||||
}
|
||||
}
|
||||
_revision.value += 1
|
||||
// update {} not `value += 1`: this runs on the audio capture thread while
|
||||
// clear() runs on the main thread, and `+=` is a non-atomic
|
||||
// read-modify-write. A lost increment means a dropped redraw, and two
|
||||
// writes landing on the same value make StateFlow report no change at all.
|
||||
_revision.update { it + 1 }
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
synchronized(lock) {
|
||||
generation++
|
||||
rows.clear()
|
||||
pending.clear()
|
||||
}
|
||||
_revision.value += 1
|
||||
_revision.update { it + 1 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="cw_back">Kembali</string>
|
||||
<string name="cw_pause">Jeda pendekodean</string>
|
||||
<string name="cw_resume">Lanjutkan pendekodean</string>
|
||||
<string name="cw_clear">Hapus teks hasil dekode</string>
|
||||
<string name="cw_status_listening">Mendengarkan…</string>
|
||||
<string name="cw_status_tone">%1$d Hz · %2$d ms</string>
|
||||
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk pendekodean CW</string>
|
||||
<string name="cw_grant_permission">Berikan izin</string>
|
||||
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="cw_back">Kembali</string>
|
||||
<string name="cw_pause">Jeda pendekodean</string>
|
||||
<string name="cw_resume">Lanjutkan pendekodean</string>
|
||||
<string name="cw_clear">Hapus teks hasil dekode</string>
|
||||
<string name="cw_status_listening">Mendengarkan…</string>
|
||||
<string name="cw_status_tone">%1$d Hz · %2$d ms</string>
|
||||
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk pendekodean CW</string>
|
||||
<string name="cw_grant_permission">Berikan izin</string>
|
||||
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="cw_back">Geri</string>
|
||||
<string name="cw_pause">Çözmeyi duraklat</string>
|
||||
<string name="cw_resume">Çözmeyi sürdür</string>
|
||||
<string name="cw_clear">Çözülen metni temizle</string>
|
||||
<string name="cw_status_listening">Dinleniyor…</string>
|
||||
<string name="cw_status_tone">%1$d Hz · %2$d ms</string>
|
||||
<string name="cw_mic_permission">CW çözümü için mikrofon izni gerekli</string>
|
||||
<string name="cw_grant_permission">İzin ver</string>
|
||||
<string name="cw_open_settings">Uygulama ayarlarını aç</string>
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="cw_back">返回</string>
|
||||
<string name="cw_pause">暂停解码</string>
|
||||
<string name="cw_resume">继续解码</string>
|
||||
<string name="cw_clear">清空解码文本</string>
|
||||
<string name="cw_status_listening">正在监听…</string>
|
||||
<string name="cw_status_tone">%1$d Hz · %2$d 毫秒</string>
|
||||
<string name="cw_mic_permission">CW 解码需要麦克风权限</string>
|
||||
<string name="cw_grant_permission">授予权限</string>
|
||||
<string name="cw_open_settings">打开应用设置</string>
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="cw_back">Back</string>
|
||||
<string name="cw_pause">Pause decoding</string>
|
||||
<string name="cw_resume">Resume decoding</string>
|
||||
<string name="cw_clear">Clear decoded text</string>
|
||||
<string name="cw_status_listening">Listening…</string>
|
||||
<string name="cw_status_tone">%1$d Hz · %2$d ms</string>
|
||||
<string name="cw_mic_permission">Microphone permission is required for CW decoding</string>
|
||||
<string name="cw_grant_permission">Grant permission</string>
|
||||
<string name="cw_open_settings">Open app settings</string>
|
||||
|
||||
@@ -49,6 +49,7 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
import kotlin.math.abs
|
||||
|
||||
class MapViewModel(
|
||||
private val satelliteRepo: ISatelliteRepo,
|
||||
@@ -219,26 +220,23 @@ class MapViewModel(
|
||||
var orbitalPass = defaultPass
|
||||
var aosTime = 0L.toTimerString()
|
||||
var isTimeAos = true
|
||||
allPasses.find { pass -> pass.catNum == sat.data.catnum && pass.progress < 1 }
|
||||
?.let { satPass ->
|
||||
orbitalPass = satPass
|
||||
if (!satPass.isDeepSpace) {
|
||||
when {
|
||||
date.time < satPass.aosTime -> {
|
||||
// Pass hasn't started yet — count down to AOS
|
||||
isTimeAos = true
|
||||
aosTime = (satPass.aosTime - date.time).toTimerString()
|
||||
}
|
||||
|
||||
date.time < satPass.losTime -> {
|
||||
// Pass is in progress — count down to LOS
|
||||
isTimeAos = false
|
||||
aosTime = (satPass.losTime - date.time).toTimerString()
|
||||
}
|
||||
// else: pass has ended (losTime <= date.time) — keep default "00:00:00"
|
||||
}
|
||||
// Select by time, not by OrbitalPass.progress: that field defaults to 0
|
||||
// and is only ever filled in by PassesViewModel's own local copy, never
|
||||
// written back to the repository. Filtering on `progress < 1` therefore
|
||||
// always matched the satellite's *first* pass, so once it ended the map
|
||||
// countdown stuck at 00:00:00 and never advanced to the next pass.
|
||||
currentOrNextPass(sat.data.catnum, date.time)?.let { satPass ->
|
||||
orbitalPass = satPass
|
||||
if (!satPass.isDeepSpace) {
|
||||
if (date.time < satPass.aosTime) {
|
||||
isTimeAos = true
|
||||
aosTime = (satPass.aosTime - date.time).toTimerString()
|
||||
} else if (date.time < satPass.losTime) {
|
||||
isTimeAos = false
|
||||
aosTime = (satPass.losTime - date.time).toTimerString()
|
||||
}
|
||||
}
|
||||
}
|
||||
val azimuth = satPos.azimuth.toDegrees()
|
||||
val elevation = satPos.elevation.toDegrees()
|
||||
val osmPos = satPos.toMapGeoPos()
|
||||
@@ -265,31 +263,15 @@ class MapViewModel(
|
||||
return satData to orbitalPass
|
||||
}
|
||||
|
||||
/** Ongoing pass for [catnum] if any, otherwise its next upcoming pass. */
|
||||
private fun currentOrNextPass(catnum: Int, timeMillis: Long): OrbitalPass? =
|
||||
selectCurrentOrNextPass(allPasses, catnum, timeMillis)
|
||||
|
||||
private suspend fun getSatTrack(orbitalObject: OrbitalObject, pos: GeoPos, date: Date) {
|
||||
val satTracks = mutableListOf<List<GeoPos>>()
|
||||
val currentTrack = mutableListOf<GeoPos>()
|
||||
val endDate = Date(date.time + (orbitalObject.data.orbitalPeriod * 2.4 * 60000L).toLong())
|
||||
var oldLongitude = 0.0
|
||||
satelliteRepo.getTrack(orbitalObject, pos, date.time, endDate.time).forEach { satPos ->
|
||||
val currentPosition = satPos.toMapGeoPos()
|
||||
if (oldLongitude < -170.0 && currentPosition.longitude > 170.0) {
|
||||
// adding left terminal position
|
||||
currentTrack.add(GeoPos(currentPosition.latitude, -180.0))
|
||||
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
|
||||
satTracks.add(finishedTrack)
|
||||
currentTrack.clear()
|
||||
} else if (oldLongitude > 170.0 && currentPosition.longitude < -170.0) {
|
||||
// adding right terminal position
|
||||
currentTrack.add(GeoPos(currentPosition.latitude, 180.0))
|
||||
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
|
||||
satTracks.add(finishedTrack)
|
||||
currentTrack.clear()
|
||||
}
|
||||
oldLongitude = currentPosition.longitude
|
||||
currentTrack.add(currentPosition)
|
||||
}
|
||||
satTracks.add(currentTrack)
|
||||
_uiState.update { it.copy(track = satTracks) }
|
||||
val track = satelliteRepo.getTrack(orbitalObject, pos, date.time, endDate.time)
|
||||
.map { it.toMapGeoPos() }
|
||||
_uiState.update { it.copy(track = splitAtAntimeridian(track)) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -306,3 +288,64 @@ class MapViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a ground track into polylines that never span the antimeridian.
|
||||
*
|
||||
* Each crossing closes the current polyline on the edge it leaves through and
|
||||
* opens the next one on the opposite edge, both at the interpolated crossing
|
||||
* latitude. Without the entry point a new segment started inland (e.g. at -178)
|
||||
* and the drawn track broke visibly at 180 degrees; reusing the next sample's
|
||||
* latitude for the edge made the closing leg jump north or south.
|
||||
*/
|
||||
internal fun splitAtAntimeridian(track: List<GeoPos>): List<List<GeoPos>> {
|
||||
val segments = mutableListOf<List<GeoPos>>()
|
||||
val current = mutableListOf<GeoPos>()
|
||||
var previous: GeoPos? = null
|
||||
track.forEach { position ->
|
||||
val last = previous
|
||||
if (last != null && abs(position.longitude - last.longitude) > 180.0) {
|
||||
val exitEdge = if (last.longitude > 0.0) 180.0 else -180.0
|
||||
val edgeLatitude = crossingLatitude(last, position, exitEdge)
|
||||
current.add(GeoPos(edgeLatitude, exitEdge))
|
||||
segments.add(current.toList())
|
||||
current.clear()
|
||||
current.add(GeoPos(edgeLatitude, -exitEdge))
|
||||
}
|
||||
previous = position
|
||||
current.add(position)
|
||||
}
|
||||
segments.add(current.toList())
|
||||
return segments
|
||||
}
|
||||
|
||||
/** Latitude where the leg between [from] and [to] crosses [edgeLongitude]. */
|
||||
internal fun crossingLatitude(from: GeoPos, to: GeoPos, edgeLongitude: Double): Double {
|
||||
// Unwrap the destination so the leg is continuous, then interpolate.
|
||||
val unwrappedTo = when {
|
||||
from.longitude > 0.0 && to.longitude < 0.0 -> to.longitude + 360.0
|
||||
from.longitude < 0.0 && to.longitude > 0.0 -> to.longitude - 360.0
|
||||
else -> to.longitude
|
||||
}
|
||||
val span = unwrappedTo - from.longitude
|
||||
if (abs(span) < 1e-12) return from.latitude
|
||||
val fraction = (edgeLongitude - from.longitude) / span
|
||||
return from.latitude + (to.latitude - from.latitude) * fraction
|
||||
}
|
||||
/**
|
||||
* Picks the pass to describe for [catnum] at [timeMillis]: the one in progress,
|
||||
* or else the earliest one still to come.
|
||||
*
|
||||
* Deliberately ignores OrbitalPass.progress. That field is a UI-side value filled
|
||||
* in by PassesViewModel on its own copy of the list; the repository always
|
||||
* reports 0, so any `progress` predicate here silently matches everything.
|
||||
*/
|
||||
internal fun selectCurrentOrNextPass(
|
||||
passes: List<OrbitalPass>,
|
||||
catnum: Int,
|
||||
timeMillis: Long
|
||||
): OrbitalPass? {
|
||||
val forSatellite = passes.filter { it.catNum == catnum }
|
||||
return forSatellite.firstOrNull { timeMillis >= it.aosTime && timeMillis < it.losTime }
|
||||
?: forSatellite.filter { it.aosTime > timeMillis }.minByOrNull { it.aosTime }
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.rtbishop.look4sat.feature.map
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* A ground track must be cut into polylines that never span the antimeridian,
|
||||
* and the cut must be seamless: the closing point of one segment and the opening
|
||||
* point of the next sit on opposite edges at the same interpolated latitude.
|
||||
*
|
||||
* Regression: the previous implementation only appended the outgoing edge point,
|
||||
* so every following segment began inland (e.g. at -178) and the drawn track
|
||||
* broke at 180 degrees. It also reused the next sample's latitude for the edge,
|
||||
* which made the closing leg jump.
|
||||
*/
|
||||
class AntimeridianSplitTest {
|
||||
|
||||
private fun pos(lat: Double, lon: Double) = GeoPos(lat, lon)
|
||||
|
||||
private fun assertNoSegmentSpansTheEdge(segments: List<List<GeoPos>>) {
|
||||
segments.forEachIndexed { index, segment ->
|
||||
segment.zipWithNext { a, b ->
|
||||
assertTrue(
|
||||
"segment $index still spans the antimeridian: $a -> $b",
|
||||
abs(b.longitude - a.longitude) <= 180.0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertSeamsAreOnOppositeEdges(segments: List<List<GeoPos>>) {
|
||||
segments.zipWithNext { previous, next ->
|
||||
val exit = previous.last()
|
||||
val entry = next.first()
|
||||
assertEquals("exit longitude", 180.0, abs(exit.longitude), 1e-9)
|
||||
assertEquals("entry longitude", 180.0, abs(entry.longitude), 1e-9)
|
||||
assertEquals("edges must be opposite", 0.0, exit.longitude + entry.longitude, 1e-9)
|
||||
assertEquals("latitude must be continuous", exit.latitude, entry.latitude, 1e-9)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eastward crossing is cut seamlessly`() {
|
||||
val segments = splitAtAntimeridian(
|
||||
listOf(
|
||||
pos(10.0, 165.0), pos(12.0, 172.0), pos(14.0, 179.0),
|
||||
pos(16.0, -178.0), pos(18.0, -171.0)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(2, segments.size)
|
||||
assertEquals(180.0, segments[0].last().longitude, 1e-9)
|
||||
assertEquals(-180.0, segments[1].first().longitude, 1e-9)
|
||||
assertNoSegmentSpansTheEdge(segments)
|
||||
assertSeamsAreOnOppositeEdges(segments)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `westward crossing is cut seamlessly`() {
|
||||
val segments = splitAtAntimeridian(
|
||||
listOf(
|
||||
pos(10.0, -165.0), pos(12.0, -172.0), pos(14.0, -179.0),
|
||||
pos(16.0, 178.0), pos(18.0, 171.0)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(2, segments.size)
|
||||
assertEquals(-180.0, segments[0].last().longitude, 1e-9)
|
||||
assertEquals(180.0, segments[1].first().longitude, 1e-9)
|
||||
assertNoSegmentSpansTheEdge(segments)
|
||||
assertSeamsAreOnOppositeEdges(segments)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edge latitude is interpolated rather than copied`() {
|
||||
// 179 -> -178 spans three degrees of longitude and two of latitude, so
|
||||
// the edge sits one degree past the first sample, not at the second one.
|
||||
val segments = splitAtAntimeridian(listOf(pos(14.0, 179.0), pos(16.0, -178.0)))
|
||||
|
||||
assertEquals(14.0 + 2.0 / 3.0, segments[0].last().latitude, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `track that stays near the edge is not split`() {
|
||||
val segments = splitAtAntimeridian(listOf(pos(10.0, 175.0), pos(12.0, 178.0)))
|
||||
|
||||
assertEquals(1, segments.size)
|
||||
assertEquals(2, segments[0].size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeated crossings all produce seams`() {
|
||||
val segments = splitAtAntimeridian(
|
||||
listOf(pos(0.0, 170.0), pos(5.0, -175.0), pos(10.0, 175.0), pos(15.0, -170.0))
|
||||
)
|
||||
|
||||
assertEquals(4, segments.size)
|
||||
assertNoSegmentSpansTheEdge(segments)
|
||||
assertSeamsAreOnOppositeEdges(segments)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty and single point tracks are handled`() {
|
||||
assertEquals(listOf(emptyList<GeoPos>()), splitAtAntimeridian(emptyList()))
|
||||
assertEquals(1, splitAtAntimeridian(listOf(pos(1.0, 2.0))).size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.rtbishop.look4sat.feature.map
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The map info panel must describe the pass that is happening now, or the next
|
||||
* one if none is.
|
||||
*
|
||||
* Regression: selection used to filter on `OrbitalPass.progress < 1`. That field
|
||||
* defaults to 0 and is only ever updated on PassesViewModel's private copy, so
|
||||
* the predicate matched every pass and the map always described the satellite's
|
||||
* first pass - leaving the countdown frozen at 00:00:00 once it had ended.
|
||||
*/
|
||||
class CurrentOrNextPassTest {
|
||||
|
||||
private val hour = 3_600_000L
|
||||
private val iss = 25544
|
||||
private val noaa = 33591
|
||||
|
||||
private fun orbitalData(catnum: Int) = OrbitalData(
|
||||
name = "sat-$catnum",
|
||||
epoch = 21320.51955234,
|
||||
meanmo = 15.48582035,
|
||||
eccn = 0.0004694,
|
||||
incl = 51.6447,
|
||||
raan = 309.4881,
|
||||
argper = 203.6966,
|
||||
meanan = 299.8876,
|
||||
catnum = catnum,
|
||||
bstar = 0.31985e-4
|
||||
)
|
||||
|
||||
private fun pass(catnum: Int, aos: Long, los: Long) = OrbitalPass(
|
||||
aosTime = aos,
|
||||
losTime = los,
|
||||
orbitalObject = NearEarthObject(orbitalData(catnum))
|
||||
)
|
||||
|
||||
private val issFirst = pass(iss, 10 * hour, 10 * hour + 600_000)
|
||||
private val issSecond = pass(iss, 12 * hour, 12 * hour + 600_000)
|
||||
private val issThird = pass(iss, 14 * hour, 14 * hour + 600_000)
|
||||
private val other = pass(noaa, 11 * hour, 11 * hour + 600_000)
|
||||
private val passes = listOf(issFirst, other, issSecond, issThird)
|
||||
|
||||
@Test
|
||||
fun `before the first pass returns the first pass`() {
|
||||
assertEquals(issFirst, selectCurrentOrNextPass(passes, iss, (9.5 * hour).toLong()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `during a pass returns that pass`() {
|
||||
assertEquals(issFirst, selectCurrentOrNextPass(passes, iss, 10 * hour + 300_000))
|
||||
assertEquals(issSecond, selectCurrentOrNextPass(passes, iss, 12 * hour + 300_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `after a pass ends returns the following pass`() {
|
||||
// The case the progress predicate got wrong: it kept returning issFirst.
|
||||
assertEquals(issSecond, selectCurrentOrNextPass(passes, iss, (10.5 * hour).toLong()))
|
||||
assertEquals(issThird, selectCurrentOrNextPass(passes, iss, 13 * hour))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `after the last pass returns null`() {
|
||||
assertNull(selectCurrentOrNextPass(passes, iss, 15 * hour))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `other satellites are ignored`() {
|
||||
assertEquals(other, selectCurrentOrNextPass(passes, noaa, 11 * hour + 60_000))
|
||||
assertNull(selectCurrentOrNextPass(passes, noaa, 12 * hour))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exact aos belongs to the pass and exact los does not`() {
|
||||
assertEquals(issFirst, selectCurrentOrNextPass(passes, iss, 10 * hour))
|
||||
assertEquals(issSecond, selectCurrentOrNextPass(passes, iss, 10 * hour + 600_000))
|
||||
}
|
||||
}
|
||||
@@ -608,7 +608,7 @@ private fun MutualPassCard(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_radar),
|
||||
painter = painterResource(id = R.drawable.ic_satellite),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.height(16.dp).width(16.dp)
|
||||
)
|
||||
|
||||
+9
-1
@@ -400,7 +400,15 @@ class MutualViewModel(
|
||||
|
||||
val refinedAos = refineEdge(sat, posA, posB, aos, 1_000L, true)
|
||||
val refinedLos = refineEdge(sat, posA, posB, los, 1_000L, false)
|
||||
if (refinedLos <= refinedAos) continue
|
||||
if (refinedLos <= refinedAos) {
|
||||
// Refine collapsed the window (e.g. a near-horizon pass whose
|
||||
// AOS/LOS both sit at the edge of the 70 s refine window).
|
||||
// Advance the search regardless: skipping without moving
|
||||
// searchStart would make findNextMutualPass return the same
|
||||
// pass forever once the refine windows can no longer span it.
|
||||
searchStart = los + 120_000L
|
||||
continue
|
||||
}
|
||||
|
||||
val mutualPass = sampleMutualPass(sat, posA, posB, refinedAos, refinedLos,
|
||||
minElevADeg, minElevBDeg, sampleInterval)
|
||||
|
||||
@@ -58,20 +58,13 @@ import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
|
||||
import com.rtbishop.look4sat.core.presentation.ElevationHighColor
|
||||
import com.rtbishop.look4sat.core.presentation.ElevationLowColor
|
||||
import com.rtbishop.look4sat.core.presentation.elevationColor
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val allModes = listOf(
|
||||
"AFSK", "AFSK S-Net", "AFSK SALSAT", "AHRPT", "AM", "APT", "BPSK", "BPSK PMT-A3",
|
||||
"CERTO", "CW", "DQPSK", "DSTAR", "DUV", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5",
|
||||
"FSK AX.100 Mode 6", "FSK AX.25 G3RUH", "GFSK", "GFSK Rktr", "GMSK", "HRPT", "LoRa",
|
||||
"LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5", "MSK AX.100 Mode 6", "OFDM", "OQPSK",
|
||||
"PSK", "PSK31", "PSK63", "QPSK", "QPSK31", "QPSK63", "SSTV", "USB", "WSJT"
|
||||
)
|
||||
|
||||
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
|
||||
@@ -147,7 +140,7 @@ internal fun PassesFilterDialog(
|
||||
)
|
||||
cancel()
|
||||
}
|
||||
SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
|
||||
ConfirmDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
|
||||
SliderRow(
|
||||
title = stringResource(R.string.pass_filter_elev),
|
||||
value = elevationValueNew.doubleValue,
|
||||
@@ -372,7 +365,7 @@ internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List
|
||||
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
|
||||
}
|
||||
val onAccept = { accept(selected.value.toList()).also { cancel() } }
|
||||
SharedDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) {
|
||||
ConfirmDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(240.dp),
|
||||
modifier = Modifier
|
||||
@@ -381,7 +374,7 @@ internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List
|
||||
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||
) {
|
||||
itemsIndexed(allModes) { index, item ->
|
||||
itemsIndexed(Sources.satelliteModes) { index, item ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
|
||||
@@ -71,10 +71,10 @@ import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import com.rtbishop.look4sat.core.presentation.SwipeableItem
|
||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
|
||||
import com.rtbishop.look4sat.core.presentation.elevationColor
|
||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
@@ -135,26 +135,14 @@ private fun PassesScreen(
|
||||
}
|
||||
if (uiState.shouldSeeWhatsNew) {
|
||||
val dismiss = { onAction(PassesAction.DismissWhatsNew) }
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.pass_whatsnew_title),
|
||||
onDismissRequest = dismiss,
|
||||
onAccept = dismiss,
|
||||
titleFontSize = 18
|
||||
) { padding ->
|
||||
Text(
|
||||
text = stringResource(R.string.pass_whatsnew_message),
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = padding)
|
||||
)
|
||||
}
|
||||
WhatsNewDialog(onDismiss = dismiss)
|
||||
}
|
||||
ScreenColumn(
|
||||
topBar = { isVerticalLayout ->
|
||||
TopBar(
|
||||
isVerticalLayout = isVerticalLayout,
|
||||
startAction = {
|
||||
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
|
||||
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
|
||||
},
|
||||
topInfo = {
|
||||
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
|
||||
@@ -163,7 +151,7 @@ private fun PassesScreen(
|
||||
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
||||
},
|
||||
endAction = {
|
||||
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
|
||||
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -290,6 +278,11 @@ private fun StickyDateHeader(label: String, sunriseTime: String, sunsetTime: Str
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayLocale(): Locale {
|
||||
val locale = Locale.getDefault()
|
||||
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun DeepSpacePassPreview() {
|
||||
@@ -316,12 +309,13 @@ private fun PassItem(
|
||||
isVerticalLayout: Boolean = true,
|
||||
isUtc: Boolean = false
|
||||
) {
|
||||
val passSatId = stringResource(id = R.string.pass_satId, pass.catNum)
|
||||
val horizontalPadding = if (isVerticalLayout) 6.dp else 10.dp
|
||||
val timeZone = remember(isUtc) {
|
||||
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||
}
|
||||
val sdfTime = remember(isUtc) {
|
||||
SimpleDateFormat("HH:mm:ss", Locale.getDefault()).also { it.timeZone = timeZone }
|
||||
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
|
||||
}
|
||||
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
|
||||
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
|
||||
@@ -341,6 +335,10 @@ private fun PassItem(
|
||||
.padding(horizontal = horizontalPadding, vertical = 4.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "$passSatId - ",
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = pass.name,
|
||||
modifier = Modifier
|
||||
@@ -350,7 +348,7 @@ private fun PassItem(
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
val elevColor = elevationColor(pass.maxElevation)
|
||||
Icon(
|
||||
|
||||
+75
-50
@@ -34,6 +34,9 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -42,6 +45,7 @@ import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class PassesViewModel(
|
||||
private val satelliteRepo: ISatelliteRepo,
|
||||
@@ -61,7 +65,7 @@ class PassesViewModel(
|
||||
aosEndMinute = settingsRepo.passesSettings.value.aosEndMinute,
|
||||
invertAosTimeWindow = settingsRepo.passesSettings.value.invertAosTimeWindow,
|
||||
showDeepSpace = settingsRepo.passesSettings.value.showDeepSpace,
|
||||
modes = settingsRepo.passesSettings.value.selectedModes,
|
||||
modes = settingsRepo.selectedSatModes.value,
|
||||
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew
|
||||
)
|
||||
)
|
||||
@@ -87,33 +91,43 @@ class PassesViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Main tick loop: reacts to new passes, then ticks every second
|
||||
viewModelScope.launch {
|
||||
satelliteRepo.passes.collectLatest { allPasses ->
|
||||
while (isActive) {
|
||||
val timeNow = System.currentTimeMillis()
|
||||
val isUtc = _uiState.value.isUtc
|
||||
val showDeepSpace = _uiState.value.showDeepSpace
|
||||
val filtered = allPasses
|
||||
.let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
|
||||
val processed = computePassProgress(filtered, timeNow)
|
||||
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
||||
val sunTimes = computeSunTimes(processed, isUtc)
|
||||
val grouped = groupPasses(processed, isUtc)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
itemsList = processed,
|
||||
groupedPasses = grouped,
|
||||
sunTimes = sunTimes,
|
||||
nextPass = nextPass,
|
||||
nextTime = nextTime,
|
||||
isNextTimeAos = isAos
|
||||
)
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
settingsRepo.selectedSatModes.collectLatest { modes ->
|
||||
_uiState.update { it.copy(modes = modes) }
|
||||
}
|
||||
}
|
||||
// Tick loop: restarts on passes change, UTC/DeepSpace changes. Grouping/sun-time
|
||||
// computations run once per restart, progress/countdown are calculated every second.
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
satelliteRepo.passes,
|
||||
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged(),
|
||||
settingsRepo.passesSettings.map { it.showDeepSpace }.distinctUntilChanged()
|
||||
) { passes, isUtc, showDeepSpace -> Triple(passes, isUtc, showDeepSpace) }
|
||||
.collectLatest { (allPasses, isUtc, showDeepSpace) ->
|
||||
val filtered = if (showDeepSpace) allPasses
|
||||
else allPasses.filter { !it.isDeepSpace }
|
||||
// Expensive: recompute sun times once per items/UTC change, not every second
|
||||
val sunTimes = computeSunTimes(filtered, isUtc)
|
||||
_uiState.update { it.copy(sunTimes = sunTimes) }
|
||||
while (isActive) {
|
||||
val timeNow = System.currentTimeMillis()
|
||||
val processed = computePassProgress(filtered, timeNow)
|
||||
val grouped = groupPasses(processed, isUtc)
|
||||
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
itemsList = processed,
|
||||
groupedPasses = grouped,
|
||||
nextPass = nextPass,
|
||||
nextTime = nextTime,
|
||||
isNextTimeAos = isAos
|
||||
)
|
||||
}
|
||||
delay(1000.milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onAction(action: PassesAction) {
|
||||
@@ -128,21 +142,9 @@ class PassesViewModel(
|
||||
aosStartMinute = action.aosStartMinute,
|
||||
aosEndMinute = action.aosEndMinute,
|
||||
invertAosTimeWindow = action.invertAosTimeWindow,
|
||||
showDeepSpace = action.showDeepSpace,
|
||||
modes = _uiState.value.modes
|
||||
)
|
||||
is PassesAction.FilterRadios ->
|
||||
applyFilter(
|
||||
hoursAhead = _uiState.value.hours,
|
||||
minElevation = _uiState.value.elevation,
|
||||
lowElevation = _uiState.value.lowElevation,
|
||||
highElevation = _uiState.value.highElevation,
|
||||
aosStartMinute = _uiState.value.aosStartMinute,
|
||||
aosEndMinute = _uiState.value.aosEndMinute,
|
||||
invertAosTimeWindow = _uiState.value.invertAosTimeWindow,
|
||||
showDeepSpace = _uiState.value.showDeepSpace,
|
||||
modes = action.modes
|
||||
showDeepSpace = action.showDeepSpace
|
||||
)
|
||||
is PassesAction.FilterRadios -> setModesFilter(action.modes)
|
||||
PassesAction.RefreshPasses -> refreshPasses()
|
||||
PassesAction.TogglePassesDialog ->
|
||||
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
|
||||
@@ -153,14 +155,20 @@ class PassesViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a locale-appropriate date format for pass grouping headers. */
|
||||
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 pattern = if (Locale.getDefault().language == "zh") {
|
||||
val locale = displayLocale()
|
||||
val pattern = if (locale.language == Locale.CHINESE.language) {
|
||||
"yyyy'年'M'月'd'日' EEEE"
|
||||
} else {
|
||||
"EEE, dd MMM yyyy"
|
||||
}
|
||||
return SimpleDateFormat(pattern, Locale.getDefault()).also { it.timeZone = tz }
|
||||
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.
|
||||
@@ -171,7 +179,7 @@ class PassesViewModel(
|
||||
val stationPos = settingsRepo.stationPosition.value
|
||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||
val sdfDate = dateFormat(tz)
|
||||
val sdfTime = SimpleDateFormat("HH:mm", Locale.getDefault()).also { it.timeZone = tz }
|
||||
val sdfTime = SimpleDateFormat("HH:mm", displayLocale()).also { it.timeZone = tz }
|
||||
val result = LinkedHashMap<String, Pair<String, String>>()
|
||||
// DeepSpace group always shows today's sun times
|
||||
if (passes.any { it.isDeepSpace }) {
|
||||
@@ -221,6 +229,11 @@ class PassesViewModel(
|
||||
if (!pass.isDeepSpace && time > pass.aosTime) {
|
||||
val deltaNow = time.minus(pass.aosTime).toFloat()
|
||||
val deltaTotal = pass.losTime.minus(pass.aosTime).toFloat()
|
||||
// Guard against division by zero (corrupted TLE, degenerate orbit, etc.)
|
||||
if (deltaTotal <= 0f) {
|
||||
// Skip passes with zero or negative duration
|
||||
continue
|
||||
}
|
||||
val newProgress = (deltaNow / deltaTotal).round(2)
|
||||
if (newProgress >= 1.0f) continue
|
||||
if (newProgress != pass.progress) {
|
||||
@@ -259,8 +272,7 @@ class PassesViewModel(
|
||||
aosStartMinute: Int,
|
||||
aosEndMinute: Int,
|
||||
invertAosTimeWindow: Boolean,
|
||||
showDeepSpace: Boolean,
|
||||
modes: List<String>
|
||||
showDeepSpace: Boolean
|
||||
) = viewModelScope.launch {
|
||||
settingsRepo.setPassesSettings(
|
||||
PassesSettings(
|
||||
@@ -269,8 +281,7 @@ class PassesViewModel(
|
||||
minElevation,
|
||||
aosStartMinute,
|
||||
aosEndMinute,
|
||||
invertAosTimeWindow,
|
||||
modes
|
||||
invertAosTimeWindow
|
||||
)
|
||||
)
|
||||
settingsRepo.updateOtherSettings { it.copy(lowElevation = lowElevation, highElevation = highElevation) }
|
||||
@@ -283,10 +294,10 @@ class PassesViewModel(
|
||||
aosStartMinute = aosStartMinute,
|
||||
aosEndMinute = aosEndMinute,
|
||||
invertAosTimeWindow = invertAosTimeWindow,
|
||||
showDeepSpace = showDeepSpace,
|
||||
modes = modes
|
||||
showDeepSpace = showDeepSpace
|
||||
)
|
||||
}
|
||||
val modes = settingsRepo.selectedSatModes.value
|
||||
satelliteRepo.calculatePasses(
|
||||
time = System.currentTimeMillis(),
|
||||
hoursAhead = hoursAhead,
|
||||
@@ -298,6 +309,20 @@ class PassesViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
private fun setModesFilter(modes: List<String>) = viewModelScope.launch {
|
||||
settingsRepo.setSelectedSatModes(modes)
|
||||
_uiState.update { it.copy(modes = modes) }
|
||||
satelliteRepo.calculatePasses(
|
||||
time = System.currentTimeMillis(),
|
||||
hoursAhead = _uiState.value.hours,
|
||||
minElevation = _uiState.value.elevation,
|
||||
aosStartMinute = _uiState.value.aosStartMinute,
|
||||
aosEndMinute = _uiState.value.aosEndMinute,
|
||||
invertAosTimeWindow = _uiState.value.invertAosTimeWindow,
|
||||
modes = modes
|
||||
)
|
||||
}
|
||||
|
||||
private fun refreshPasses() = viewModelScope.launch {
|
||||
val settings = settingsRepo.passesSettings.value
|
||||
satelliteRepo.calculatePasses(
|
||||
@@ -307,7 +332,7 @@ class PassesViewModel(
|
||||
aosStartMinute = settings.aosStartMinute,
|
||||
aosEndMinute = settings.aosEndMinute,
|
||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||
modes = settings.selectedModes
|
||||
modes = settingsRepo.selectedSatModes.value
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -82,6 +83,9 @@ import kotlin.math.roundToInt
|
||||
|
||||
private val WaveLogYellow = Color(0xFFFFC107)
|
||||
|
||||
/** Repeat "done" events for the same callsign inside this window are treated as one QSO. */
|
||||
private const val DUPLICATE_WINDOW_MS = 2000L
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LogTab(
|
||||
@@ -198,6 +202,11 @@ fun LogTab(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||
)
|
||||
groupEntries.forEach { entry ->
|
||||
// key() ties the row's swipe/countdown state to the QSO id. Without it
|
||||
// Compose reuses state by position, so a list reorder mid-countdown
|
||||
// (a new QSO is inserted at index 0) moves the pending deletion onto a
|
||||
// different record and deletes the wrong one.
|
||||
key(entry.id) {
|
||||
SwipeDeleteRow(
|
||||
onDelete = {
|
||||
queue.remove(entry.id)
|
||||
@@ -231,6 +240,7 @@ fun LogTab(
|
||||
)
|
||||
}
|
||||
}
|
||||
} // key(entry.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,12 +263,20 @@ private fun ExpandedLogInput(
|
||||
val scope = rememberCoroutineScope()
|
||||
var callsign by remember { mutableStateOf("") }
|
||||
var mode by remember { mutableStateOf(radio.uplinkMode ?: "FM") }
|
||||
// Last accepted callsign + timestamp, used to swallow duplicate IME "done" events.
|
||||
// A plain in-flight flag cannot help: submit() is synchronous, so it always
|
||||
// clears before the next key event arrives.
|
||||
var lastSaved by remember { mutableStateOf("" to 0L) }
|
||||
|
||||
val savedMsg = stringResource(id = R.string.wavelog_saved)
|
||||
|
||||
fun submit() {
|
||||
val call = callsign.trim().uppercase()
|
||||
if (call.length < 3) return
|
||||
val now = System.currentTimeMillis()
|
||||
val (lastCall, lastAt) = lastSaved
|
||||
if (call == lastCall && now - lastAt < DUPLICATE_WINDOW_MS) return
|
||||
lastSaved = call to now
|
||||
// Freq taken directly from the transponder bar (radio Doppler-corrected each second; value at the Enter moment)
|
||||
val tx = radio.uplinkLow ?: radio.downlinkLow ?: 0L
|
||||
val rx = radio.downlinkLow ?: radio.uplinkLow ?: 0L
|
||||
@@ -286,7 +304,10 @@ private fun ExpandedLogInput(
|
||||
val grid = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.lookupGrid(
|
||||
call, com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.parseCookies(rawCookie)
|
||||
)
|
||||
if (grid != null) queue.updateGridsquare(qsoId, grid)
|
||||
if (grid != null) {
|
||||
queue.updateGridsquare(qsoId, grid)
|
||||
onSaved() // 触发 refreshTick++ 刷新列表
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -458,7 +479,12 @@ internal fun SwipeDeleteRow(
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { if (pending) { pending = false } },
|
||||
onDragStart = {
|
||||
if (pending) {
|
||||
pending = false
|
||||
offsetX = 0f
|
||||
}
|
||||
},
|
||||
onHorizontalDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
offsetX = (offsetX + dragAmount).coerceIn(-rowWidth.toFloat(), 0f)
|
||||
|
||||
@@ -284,7 +284,8 @@ private fun PagerCard(
|
||||
orbitalPos = uiState.orbitalPos,
|
||||
cw = uiState.cw,
|
||||
onAction = onAction,
|
||||
requestMicPermission = requestMicPermission
|
||||
requestMicPermission = requestMicPermission,
|
||||
calculatorOffsetKHz = uiState.calculatorOffsetKHz
|
||||
)
|
||||
RadarPage.Sstv -> SstvPage(
|
||||
sstv = uiState.sstv,
|
||||
@@ -352,6 +353,7 @@ private fun RadarCard(
|
||||
azimElev = uiState.orientationValues,
|
||||
shouldShowSweep = uiState.shouldShowSweep,
|
||||
shouldUseCompass = uiState.shouldUseCompass,
|
||||
shouldFlipRadar = uiState.shouldFlipRadar,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
sunPosition = uiState.sunPosition,
|
||||
moonPosition = uiState.moonPosition,
|
||||
|
||||
@@ -58,6 +58,7 @@ data class RadarState(
|
||||
val satTrack: List<OrbitalPos> = emptyList(),
|
||||
val shouldShowSweep: Boolean = false,
|
||||
val shouldUseCompass: Boolean = false,
|
||||
val shouldFlipRadar: Boolean = false,
|
||||
val sunPosition: CelestialComputer.SunPosition? = null,
|
||||
val moonPosition: CelestialComputer.MoonPosition? = null,
|
||||
val transceivers: TransceiverSubState = TransceiverSubState(),
|
||||
@@ -69,7 +70,9 @@ data class RadarState(
|
||||
val mutualEndTime: Long = 0L,
|
||||
val mutualMaxElev: Double = 10.0,
|
||||
val mutualLabelA: String = "你",
|
||||
val mutualLabelB: String = "友台"
|
||||
val mutualLabelB: String = "友台",
|
||||
// Upstream Doppler calculator offset (merged from rt-bishop)
|
||||
val calculatorOffsetKHz: String = ""
|
||||
)
|
||||
|
||||
enum class SstvStatus { Idle, Recording }
|
||||
@@ -124,4 +127,6 @@ sealed interface RadarAction {
|
||||
data object CwReset : RadarAction
|
||||
data class CwToggleExpanded(val expanded: Boolean) : RadarAction
|
||||
data class CwPermissionResult(val granted: Boolean) : RadarAction
|
||||
// Calculator actions
|
||||
data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction
|
||||
}
|
||||
@@ -79,6 +79,7 @@ fun RadarViewCompose(
|
||||
azimElev: Pair<Float, Float>,
|
||||
shouldShowSweep: Boolean,
|
||||
shouldUseCompass: Boolean,
|
||||
shouldFlipRadar: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
sunPosition: CelestialComputer.SunPosition? = null,
|
||||
moonPosition: CelestialComputer.MoonPosition? = null,
|
||||
@@ -146,7 +147,9 @@ fun RadarViewCompose(
|
||||
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
||||
cachedSweepColor = primaryColor
|
||||
}
|
||||
rotate(if (shouldUseCompass) -azimElev.first else 0f) {
|
||||
val baseRotation = if (shouldUseCompass) -azimElev.first else 0f
|
||||
val rotation = if (shouldFlipRadar) baseRotation + 180f else baseRotation
|
||||
rotate(rotation) {
|
||||
if (shouldShowSweep) cachedSweepBrush?.let { drawSweep(center, sweepDegrees, radius, it) }
|
||||
drawRadar(radius, radarColor)
|
||||
drawElevationLabels(radius, primaryColor, measurer)
|
||||
|
||||
@@ -67,13 +67,16 @@ class RadarViewModel(
|
||||
private val showToast: IShowToast
|
||||
) : ViewModel() {
|
||||
|
||||
private val stationPos = settingsRepo.stationPosition.value
|
||||
private val magDeclination = sensorsRepo.getMagDeclination(stationPos)
|
||||
private var stationPos = settingsRepo.stationPosition.value
|
||||
private var magDeclination = sensorsRepo.getMagDeclination(stationPos)
|
||||
private var compassOffset = settingsRepo.otherSettings.value.radarCompassOffset
|
||||
private var compassOffsetElev = settingsRepo.otherSettings.value.radarCompassOffsetElev
|
||||
private var transponders: List<SatRadio> = emptyList()
|
||||
private var sstvDecoder: SstvDecoder? = null
|
||||
private var sstvRecordingJob: Job? = null
|
||||
private var cwDecoder: ICwDecoder? = null
|
||||
private var cwListeningJob: Job? = null
|
||||
private var sensorCollectionJob: Job? = null
|
||||
|
||||
// Celestial positions change slowly, recompute at most once per minute
|
||||
private var lastCelestialUpdateMs = 0L
|
||||
@@ -92,33 +95,67 @@ class RadarViewModel(
|
||||
val uiState: StateFlow<RadarState> = _uiState
|
||||
|
||||
init {
|
||||
collectCompassSensor()
|
||||
collectSettingsChanges()
|
||||
collectSettingsChanges() // also handles initial sensor subscription
|
||||
collectStationPositionChanges()
|
||||
collectPassAndStartTickLoop()
|
||||
collectRadioTrackingState()
|
||||
}
|
||||
|
||||
private fun collectCompassSensor() {
|
||||
if (!settingsRepo.otherSettings.value.stateOfSensors) return
|
||||
viewModelScope.launch {
|
||||
// Starts sensor collection if not already running.
|
||||
private fun startSensorCollection() {
|
||||
if (sensorCollectionJob?.isActive == true) return
|
||||
sensorCollectionJob = viewModelScope.launch {
|
||||
sensorsRepo.enableSensor()
|
||||
sensorsRepo.sensorData.collect { data ->
|
||||
val orientationValues = (data.first + magDeclination) to data.second
|
||||
val orientationValues =
|
||||
(data.first + magDeclination + compassOffset) to (data.second + compassOffsetElev)
|
||||
_uiState.update { it.copy(orientationValues = orientationValues) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stops sensor collection and disables the hardware sensor.
|
||||
private fun stopSensorCollection() {
|
||||
sensorCollectionJob?.cancel()
|
||||
sensorCollectionJob = null
|
||||
sensorsRepo.disableSensor()
|
||||
}
|
||||
|
||||
private fun collectSettingsChanges() {
|
||||
viewModelScope.launch {
|
||||
settingsRepo.otherSettings.collectLatest { settings ->
|
||||
settingsRepo.otherSettings.collect { settings ->
|
||||
compassOffset = settings.radarCompassOffset
|
||||
compassOffsetElev = settings.radarCompassOffsetElev
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isUtc = settings.stateOfUtc,
|
||||
shouldShowSweep = settings.stateOfSweep,
|
||||
shouldUseCompass = settings.stateOfSensors
|
||||
shouldUseCompass = settings.stateOfSensors,
|
||||
shouldFlipRadar = settings.radarCompassOffsetElev < 0f
|
||||
)
|
||||
}
|
||||
// Reactively wire sensor hardware to the compass setting
|
||||
when {
|
||||
settings.stateOfSensors -> startSensorCollection()
|
||||
else -> stopSensorCollection()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectStationPositionChanges() {
|
||||
viewModelScope.launch {
|
||||
settingsRepo.stationPosition.collect { newPos ->
|
||||
if (stationPos != newPos) {
|
||||
stationPos = newPos
|
||||
magDeclination = sensorsRepo.getMagDeclination(stationPos)
|
||||
lastCelestialUpdateMs = 0L // force celestial recompute on next tick
|
||||
val pass = _uiState.value.currentPass ?: return@collect
|
||||
if (!pass.isDeepSpace) {
|
||||
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
|
||||
_uiState.update { it.copy(satTrack = track) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,9 +164,19 @@ class RadarViewModel(
|
||||
|
||||
private fun collectPassAndStartTickLoop() {
|
||||
viewModelScope.launch {
|
||||
val pass = findCurrentPass() ?: return@launch
|
||||
val allRadios = loadPassData(pass)
|
||||
var pass = findCurrentPass() ?: return@launch
|
||||
var allRadios = loadPassData(pass)
|
||||
while (isActive) {
|
||||
// Once the tracked pass is over, follow the same satellite into its
|
||||
// next pass. findCurrentPass() cannot do this: it keys off
|
||||
// selectedPass, which only changes when the user taps a pass, so it
|
||||
// would keep resolving to the pass that just ended.
|
||||
if (System.currentTimeMillis() > pass.losTime) {
|
||||
findNextPassAfter(pass)?.let { next ->
|
||||
pass = next
|
||||
allRadios = loadPassData(next)
|
||||
}
|
||||
}
|
||||
tickPass(pass, allRadios)
|
||||
delay(1000.milliseconds)
|
||||
}
|
||||
@@ -145,6 +192,17 @@ class RadarViewModel(
|
||||
?: passes.firstOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Earliest pass that starts after [current] ends. Prefers the satellite being
|
||||
* tracked so the radar keeps following it; falls back to any satellite so the
|
||||
* page moves on instead of freezing on an expired pass.
|
||||
*/
|
||||
private fun findNextPassAfter(current: OrbitalPass): OrbitalPass? {
|
||||
val later = satelliteRepo.passes.value.filter { it.aosTime > current.losTime }
|
||||
return later.filter { it.catNum == current.catNum }.minByOrNull { it.aosTime }
|
||||
?: later.minByOrNull { it.aosTime }
|
||||
}
|
||||
|
||||
// Loads transmitters and satellite track for pass, sets initial state, returns full radio list
|
||||
private suspend fun loadPassData(pass: OrbitalPass): List<SatRadio> {
|
||||
_uiState.update { it.copy(currentPass = pass) }
|
||||
@@ -223,6 +281,7 @@ class RadarViewModel(
|
||||
// OrtSession holds native memory; it must be released explicitly.
|
||||
cwDecoder?.close()
|
||||
cwDecoder = null
|
||||
stopSensorCollection()
|
||||
}
|
||||
|
||||
fun onAction(action: RadarAction) {
|
||||
@@ -232,7 +291,17 @@ class RadarViewModel(
|
||||
// Compute toggle state before the update so we don't read post-update value
|
||||
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
|
||||
val newUuid = if (isTogglingOff) null else action.uuid
|
||||
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = newUuid)) }
|
||||
// Load the saved offset for the newly selected satellite
|
||||
val offsetKHz = if (!isTogglingOff) {
|
||||
transponders.find { it.uuid == action.uuid }
|
||||
?.catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
|
||||
} else ""
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
transceivers = it.transceivers.copy(selectedUuid = newUuid),
|
||||
calculatorOffsetKHz = offsetKHz
|
||||
)
|
||||
}
|
||||
// Only update the tracking service when selecting a different transponder to
|
||||
// avoid resetting a user-adjusted TX base on re-expand
|
||||
if (!isTogglingOff) {
|
||||
@@ -277,7 +346,7 @@ class RadarViewModel(
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(selectedMode = action.modeName)) }
|
||||
settingsRepo.updateOtherSettings { it.copy(sstvMode = action.modeName) }
|
||||
}
|
||||
RadarAction.SstvReset -> {
|
||||
is RadarAction.SstvReset -> {
|
||||
sstvDecoder?.clearPixels()
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
|
||||
}
|
||||
@@ -296,6 +365,15 @@ class RadarViewModel(
|
||||
is RadarAction.CwToggleExpanded -> {
|
||||
_uiState.update { it.copy(cw = it.cw.copy(isExpanded = action.expanded)) }
|
||||
}
|
||||
is RadarAction.ChangeCalculatorOffset -> {
|
||||
val catnum = _uiState.value.transceivers.selectedUuid?.let { uuid ->
|
||||
transponders.find { it.uuid == uuid }?.catnum
|
||||
}
|
||||
if (catnum != null) {
|
||||
settingsRepo.setSatelliteOffset(catnum, action.offsetKHz)
|
||||
}
|
||||
_uiState.update { it.copy(calculatorOffsetKHz = action.offsetKHz) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,7 +551,7 @@ class RadarViewModel(
|
||||
// - 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
|
||||
private const val SSTV_TARGET_RMS = 0.45f // Changed from 0.25 to safer default
|
||||
|
||||
// Enable/disable RMS normalization entirely. Set false for direct receiver outputs.
|
||||
private const val SSTV_ENABLE_RMS_NORMALIZATION = true
|
||||
@@ -496,7 +574,7 @@ class RadarViewModel(
|
||||
// - 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
|
||||
private val SSTV_LINE_RECOVERY_STRATEGY = LineRecoveryStrategy.Robot36Compatible
|
||||
|
||||
// Debug: Return scope visualization in frames (increases per-frame memory usage).
|
||||
private const val SSTV_INCLUDE_SCOPE = false
|
||||
@@ -508,7 +586,6 @@ class RadarViewModel(
|
||||
218.1, 225.7, 233.6, 241.8, 250.3
|
||||
)
|
||||
|
||||
|
||||
fun factory(container: IMainContainer) = viewModelFactory {
|
||||
initializer {
|
||||
RadarViewModel(
|
||||
|
||||
@@ -53,10 +53,10 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.OutlinedText
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -96,14 +96,14 @@ internal fun SstvPage(
|
||||
if (showModeDialog.value) {
|
||||
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
|
||||
val dismiss = { showModeDialog.value = false }
|
||||
SharedDialog(
|
||||
ConfirmDialog(
|
||||
title = "SSTV Mode",
|
||||
onDismissRequest = dismiss,
|
||||
onAccept = dismiss
|
||||
) { _ ->
|
||||
onAccept = dismiss,
|
||||
onCancel = dismiss
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight(0.7f)
|
||||
.fillMaxHeight(0.69f)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||
) {
|
||||
|
||||
+343
-118
@@ -21,6 +21,7 @@ import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -31,8 +32,11 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -43,6 +47,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilterChip
|
||||
@@ -51,11 +56,13 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -67,6 +74,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -76,6 +84,7 @@ import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||
@@ -128,10 +137,12 @@ fun CalculatorPage(
|
||||
cw: CwSubState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
requestMicPermission: () -> Unit = {},
|
||||
calculatorOffsetKHz: String = "",
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val calculatorTransceivers = remember(transceivers) {
|
||||
transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder)
|
||||
.let(DopplerFrequencyCalculator::deduplicateTransponders)
|
||||
}
|
||||
val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) {
|
||||
calculatorTransceivers.firstOrNull { it.uuid == selectedUuid }
|
||||
@@ -179,6 +190,8 @@ fun CalculatorPage(
|
||||
DopplerFrequencyCalculator(
|
||||
transponder = selectedTransceiver,
|
||||
orbitalPos = orbitalPos,
|
||||
offsetKHz = calculatorOffsetKHz,
|
||||
onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
@@ -463,10 +476,11 @@ private fun ExpandedRadioControl(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
maxItemsInEachRow = 5,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
val chipModifier = Modifier.width(64.dp)
|
||||
val chipModifier = Modifier.weight(1f)
|
||||
FilterChip(
|
||||
selected = radioControl.ctcssTone == null,
|
||||
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
|
||||
@@ -530,152 +544,362 @@ private fun ExpandedRadioControl(
|
||||
}
|
||||
}
|
||||
|
||||
private enum class EditedField { TX, PASSBAND, RX }
|
||||
|
||||
@Composable
|
||||
private fun DopplerFrequencyCalculator(
|
||||
transponder: SatRadio,
|
||||
orbitalPos: OrbitalPos?,
|
||||
offsetKHz: String = "",
|
||||
onOffsetChange: (String) -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
|
||||
|
||||
var txInputMHz by remember { mutableStateOf("") }
|
||||
var rxInputMHz by remember { mutableStateOf("") }
|
||||
var offsetKHz by remember { mutableStateOf("") }
|
||||
var lastEditedBy by remember { mutableStateOf(EditedField.TX) }
|
||||
var lastEditedField by remember { mutableStateOf(EditedField.TX) }
|
||||
var txFrequencyHz by remember { mutableStateOf(0L) }
|
||||
var rxFrequencyHz by remember { mutableStateOf(0L) }
|
||||
var passbandPosition by remember { mutableStateOf(0.5f) }
|
||||
var stepSizeKHz by remember { mutableIntStateOf(1) }
|
||||
|
||||
val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
|
||||
|
||||
// Real-time refresh: when orbitalPos changes (1Hz), recompute the opposite field
|
||||
val txLow = transponder.uplinkLow ?: return
|
||||
val txHigh = transponder.uplinkHigh ?: return
|
||||
val rxLow = transponder.downlinkLow ?: return
|
||||
val rxHigh = transponder.downlinkHigh ?: return
|
||||
val txRange = txHigh - txLow
|
||||
val rxRange = rxHigh - rxLow
|
||||
if (txRange <= 0L || rxRange <= 0L) return
|
||||
|
||||
// 原始转发器值(逆转多普勒),用于 passband 映射计算
|
||||
val rawTxLow = orbitalPos.getDownlinkFreq(txLow)
|
||||
val rawTxHigh = orbitalPos.getDownlinkFreq(txHigh)
|
||||
val rawRxLow = orbitalPos.getUplinkFreq(rxLow)
|
||||
val rawRxHigh = orbitalPos.getUplinkFreq(rxHigh)
|
||||
val rawTxRange = rawTxHigh - rawTxLow
|
||||
val rawRxRange = rawRxHigh - rawRxLow
|
||||
val rawTransponder = transponder.copy(
|
||||
uplinkLow = rawTxLow, uplinkHigh = rawTxHigh,
|
||||
downlinkLow = rawRxLow, downlinkHigh = rawRxHigh
|
||||
)
|
||||
|
||||
// 初始化
|
||||
LaunchedEffect(Unit) {
|
||||
if (txFrequencyHz == 0L) {
|
||||
txFrequencyHz = txLow + txRange / 2
|
||||
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
||||
) ?: (rxLow + rxRange / 2)
|
||||
}
|
||||
}
|
||||
|
||||
// 实时重算:卫星移动时,锚点侧不变,重算另一侧
|
||||
LaunchedEffect(orbitalPos) {
|
||||
if (lastEditedBy == EditedField.TX) {
|
||||
val txMHz = txInputMHz.toDoubleOrNull()
|
||||
if (txMHz != null && txMHz > 0) {
|
||||
val txHz = (txMHz * 1_000_000).toLong()
|
||||
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||
txHz, transponder, orbitalPos, offsetHz
|
||||
)
|
||||
if (rxHz != null) {
|
||||
rxInputMHz = String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0)
|
||||
}
|
||||
if (lastEditedField == EditedField.TX) {
|
||||
// TX 是锚点,不变;重算 RX
|
||||
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
||||
) ?: rxFrequencyHz
|
||||
} else if (lastEditedField == EditedField.RX) {
|
||||
// RX 是锚点,不变;重算 TX
|
||||
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
||||
) ?: txFrequencyHz
|
||||
} else if (lastEditedField == EditedField.PASSBAND) {
|
||||
// 相对位置不变(passbandPosition),TX 用地面频率,RX 经 Transceiver 完整路径计算
|
||||
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
|
||||
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
||||
) ?: rxFrequencyHz
|
||||
}
|
||||
}
|
||||
|
||||
val txSliderValue = if (lastEditedField == EditedField.PASSBAND) {
|
||||
passbandPosition
|
||||
} else {
|
||||
((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
|
||||
}
|
||||
val rxSliderValue = if (lastEditedField == EditedField.PASSBAND) {
|
||||
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
|
||||
} else {
|
||||
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
fun updateTx(newTxHz: Long) {
|
||||
when (lastEditedField) {
|
||||
EditedField.PASSBAND -> {
|
||||
// Passband 模式:滑块位置 → passbandPosition → 地面频率,RX 经 Transceiver 计算
|
||||
val pos = ((newTxHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
|
||||
passbandPosition = pos
|
||||
txFrequencyHz = txLow + (pos * txRange).toLong()
|
||||
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
||||
) ?: rxFrequencyHz
|
||||
}
|
||||
} else {
|
||||
val rxMHz = rxInputMHz.toDoubleOrNull()
|
||||
if (rxMHz != null && rxMHz > 0) {
|
||||
val rxHz = (rxMHz * 1_000_000).toLong()
|
||||
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||
rxHz, transponder, orbitalPos, offsetHz
|
||||
)
|
||||
if (txHz != null) {
|
||||
txInputMHz = String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0)
|
||||
}
|
||||
else -> {
|
||||
txFrequencyHz = newTxHz.coerceIn(txLow, txHigh)
|
||||
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
||||
) ?: rxFrequencyHz
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateRx(newRxHz: Long) {
|
||||
when (lastEditedField) {
|
||||
EditedField.PASSBAND -> {
|
||||
// Passband 模式:从 RX 反推 TX 位置,TX 用地面频率
|
||||
val pos = ((newRxHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
|
||||
val txFromRx = TransponderMapper.mapDownlinkToUplink(newRxHz, rawTransponder)
|
||||
passbandPosition = if (txFromRx != null) {
|
||||
((txFromRx - rawTxLow).toFloat() / rawTxRange).coerceIn(0f, 1f)
|
||||
} else pos
|
||||
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
|
||||
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
||||
) ?: rxFrequencyHz
|
||||
}
|
||||
else -> {
|
||||
rxFrequencyHz = newRxHz.coerceIn(rxLow, rxHigh)
|
||||
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
||||
) ?: txFrequencyHz
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
modifier = modifier.fillMaxWidth().padding(top = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.radar_doppler_calc),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.radar_doppler_info),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
// Offset input
|
||||
OutlinedTextField(
|
||||
value = offsetKHz,
|
||||
onValueChange = { newVal ->
|
||||
offsetKHz = newVal
|
||||
// Trigger recompute based on last edited field
|
||||
if (lastEditedBy == EditedField.TX) {
|
||||
val txMHz = txInputMHz.toDoubleOrNull()
|
||||
if (txMHz != null && txMHz > 0) {
|
||||
val txHz = (txMHz * 1_000_000).toLong()
|
||||
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||
txHz, transponder, orbitalPos, offsetHz
|
||||
)
|
||||
rxInputMHz = if (rxHz != null) String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) else ""
|
||||
}
|
||||
} else {
|
||||
val rxMHz = rxInputMHz.toDoubleOrNull()
|
||||
if (rxMHz != null && rxMHz > 0) {
|
||||
val rxHz = (rxMHz * 1_000_000).toLong()
|
||||
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||
rxHz, transponder, orbitalPos, offsetHz
|
||||
)
|
||||
txInputMHz = if (txHz != null) String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) else ""
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
// 左半边: TX + RX 按钮
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
FilterChip(
|
||||
selected = lastEditedField == EditedField.TX,
|
||||
onClick = {
|
||||
when (lastEditedField) {
|
||||
EditedField.TX -> {
|
||||
passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
|
||||
lastEditedField = EditedField.PASSBAND
|
||||
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
||||
) ?: rxFrequencyHz
|
||||
}
|
||||
EditedField.PASSBAND -> { lastEditedField = EditedField.TX }
|
||||
else -> { lastEditedField = EditedField.TX }
|
||||
}
|
||||
},
|
||||
label = {
|
||||
Text("TX", fontSize = 13.sp, textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
},
|
||||
modifier = Modifier.weight(1f).fillMaxHeight()
|
||||
)
|
||||
FilterChip(
|
||||
selected = lastEditedField == EditedField.RX,
|
||||
onClick = {
|
||||
when (lastEditedField) {
|
||||
EditedField.RX -> {
|
||||
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
||||
) ?: txFrequencyHz
|
||||
passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
|
||||
lastEditedField = EditedField.PASSBAND
|
||||
}
|
||||
EditedField.PASSBAND -> {
|
||||
lastEditedField = EditedField.RX
|
||||
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
||||
) ?: txFrequencyHz
|
||||
}
|
||||
else -> { lastEditedField = EditedField.RX }
|
||||
}
|
||||
},
|
||||
label = {
|
||||
Text("RX", fontSize = 13.sp, textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
},
|
||||
modifier = Modifier.weight(1f).fillMaxHeight()
|
||||
)
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(R.string.radar_doppler_offset_hint)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
// 右半边: Offset 输入框(带细线边框)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline, MaterialTheme.shapes.small),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
BasicTextField(
|
||||
value = offsetKHz,
|
||||
onValueChange = onOffsetChange,
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
decorationBox = { innerTextField ->
|
||||
if (offsetKHz.isEmpty()) {
|
||||
Text(
|
||||
text = "Offset (kHz)",
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TX and RX inputs on the same row
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// TX input → compute RX
|
||||
OutlinedTextField(
|
||||
value = txInputMHz,
|
||||
onValueChange = { newVal ->
|
||||
txInputMHz = newVal
|
||||
lastEditedBy = EditedField.TX
|
||||
val mhz = newVal.toDoubleOrNull()
|
||||
if (mhz != null && mhz > 0) {
|
||||
val txHz = (mhz * 1_000_000).toLong()
|
||||
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||
txHz, transponder, orbitalPos, offsetHz
|
||||
// TX 行
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedButton(
|
||||
onClick = { updateTx(txFrequencyHz - stepSizeKHz * 1000L) },
|
||||
modifier = Modifier.width(32.dp).height(28.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
shape = MaterialTheme.shapes.extraSmall
|
||||
) { Text("−", fontSize = 14.sp) }
|
||||
|
||||
Slider(
|
||||
value = txSliderValue,
|
||||
onValueChange = { norm ->
|
||||
val rawFreq = txLow + (txRange * norm).toLong()
|
||||
val snapped = ((rawFreq + 500) / 1000L) * 1000L
|
||||
updateTx(snapped.coerceIn(txLow, txHigh))
|
||||
},
|
||||
valueRange = 0f..1f,
|
||||
modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp)
|
||||
)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { updateTx(txFrequencyHz + stepSizeKHz * 1000L) },
|
||||
modifier = Modifier.width(32.dp).height(28.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
shape = MaterialTheme.shapes.extraSmall
|
||||
) { Text("+", fontSize = 14.sp) }
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = String.format(Locale.ENGLISH, "%.4f", txLow / 1_000_000.0),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = String.format(Locale.ENGLISH, "%.6f", txFrequencyHz / 1_000_000.0),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = " MHz",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
rxInputMHz = if (rxHz != null) String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) else ""
|
||||
} else if (newVal.isEmpty()) {
|
||||
rxInputMHz = ""
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(R.string.radar_doppler_tx_hint)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
modifier = Modifier.weight(1f)
|
||||
Text(
|
||||
text = String.format(Locale.ENGLISH, "%.4f", txHigh / 1_000_000.0),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.End
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
||||
)
|
||||
|
||||
// RX input → compute TX
|
||||
OutlinedTextField(
|
||||
value = rxInputMHz,
|
||||
onValueChange = { newVal ->
|
||||
rxInputMHz = newVal
|
||||
lastEditedBy = EditedField.RX
|
||||
val mhz = newVal.toDoubleOrNull()
|
||||
if (mhz != null && mhz > 0) {
|
||||
val rxHz = (mhz * 1_000_000).toLong()
|
||||
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||
rxHz, transponder, orbitalPos, offsetHz
|
||||
// RX 行
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedButton(
|
||||
onClick = { updateRx(rxFrequencyHz - stepSizeKHz * 1000L) },
|
||||
modifier = Modifier.width(32.dp).height(28.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
shape = MaterialTheme.shapes.extraSmall
|
||||
) { Text("−", fontSize = 14.sp) }
|
||||
|
||||
Slider(
|
||||
value = rxSliderValue,
|
||||
onValueChange = { norm ->
|
||||
val rawFreq = rxLow + (rxRange * norm).toLong()
|
||||
val snapped = ((rawFreq + 500) / 1000L) * 1000L
|
||||
updateRx(snapped.coerceIn(rxLow, rxHigh))
|
||||
},
|
||||
valueRange = 0f..1f,
|
||||
modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp)
|
||||
)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { updateRx(rxFrequencyHz + stepSizeKHz * 1000L) },
|
||||
modifier = Modifier.width(32.dp).height(28.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
shape = MaterialTheme.shapes.extraSmall
|
||||
) { Text("+", fontSize = 14.sp) }
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = String.format(Locale.ENGLISH, "%.4f", rxLow / 1_000_000.0),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = String.format(Locale.ENGLISH, "%.6f", rxFrequencyHz / 1_000_000.0),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = " MHz",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
txInputMHz = if (txHz != null) String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) else ""
|
||||
} else if (newVal.isEmpty()) {
|
||||
txInputMHz = ""
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(R.string.radar_doppler_rx_hint)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = String.format(Locale.ENGLISH, "%.4f", rxHigh / 1_000_000.0),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.End
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class EditedField { TX, RX }
|
||||
|
||||
@Composable
|
||||
private fun CwDecoderPanel(
|
||||
cw: CwSubState,
|
||||
@@ -804,3 +1028,4 @@ private fun transceiverTitle(radio: SatRadio): String {
|
||||
|
||||
private val FREQ_ADJUSTMENTS =
|
||||
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
|
||||
|
||||
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user