mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
604c2ec59d | ||
|
|
1904a6b3e6 | ||
|
|
7f390117b8 | ||
|
|
346803fe6e | ||
|
|
47b87a1604 | ||
|
|
935fe6d99f | ||
|
|
69af1c38bb | ||
|
|
5c35f2418d | ||
|
|
2aa9858660 | ||
|
|
bc6e065804 | ||
|
|
7924a6f69e | ||
|
|
9a2a715d1f | ||
|
|
ee32c7a1eb | ||
|
|
1d54ac4718 | ||
|
|
433ce85561 | ||
|
|
cebe3b7ec4 | ||
|
|
d8f60cf270 | ||
|
|
4a3332fdb5 | ||
|
|
9e7fdbfe71 | ||
|
|
463df58eb9 | ||
|
|
58b8801c0c | ||
|
|
2581ba92c0 | ||
|
|
e5131ebb72 | ||
|
|
f9accdd517 | ||
|
|
ee3c1e7058 | ||
|
|
a08a28df39 | ||
|
|
759a6de2c6 | ||
|
|
ba5a984c16 | ||
|
|
083b8a2847 | ||
|
|
e8ebfb1ef3 | ||
|
|
12eed1d9a9 | ||
|
|
b3859fbece | ||
|
|
f40a67e1b7 | ||
|
|
16dce5df2a | ||
|
|
80a58bed61 | ||
|
|
a8afc63bba | ||
|
|
dbf9a2a65f | ||
|
|
ddecaa6017 | ||
|
|
418a05e343 | ||
|
|
c80e351212 | ||
|
|
a333192df5 | ||
|
|
a7b41c95c7 | ||
|
|
2298d8eea4 | ||
|
|
1813e1bfa4 | ||
|
|
bd09ad22ff | ||
|
|
7e90071c4d | ||
|
|
d1cc944be2 | ||
|
|
fb28283679 | ||
|
|
81ae8772d2 | ||
|
|
571145838d | ||
|
|
017b178f2e | ||
|
|
670444858c | ||
|
|
7bdff5df81 | ||
|
|
df083840ac | ||
|
|
a0279bac17 | ||
|
|
3d630041e1 | ||
|
|
ea92d3c150 | ||
|
|
86ee3dcbac | ||
|
|
43788ba7a7 | ||
|
|
1a3f1bb34c | ||
|
|
998c98267b | ||
|
|
28fa373db0 | ||
|
|
4ca33c37ad | ||
|
|
bd42e643ea | ||
|
|
4baf3821e5 | ||
|
|
d7e1ce11c9 | ||
|
|
5de370983d | ||
|
|
f272e268ab | ||
|
|
e59507a4d3 | ||
|
|
0bae306312 | ||
|
|
65f61a5147 | ||
|
|
1064dc739e | ||
|
|
8aedd38b51 | ||
|
|
ad8f08a577 | ||
|
|
2afce54472 | ||
|
|
8e50fcf8fb | ||
|
|
e11b03d56b | ||
|
|
fa55b82a5a | ||
|
|
d57832b0d4 | ||
|
|
1d0e8921a9 | ||
|
|
23d95b8241 | ||
|
|
8af80866bc | ||
|
|
b9e5ff70f5 | ||
|
|
c176d6ad88 | ||
|
|
5a6b162e4f | ||
|
|
33712c29fc | ||
|
|
eac1e6e273 | ||
|
|
f585372594 | ||
|
|
4585332b1a | ||
|
|
6d2b0ced49 | ||
|
|
e86bc2c700 | ||
|
|
9a52fdfde5 | ||
|
|
07504a2a95 | ||
|
|
81525b6dd3 | ||
|
|
c7089ab314 | ||
|
|
77dfb7bb05 | ||
|
|
58fb688593 | ||
|
|
dc596b4348 | ||
|
|
f9b1a97a37 | ||
|
|
d700432829 |
No files matched your search
@@ -1,6 +1,9 @@
|
|||||||
# Built application files
|
# Built application files
|
||||||
*.ap_
|
*.ap_
|
||||||
|
|
||||||
|
# Hermes AI plans and metadata
|
||||||
|
.hermes/
|
||||||
|
|
||||||
# Files for the ART/Dalvik VM
|
# Files for the ART/Dalvik VM
|
||||||
*.dex
|
*.dex
|
||||||
|
|
||||||
@@ -15,6 +18,7 @@ out/
|
|||||||
# Gradle files
|
# Gradle files
|
||||||
.gradle/
|
.gradle/
|
||||||
build/
|
build/
|
||||||
|
.cxx/
|
||||||
|
|
||||||
# Local configuration file (sdk path, etc)
|
# Local configuration file (sdk path, etc)
|
||||||
local.properties
|
local.properties
|
||||||
@@ -64,3 +68,4 @@ fastlane/readme.md
|
|||||||
/app/release/output-metadata.json
|
/app/release/output-metadata.json
|
||||||
/app/release/
|
/app/release/
|
||||||
/.kotlin/sessions/
|
/.kotlin/sessions/
|
||||||
|
dist/
|
||||||
@@ -1,45 +1,38 @@
|
|||||||
# Look4Sat: Satellite tracker
|
# Look4Sat-BA7OPF
|
||||||
|
|
||||||
[](https://github.com/rt-bishop/Look4Sat/actions/workflows/release.yml)
|
[](https://github.com/atsunatsu/Look4Sat/releases)
|
||||||
|
|
||||||
[<img src="https://play.google.com/intl/en_gb/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="80">](https://play.google.com/store/apps/details?id=com.rtbishop.look4sat)
|
**BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的业余无线电卫星追踪器,增加了线性卫星频率计算器等功能。
|
||||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/com.rtbishop.look4sat/)
|
|
||||||
|
|
||||||
### Radio satellite tracker and pass predictor for Android, inspired by Gpredict
|
## 本仓库特色功能
|
||||||
|
|
||||||
<p float="left">
|
- **双站过境匹配(Mutual Pass)** — 输入友台经纬度/网格,筛选双方同时可见的卫星过境,在地图上同时显示双方仰角曲线和地面轨迹,支持一键跳转雷达页查看详情
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="192"/>
|
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="192"/>
|
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="192"/>
|
- **Passband 模式** — 支持通过位置滑块(Passband)自动计算 TX/RX 频率,避免切换时跳变
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="192">
|
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放
|
||||||
</p>
|
- **中文界面优化** — 翻译修正、UI 布局调整,系统语言自动切换
|
||||||
|
|
||||||
### Track satellite passes with ease!
|
## 上游仓库
|
||||||
|
|
||||||
Thanks to [Celestrak](https://celestrak.com/) and [SatNOGS](https://satnogs.org/) you have access to over 9000 active satellites.\
|
本仓库是 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的分支,上游仓库的原始功能包括:
|
||||||
You can search the entire database by NORAD Catalog Number or the satellite's name.
|
|
||||||
|
|
||||||
Orbital positions and passes are calculated relative to your location.\
|
- 基于 Celestrak / SatNOGS 数据的 9000+ 活跃卫星追踪
|
||||||
To get reliable data make sure to set the station position via the app Settings.
|
- SGP4/SDP4 轨道预测,10 天过境预报
|
||||||
|
- 极坐标雷达图、地面轨迹图
|
||||||
|
- SSTV 图像解码
|
||||||
|
- 无广告、无跟踪、完全离线
|
||||||
|
|
||||||
The application is built using Kotlin, Coroutines, Jetpack Compose and Navigation.\
|
## 许可证
|
||||||
It is now and always will be completely ad-free and open-source.
|
|
||||||
|
|
||||||
## Main features:
|
GNU General Public License v3.0。详见 [LICENSE](LICENSE)。
|
||||||
|
|
||||||
* Predicting satellite positions and passes for up to 10 days
|
|
||||||
* Showing the list of currently active and upcoming satellite passes
|
|
||||||
* Showing the active pass progress, polar trajectory and transceivers info
|
|
||||||
* Showing the satellite positional data, footprint and ground track on the map
|
|
||||||
* Custom TLE satellite data import is available via Three Line Element .txt files
|
|
||||||
* Offline first: calculations are made offline. Weekly TLE data update is recommended.
|
|
||||||
|
|
||||||
## Star History
|
## 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/#atsunatsu/Look4Sat&type=timeline&legend=top-left">
|
||||||
<picture>
|
<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: dark)" srcset="https://star-history.dera.page/svg?repos=atsunatsu/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" />
|
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=atsunatsu/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" />
|
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&legend=top-left" />
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
@@ -1,3 +1,24 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.convention.applicationPlugin)
|
alias(libs.plugins.convention.applicationPlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = libs.versions.packageName.get()
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "cn.ba7opf.look4sat"
|
||||||
|
ndk { abiFilters.add("armeabi-v7a") }
|
||||||
|
}
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(System.getProperty("user.home") + "/my-release-key.jks")
|
||||||
|
storePassword = "look4sat123"
|
||||||
|
keyAlias = "look4sat"
|
||||||
|
keyPassword = "look4sat123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
-3
@@ -1,3 +0,0 @@
|
|||||||
-keep class org.osmdroid.** { *; }
|
|
||||||
-keep class com.rtbishop.look4sat.feature.map.*TiandituTileSource* { *; }
|
|
||||||
-dontwarn org.osmdroid.**
|
|
||||||
@@ -13,9 +13,10 @@
|
|||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name="cn.bh2vsq.look4sat.MainApplication"
|
android:name=".MainApplication"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
@@ -23,8 +24,9 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round">
|
android:roundIcon="@mipmap/ic_launcher_round">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name="cn.bh2vsq.look4sat.MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
android:theme="@style/Theme.Look4Sat.SplashScreen">
|
android:theme="@style/Theme.Look4Sat.SplashScreen">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
@@ -42,7 +44,17 @@
|
|||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||||
android:value="cn.bh2vsq.look4sat" />
|
android:value="com.rtbishop.look4sat" />
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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 cn.bh2vsq.look4sat
|
|
||||||
|
|
||||||
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.scaleIn
|
|
||||||
import androidx.compose.animation.scaleOut
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
|
||||||
import androidx.compose.animation.togetherWith
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
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.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
|
||||||
import androidx.navigation3.runtime.entryProvider
|
|
||||||
import androidx.navigation3.runtime.rememberNavBackStack
|
|
||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
|
||||||
import androidx.navigation3.ui.NavDisplay
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
|
||||||
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
|
||||||
import com.rtbishop.look4sat.core.presentation.RadarDestination
|
|
||||||
import com.rtbishop.look4sat.core.presentation.Screen
|
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
|
||||||
import com.rtbishop.look4sat.feature.map.MapDestination
|
|
||||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
|
||||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
|
||||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
|
||||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
|
||||||
|
|
||||||
@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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
|
||||||
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
|
||||||
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
|
||||||
NavDisplay(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
backStack = rootBackStack,
|
|
||||||
onBack = navigateBack,
|
|
||||||
transitionSpec = { slideInTransition },
|
|
||||||
popTransitionSpec = { slideOutTransition },
|
|
||||||
predictivePopTransitionSpec = { slideOutTransition },
|
|
||||||
entryDecorators = listOf(
|
|
||||||
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
|
|
||||||
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
|
|
||||||
),
|
|
||||||
entryProvider = entryProvider {
|
|
||||||
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
|
|
||||||
entry<RadarDestination> {
|
|
||||||
Scaffold { innerPadding ->
|
|
||||||
RadarDestination(navigateUp = navigateBack)
|
|
||||||
innerPadding.calculateTopPadding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|
||||||
val backStack = rememberNavBackStack(Screen.Passes)
|
|
||||||
val currentKey = backStack.lastOrNull()
|
|
||||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
|
||||||
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
|
||||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
|
||||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
NavigationSuiteScaffold(
|
|
||||||
navigationSuiteItems = {
|
|
||||||
navItems.forEach { screen ->
|
|
||||||
val isSelected = when (currentKey) {
|
|
||||||
is Screen.Satellites -> screen is Screen.Satellites
|
|
||||||
is Screen.Passes -> screen is Screen.Passes
|
|
||||||
is Screen.Radar -> screen is Screen.Radar
|
|
||||||
is Screen.Map -> screen is Screen.Map
|
|
||||||
is Screen.Settings -> screen is Screen.Settings
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
item(
|
|
||||||
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
|
|
||||||
label = { Text(stringResource(screen.titleResId)) },
|
|
||||||
selected = isSelected,
|
|
||||||
onClick = {
|
|
||||||
if (isSelected) return@item
|
|
||||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
|
||||||
if (screen !is Screen.Passes) backStack.add(screen)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
|
||||||
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
),
|
|
||||||
layoutType = when {
|
|
||||||
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
|
|
||||||
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
|
|
||||||
else -> NavigationSuiteType.ShortNavigationBarMedium
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
NavDisplay(
|
|
||||||
backStack = backStack,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
onBack = navigateBack,
|
|
||||||
transitionSpec = { fadeTransition },
|
|
||||||
popTransitionSpec = { fadeTransition },
|
|
||||||
predictivePopTransitionSpec = { fadeTransition },
|
|
||||||
entryDecorators = listOf(
|
|
||||||
// Required for saving Compose state per entry
|
|
||||||
rememberSaveableStateHolderNavEntryDecorator(),
|
|
||||||
// Required for ViewModel scoping per entry
|
|
||||||
rememberViewModelStoreNavEntryDecorator()
|
|
||||||
),
|
|
||||||
entryProvider = entryProvider {
|
|
||||||
entry<Screen.Satellites> {
|
|
||||||
SatellitesDestination(navigateUp = navigateBack)
|
|
||||||
}
|
|
||||||
entry<Screen.Passes> {
|
|
||||||
PassesDestination { catNum, aosTime ->
|
|
||||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
|
||||||
backStack.add(Screen.Radar)
|
|
||||||
// navigateToRadar()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entry<Screen.Radar> {
|
|
||||||
RadarDestination(navigateUp = navigateBack)
|
|
||||||
}
|
|
||||||
entry<Screen.Map> {
|
|
||||||
MapDestination()
|
|
||||||
}
|
|
||||||
entry<Screen.Settings> {
|
|
||||||
SettingsDestination()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
// Radio tracking status banner
|
|
||||||
if (trackingState.isActive) {
|
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
|
||||||
val alpha by infiniteTransition.animateFloat(
|
|
||||||
initialValue = 1f, targetValue = 0.4f,
|
|
||||||
animationSpec = infiniteRepeatable(
|
|
||||||
animation = tween(1000, easing = LinearEasing),
|
|
||||||
repeatMode = RepeatMode.Reverse
|
|
||||||
), label = "pulseAlpha"
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
|
||||||
.clickable {
|
|
||||||
val pass = trackingState.currentPass
|
|
||||||
if (pass != null) {
|
|
||||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
|
||||||
backStack.add(Screen.Radar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(8.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
|
|
||||||
fontSize = 13.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
val txOk = if (trackingState.txConnected) "TX" else ""
|
|
||||||
val rxOk = if (trackingState.rxConnected) "RX" else ""
|
|
||||||
Text(
|
|
||||||
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-2
@@ -15,7 +15,7 @@
|
|||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
package cn.bh2vsq.look4sat
|
package com.rtbishop.look4sat
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
observeNightFilterState()
|
observeNightFilterState()
|
||||||
setContent {
|
setContent {
|
||||||
MainTheme(isDarkTheme = true) { MainScreen() }
|
MainTheme(isDarkTheme = true) { NavRoot() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+29
-1
@@ -15,9 +15,12 @@
|
|||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
package cn.bh2vsq.look4sat
|
package com.rtbishop.look4sat
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.app.Application.ActivityLifecycleCallbacks
|
||||||
|
import android.os.Bundle
|
||||||
import com.rtbishop.look4sat.core.data.injection.MainContainer
|
import com.rtbishop.look4sat.core.data.injection.MainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
@@ -35,12 +38,37 @@ class MainApplication : Application(), IContainerProvider {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
container = MainContainer(this)
|
container = MainContainer(this)
|
||||||
|
clearAmSatCacheOnAppBackground()
|
||||||
// trigger automatic update every 48 hours
|
// trigger automatic update every 48 hours
|
||||||
container.appScope.launch { checkAutoUpdate() }
|
container.appScope.launch { checkAutoUpdate() }
|
||||||
// load satellite data on every app start
|
// load satellite data on every app start
|
||||||
container.appScope.launch { container.satelliteRepo.initRepository() }
|
container.appScope.launch { container.satelliteRepo.initRepository() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun clearAmSatCacheOnAppBackground() {
|
||||||
|
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
|
||||||
|
private var startedActivityCount = 0
|
||||||
|
|
||||||
|
override fun onActivityStarted(activity: Activity) {
|
||||||
|
if (startedActivityCount == 0) {
|
||||||
|
container.appScope.launch { container.amSatRepo.prefetchStatus() }
|
||||||
|
}
|
||||||
|
startedActivityCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityStopped(activity: Activity) {
|
||||||
|
startedActivityCount = (startedActivityCount - 1).coerceAtLeast(0)
|
||||||
|
if (startedActivityCount == 0) container.amSatRepo.clearStatusCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
|
||||||
|
override fun onActivityResumed(activity: Activity) = Unit
|
||||||
|
override fun onActivityPaused(activity: Activity) = Unit
|
||||||
|
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
|
||||||
|
override fun onActivityDestroyed(activity: Activity) = Unit
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun checkAutoUpdate(timeNow: Long = System.currentTimeMillis()) {
|
private suspend fun checkAutoUpdate(timeNow: Long = System.currentTimeMillis()) {
|
||||||
if (container.settingsRepo.otherSettings.value.stateOfAutoUpdate) {
|
if (container.settingsRepo.otherSettings.value.stateOfAutoUpdate) {
|
||||||
val timeDelta = timeNow - container.settingsRepo.databaseState.value.updateTimestamp
|
val timeDelta = timeNow - container.settingsRepo.databaseState.value.updateTimestamp
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
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
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
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.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||||
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||||
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.ViewModelStoreOwner
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
|
import androidx.navigation3.runtime.entryProvider
|
||||||
|
import androidx.navigation3.runtime.rememberNavBackStack
|
||||||
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
|
import androidx.navigation3.ui.NavDisplay
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||||
|
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
||||||
|
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
||||||
|
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
||||||
|
import com.rtbishop.look4sat.core.presentation.RadarDestination
|
||||||
|
import com.rtbishop.look4sat.core.presentation.Screen
|
||||||
|
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||||
|
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
||||||
|
import com.rtbishop.look4sat.feature.map.MapDestination
|
||||||
|
import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
||||||
|
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.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()
|
||||||
|
var openMapRequest by remember { mutableIntStateOf(0) }
|
||||||
|
LaunchedEffect(deeplink) {
|
||||||
|
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
||||||
|
}
|
||||||
|
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||||
|
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
||||||
|
val navigateToMap: () -> Unit = {
|
||||||
|
while (rootBackStack.size > 1) rootBackStack.removeAt(rootBackStack.size - 1)
|
||||||
|
openMapRequest += 1
|
||||||
|
}
|
||||||
|
// 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 = { pushTransition },
|
||||||
|
popTransitionSpec = { popTransition },
|
||||||
|
predictivePopTransitionSpec = { popTransition },
|
||||||
|
entryDecorators = listOf(
|
||||||
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
|
),
|
||||||
|
entryProvider = entryProvider {
|
||||||
|
entry<Screen.Passes> {
|
||||||
|
MainScreen(
|
||||||
|
navigateToRadar = navigateToRadar,
|
||||||
|
openMapRequest = openMapRequest,
|
||||||
|
onOpenMapRequestHandled = { openMapRequest = 0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
entry<RadarDestination> {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
RadarDestination(navigateUp = navigateBack, navigateToMap = navigateToMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainScreen(
|
||||||
|
navigateToRadar: () -> Unit = {},
|
||||||
|
openMapRequest: Int = 0,
|
||||||
|
onOpenMapRequestHandled: () -> Unit = {}
|
||||||
|
) {
|
||||||
|
val backStack = rememberNavBackStack(Screen.Passes)
|
||||||
|
val currentKey = backStack.lastOrNull()
|
||||||
|
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||||
|
val navigateToMap: () -> Unit = {
|
||||||
|
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||||
|
backStack.add(Screen.Map)
|
||||||
|
}
|
||||||
|
LaunchedEffect(openMapRequest) {
|
||||||
|
if (openMapRequest > 0) {
|
||||||
|
navigateToMap()
|
||||||
|
onOpenMapRequestHandled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
||||||
|
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.AMSAT, Screen.Mutual, Screen.Settings)
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
|
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||||
|
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||||
|
// Activity-scoped so the mutual query results survive navigation to Radar and back
|
||||||
|
val mutualViewModel: MutualViewModel = viewModel(
|
||||||
|
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||||
|
factory = MutualViewModel.factory(container)
|
||||||
|
)
|
||||||
|
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalElevationThresholds provides ElevationThresholds(
|
||||||
|
low = otherSettings.lowElevation,
|
||||||
|
high = otherSettings.highElevation
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
NavigationSuiteScaffold(
|
||||||
|
navigationSuiteItems = {
|
||||||
|
navItems.forEach { screen ->
|
||||||
|
val isSelected = when (currentKey) {
|
||||||
|
is Screen.Satellites -> screen is Screen.Satellites
|
||||||
|
is Screen.Passes -> screen is Screen.Passes
|
||||||
|
is Screen.Map -> screen is Screen.Passes
|
||||||
|
is Screen.AMSAT -> screen is Screen.AMSAT
|
||||||
|
is Screen.Mutual -> screen is Screen.Mutual
|
||||||
|
is Screen.Settings -> screen is Screen.Settings
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
item(
|
||||||
|
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
|
||||||
|
label = { Text(stringResource(screen.titleResId)) },
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = {
|
||||||
|
if (isSelected && !(currentKey is Screen.Map && screen is Screen.Passes)) return@item
|
||||||
|
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||||
|
if (screen !is Screen.Passes) backStack.add(screen)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
||||||
|
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
),
|
||||||
|
layoutType = when {
|
||||||
|
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
|
||||||
|
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
|
||||||
|
else -> NavigationSuiteType.ShortNavigationBarMedium
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
NavDisplay(
|
||||||
|
backStack = backStack,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
onBack = navigateBack,
|
||||||
|
transitionSpec = { fadeTransition },
|
||||||
|
popTransitionSpec = { fadeTransition },
|
||||||
|
predictivePopTransitionSpec = { fadeTransition },
|
||||||
|
entryDecorators = listOf(
|
||||||
|
// Required for saving Compose state per entry
|
||||||
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
|
// Required for ViewModel scoping per entry
|
||||||
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
|
),
|
||||||
|
entryProvider = entryProvider {
|
||||||
|
entry<Screen.Satellites> {
|
||||||
|
SatellitesDestination(navigateUp = navigateBack)
|
||||||
|
}
|
||||||
|
entry<Screen.Passes> {
|
||||||
|
PassesDestination(
|
||||||
|
navigateToRadar = { catNum, aosTime ->
|
||||||
|
container.setMutualPassData(MutualPassData())
|
||||||
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
|
navigateToRadar()
|
||||||
|
},
|
||||||
|
navigateToMap = navigateToMap
|
||||||
|
)
|
||||||
|
}
|
||||||
|
entry<Screen.Map> {
|
||||||
|
MapDestination()
|
||||||
|
}
|
||||||
|
entry<Screen.Mutual> {
|
||||||
|
MutualScreen(
|
||||||
|
viewModel = mutualViewModel,
|
||||||
|
navigateToRadar = { catNum, aosTime, pass ->
|
||||||
|
container.setMutualPassData(pass ?: MutualPassData())
|
||||||
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
|
navigateToRadar()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
entry<Screen.AMSAT> { SatStatusDestination() }
|
||||||
|
entry<Screen.Settings> {
|
||||||
|
SettingsDestination()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// Radio tracking status banner
|
||||||
|
if (trackingState.isActive) {
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
||||||
|
val alpha by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 1f, targetValue = 0.4f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(1000, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
), label = "pulseAlpha"
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
|
.clickable {
|
||||||
|
val pass = trackingState.currentPass
|
||||||
|
if (pass != null) {
|
||||||
|
container.setMutualPassData(MutualPassData())
|
||||||
|
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||||
|
navigateToRadar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
val txOk = if (trackingState.txConnected) "TX" else ""
|
||||||
|
val rxOk = if (trackingState.rxConnected) "RX" else ""
|
||||||
|
Text(
|
||||||
|
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<cache-path name="updates" path="." />
|
||||||
|
</paths>
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<network-security-config>
|
<network-security-config>
|
||||||
<domain-config cleartextTrafficPermitted="true">
|
<base-config cleartextTrafficPermitted="true" />
|
||||||
<domain includeSubdomains="true">tianditu.gov.cn</domain>
|
|
||||||
</domain-config>
|
|
||||||
</network-security-config>
|
</network-security-config>
|
||||||
+2
@@ -32,10 +32,12 @@ internal class ApplicationPlugin : Plugin<Project> {
|
|||||||
implementation(project(":core:domain"))
|
implementation(project(":core:domain"))
|
||||||
implementation(project(":core:presentation"))
|
implementation(project(":core:presentation"))
|
||||||
implementation(project(":feature:map"))
|
implementation(project(":feature:map"))
|
||||||
|
implementation(project(":feature:mutual"))
|
||||||
implementation(project(":feature:passes"))
|
implementation(project(":feature:passes"))
|
||||||
implementation(project(":feature:radar"))
|
implementation(project(":feature:radar"))
|
||||||
implementation(project(":feature:satellites"))
|
implementation(project(":feature:satellites"))
|
||||||
implementation(project(":feature:settings"))
|
implementation(project(":feature:settings"))
|
||||||
|
implementation(project(":feature:status"))
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
implementation(libs.compose.material3.adaptive)
|
implementation(libs.compose.material3.adaptive)
|
||||||
implementation(libs.compose.navigation3)
|
implementation(libs.compose.navigation3)
|
||||||
|
|||||||
+1
-1
@@ -69,7 +69,7 @@ internal fun Project.setupAndroidApp() {
|
|||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
androidResources {
|
androidResources {
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.bluetooth.BluetoothSocket
|
||||||
|
import android.util.Log
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icom IC-705 CI-V controller over Bluetooth SPP.
|
||||||
|
*
|
||||||
|
* The IC-705 emits broadcast frames continuously (band scope, UTC, signal
|
||||||
|
* level, …). A reply to any command we send may therefore be buried in
|
||||||
|
* that noise. All response reads drain up to [ACK_TIMEOUT_MS] and scan the
|
||||||
|
* entire accumulated buffer for the frame we expect rather than assuming
|
||||||
|
* the very next byte is the response.
|
||||||
|
*/
|
||||||
|
class Ic705Controller(
|
||||||
|
private val bluetoothManager: BluetoothManager,
|
||||||
|
private val deviceAddress: String
|
||||||
|
) : IRadioController {
|
||||||
|
|
||||||
|
private val tag = "IC705"
|
||||||
|
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
|
||||||
|
private val ioMutex = Mutex()
|
||||||
|
|
||||||
|
/** Time budget (ms) to wait for a response amid broadcast noise. */
|
||||||
|
private val ACK_TIMEOUT_MS = 500L
|
||||||
|
/** Polling interval while draining the input buffer. */
|
||||||
|
private val POLL_INTERVAL_MS = 20L
|
||||||
|
/** Small pause after writing a command before reading the response. */
|
||||||
|
private val WRITE_SETTLE_MS = 50L
|
||||||
|
|
||||||
|
private var socket: BluetoothSocket? = null
|
||||||
|
private var outputStream: OutputStream? = null
|
||||||
|
private var inputStream: InputStream? = null
|
||||||
|
|
||||||
|
override var isConnected: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
// ── Connection ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
if (isConnected) return@withContext true
|
||||||
|
if (deviceAddress.isBlank()) return@withContext false
|
||||||
|
try {
|
||||||
|
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
|
||||||
|
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||||
|
btSocket.connect()
|
||||||
|
socket = btSocket
|
||||||
|
outputStream = btSocket.outputStream
|
||||||
|
inputStream = btSocket.inputStream
|
||||||
|
isConnected = true
|
||||||
|
// Enter VFO mode — frequency/mode commands return FA if the radio
|
||||||
|
// is in memory-channel mode. Safe to send regardless of current state.
|
||||||
|
Log.i(tag, "Connected to $deviceAddress — entering VFO mode")
|
||||||
|
val vfoCmd = IcomCivProtocol.buildEnterVfoModeCommand()
|
||||||
|
Log.d(tag, "CMD enterVfoMode → ${IcomCivProtocol.toHex(vfoCmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(vfoCmd) }
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Connect error: ${e.message}")
|
||||||
|
isConnected = false
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun disconnect() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
inputStream?.close()
|
||||||
|
outputStream?.close()
|
||||||
|
socket?.close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Disconnect error: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
inputStream = null
|
||||||
|
outputStream = null
|
||||||
|
socket = null
|
||||||
|
isConnected = false
|
||||||
|
Log.i(tag, "Disconnected from $deviceAddress")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IRadioController – standard operations ──────────────────────────────
|
||||||
|
|
||||||
|
override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(tag, "setFrequency: ${frequencyHz}Hz")
|
||||||
|
ioMutex.withLock {
|
||||||
|
val cmd = IcomCivProtocol.buildSetFreqCommand(frequencyHz)
|
||||||
|
Log.d(tag, "CMD setFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
sendAndWaitAck(cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setMode(mode: String): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val cmd = IcomCivProtocol.buildSetModeCommand(mode) ?: run {
|
||||||
|
Log.w(tag, "setMode: unknown mode '$mode'")
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
Log.d(tag, "setMode: $mode")
|
||||||
|
Log.d(tag, "CMD setMode → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setCtcssMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(tag, "setCtcssMode: $enabled")
|
||||||
|
val cmd = IcomCivProtocol.buildCtcssModeCommand(enabled)
|
||||||
|
Log.d(tag, "CMD ctcssMode → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setCtcssTone(toneHz: Double): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(tag, "setCtcssTone: ${toneHz}Hz")
|
||||||
|
val cmd = IcomCivProtocol.buildSetCtcssToneCommand(toneHz)
|
||||||
|
Log.d(tag, "CMD ctcssTone → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readFrequencyAndMode(): Pair<Long, String>? = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock {
|
||||||
|
val cmd = IcomCivProtocol.buildReadFreqCommand()
|
||||||
|
Log.d(tag, "CMD readFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_READ_FREQ) ?: return@withContext null
|
||||||
|
// Read-freq reply payload: [cmd byte already stripped by parseResponse] [5 freq bytes] [mode] [filter]
|
||||||
|
IcomCivProtocol.parseFreqModePayload(payload).also {
|
||||||
|
if (it != null) Log.d(tag, "readFreqMode: ${it.first}Hz, ${it.second}")
|
||||||
|
else Log.w(tag, "readFreqMode: parse failed, payload=${IcomCivProtocol.toHex(payload)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun pttOn(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.w(tag, "pttOn: not used for IC-705")
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun pttOff(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.w(tag, "pttOff: not used for IC-705")
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IRadioController – IC-705 extended operations ───────────────────────
|
||||||
|
|
||||||
|
/** Select the band for [frequencyHz] via CMD 0x1A sub 0x00 (band stacking register). */
|
||||||
|
override suspend fun setBand(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val cmd = IcomCivProtocol.buildBandSelectCommand(frequencyHz) ?: run {
|
||||||
|
Log.w(tag, "setBand: no band code for ${frequencyHz}Hz — skipping")
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
Log.d(tag, "CMD setBand (${frequencyHz}Hz) → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select VFO-A (main/RX) or VFO-B (sub/TX). */
|
||||||
|
override suspend fun setVfo(vfoA: Boolean): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val cmd = if (vfoA) IcomCivProtocol.buildSelectVfoACommand()
|
||||||
|
else IcomCivProtocol.buildSelectVfoBCommand()
|
||||||
|
Log.d(tag, "CMD selectVFO${if (vfoA) "A" else "B"} → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable SPLIT mode (TX on sub-VFO while listening on main VFO).
|
||||||
|
*/
|
||||||
|
override suspend fun setSplitMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val cmd = IcomCivProtocol.buildSplitModeCommand(enabled)
|
||||||
|
Log.d(tag, "CMD split ${if (enabled) "ON" else "OFF"} → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the frequency of the **currently active** VFO (CMD 0x25 sub 0x00).
|
||||||
|
* In split mode the radio automatically switches active VFO on PTT, so
|
||||||
|
* always writing to the active VFO is the correct strategy.
|
||||||
|
*/
|
||||||
|
override suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(tag, "setWorkingFrequency (0x25/00): ${frequencyHz}Hz")
|
||||||
|
val cmd = IcomCivProtocol.buildSetWorkingFreqCommand(frequencyHz)
|
||||||
|
Log.d(tag, "CMD setWorkingFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set TX VFO frequency via CMD 0x25 sub 0x01 (unselected VFO).
|
||||||
|
* Sent every tracking cycle in split mode alongside [setWorkingFrequency].
|
||||||
|
*/
|
||||||
|
override suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(tag, "setTxVfoFrequency (0x25/01): ${frequencyHz}Hz")
|
||||||
|
val cmd = IcomCivProtocol.buildSetUnselectedVfoFreqCommand(frequencyHz)
|
||||||
|
Log.d(tag, "CMD setTxVfoFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the frequency of the currently active VFO (CMD 0x25 sub 0x00).
|
||||||
|
* Used for tuning detection in split mode.
|
||||||
|
*/
|
||||||
|
override suspend fun readWorkingFrequency(): Long? = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock {
|
||||||
|
val cmd = IcomCivProtocol.buildReadWorkingFreqCommand()
|
||||||
|
Log.d(tag, "CMD readWorkingFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_SELECTED_VFO_FREQ) ?: return@withContext null
|
||||||
|
// Response payload: [sub] [5 freq bytes] — CMD byte already stripped by parseResponse
|
||||||
|
Log.d(tag, "readWorkingFreq: got ${payload.size} bytes: ${IcomCivProtocol.toHex(payload)}")
|
||||||
|
if (payload.size < 6) {
|
||||||
|
Log.w(tag, "readWorkingFreq: payload too short (${payload.size} bytes)")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
val freqBcd = payload.sliceArray(1..5)
|
||||||
|
val freq = IcomCivProtocol.decodeFrequencyBcd(freqBcd)
|
||||||
|
Log.d(tag, "readWorkingFreq: ${freq}Hz")
|
||||||
|
freq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the frequency of the inactive/TX VFO (CMD 0x25 sub 0x01).
|
||||||
|
* Used for tuning detection in split mode.
|
||||||
|
*/
|
||||||
|
override suspend fun readTxVfoFrequency(): Long? = withContext(Dispatchers.IO) {
|
||||||
|
ioMutex.withLock {
|
||||||
|
val cmd = IcomCivProtocol.buildReadTxVfoFreqCommand()
|
||||||
|
Log.d(tag, "CMD readTxVfoFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||||
|
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_SELECTED_VFO_FREQ) ?: return@withContext null
|
||||||
|
// Response payload: [sub] [5 freq bytes] — CMD byte already stripped by parseResponse
|
||||||
|
Log.d(tag, "readTxVfoFreq: got ${payload.size} bytes: ${IcomCivProtocol.toHex(payload)}")
|
||||||
|
if (payload.size < 6) {
|
||||||
|
Log.w(tag, "readTxVfoFreq: payload too short (${payload.size} bytes)")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
val freqBcd = payload.sliceArray(1..5)
|
||||||
|
val freq = IcomCivProtocol.decodeFrequencyBcd(freqBcd)
|
||||||
|
Log.d(tag, "readTxVfoFreq: ${freq}Hz")
|
||||||
|
freq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal I/O helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write [cmd] to the radio and drain the input stream for up to
|
||||||
|
* [ACK_TIMEOUT_MS], looking for an OK/NG acknowledgement frame.
|
||||||
|
*/
|
||||||
|
private suspend fun sendAndWaitAck(cmd: ByteArray): Boolean {
|
||||||
|
if (!write(cmd)) return false
|
||||||
|
delay(WRITE_SETTLE_MS)
|
||||||
|
val buf = drainWithTimeout(ACK_TIMEOUT_MS)
|
||||||
|
val ok = IcomCivProtocol.containsAck(buf)
|
||||||
|
if (!ok) Log.w(tag, "ACK not found in ${buf.size} bytes: ${IcomCivProtocol.toHex(buf)}")
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write [cmd] to the radio and drain the input stream for up to
|
||||||
|
* [ACK_TIMEOUT_MS], scanning for a response frame carrying [expectCmd].
|
||||||
|
* Returns the payload bytes of that frame, or null on timeout/error.
|
||||||
|
*/
|
||||||
|
private suspend fun sendAndReadResponse(cmd: ByteArray, expectCmd: Byte): ByteArray? {
|
||||||
|
if (!write(cmd)) return null
|
||||||
|
delay(WRITE_SETTLE_MS)
|
||||||
|
val buf = drainWithTimeout(ACK_TIMEOUT_MS)
|
||||||
|
val response = IcomCivProtocol.parseResponse(buf, expectCmd)
|
||||||
|
if (response == null) {
|
||||||
|
Log.w(tag, "No response for cmd 0x${String.format("%02X", expectCmd.toInt() and 0xFF)} " +
|
||||||
|
"in ${buf.size} bytes: ${IcomCivProtocol.toHex(buf)}")
|
||||||
|
}
|
||||||
|
return response?.payload
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drain whatever bytes the radio has buffered within a [timeoutMs] window.
|
||||||
|
* Exits early as soon as a complete CI-V frame addressed to us is present
|
||||||
|
* in the buffer (i.e., FE FE E0 A4 … FD), so we don't waste the remaining
|
||||||
|
* timeout on responses that already arrived.
|
||||||
|
*/
|
||||||
|
private suspend fun drainWithTimeout(timeoutMs: Long): ByteArray {
|
||||||
|
val result = mutableListOf<Byte>()
|
||||||
|
val deadline = System.currentTimeMillis() + timeoutMs
|
||||||
|
val stream = inputStream ?: return ByteArray(0)
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
try {
|
||||||
|
val available = stream.available()
|
||||||
|
if (available > 0) {
|
||||||
|
val chunk = ByteArray(available)
|
||||||
|
val read = stream.read(chunk)
|
||||||
|
if (read > 0) {
|
||||||
|
result.addAll(chunk.take(read))
|
||||||
|
// Exit early once we have a complete frame for us
|
||||||
|
if (hasCompleteFrameForUs(result)) break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delay(POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Drain error: ${e.message}")
|
||||||
|
isConnected = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if [buf] contains a complete CI-V frame addressed to the
|
||||||
|
* controller (FE FE [ADDR_CTRL] [ADDR_IC705] … FD).
|
||||||
|
* CI-V data bytes cannot be 0xFD, so the first 0xFD after the header is
|
||||||
|
* always the frame terminator.
|
||||||
|
*/
|
||||||
|
private fun hasCompleteFrameForUs(buf: List<Byte>): Boolean {
|
||||||
|
var i = 0
|
||||||
|
while (i < buf.size - 4) {
|
||||||
|
if (buf[i] == IcomCivProtocol.PREAMBLE &&
|
||||||
|
buf[i + 1] == IcomCivProtocol.PREAMBLE &&
|
||||||
|
buf[i + 2] == IcomCivProtocol.ADDR_CTRL &&
|
||||||
|
buf[i + 3] == IcomCivProtocol.ADDR_IC705
|
||||||
|
) {
|
||||||
|
for (k in i + 4 until buf.size) {
|
||||||
|
if (buf[k] == IcomCivProtocol.END_OF_MSG) return true
|
||||||
|
}
|
||||||
|
return false // header found but no FD yet
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun write(bytes: ByteArray): Boolean {
|
||||||
|
return try {
|
||||||
|
outputStream?.write(bytes)
|
||||||
|
outputStream?.flush()
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Write error: ${e.message}")
|
||||||
|
isConnected = false
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icom CI-V protocol encoder/decoder for the IC-705.
|
||||||
|
*
|
||||||
|
* Frame structure:
|
||||||
|
* FE FE <DEST> <SRC> <CMD> [<SUB>] [<DATA...>] FD
|
||||||
|
*
|
||||||
|
* IC-705 default CI-V address : 0xA4
|
||||||
|
* Controller (us) address : 0xE0
|
||||||
|
*/
|
||||||
|
object IcomCivProtocol {
|
||||||
|
|
||||||
|
// ── Framing constants ──────────────────────────────────────────────────
|
||||||
|
const val PREAMBLE: Byte = 0xFE.toByte()
|
||||||
|
const val END_OF_MSG: Byte = 0xFD.toByte()
|
||||||
|
const val ACK_OK: Byte = 0xFB.toByte()
|
||||||
|
const val ACK_NG: Byte = 0xFA.toByte()
|
||||||
|
|
||||||
|
// ── Address constants ──────────────────────────────────────────────────
|
||||||
|
/** Default CI-V address of the IC-705. */
|
||||||
|
const val ADDR_IC705: Byte = 0xA4.toByte()
|
||||||
|
/** Default CI-V address of the controller (us). */
|
||||||
|
const val ADDR_CTRL: Byte = 0xE0.toByte()
|
||||||
|
|
||||||
|
// ── Command bytes ──────────────────────────────────────────────────────
|
||||||
|
/** Read operating frequency (main VFO). */
|
||||||
|
const val CMD_READ_FREQ: Byte = 0x03
|
||||||
|
/** Set operating frequency (main VFO). */
|
||||||
|
const val CMD_SET_FREQ: Byte = 0x05
|
||||||
|
/** Set operating mode. */
|
||||||
|
const val CMD_SET_MODE: Byte = 0x06
|
||||||
|
/** Select VFO / memory. */
|
||||||
|
const val CMD_SELECT_VFO: Byte = 0x07
|
||||||
|
/**
|
||||||
|
* Select operating mode (VFO vs memory-channel).
|
||||||
|
* Sub 0x00 = VFO mode. Must be sent after connect if the radio is in
|
||||||
|
* memory-channel mode — frequency/mode commands return FA until it is.
|
||||||
|
*/
|
||||||
|
const val CMD_SELECT_OP_MODE: Byte = 0x08
|
||||||
|
/** Set repeater duplex / SPLIT. */
|
||||||
|
const val CMD_DUPLEX_SPLIT: Byte = 0x0F
|
||||||
|
/** Band stacking register / band select (sub 0x00 = select, data = BCD band number). */
|
||||||
|
const val CMD_BAND_SELECT: Byte = 0x1A
|
||||||
|
/** Read/write CTCSS tone frequency. */
|
||||||
|
const val CMD_CTCSS_TONE: Byte = 0x1B
|
||||||
|
/** Read/write misc settings (used for enabling CTCSS encode). */
|
||||||
|
const val CMD_MISC_SETTING: Byte = 0x16
|
||||||
|
/** Read/write selected-VFO frequency (cmd 0x25). */
|
||||||
|
const val CMD_SELECTED_VFO_FREQ: Byte = 0x25
|
||||||
|
|
||||||
|
// ── Sub-command bytes ──────────────────────────────────────────────────
|
||||||
|
/** Sub for CMD_SELECT_VFO: select VFO-A (main). */
|
||||||
|
const val SUB_VFO_A: Byte = 0x00
|
||||||
|
/** Sub for CMD_SELECT_VFO: select VFO-B (sub). */
|
||||||
|
const val SUB_VFO_B: Byte = 0x01
|
||||||
|
/** Sub for CMD_DUPLEX_SPLIT: simplex / split OFF. */
|
||||||
|
const val SUB_SPLIT_OFF: Byte = 0x00
|
||||||
|
/** Sub for CMD_DUPLEX_SPLIT: SPLIT ON. */
|
||||||
|
const val SUB_SPLIT_ON: Byte = 0x01
|
||||||
|
/** Sub for CMD_SELECTED_VFO_FREQ: selected (active) VFO frequency. */
|
||||||
|
const val SUB_SELECTED_VFO: Byte = 0x00
|
||||||
|
/** Sub for CMD_SELECTED_VFO_FREQ: unselected (inactive / TX in split) VFO frequency. */
|
||||||
|
const val SUB_UNSELECTED_VFO: Byte = 0x01
|
||||||
|
/** Sub for CMD_MISC_SETTING: CTCSS/DTCS tone squelch. */
|
||||||
|
const val SUB_CTCSS_SETTING: Byte = 0x42.toByte()
|
||||||
|
|
||||||
|
// ── Mode bytes ────────────────────────────────────────────────────────
|
||||||
|
/** Maps mode strings (upper-case) → IC-705 mode bytes. */
|
||||||
|
val MODE_TO_BYTE: Map<String, Byte> = mapOf(
|
||||||
|
"LSB" to 0x00,
|
||||||
|
"USB" to 0x01,
|
||||||
|
"AM" to 0x02,
|
||||||
|
"CW" to 0x03,
|
||||||
|
"RTTY" to 0x04,
|
||||||
|
"FM" to 0x05,
|
||||||
|
"WFM" to 0x06,
|
||||||
|
"CW-R" to 0x07,
|
||||||
|
"RTTY-R" to 0x08,
|
||||||
|
"DV" to 0x12,
|
||||||
|
"AFSK" to 0x05 // AFSK uses FM modulation
|
||||||
|
)
|
||||||
|
|
||||||
|
val BYTE_TO_MODE: Map<Byte, String> = MODE_TO_BYTE.entries.associate { it.value to it.key }
|
||||||
|
|
||||||
|
// ── Frequency BCD encoding ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a frequency in Hz to the IC-705's 5-byte BCD format.
|
||||||
|
*
|
||||||
|
* The IC-705 uses 5 bytes, LSB pair first, with 1 Hz resolution.
|
||||||
|
* Example: 145,500,000 Hz → "0145500000" → pairs LSB→MSB:
|
||||||
|
* [00, 00, 50, 45, 01]
|
||||||
|
*/
|
||||||
|
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
|
||||||
|
val digits = String.format(Locale.US, "%010d", frequencyHz)
|
||||||
|
val bcd = ByteArray(5)
|
||||||
|
for (i in 0 until 5) {
|
||||||
|
// digits are MSB first; we want pair index 0 = LSB pair
|
||||||
|
val pairIndex = 4 - i
|
||||||
|
val high = digits[pairIndex * 2] - '0'
|
||||||
|
val low = digits[pairIndex * 2 + 1] - '0'
|
||||||
|
bcd[i] = ((high shl 4) or low).toByte()
|
||||||
|
}
|
||||||
|
return bcd
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode 5-byte BCD frequency (LSB pair first) to Hz.
|
||||||
|
*/
|
||||||
|
fun decodeFrequencyBcd(bcd: ByteArray): Long {
|
||||||
|
// Build digit string MSB→LSB by reversing the byte order
|
||||||
|
var freqHz = 0L
|
||||||
|
for (i in 4 downTo 0) {
|
||||||
|
val b = bcd[i].toInt() and 0xFF
|
||||||
|
val high = b shr 4
|
||||||
|
val low = b and 0x0F
|
||||||
|
freqHz = freqHz * 100 + high * 10 + low
|
||||||
|
}
|
||||||
|
return freqHz
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a CTCSS tone (Hz, e.g. 67.0) to 2-byte BCD (0.1 Hz resolution).
|
||||||
|
* 67.0 → 670 (tenths of Hz) → BCD bytes [0x06, 0x70].
|
||||||
|
*/
|
||||||
|
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
|
||||||
|
val tone01 = (toneHz * 10).toLong()
|
||||||
|
val digits = String.format(Locale.US, "%04d", tone01)
|
||||||
|
return byteArrayOf(
|
||||||
|
((digits[0] - '0') shl 4 or (digits[1] - '0')).toByte(),
|
||||||
|
((digits[2] - '0') shl 4 or (digits[3] - '0')).toByte()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Message builders ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Wrap payload bytes in a CI-V frame: FE FE DEST SRC ... FD. */
|
||||||
|
private fun frame(vararg payload: Byte): ByteArray {
|
||||||
|
return byteArrayOf(PREAMBLE, PREAMBLE, ADDR_IC705, ADDR_CTRL) +
|
||||||
|
payload +
|
||||||
|
byteArrayOf(END_OF_MSG)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set operating frequency via CMD 0x05 (main VFO). */
|
||||||
|
fun buildSetFreqCommand(frequencyHz: Long): ByteArray {
|
||||||
|
return frame(CMD_SET_FREQ, *encodeFrequencyBcd(frequencyHz))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set selected-VFO frequency via CMD 0x25 sub 0x00.
|
||||||
|
* This updates whichever VFO is currently active (RX or TX after split).
|
||||||
|
*/
|
||||||
|
fun buildSetWorkingFreqCommand(frequencyHz: Long): ByteArray {
|
||||||
|
return frame(CMD_SELECTED_VFO_FREQ, SUB_SELECTED_VFO, *encodeFrequencyBcd(frequencyHz))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set unselected-VFO frequency via CMD 0x25 sub 0x01.
|
||||||
|
* In split mode while PTT is pressed the IC-705 makes VFO-B active, so
|
||||||
|
* this command targets VFO-A (the RX VFO) — and vice-versa when in RX.
|
||||||
|
* Use this to update the TX VFO when PTT is on.
|
||||||
|
*/
|
||||||
|
fun buildSetUnselectedVfoFreqCommand(frequencyHz: Long): ByteArray {
|
||||||
|
return frame(CMD_SELECTED_VFO_FREQ, SUB_UNSELECTED_VFO, *encodeFrequencyBcd(frequencyHz))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read operating frequency (CMD 0x03). */
|
||||||
|
fun buildReadFreqCommand(): ByteArray = frame(CMD_READ_FREQ)
|
||||||
|
|
||||||
|
/** Read selected (active) VFO frequency (CMD 0x25 sub 0x00). */
|
||||||
|
fun buildReadWorkingFreqCommand(): ByteArray = frame(CMD_SELECTED_VFO_FREQ, SUB_SELECTED_VFO)
|
||||||
|
|
||||||
|
/** Read unselected (inactive/TX in split) VFO frequency (CMD 0x25 sub 0x01). */
|
||||||
|
fun buildReadTxVfoFreqCommand(): ByteArray = frame(CMD_SELECTED_VFO_FREQ, SUB_UNSELECTED_VFO)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select band via CMD 0x1A sub 0x00.
|
||||||
|
* Band codes are BCD-numbered: 1=160m, 2=80m, …, 9=10m, 0x10=6m, 0x11=2m, 0x12=70cm, 0x13=23cm.
|
||||||
|
* Returns null if [frequencyHz] doesn't fall in a known amateur band.
|
||||||
|
*/
|
||||||
|
fun buildBandSelectCommand(frequencyHz: Long): ByteArray? {
|
||||||
|
val code = bandCodeForFrequency(frequencyHz) ?: return null
|
||||||
|
return frame(CMD_BAND_SELECT, 0x00, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a frequency in Hz to the IC-705 band stacking register code.
|
||||||
|
* Codes are BCD (band number in decimal expressed as hex nibbles).
|
||||||
|
*/
|
||||||
|
fun bandCodeForFrequency(frequencyHz: Long): Byte? = when {
|
||||||
|
frequencyHz in 1_800_000L ..1_999_999L -> 0x01 // 160 m
|
||||||
|
frequencyHz in 3_500_000L ..3_999_999L -> 0x02 // 80 m
|
||||||
|
frequencyHz in 7_000_000L ..7_299_999L -> 0x03 // 40 m
|
||||||
|
frequencyHz in 10_100_000L ..10_149_999L -> 0x04 // 30 m
|
||||||
|
frequencyHz in 14_000_000L ..14_349_999L -> 0x05 // 20 m
|
||||||
|
frequencyHz in 18_068_000L ..18_167_999L -> 0x06 // 17 m
|
||||||
|
frequencyHz in 21_000_000L ..21_449_999L -> 0x07 // 15 m
|
||||||
|
frequencyHz in 24_890_000L ..24_989_999L -> 0x08 // 12 m
|
||||||
|
frequencyHz in 28_000_000L ..29_699_999L -> 0x09 // 10 m
|
||||||
|
frequencyHz in 50_000_000L ..53_999_999L -> 0x10 // 6 m (BCD 10)
|
||||||
|
frequencyHz in 144_000_000L ..147_999_999L -> 0x11 // 2 m (BCD 11)
|
||||||
|
frequencyHz in 420_000_000L ..449_999_999L -> 0x12 // 70 cm (BCD 12)
|
||||||
|
frequencyHz in 1_240_000_000L ..1_299_999_999L -> 0x13 // 23 cm (BCD 13)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set operating mode (CMD 0x06). Filter byte is omitted — radio uses its default filter for the mode. */
|
||||||
|
fun buildSetModeCommand(mode: String): ByteArray? {
|
||||||
|
val modeByte = MODE_TO_BYTE[mode.uppercase(Locale.US)] ?: return null
|
||||||
|
return frame(CMD_SET_MODE, modeByte)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select VFO-A (CMD 0x07 sub 0x00). */
|
||||||
|
fun buildSelectVfoACommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_A)
|
||||||
|
|
||||||
|
/** Select VFO-B (CMD 0x07 sub 0x01). */
|
||||||
|
fun buildSelectVfoBCommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_B)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter VFO operating mode (CMD 0x08 sub 0x00).
|
||||||
|
* Sent after connect — if the radio is in memory-channel mode frequency
|
||||||
|
* and mode commands return FA until this is issued.
|
||||||
|
*/
|
||||||
|
fun buildEnterVfoModeCommand(): ByteArray = frame(CMD_SELECT_OP_MODE, 0x00)
|
||||||
|
|
||||||
|
/** Enable or disable SPLIT mode (CMD 0x0F). */
|
||||||
|
fun buildSplitModeCommand(enable: Boolean): ByteArray {
|
||||||
|
val sub = if (enable) SUB_SPLIT_ON else SUB_SPLIT_OFF
|
||||||
|
return frame(CMD_DUPLEX_SPLIT, sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable CTCSS encode (CMD 0x16 sub 0x42).
|
||||||
|
* 0x01 = CTCSS encoder ON, 0x00 = OFF.
|
||||||
|
*/
|
||||||
|
fun buildCtcssModeCommand(enabled: Boolean): ByteArray {
|
||||||
|
val value: Byte = if (enabled) 0x01 else 0x00
|
||||||
|
return frame(CMD_MISC_SETTING, SUB_CTCSS_SETTING, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set CTCSS tone frequency (CMD 0x1B sub 0x00).
|
||||||
|
*/
|
||||||
|
fun buildSetCtcssToneCommand(toneHz: Double): ByteArray {
|
||||||
|
val bcd = encodeCtcssToneBcd(toneHz)
|
||||||
|
return frame(CMD_CTCSS_TONE, 0x00, *bcd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Response parsing ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find and parse a complete CI-V response frame from a buffer.
|
||||||
|
*
|
||||||
|
* Returns the bytes between "FE FE E0 A4 <CMD>" and FD, or null if no
|
||||||
|
* complete frame was found. The search is tolerant of interleaved
|
||||||
|
* broadcast traffic.
|
||||||
|
*
|
||||||
|
* @param buf bytes accumulated from the radio
|
||||||
|
* @param expectCmd the command byte we are looking for in the reply, or
|
||||||
|
* null to accept any command response from the radio
|
||||||
|
*/
|
||||||
|
fun parseResponse(buf: ByteArray, expectCmd: Byte?): ParsedResponse? {
|
||||||
|
var i = 0
|
||||||
|
while (i < buf.size - 5) {
|
||||||
|
// Look for FE FE preamble
|
||||||
|
if (buf[i] != PREAMBLE || buf[i + 1] != PREAMBLE) { i++; continue }
|
||||||
|
val dest = buf[i + 2]
|
||||||
|
val src = buf[i + 3]
|
||||||
|
val cmd = buf[i + 4]
|
||||||
|
// We only care about frames addressed to us from the radio
|
||||||
|
if (dest != ADDR_CTRL || src != ADDR_IC705) { i++; continue }
|
||||||
|
// Find the terminating FD
|
||||||
|
val fdIdx = buf.indexOf(END_OF_MSG, startIndex = i + 5)
|
||||||
|
if (fdIdx < 0) break // incomplete frame, wait for more data
|
||||||
|
val payload = buf.copyOfRange(i + 5, fdIdx)
|
||||||
|
if (expectCmd == null || cmd == expectCmd) {
|
||||||
|
return ParsedResponse(cmd, payload, fdIdx + 1)
|
||||||
|
}
|
||||||
|
i = fdIdx + 1
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.indexOf(b: Byte, startIndex: Int): Int {
|
||||||
|
for (k in startIndex until size) if (this[k] == b) return k
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a buffer contains an OK acknowledgement (FB FD) from
|
||||||
|
* the radio. Tolerates broadcast noise before the ACK.
|
||||||
|
*/
|
||||||
|
fun containsAck(buf: ByteArray): Boolean {
|
||||||
|
var i = 0
|
||||||
|
while (i < buf.size - 5) {
|
||||||
|
if (buf[i] != PREAMBLE || buf[i + 1] != PREAMBLE) { i++; continue }
|
||||||
|
val dest = buf[i + 2]
|
||||||
|
val src = buf[i + 3]
|
||||||
|
val cmd = buf[i + 4]
|
||||||
|
if (dest != ADDR_CTRL || src != ADDR_IC705) { i++; continue }
|
||||||
|
// Skip to FD
|
||||||
|
val fdIdx = buf.indexOf(END_OF_MSG, startIndex = i + 5)
|
||||||
|
if (fdIdx < 0) break
|
||||||
|
if (cmd == ACK_OK) return true
|
||||||
|
if (cmd == ACK_NG) return false
|
||||||
|
i = fdIdx + 1
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse frequency + mode from a CMD_READ_FREQ reply payload.
|
||||||
|
* Payload layout after stripping command byte: [5 freq bytes] [mode byte] [filter byte]
|
||||||
|
*/
|
||||||
|
fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? {
|
||||||
|
if (payload.size < 6) return null
|
||||||
|
val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5))
|
||||||
|
val mode = BYTE_TO_MODE[payload[5]] ?: return null
|
||||||
|
return freqHz to mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hex dump of bytes, useful for debug logging. */
|
||||||
|
fun toHex(bytes: ByteArray): String =
|
||||||
|
bytes.joinToString(" ") { String.format(Locale.US, "%02X", it.toInt() and 0xFF) }
|
||||||
|
|
||||||
|
data class ParsedResponse(
|
||||||
|
val cmd: Byte,
|
||||||
|
val payload: ByteArray,
|
||||||
|
/** Index in the source buffer immediately after the FD terminator. */
|
||||||
|
val nextOffset: Int
|
||||||
|
)
|
||||||
|
}
|
||||||
+69
-28
@@ -17,8 +17,10 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.data.framework
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
@@ -31,18 +33,30 @@ class NetworkReporter(
|
|||||||
private val rotatorServer: String,
|
private val rotatorServer: String,
|
||||||
private val rotatorPort: Int,
|
private val rotatorPort: Int,
|
||||||
private val frequencyServer: String,
|
private val frequencyServer: String,
|
||||||
private val frequencyPort: Int
|
private val frequencyPort: Int,
|
||||||
|
private val frequencyOffsetHz: Long = 0L
|
||||||
) : IReporter {
|
) : IReporter {
|
||||||
|
|
||||||
private val writeMutex = Mutex()
|
private val writeMutex = Mutex()
|
||||||
|
private val connectionMutex = Mutex()
|
||||||
|
private val frequencyCommands = Channel<String>(Channel.CONFLATED)
|
||||||
|
|
||||||
private var rotatorSocket: SocketChannel? = null
|
private var rotatorSocket: SocketChannel? = null
|
||||||
private var rotatorConnected = false
|
private var rotatorConnected = false
|
||||||
private var rotatorConnecting = false
|
|
||||||
|
|
||||||
private var frequencySocket: SocketChannel? = null
|
private var frequencySocket: SocketChannel? = null
|
||||||
private var frequencyConnected = false
|
private var frequencyConnected = false
|
||||||
private var frequencyConnecting = false
|
|
||||||
|
init {
|
||||||
|
// Keep only the latest frequency command to avoid stale backlog and effective lag.
|
||||||
|
reporterScope.launch {
|
||||||
|
for (command in frequencyCommands) {
|
||||||
|
ensureFrequencyConnected()
|
||||||
|
if (!frequencyConnected) continue
|
||||||
|
write(frequencySocket, command) { resetFrequencyConnection() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
|
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
|
||||||
reporterScope.launch {
|
reporterScope.launch {
|
||||||
@@ -53,51 +67,48 @@ class NetworkReporter(
|
|||||||
.replace($$"$AZ", azimuth.toString())
|
.replace($$"$AZ", azimuth.toString())
|
||||||
.replace($$"$EL", el.toString())
|
.replace($$"$EL", el.toString())
|
||||||
.unescapeControlChars()
|
.unescapeControlChars()
|
||||||
write(rotatorSocket, command) { rotatorConnected = false }
|
write(rotatorSocket, command) { resetRotatorConnection() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun reportFrequency(format: String, frequency: Long) {
|
override fun reportFrequency(format: String, frequency: Long) {
|
||||||
reporterScope.launch {
|
val clampedOffset = frequencyOffsetHz.coerceIn(
|
||||||
ensureFrequencyConnected()
|
Constants.FREQ_OFFSET_MIN_HZ,
|
||||||
if (!frequencyConnected) return@launch
|
Constants.FREQ_OFFSET_MAX_HZ
|
||||||
val command = format
|
)
|
||||||
.replace($$"$FREQ", frequency.toString())
|
val correctedFreq = frequency.coerceAtLeast(0L).safeAdd(clampedOffset).coerceAtLeast(0L)
|
||||||
.unescapeControlChars()
|
val command = format
|
||||||
write(frequencySocket, command) { frequencyConnected = false }
|
.replace($$"$FREQ", correctedFreq.toString())
|
||||||
}
|
.unescapeControlChars()
|
||||||
|
frequencyCommands.trySend(command)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureRotatorConnected() {
|
private suspend fun ensureRotatorConnected() {
|
||||||
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
|
connectionMutex.withLock {
|
||||||
reporterScope.launch {
|
if (rotatorConnected || rotatorServer.isBlank()) return
|
||||||
try {
|
try {
|
||||||
rotatorConnecting = true
|
resetRotatorConnection()
|
||||||
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
||||||
rotatorConnected = true
|
rotatorConnected = true
|
||||||
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
|
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter rotator connect error: ${e.message}")
|
println("NetworkReporter rotator connect error: ${e.message}")
|
||||||
rotatorConnected = false
|
resetRotatorConnection()
|
||||||
} finally {
|
|
||||||
rotatorConnecting = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureFrequencyConnected() {
|
private suspend fun ensureFrequencyConnected() {
|
||||||
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
|
connectionMutex.withLock {
|
||||||
reporterScope.launch {
|
if (frequencyConnected || frequencyServer.isBlank()) return
|
||||||
try {
|
try {
|
||||||
frequencyConnecting = true
|
resetFrequencyConnection()
|
||||||
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
||||||
frequencyConnected = true
|
frequencyConnected = true
|
||||||
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
|
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter frequency connect error: ${e.message}")
|
println("NetworkReporter frequency connect error: ${e.message}")
|
||||||
frequencyConnected = false
|
resetFrequencyConnection()
|
||||||
} finally {
|
|
||||||
frequencyConnecting = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +117,9 @@ class NetworkReporter(
|
|||||||
try {
|
try {
|
||||||
writeMutex.withLock {
|
writeMutex.withLock {
|
||||||
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
|
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
|
||||||
socket?.write(buffer)
|
while (buffer.hasRemaining()) {
|
||||||
|
socket?.write(buffer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter write error: ${e.message}")
|
println("NetworkReporter write error: ${e.message}")
|
||||||
@@ -114,6 +127,34 @@ class NetworkReporter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resetRotatorConnection() {
|
||||||
|
rotatorConnected = false
|
||||||
|
closeQuietly(rotatorSocket)
|
||||||
|
rotatorSocket = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetFrequencyConnection() {
|
||||||
|
frequencyConnected = false
|
||||||
|
closeQuietly(frequencySocket)
|
||||||
|
frequencySocket = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeQuietly(socket: SocketChannel?) {
|
||||||
|
try {
|
||||||
|
socket?.close()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Long.safeAdd(delta: Long): Long {
|
||||||
|
return when {
|
||||||
|
delta > 0 && this > Long.MAX_VALUE - delta -> Long.MAX_VALUE
|
||||||
|
delta < 0 && this < Long.MIN_VALUE - delta -> Long.MIN_VALUE
|
||||||
|
else -> this + delta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun String.unescapeControlChars(): String =
|
private fun String.unescapeControlChars(): String =
|
||||||
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
|
replace("""\r""", 13.toChar().toString())
|
||||||
|
.replace("""\n""", 10.toChar().toString())
|
||||||
|
.replace("""\t""", 9.toChar().toString())
|
||||||
}
|
}
|
||||||
+403
-204
@@ -19,8 +19,10 @@ package com.rtbishop.look4sat.core.data.framework
|
|||||||
|
|
||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
@@ -29,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
|
|||||||
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -44,6 +47,8 @@ class RadioTrackingService(
|
|||||||
) : IRadioTrackingService {
|
) : IRadioTrackingService {
|
||||||
|
|
||||||
private val tag = "RadioTracking"
|
private val tag = "RadioTracking"
|
||||||
|
/** Delay between each step of the split-mode init sequence (ms). */
|
||||||
|
private val INIT_STEP_DELAY_MS = 200L
|
||||||
private val _state = MutableStateFlow(RadioTrackingState())
|
private val _state = MutableStateFlow(RadioTrackingState())
|
||||||
override val state: StateFlow<RadioTrackingState> = _state
|
override val state: StateFlow<RadioTrackingState> = _state
|
||||||
|
|
||||||
@@ -51,227 +56,427 @@ class RadioTrackingService(
|
|||||||
private var rxController: IRadioController? = null
|
private var rxController: IRadioController? = null
|
||||||
private var trackingJob: Job? = null
|
private var trackingJob: Job? = null
|
||||||
|
|
||||||
|
// ── Connection ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
override suspend fun connectRadios() {
|
override suspend fun connectRadios() {
|
||||||
// Disconnect old controllers if any
|
|
||||||
txController?.disconnect()
|
txController?.disconnect()
|
||||||
rxController?.disconnect()
|
rxController?.disconnect()
|
||||||
|
|
||||||
// Read current addresses from settings
|
|
||||||
val rcSettings = settingsRepo.radioControlSettings.value
|
val rcSettings = settingsRepo.radioControlSettings.value
|
||||||
val txAddr = rcSettings.txRadioAddress
|
val txAddr = rcSettings.txRadioAddress
|
||||||
val rxAddr = rcSettings.rxRadioAddress
|
val rxAddr = rcSettings.rxRadioAddress
|
||||||
|
val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705
|
||||||
|
val isSplit = isIcom && rcSettings.splitMode
|
||||||
|
|
||||||
Log.i(tag, "Connecting TX=$txAddr RX=$rxAddr")
|
Log.i(tag, "connectRadios model=${rcSettings.radioModel} split=$isSplit TX=$txAddr RX=$rxAddr")
|
||||||
|
|
||||||
if (txAddr.isBlank() && rxAddr.isBlank()) {
|
if (isSplit) {
|
||||||
_state.update { it.copy(errorMessage = "No radio addresses configured in Settings") }
|
// Single-radio split mode: only TX slot is used
|
||||||
return
|
if (txAddr.isBlank()) {
|
||||||
}
|
_state.update { it.copy(errorMessage = "No radio address configured in Settings") }
|
||||||
|
return
|
||||||
val tx = Ft817Controller(bluetoothManager, txAddr)
|
}
|
||||||
val rx = Ft817Controller(bluetoothManager, rxAddr)
|
val tx = makeController(isIcom, txAddr)
|
||||||
txController = tx
|
txController = tx
|
||||||
rxController = rx
|
rxController = null
|
||||||
|
_state.update { it.copy(errorMessage = null) }
|
||||||
_state.update { it.copy(errorMessage = null) }
|
val txOk = tx.connect()
|
||||||
val txOk = if (txAddr.isNotBlank()) tx.connect() else false
|
_state.update {
|
||||||
val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false
|
it.copy(
|
||||||
_state.update {
|
txConnected = txOk,
|
||||||
it.copy(
|
rxConnected = false,
|
||||||
txConnected = txOk,
|
errorMessage = if (!txOk) "Could not connect to radio ($txAddr)" else null
|
||||||
rxConnected = rxOk,
|
)
|
||||||
errorMessage = when {
|
}
|
||||||
!txOk && !rxOk -> "Could not connect to TX and RX radios"
|
Log.i(tag, "IC-705 split mode connected: txOk=$txOk")
|
||||||
!txOk -> "Could not connect to TX radio ($txAddr)"
|
} else {
|
||||||
!rxOk -> "Could not connect to RX radio ($rxAddr)"
|
if (txAddr.isBlank() && rxAddr.isBlank()) {
|
||||||
else -> null
|
_state.update { it.copy(errorMessage = "No radio addresses configured in Settings") }
|
||||||
}
|
return
|
||||||
)
|
}
|
||||||
|
val tx = makeController(isIcom, txAddr)
|
||||||
|
val rx = makeController(isIcom, rxAddr)
|
||||||
|
txController = tx
|
||||||
|
rxController = rx
|
||||||
|
_state.update { it.copy(errorMessage = null) }
|
||||||
|
val txOk = if (txAddr.isNotBlank()) tx.connect() else false
|
||||||
|
val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
txConnected = txOk,
|
||||||
|
rxConnected = rxOk,
|
||||||
|
errorMessage = when {
|
||||||
|
!txOk && !rxOk -> "Could not connect to TX and RX radios"
|
||||||
|
!txOk -> "Could not connect to TX radio ($txAddr)"
|
||||||
|
!rxOk -> "Could not connect to RX radio ($rxAddr)"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Log.i(tag, "Dual-radio connected: txOk=$txOk rxOk=$rxOk")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun makeController(isIcom: Boolean, address: String): IRadioController =
|
||||||
|
if (isIcom) Ic705Controller(bluetoothManager, address)
|
||||||
|
else Ft817Controller(bluetoothManager, address)
|
||||||
|
|
||||||
override suspend fun disconnectRadios() {
|
override suspend fun disconnectRadios() {
|
||||||
stopTracking()
|
stopTracking()
|
||||||
txController?.disconnect()
|
txController?.disconnect()
|
||||||
rxController?.disconnect()
|
rxController?.disconnect()
|
||||||
txController = null
|
txController = null
|
||||||
rxController = null
|
rxController = null
|
||||||
_state.update {
|
_state.update { it.copy(txConnected = false, rxConnected = false, isActive = false) }
|
||||||
it.copy(
|
|
||||||
txConnected = false,
|
|
||||||
rxConnected = false,
|
|
||||||
isActive = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tracking ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) {
|
override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) {
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isActive = true,
|
isActive = true,
|
||||||
currentPass = pass,
|
currentPass = pass,
|
||||||
selectedTransponder = transponder,
|
selectedTransponder = transponder,
|
||||||
txBaseFrequencyHz = txBaseFreqHz
|
txBaseFrequencyHz = txBaseFreqHz
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
trackingJob?.cancel()
|
trackingJob?.cancel()
|
||||||
trackingJob = appScope.launch {
|
|
||||||
// Set modes on both radios at tracking start
|
|
||||||
val tx = txController
|
|
||||||
val rx = rxController
|
|
||||||
val txMode = transponder.uplinkMode
|
|
||||||
val rxMode = transponder.downlinkMode
|
|
||||||
?: transponder.uplinkMode?.let {
|
|
||||||
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
|
||||||
}
|
|
||||||
if (tx != null && tx.isConnected && txMode != null) {
|
|
||||||
tx.setMode(txMode)
|
|
||||||
Log.i(tag, "TX mode set to $txMode")
|
|
||||||
}
|
|
||||||
if (rx != null && rx.isConnected && rxMode != null) {
|
|
||||||
rx.setMode(rxMode)
|
|
||||||
Log.i(tag, "RX mode set to $rxMode")
|
|
||||||
}
|
|
||||||
// Set CTCSS if FM
|
|
||||||
if (txMode?.uppercase() == "FM") {
|
|
||||||
_state.value.ctcssTone?.let { tone ->
|
|
||||||
tx?.setCtcssTone(tone)
|
|
||||||
tx?.setCtcssMode(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
|
||||||
|
|
||||||
var lastSetTxFreq = 0.0
|
val rcSettings = settingsRepo.radioControlSettings.value
|
||||||
var lastSetRxFreq = 0.0
|
val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705
|
||||||
var tuningRadio = "" // "", "tx", or "rx" - which radio the user is tuning
|
val isSplit = isIcom && rcSettings.splitMode
|
||||||
var lastReadFreq = 0L
|
|
||||||
var stableCount = 0
|
|
||||||
|
|
||||||
while (isActive) {
|
if (isSplit) {
|
||||||
val currentState = _state.value
|
trackingJob = appScope.launch { runSplitTracking(transponder, txBaseFreqHz) }
|
||||||
if (!currentState.isActive) break
|
} else {
|
||||||
|
trackingJob = appScope.launch { runDualRadioTracking(transponder, txBaseFreqHz) }
|
||||||
val satPass = currentState.currentPass ?: break
|
|
||||||
val xpdr = currentState.selectedTransponder ?: break
|
|
||||||
var txBaseFreq = currentState.txBaseFrequencyHz
|
|
||||||
val stationPos = settingsRepo.stationPosition.value
|
|
||||||
val timeNow = System.currentTimeMillis()
|
|
||||||
|
|
||||||
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
|
|
||||||
val tx = txController
|
|
||||||
val rx = rxController
|
|
||||||
val hasUplink = txBaseFreq != null
|
|
||||||
val c = com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT
|
|
||||||
val v = pos.distanceRate * 1000.0
|
|
||||||
|
|
||||||
if (tuningRadio.isNotEmpty()) {
|
|
||||||
// --- User is tuning: keep reading, wait for stabilization ---
|
|
||||||
val radio = if (tuningRadio == "tx") tx else rx
|
|
||||||
if (radio != null && radio.isConnected) {
|
|
||||||
val readResult = radio.readFrequencyAndMode()
|
|
||||||
if (readResult != null) {
|
|
||||||
val (freq, _) = readResult
|
|
||||||
if (kotlin.math.abs(freq - lastReadFreq) <= 20) {
|
|
||||||
stableCount++
|
|
||||||
} else {
|
|
||||||
stableCount = 0
|
|
||||||
lastReadFreq = freq
|
|
||||||
}
|
|
||||||
// Stable for 2 reads → user stopped turning
|
|
||||||
if (stableCount >= 2) {
|
|
||||||
if (tuningRadio == "tx" && txBaseFreq != null) {
|
|
||||||
val newBase = (freq.toDouble() * c / (c + v)).toLong()
|
|
||||||
if (newBase > 0) {
|
|
||||||
txBaseFreq = newBase
|
|
||||||
_state.update { it.copy(txBaseFrequencyHz = newBase) }
|
|
||||||
Log.i(tag, "TX tuning done → base=$newBase")
|
|
||||||
}
|
|
||||||
} else if (tuningRadio == "rx") {
|
|
||||||
val rxNominal = (freq.toDouble() * c / (c - v)).toLong()
|
|
||||||
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
|
|
||||||
if (newTxBase != null && newTxBase > 0) {
|
|
||||||
txBaseFreq = newTxBase
|
|
||||||
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
|
|
||||||
Log.i(tag, "RX tuning done → txBase=$newTxBase")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tuningRadio = ""
|
|
||||||
stableCount = 0
|
|
||||||
lastSetTxFreq = 0.0
|
|
||||||
lastSetRxFreq = 0.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// --- Normal tracking: read, detect changes, command ---
|
|
||||||
|
|
||||||
// TX dial feedback
|
|
||||||
if (hasUplink && tx != null && tx.isConnected && lastSetTxFreq > 0.0) {
|
|
||||||
val readResult = tx.readFrequencyAndMode()
|
|
||||||
if (readResult != null) {
|
|
||||||
val (actualTxFreq, _) = readResult
|
|
||||||
if (kotlin.math.abs(actualTxFreq - lastSetTxFreq) >= 20.0) {
|
|
||||||
tuningRadio = "tx"
|
|
||||||
lastReadFreq = actualTxFreq
|
|
||||||
stableCount = 0
|
|
||||||
Log.i(tag, "TX tuning detected (read=$actualTxFreq, lastSet=$lastSetTxFreq)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RX dial feedback (only if TX not tuning)
|
|
||||||
if (tuningRadio.isEmpty() && rx != null && rx.isConnected && lastSetRxFreq > 0.0) {
|
|
||||||
val readResult = rx.readFrequencyAndMode()
|
|
||||||
if (readResult != null) {
|
|
||||||
val (actualRxFreq, _) = readResult
|
|
||||||
if (kotlin.math.abs(actualRxFreq - lastSetRxFreq) >= 20.0) {
|
|
||||||
tuningRadio = "rx"
|
|
||||||
lastReadFreq = actualRxFreq
|
|
||||||
stableCount = 0
|
|
||||||
Log.i(tag, "RX tuning detected (read=$actualRxFreq, lastSet=$lastSetRxFreq)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute Doppler-corrected frequencies
|
|
||||||
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
|
|
||||||
val rxBaseFreq = if (txBaseFreq != null) {
|
|
||||||
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
|
|
||||||
} else {
|
|
||||||
xpdr.downlinkLow
|
|
||||||
}
|
|
||||||
val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) }
|
|
||||||
|
|
||||||
// Command radios (only when not tuning)
|
|
||||||
if (tuningRadio.isEmpty()) {
|
|
||||||
if (tx != null && tx.isConnected && txRadioFreq != null) {
|
|
||||||
tx.setFrequency(txRadioFreq)
|
|
||||||
lastSetTxFreq = txRadioFreq.toDouble()
|
|
||||||
}
|
|
||||||
if (rx != null && rx.isConnected && rxRadioFreq != null) {
|
|
||||||
rx.setFrequency(rxRadioFreq)
|
|
||||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
txConnected = tx?.isConnected ?: false,
|
|
||||||
rxConnected = rx?.isConnected ?: false,
|
|
||||||
txFrequencyHz = txRadioFreq,
|
|
||||||
rxFrequencyHz = rxRadioFreq,
|
|
||||||
azimuth = Math.toDegrees(pos.azimuth),
|
|
||||||
elevation = Math.toDegrees(pos.elevation),
|
|
||||||
distance = pos.distance
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(1000)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Dual-radio tracking (Yaesu or two IC-705s) ──────────────────────────
|
||||||
|
|
||||||
|
private suspend fun runDualRadioTracking(transponder: SatRadio, initialTxBaseFreqHz: Long?) {
|
||||||
|
val tx = txController
|
||||||
|
val rx = rxController
|
||||||
|
|
||||||
|
// Initial setup: set band/mode/CTCSS on both radios
|
||||||
|
val txMode = transponder.uplinkMode
|
||||||
|
val rxMode = transponder.downlinkMode
|
||||||
|
?: transponder.uplinkMode?.let {
|
||||||
|
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(tag, "DualRadio start: txMode=$txMode rxMode=$rxMode")
|
||||||
|
|
||||||
|
if (tx != null && tx.isConnected && txMode != null) {
|
||||||
|
Log.d(tag, "Setting TX mode: $txMode")
|
||||||
|
tx.setMode(txMode)
|
||||||
|
}
|
||||||
|
if (rx != null && rx.isConnected && rxMode != null) {
|
||||||
|
Log.d(tag, "Setting RX mode: $rxMode")
|
||||||
|
rx.setMode(rxMode)
|
||||||
|
}
|
||||||
|
if (txMode?.uppercase() == "FM") {
|
||||||
|
_state.value.ctcssTone?.let { tone ->
|
||||||
|
Log.d(tag, "Setting CTCSS: ${tone}Hz")
|
||||||
|
tx?.setCtcssTone(tone)
|
||||||
|
tx?.setCtcssMode(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
||||||
|
|
||||||
|
var lastSetTxFreq = 0.0
|
||||||
|
var lastSetRxFreq = 0.0
|
||||||
|
var tuningRadio = ""
|
||||||
|
var lastReadFreq = 0L
|
||||||
|
var stableCount = 0
|
||||||
|
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
val currentState = _state.value
|
||||||
|
if (!currentState.isActive) break
|
||||||
|
|
||||||
|
val satPass = currentState.currentPass ?: break
|
||||||
|
val xpdr = currentState.selectedTransponder ?: break
|
||||||
|
var txBaseFreq = currentState.txBaseFrequencyHz
|
||||||
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
|
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis())
|
||||||
|
val txNow = txController
|
||||||
|
val rxNow = rxController
|
||||||
|
val v = pos.distanceRate * 1000.0
|
||||||
|
|
||||||
|
if (tuningRadio.isNotEmpty()) {
|
||||||
|
val radio = if (tuningRadio == "tx") txNow else rxNow
|
||||||
|
if (radio != null && radio.isConnected) {
|
||||||
|
val read = radio.readFrequencyAndMode()
|
||||||
|
if (read != null) {
|
||||||
|
val (freq, _) = read
|
||||||
|
if (kotlin.math.abs(freq - lastReadFreq) <= 20) stableCount++
|
||||||
|
else { stableCount = 0; lastReadFreq = freq }
|
||||||
|
if (stableCount >= 2) {
|
||||||
|
if (tuningRadio == "tx" && txBaseFreq != null) {
|
||||||
|
val newBase = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong()
|
||||||
|
if (newBase > 0) {
|
||||||
|
txBaseFreq = newBase
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = newBase) }
|
||||||
|
Log.i(tag, "TX tuning done → base=$newBase")
|
||||||
|
}
|
||||||
|
} else if (tuningRadio == "rx") {
|
||||||
|
val rxNominal = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong()
|
||||||
|
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
|
||||||
|
if (newTxBase != null && newTxBase > 0) {
|
||||||
|
txBaseFreq = newTxBase
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
|
||||||
|
Log.i(tag, "RX tuning done → txBase=$newTxBase")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tuningRadio = ""
|
||||||
|
stableCount = 0
|
||||||
|
lastSetTxFreq = 0.0
|
||||||
|
lastSetRxFreq = 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Detect manual dial changes
|
||||||
|
if (txBaseFreq != null && txNow != null && txNow.isConnected && lastSetTxFreq > 0.0) {
|
||||||
|
val read = txNow.readFrequencyAndMode()
|
||||||
|
if (read != null && kotlin.math.abs(read.first - lastSetTxFreq) >= 20.0) {
|
||||||
|
tuningRadio = "tx"
|
||||||
|
lastReadFreq = read.first
|
||||||
|
stableCount = 0
|
||||||
|
Log.i(tag, "TX tuning detected (read=${read.first}, lastSet=$lastSetTxFreq)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tuningRadio.isEmpty() && rxNow != null && rxNow.isConnected && lastSetRxFreq > 0.0) {
|
||||||
|
val read = rxNow.readFrequencyAndMode()
|
||||||
|
if (read != null && kotlin.math.abs(read.first - lastSetRxFreq) >= 20.0) {
|
||||||
|
tuningRadio = "rx"
|
||||||
|
lastReadFreq = read.first
|
||||||
|
stableCount = 0
|
||||||
|
Log.i(tag, "RX tuning detected (read=${read.first}, lastSet=$lastSetRxFreq)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
|
||||||
|
val rxBaseFreq = if (txBaseFreq != null) {
|
||||||
|
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
|
||||||
|
} else xpdr.downlinkLow
|
||||||
|
val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) }
|
||||||
|
|
||||||
|
if (tuningRadio.isEmpty()) {
|
||||||
|
if (txNow != null && txNow.isConnected && txRadioFreq != null) {
|
||||||
|
txNow.setFrequency(txRadioFreq)
|
||||||
|
lastSetTxFreq = txRadioFreq.toDouble()
|
||||||
|
}
|
||||||
|
if (rxNow != null && rxNow.isConnected && rxRadioFreq != null) {
|
||||||
|
rxNow.setFrequency(rxRadioFreq)
|
||||||
|
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
txConnected = txNow?.isConnected ?: false,
|
||||||
|
rxConnected = rxNow?.isConnected ?: false,
|
||||||
|
txFrequencyHz = txRadioFreq,
|
||||||
|
rxFrequencyHz = rxRadioFreq,
|
||||||
|
azimuth = Math.toDegrees(pos.azimuth),
|
||||||
|
elevation = Math.toDegrees(pos.elevation),
|
||||||
|
distance = pos.distance
|
||||||
|
)
|
||||||
|
}
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IC-705 split-radio tracking ─────────────────────────────────────────
|
||||||
|
|
||||||
|
private suspend fun runSplitTracking(transponder: SatRadio, initialTxBaseFreqHz: Long?) {
|
||||||
|
val radio = txController ?: return
|
||||||
|
if (!radio.isConnected) return
|
||||||
|
|
||||||
|
val txMode = transponder.uplinkMode
|
||||||
|
val rxMode = transponder.downlinkMode
|
||||||
|
?: transponder.uplinkMode?.let {
|
||||||
|
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute nominal base frequencies
|
||||||
|
val txCenter = when {
|
||||||
|
transponder.uplinkLow != null && transponder.uplinkHigh != null ->
|
||||||
|
(transponder.uplinkLow!! + transponder.uplinkHigh!!) / 2
|
||||||
|
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val rxNominal = if (txCenter != null) {
|
||||||
|
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
||||||
|
} else transponder.downlinkLow
|
||||||
|
|
||||||
|
val txBase = initialTxBaseFreqHz ?: txCenter
|
||||||
|
Log.i(tag, "IC-705 split setup: txBase=${txBase}Hz rxNominal=${rxNominal}Hz txMode=$txMode rxMode=$rxMode")
|
||||||
|
|
||||||
|
// ── Initial setup sequence ──────────────────────────────────────────
|
||||||
|
// Sequence per IC-705: explicitly select VFO, then band → freq → mode.
|
||||||
|
// ACK from each command gates the next — no fixed delays needed.
|
||||||
|
|
||||||
|
// VFO-A = RX (downlink)
|
||||||
|
Log.d(tag, "Split init: selecting VFO-A for RX (downlink)")
|
||||||
|
radio.setVfo(vfoA = true)
|
||||||
|
if (rxNominal != null) {
|
||||||
|
Log.d(tag, "Split init: VFO-A band for ${rxNominal}Hz")
|
||||||
|
radio.setBand(rxNominal)
|
||||||
|
Log.d(tag, "Split init: VFO-A freq=${rxNominal}Hz")
|
||||||
|
radio.setFrequency(rxNominal)
|
||||||
|
}
|
||||||
|
if (rxMode != null) {
|
||||||
|
Log.d(tag, "Split init: VFO-A mode=$rxMode")
|
||||||
|
radio.setMode(rxMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VFO-B = TX (uplink)
|
||||||
|
Log.d(tag, "Split init: selecting VFO-B for TX (uplink)")
|
||||||
|
radio.setVfo(vfoA = false)
|
||||||
|
if (txBase != null) {
|
||||||
|
Log.d(tag, "Split init: VFO-B band for ${txBase}Hz")
|
||||||
|
radio.setBand(txBase)
|
||||||
|
Log.d(tag, "Split init: VFO-B freq=${txBase}Hz")
|
||||||
|
radio.setFrequency(txBase)
|
||||||
|
}
|
||||||
|
if (txMode != null) {
|
||||||
|
Log.d(tag, "Split init: VFO-B mode=$txMode")
|
||||||
|
radio.setMode(txMode)
|
||||||
|
}
|
||||||
|
if (txMode?.uppercase() == "FM") {
|
||||||
|
val tone = _state.value.ctcssTone
|
||||||
|
if (tone != null) {
|
||||||
|
Log.d(tag, "Split init: CTCSS=${tone}Hz")
|
||||||
|
radio.setCtcssTone(tone)
|
||||||
|
radio.setCtcssMode(true)
|
||||||
|
} else {
|
||||||
|
radio.setCtcssMode(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable SPLIT on VFO-A (return display to RX VFO first)
|
||||||
|
Log.d(tag, "Split init: returning to VFO-A, then enabling SPLIT mode")
|
||||||
|
radio.setVfo(vfoA = true)
|
||||||
|
radio.setSplitMode(enabled = true)
|
||||||
|
|
||||||
|
_state.update { it.copy(txMode = txMode, rxMode = rxMode, txBaseFrequencyHz = txBase) }
|
||||||
|
Log.i(tag, "IC-705 split init done — entering tracking loop")
|
||||||
|
|
||||||
|
// ── Tracking loop with tuning detection ─────────────────────────────
|
||||||
|
var lastSetTxFreq = 0.0
|
||||||
|
var lastSetRxFreq = 0.0
|
||||||
|
var tuningRadio = "" // "tx" or "rx" when manual tuning detected
|
||||||
|
var lastReadFreq = 0L
|
||||||
|
var stableCount = 0
|
||||||
|
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
val currentState = _state.value
|
||||||
|
if (!currentState.isActive) break
|
||||||
|
|
||||||
|
val satPass = currentState.currentPass ?: break
|
||||||
|
val xpdr = currentState.selectedTransponder ?: break
|
||||||
|
var txBaseFreq = currentState.txBaseFrequencyHz
|
||||||
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
|
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis())
|
||||||
|
val v = pos.distanceRate * 1000.0
|
||||||
|
|
||||||
|
if (tuningRadio.isNotEmpty()) {
|
||||||
|
// User is tuning — wait for frequency to stabilize
|
||||||
|
val readFreq = if (tuningRadio == "tx") radio.readTxVfoFrequency() else radio.readWorkingFrequency()
|
||||||
|
if (readFreq != null) {
|
||||||
|
if (kotlin.math.abs(readFreq - lastReadFreq) <= 20) stableCount++
|
||||||
|
else { stableCount = 0; lastReadFreq = readFreq }
|
||||||
|
|
||||||
|
if (stableCount >= 2) {
|
||||||
|
// Frequency stable — reverse-calculate base frequency
|
||||||
|
if (tuningRadio == "tx" && txBaseFreq != null) {
|
||||||
|
val newBase = (readFreq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong()
|
||||||
|
if (newBase > 0) {
|
||||||
|
txBaseFreq = newBase
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = newBase) }
|
||||||
|
Log.i(tag, "Split TX tuning done → base=$newBase")
|
||||||
|
}
|
||||||
|
} else if (tuningRadio == "rx") {
|
||||||
|
val rxNominal = (readFreq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong()
|
||||||
|
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
|
||||||
|
if (newTxBase != null && newTxBase > 0) {
|
||||||
|
txBaseFreq = newTxBase
|
||||||
|
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
|
||||||
|
Log.i(tag, "Split RX tuning done → txBase=$newTxBase")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tuningRadio = ""
|
||||||
|
stableCount = 0
|
||||||
|
lastSetTxFreq = 0.0
|
||||||
|
lastSetRxFreq = 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Detect manual dial changes
|
||||||
|
if (txBaseFreq != null && lastSetTxFreq > 0.0) {
|
||||||
|
val readTx = radio.readTxVfoFrequency()
|
||||||
|
if (readTx != null && kotlin.math.abs(readTx - lastSetTxFreq) >= 20.0) {
|
||||||
|
tuningRadio = "tx"
|
||||||
|
lastReadFreq = readTx
|
||||||
|
stableCount = 0
|
||||||
|
Log.i(tag, "Split TX tuning detected (read=${readTx}, lastSet=$lastSetTxFreq)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tuningRadio.isEmpty() && lastSetRxFreq > 0.0) {
|
||||||
|
val readRx = radio.readWorkingFrequency()
|
||||||
|
if (readRx != null && kotlin.math.abs(readRx - lastSetRxFreq) >= 20.0) {
|
||||||
|
tuningRadio = "rx"
|
||||||
|
lastReadFreq = readRx
|
||||||
|
stableCount = 0
|
||||||
|
Log.i(tag, "Split RX tuning detected (read=${readRx}, lastSet=$lastSetRxFreq)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine Doppler-corrected frequencies
|
||||||
|
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
|
||||||
|
val rxBaseCalc = if (txBaseFreq != null) {
|
||||||
|
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
|
||||||
|
} else xpdr.downlinkLow
|
||||||
|
val rxRadioFreq = rxBaseCalc?.let { pos.getDownlinkFreq(it) }
|
||||||
|
|
||||||
|
if (radio.isConnected && tuningRadio.isEmpty()) {
|
||||||
|
// Update both VFOs every cycle — no PTT polling needed.
|
||||||
|
// 0x25/00 = active (RX) VFO, 0x25/01 = inactive (TX) VFO.
|
||||||
|
if (rxRadioFreq != null) {
|
||||||
|
Log.d(tag, "Split loop RX (0x25/00): ${rxRadioFreq}Hz")
|
||||||
|
radio.setWorkingFrequency(rxRadioFreq)
|
||||||
|
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||||
|
}
|
||||||
|
if (txRadioFreq != null) {
|
||||||
|
Log.d(tag, "Split loop TX (0x25/01): ${txRadioFreq}Hz")
|
||||||
|
radio.setTxVfoFrequency(txRadioFreq)
|
||||||
|
lastSetTxFreq = txRadioFreq.toDouble()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
txConnected = radio.isConnected,
|
||||||
|
rxConnected = false, // single radio
|
||||||
|
txFrequencyHz = txRadioFreq,
|
||||||
|
rxFrequencyHz = rxRadioFreq,
|
||||||
|
azimuth = Math.toDegrees(pos.azimuth),
|
||||||
|
elevation = Math.toDegrees(pos.elevation),
|
||||||
|
distance = pos.distance
|
||||||
|
)
|
||||||
|
}
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Other IRadioTrackingService methods ─────────────────────────────────
|
||||||
|
|
||||||
override fun stopTracking() {
|
override fun stopTracking() {
|
||||||
trackingJob?.cancel()
|
trackingJob?.cancel()
|
||||||
trackingJob = null
|
trackingJob = null
|
||||||
@@ -288,7 +493,6 @@ class RadioTrackingService(
|
|||||||
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||||
}
|
}
|
||||||
rxMode?.let { rx?.setMode(it) }
|
rxMode?.let { rx?.setMode(it) }
|
||||||
|
|
||||||
if (transponder.uplinkMode?.uppercase() == "FM") {
|
if (transponder.uplinkMode?.uppercase() == "FM") {
|
||||||
_state.value.ctcssTone?.let { tone ->
|
_state.value.ctcssTone?.let { tone ->
|
||||||
tx?.setCtcssTone(tone)
|
tx?.setCtcssTone(tone)
|
||||||
@@ -302,21 +506,17 @@ class RadioTrackingService(
|
|||||||
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
// Show nominal frequencies immediately
|
|
||||||
val rxNominal = if (txCenter != null) {
|
val rxNominal = if (txCenter != null) {
|
||||||
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
||||||
} else {
|
} else transponder.downlinkLow
|
||||||
// Downlink-only transponder (beacon etc.) - use downlink directly
|
|
||||||
transponder.downlinkLow
|
|
||||||
}
|
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
selectedTransponder = transponder,
|
selectedTransponder = transponder,
|
||||||
txBaseFrequencyHz = txCenter,
|
txBaseFrequencyHz = txCenter,
|
||||||
txFrequencyHz = txCenter,
|
txFrequencyHz = txCenter,
|
||||||
rxFrequencyHz = rxNominal,
|
rxFrequencyHz = rxNominal,
|
||||||
txMode = transponder.uplinkMode,
|
txMode = transponder.uplinkMode,
|
||||||
rxMode = transponder.downlinkMode
|
rxMode = transponder.downlinkMode
|
||||||
?: transponder.uplinkMode?.let { m ->
|
?: transponder.uplinkMode?.let { m ->
|
||||||
TransponderMapper.mapUplinkModeToDownlinkMode(m, transponder.isInverted)
|
TransponderMapper.mapUplinkModeToDownlinkMode(m, transponder.isInverted)
|
||||||
}
|
}
|
||||||
@@ -353,5 +553,4 @@ class RadioTrackingService(
|
|||||||
}
|
}
|
||||||
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -26,19 +26,23 @@ import androidx.room.Room
|
|||||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||||
|
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
||||||
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
||||||
|
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
|
||||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
|
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.data.repository.UpdateRepository
|
||||||
import com.rtbishop.look4sat.core.data.source.LocalSource
|
import com.rtbishop.look4sat.core.data.source.LocalSource
|
||||||
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
||||||
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
||||||
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
||||||
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
||||||
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
@@ -48,6 +52,7 @@ import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||||
@@ -59,22 +64,35 @@ import kotlinx.coroutines.CoroutineExceptionHandler
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
|
||||||
class MainContainer(private val context: Context) : IMainContainer {
|
class MainContainer(private val context: Context) : IMainContainer {
|
||||||
|
|
||||||
private val localSource = provideLocalSource()
|
private val localSource = provideLocalSource()
|
||||||
|
private val remoteSource = provideRemoteSource()
|
||||||
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
|
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
|
||||||
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
|
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
|
||||||
override val settingsRepo = provideSettingsRepo()
|
override val settingsRepo = provideSettingsRepo()
|
||||||
override val selectionRepo = provideSelectionRepo()
|
override val selectionRepo = provideSelectionRepo()
|
||||||
override val satelliteRepo = provideSatelliteRepo()
|
override val satelliteRepo = provideSatelliteRepo()
|
||||||
override val databaseRepo = provideDatabaseRepo()
|
override val databaseRepo = provideDatabaseRepo()
|
||||||
|
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
|
||||||
|
override val updateRepo by lazy { UpdateRepository(remoteSource) }
|
||||||
override val radioTrackingService: IRadioTrackingService by lazy {
|
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val _mutualPassData = MutableStateFlow(MutualPassData())
|
||||||
|
override val mutualPassData: StateFlow<MutualPassData> = _mutualPassData.asStateFlow()
|
||||||
|
|
||||||
|
override fun setMutualPassData(data: MutualPassData) {
|
||||||
|
_mutualPassData.value = data
|
||||||
|
}
|
||||||
|
|
||||||
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
||||||
|
|
||||||
override fun provideShowToast(): IShowToast = ShowToast(context)
|
override fun provideShowToast(): IShowToast = ShowToast(context)
|
||||||
@@ -101,20 +119,31 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
rc.rotatorAddress,
|
rc.rotatorAddress,
|
||||||
rc.rotatorPort.toIntOrNull() ?: 0,
|
rc.rotatorPort.toIntOrNull() ?: 0,
|
||||||
rc.frequencyAddress,
|
rc.frequencyAddress,
|
||||||
rc.frequencyPort.toIntOrNull() ?: 0
|
rc.frequencyPort.toIntOrNull() ?: 0,
|
||||||
|
rc.frequencyOffsetHz
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun provideTxRadioController(): IRadioController {
|
override fun provideTxRadioController(): IRadioController {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
val address = settingsRepo.radioControlSettings.value.txRadioAddress
|
val settings = settingsRepo.radioControlSettings.value
|
||||||
return Ft817Controller(manager, address)
|
val address = settings.txRadioAddress
|
||||||
|
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
|
||||||
|
Ic705Controller(manager, address)
|
||||||
|
} else {
|
||||||
|
Ft817Controller(manager, address)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun provideRxRadioController(): IRadioController {
|
override fun provideRxRadioController(): IRadioController {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
val address = settingsRepo.radioControlSettings.value.rxRadioAddress
|
val settings = settingsRepo.radioControlSettings.value
|
||||||
return Ft817Controller(manager, address)
|
val address = settings.rxRadioAddress
|
||||||
|
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
|
||||||
|
Ic705Controller(manager, address)
|
||||||
|
} else {
|
||||||
|
Ft817Controller(manager, address)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun provideSensorsRepo(): ISensorsRepo {
|
override fun provideSensorsRepo(): ISensorsRepo {
|
||||||
|
|||||||
+265
@@ -0,0 +1,265 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
/** One report from the AMSAT API (data layer model). */
|
||||||
|
private data class ApiReport(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val callsign: String,
|
||||||
|
val report: String,
|
||||||
|
val gridSquare: String,
|
||||||
|
val reportedTimeUtcSec: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
|
||||||
|
class AmSatRepository(
|
||||||
|
private val remoteSource: IRemoteSource,
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
) : IAmSatRepository {
|
||||||
|
|
||||||
|
private val statusCacheMutex = Mutex()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var statusCache: SatStatusPage? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var statusFetchInFlight: Deferred<SatStatusPage?>? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cacheGeneration = 0
|
||||||
|
|
||||||
|
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||||
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCachedStatus(): SatStatusPage? = statusCache
|
||||||
|
|
||||||
|
override suspend fun fetchStatus(forceRefresh: Boolean): SatStatusPage? {
|
||||||
|
if (!forceRefresh) statusCache?.let { return it }
|
||||||
|
|
||||||
|
var cachedPage: SatStatusPage? = null
|
||||||
|
val inFlightFetch = statusCacheMutex.withLock {
|
||||||
|
if (!forceRefresh) {
|
||||||
|
val cached = statusCache
|
||||||
|
if (cached != null) {
|
||||||
|
cachedPage = cached
|
||||||
|
return@withLock null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusFetchInFlight ?: scope.async(Dispatchers.IO) {
|
||||||
|
fetchStatusFromRemote(cacheGeneration)
|
||||||
|
}.also { statusFetchInFlight = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedPage?.let { return it }
|
||||||
|
val fetch = inFlightFetch ?: return null
|
||||||
|
return try {
|
||||||
|
fetch.await()
|
||||||
|
} finally {
|
||||||
|
statusCacheMutex.withLock {
|
||||||
|
if (statusFetchInFlight === fetch && fetch.isCompleted) statusFetchInFlight = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchStatusFromRemote(generation: Int): SatStatusPage? {
|
||||||
|
val nowSec = System.currentTimeMillis() / 1000
|
||||||
|
val catalogJson = remoteSource.getAmSatCatalog() ?: return 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 null
|
||||||
|
|
||||||
|
val names = parseCatalog(catalogJson)
|
||||||
|
val reports = parseReports(reportsJson)
|
||||||
|
|
||||||
|
if (names.isEmpty() && reports.isEmpty()) return null
|
||||||
|
|
||||||
|
val statuses = buildStatuses(names, reports, nowSec)
|
||||||
|
val reportMap = reports.associate { it.id to toSatReport(it) }
|
||||||
|
return SatStatusPage(System.currentTimeMillis(), statuses, reportMap).also { page ->
|
||||||
|
if (generation == cacheGeneration) statusCache = page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun prefetchStatus() {
|
||||||
|
fetchStatus(forceRefresh = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearStatusCache() {
|
||||||
|
cacheGeneration += 1
|
||||||
|
statusCache = null
|
||||||
|
statusFetchInFlight = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
|
||||||
|
val payload = JSONObject().apply {
|
||||||
|
put("name", submission.name)
|
||||||
|
put("report", submission.report)
|
||||||
|
put("callsign", submission.callsign)
|
||||||
|
put("reported_at", isoUtcFormat.format(Date(submission.reportedAtUtcMillis)))
|
||||||
|
if (submission.gridSquare.isNotBlank()) put("grid_square", submission.gridSquare)
|
||||||
|
}
|
||||||
|
val response = remoteSource.submitAmSatReport(payload.toString())
|
||||||
|
?: return@withContext AmSatReportSubmitResult(success = false, message = "Network request failed")
|
||||||
|
val (code, body) = response
|
||||||
|
val errorMessage = parseSubmitError(body)
|
||||||
|
return@withContext if (code in 200..299 && errorMessage.isBlank()) {
|
||||||
|
AmSatReportSubmitResult(success = true, reportId = parseSubmitReportId(body))
|
||||||
|
} else {
|
||||||
|
AmSatReportSubmitResult(
|
||||||
|
success = false,
|
||||||
|
message = errorMessage.ifBlank { "HTTP $code" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSubmitError(json: String): String {
|
||||||
|
return try {
|
||||||
|
JSONObject(json).optJSONObject("error")?.optString("message").orEmpty()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSubmitReportId(json: String): String? {
|
||||||
|
return try {
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
obj.optJSONObject("data")?.optString("id")?.takeIf { it.isNotBlank() }
|
||||||
|
?: obj.optString("id").takeIf { it.isNotBlank() }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse catalog JSON to list of satellite names */
|
||||||
|
private fun parseCatalog(json: String): List<String> {
|
||||||
|
return try {
|
||||||
|
val arr = JSONObject(json).getJSONArray("data")
|
||||||
|
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse reports JSON to list of ApiReport domain objects */
|
||||||
|
private fun parseReports(json: String): List<ApiReport> {
|
||||||
|
return try {
|
||||||
|
val arr = JSONObject(json).getJSONArray("data")
|
||||||
|
(0 until arr.length()).mapNotNull { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
val iso = o.optString("reported_time", "")
|
||||||
|
if (iso.isEmpty()) null else ApiReport(
|
||||||
|
id = o.optString("id", ""),
|
||||||
|
name = o.optString("name", ""),
|
||||||
|
callsign = o.optString("callsign", ""),
|
||||||
|
report = o.optString("report", ""),
|
||||||
|
gridSquare = o.optString("grid_square", ""),
|
||||||
|
reportedTimeUtcSec = parseIsoUtcSec(iso)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */
|
||||||
|
private fun parseIsoUtcSec(iso: String): Long {
|
||||||
|
return try {
|
||||||
|
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
|
||||||
|
} catch (_: Exception) {
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build one SatStatus (3 days x 12 slots) per catalog satellite, slotting reports by age. */
|
||||||
|
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||||
|
val byName = reports.groupBy { it.name }
|
||||||
|
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||||
|
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
|
val labels = (0 until 3).map { d ->
|
||||||
|
utc.timeInMillis = (nowSec - d * 86400L) * 1000
|
||||||
|
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
|
||||||
|
}
|
||||||
|
return names.map { name ->
|
||||||
|
val slots = (0 until 36).map { slotIdx ->
|
||||||
|
val slotStart = nowSec - (slotIdx + 1) * 7200L
|
||||||
|
val slotEnd = nowSec - slotIdx * 7200L
|
||||||
|
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
|
||||||
|
if (inSlot.isEmpty()) {
|
||||||
|
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
|
||||||
|
} else {
|
||||||
|
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
|
||||||
|
SatSlot(
|
||||||
|
statusColor = statusColorOf(newest.report),
|
||||||
|
count = inSlot.size,
|
||||||
|
reportIds = inSlot.map { it.id }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val days = (0 until 3).map { d ->
|
||||||
|
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
|
||||||
|
}
|
||||||
|
SatStatus(name = name, days = days)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toSatReport(r: ApiReport): SatReport {
|
||||||
|
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
|
cal.timeInMillis = r.reportedTimeUtcSec * 1000
|
||||||
|
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
|
||||||
|
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
|
||||||
|
val y = cal.get(Calendar.YEAR)
|
||||||
|
val mo = (cal.get(Calendar.MONTH) + 1).toString().padStart(2, '0')
|
||||||
|
val d = cal.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0')
|
||||||
|
return SatReport(
|
||||||
|
id = r.id,
|
||||||
|
statusText = r.report,
|
||||||
|
call = r.callsign,
|
||||||
|
grid = r.gridSquare,
|
||||||
|
dateUtc = "$y-$mo-$d",
|
||||||
|
timeUtc = "$hh:$mm UTC"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map status text to color value (for UI rendering). */
|
||||||
|
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
|
||||||
|
"heard", "crew active" -> ACTIVE_BLUE
|
||||||
|
"telemetry only" -> TLM_ORANGE
|
||||||
|
"not heard" -> NOT_HEARD_PINK
|
||||||
|
else -> CONFLICT_DEEP_ORANGE
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// AMSAT official status colors (from amsat.org/status)
|
||||||
|
private const val ACTIVE_BLUE = 0xFF648FFF
|
||||||
|
private const val TLM_ORANGE = 0xFFFFB000
|
||||||
|
private const val NOT_HEARD_PINK = 0xFFDC267F
|
||||||
|
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
|
||||||
|
private const val NO_REPORT_GRAY = 0xFFC0C0C0
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
-28
@@ -42,46 +42,69 @@ class DatabaseRepo(
|
|||||||
|
|
||||||
private val customSourceType = "Other"
|
private val customSourceType = "Other"
|
||||||
|
|
||||||
override suspend fun updateTLEFromFile(uri: String) = withContext(dispatcher) {
|
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
||||||
|
var importedCount = 0
|
||||||
remoteSource.getFileStream(uri)?.let { stream ->
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
val entries = dataParser.parseTLEStream(unwrapIfZipped(uri, stream))
|
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
|
||||||
localSource.insertEntries(entries)
|
localSource.insertEntries(entries)
|
||||||
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
|
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
|
||||||
|
importedCount = entries.size
|
||||||
}
|
}
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
|
importedCount
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateTransceiversFromFile(uri: String) = withContext(dispatcher) {
|
override suspend fun updateTransceiversFromFile(uri: String): Int = withContext(dispatcher) {
|
||||||
|
var importedCount = 0
|
||||||
remoteSource.getFileStream(uri)?.let { stream ->
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
||||||
localSource.insertRadios(transceivers)
|
localSource.insertRadios(transceivers)
|
||||||
|
importedCount = transceivers.size
|
||||||
}
|
}
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
|
importedCount
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||||
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
val settings = settingsRepo.dataSourcesSettings.value
|
||||||
val tleUrls = buildMap {
|
// Keep the raw URL as the status key (matches what the settings UI
|
||||||
putAll(Sources.satelliteDataUrls)
|
// displays) while requesting with the normalized URL.
|
||||||
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
|
val tleUrls = settings.satelliteUrls
|
||||||
}.filterValues { it.isNotBlank() }
|
.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) }
|
||||||
val radioUrls = buildMap {
|
.map { it to normalizeUrl(it) }
|
||||||
putAll(Sources.transceiversDataUrls)
|
.distinctBy { it.second }
|
||||||
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
|
val radioUrls = settings.transceiversUrls
|
||||||
}.filterValues { it.isNotBlank() }
|
.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) }
|
||||||
// launch all network requests concurrently
|
.map { it to normalizeUrl(it) }
|
||||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
.distinctBy { it.second }
|
||||||
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
val builtinTypesByUrl = Sources.satelliteDataUrls
|
||||||
// parse fetched data concurrently and associate with types
|
.filterValues { it.isNotBlank() }
|
||||||
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
.mapValues { normalizeUrl(it.value) }
|
||||||
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
.entries
|
||||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
.associate { (type, url) -> url to type }
|
||||||
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
val importedTypeIds = mutableMapOf<String, MutableList<Int>>()
|
||||||
}
|
// launch all network requests concurrently, keeping the raw url as key for status reporting
|
||||||
}
|
val tleJobs = tleUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
|
||||||
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
val radioJobs = radioUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
|
||||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
val tleResults = tleJobs.awaitAll()
|
||||||
}
|
val radioResults = radioJobs.awaitAll()
|
||||||
|
// report the HTTP status code of every source (200, 404, ...)
|
||||||
|
settingsRepo.updateDataSourcesStatus(
|
||||||
|
(tleResults + radioResults).associate { (url, result) -> url to result.code }
|
||||||
|
)
|
||||||
|
// parse fetched data concurrently and associate known built-in URLs with existing type filters.
|
||||||
|
val importedEntries = tleResults.flatMap { (rawUrl, result) ->
|
||||||
|
val normUrl = normalizeUrl(rawUrl)
|
||||||
|
val entries = result.stream?.let { parseSatelliteStream(normUrl, unwrapIfZipped(normUrl, it)) }.orEmpty()
|
||||||
|
val type = builtinTypesByUrl[normUrl] ?: customSourceType
|
||||||
|
importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum })
|
||||||
|
entries
|
||||||
|
}.distinctBy { it.catnum }
|
||||||
|
importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
|
||||||
|
val importedRadios = radioResults.flatMap { (rawUrl, result) ->
|
||||||
|
val normUrl = normalizeUrl(rawUrl)
|
||||||
|
result.stream?.let { dataParser.parseJSONStream(unwrapIfZipped(normUrl, it)) }.orEmpty()
|
||||||
|
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
|
||||||
// insert parsed data into the database
|
// insert parsed data into the database
|
||||||
localSource.insertEntries(importedEntries)
|
localSource.insertEntries(importedEntries)
|
||||||
localSource.insertRadios(importedRadios)
|
localSource.insertRadios(importedRadios)
|
||||||
@@ -94,9 +117,34 @@ class DatabaseRepo(
|
|||||||
setUpdateSuccessful(0L)
|
setUpdateSuccessful(0L)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> = when {
|
private fun normalizeUrl(url: String): String =
|
||||||
url.contains("FORMAT=csv", ignoreCase = true) -> dataParser.parseCSVStream(stream)
|
if (url.startsWith("http", ignoreCase = true)) url else "https://$url"
|
||||||
else -> dataParser.parseTLEStream(stream)
|
|
||||||
|
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
|
||||||
|
val bufferedStream = stream.buffered()
|
||||||
|
return when {
|
||||||
|
hasCsvHint(url) || looksLikeCsv(bufferedStream) -> dataParser.parseCSVStream(bufferedStream)
|
||||||
|
else -> dataParser.parseTLEStream(bufferedStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasCsvHint(url: String): Boolean {
|
||||||
|
return url.contains("FORMAT=csv", ignoreCase = true) ||
|
||||||
|
url.endsWith(".csv", ignoreCase = true) ||
|
||||||
|
url.endsWith(".csv.zip", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun looksLikeCsv(stream: InputStream): Boolean {
|
||||||
|
if (!stream.markSupported()) return false
|
||||||
|
stream.mark(4096)
|
||||||
|
val preview = ByteArray(4096)
|
||||||
|
val length = stream.read(preview)
|
||||||
|
stream.reset()
|
||||||
|
if (length <= 0) return false
|
||||||
|
val line = preview.decodeToString(0, length).lineSequence().firstOrNull()?.trim().orEmpty()
|
||||||
|
return line.contains("OBJECT_NAME", ignoreCase = true) ||
|
||||||
|
line.contains("NORAD_CAT_ID", ignoreCase = true) ||
|
||||||
|
line.count { it == ',' } >= 4
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
||||||
|
|||||||
+51
-7
@@ -34,8 +34,10 @@ import kotlinx.coroutines.coroutineScope
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
class SatelliteRepo(
|
class SatelliteRepo(
|
||||||
private val dispatcher: CoroutineDispatcher,
|
private val dispatcher: CoroutineDispatcher,
|
||||||
@@ -62,11 +64,23 @@ class SatelliteRepo(
|
|||||||
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||||
|
|
||||||
override suspend fun initRepository() = withContext(dispatcher) {
|
override suspend fun initRepository() = withContext(dispatcher) {
|
||||||
settingsRepo.selectedIds.collect { selectedIds ->
|
combine(
|
||||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
settingsRepo.selectedIds,
|
||||||
val (_, hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
|
settingsRepo.stationPosition
|
||||||
calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
|
) { 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 = settings.selectedModes
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
|
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
|
||||||
@@ -105,7 +119,15 @@ class SatelliteRepo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>) {
|
override suspend fun calculatePasses(
|
||||||
|
time: Long,
|
||||||
|
hoursAhead: Int,
|
||||||
|
minElevation: Double,
|
||||||
|
aosStartMinute: Int,
|
||||||
|
aosEndMinute: Int,
|
||||||
|
invertAosTimeWindow: Boolean,
|
||||||
|
modes: List<String>
|
||||||
|
) {
|
||||||
_isCalculating.value = true
|
_isCalculating.value = true
|
||||||
// Normalize to the start of the current minute so that coarse 60-second stepping
|
// Normalize to the start of the current minute so that coarse 60-second stepping
|
||||||
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
|
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
|
||||||
@@ -130,7 +152,12 @@ class SatelliteRepo(
|
|||||||
val newPasses = ArrayList<OrbitalPass>()
|
val newPasses = ArrayList<OrbitalPass>()
|
||||||
for (list in passLists) {
|
for (list in passLists) {
|
||||||
for (pass in list) {
|
for (pass in list) {
|
||||||
if (pass.losTime > time && pass.aosTime < timeFuture && pass.maxElevation > minElevation) {
|
if (
|
||||||
|
pass.losTime > time
|
||||||
|
&& pass.aosTime < timeFuture
|
||||||
|
&& pass.maxElevation > minElevation
|
||||||
|
&& (pass.isDeepSpace || isAosInRange(pass.aosTime, aosStartMinute, aosEndMinute, invertAosTimeWindow))
|
||||||
|
) {
|
||||||
newPasses.add(pass)
|
newPasses.add(pass)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,6 +169,23 @@ class SatelliteRepo(
|
|||||||
_isCalculating.value = false
|
_isCalculating.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isAosInRange(
|
||||||
|
aosTime: Long,
|
||||||
|
aosStartMinute: Int,
|
||||||
|
aosEndMinute: Int,
|
||||||
|
invertAosTimeWindow: Boolean
|
||||||
|
): Boolean {
|
||||||
|
val offsetMillis = TimeZone.getDefault().getOffset(aosTime).toLong()
|
||||||
|
val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L)
|
||||||
|
val aosMinute = (localMillis / 60_000L).toInt()
|
||||||
|
val inRange = if (aosStartMinute <= aosEndMinute) {
|
||||||
|
aosMinute in aosStartMinute..aosEndMinute
|
||||||
|
} else {
|
||||||
|
aosMinute >= aosStartMinute || aosMinute <= aosEndMinute
|
||||||
|
}
|
||||||
|
return if (invertAosTimeWindow) !inRange else inRange
|
||||||
|
}
|
||||||
|
|
||||||
private fun OrbitalObject.getPasses(pos: GeoPos, time: Long, hours: Int): List<OrbitalPass> {
|
private fun OrbitalObject.getPasses(pos: GeoPos, time: Long, hours: Int): List<OrbitalPass> {
|
||||||
val passes = mutableListOf<OrbitalPass>()
|
val passes = mutableListOf<OrbitalPass>()
|
||||||
val endDate = time + hours * 60L * 60L * 1000L
|
val endDate = time + hours * 60L * 60L * 1000L
|
||||||
|
|||||||
+197
-20
@@ -25,7 +25,6 @@ import androidx.core.location.LocationListenerCompat
|
|||||||
import androidx.core.location.LocationManagerCompat
|
import androidx.core.location.LocationManagerCompat
|
||||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||||
import com.rtbishop.look4sat.core.domain.model.MapSource
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
@@ -38,6 +37,10 @@ import com.rtbishop.look4sat.core.domain.utility.round
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.Locale
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
|
|
||||||
class SettingsRepo(
|
class SettingsRepo(
|
||||||
private val locationManager: LocationManager,
|
private val locationManager: LocationManager,
|
||||||
@@ -55,6 +58,9 @@ class SettingsRepo(
|
|||||||
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
|
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
|
||||||
private val keyFilterHoursAhead = "filterHoursAhead"
|
private val keyFilterHoursAhead = "filterHoursAhead"
|
||||||
private val keyFilterMinElevation = "filterMinElevation"
|
private val keyFilterMinElevation = "filterMinElevation"
|
||||||
|
private val keyFilterAosStartMinute = "filterAosStartMinute"
|
||||||
|
private val keyFilterAosEndMinute = "filterAosEndMinute"
|
||||||
|
private val keyFilterAosInvert = "filterAosInvert"
|
||||||
private val keyNumberOfRadios = "numberOfRadios"
|
private val keyNumberOfRadios = "numberOfRadios"
|
||||||
private val keyNumberOfSatellites = "numberOfSatellites"
|
private val keyNumberOfSatellites = "numberOfSatellites"
|
||||||
private val keyRotatorAddress = "rotatorAddress"
|
private val keyRotatorAddress = "rotatorAddress"
|
||||||
@@ -65,6 +71,7 @@ class SettingsRepo(
|
|||||||
private val keyFrequencyAddress = "frequencyAddress"
|
private val keyFrequencyAddress = "frequencyAddress"
|
||||||
private val keyFrequencyPort = "frequencyPort"
|
private val keyFrequencyPort = "frequencyPort"
|
||||||
private val keyFrequencyFormat = "frequencyFormat"
|
private val keyFrequencyFormat = "frequencyFormat"
|
||||||
|
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
|
||||||
private val keySelectedIds = "selectedIds"
|
private val keySelectedIds = "selectedIds"
|
||||||
private val keySelectedTypes = "selectedTypes"
|
private val keySelectedTypes = "selectedTypes"
|
||||||
private val keySelectedModes = "selectedModes"
|
private val keySelectedModes = "selectedModes"
|
||||||
@@ -83,13 +90,21 @@ class SettingsRepo(
|
|||||||
private val keyShouldSeeWarning = "shouldSeeWarning"
|
private val keyShouldSeeWarning = "shouldSeeWarning"
|
||||||
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
||||||
private val keySstvMode = "sstvMode"
|
private val keySstvMode = "sstvMode"
|
||||||
private val keyMapSource = "mapSource"
|
private val keyLowElevation = "lowElevation"
|
||||||
private val keyTiandituKey = "tiandituKey"
|
private val keyHighElevation = "highElevation"
|
||||||
private val keyUseCustomTle = "useCustomTle"
|
private val keyUseCustomTle = "useCustomTle"
|
||||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||||
private val keyTleUrl = "tleUrl"
|
private val keyTleUrl = "tleUrl"
|
||||||
private val keyTransceiversUrl = "transceiversUrl"
|
private val keyTransceiversUrl = "transceiversUrl"
|
||||||
|
private val keySatelliteUrls = "satelliteUrls"
|
||||||
|
private val keyTransceiversUrls = "transceiversUrls"
|
||||||
|
private val keySatelliteEnabled = "satelliteEnabled"
|
||||||
|
private val keyTransceiversEnabled = "transceiversEnabled"
|
||||||
|
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
|
||||||
private val separatorComma = ","
|
private val separatorComma = ","
|
||||||
|
private val separatorUrl = "\n"
|
||||||
|
private val legacyCelestrakSatnogsUrl =
|
||||||
|
"https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv"
|
||||||
|
|
||||||
//region # Satellites selection settings
|
//region # Satellites selection settings
|
||||||
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
||||||
@@ -130,6 +145,9 @@ class SettingsRepo(
|
|||||||
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
|
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
|
||||||
putInt(keyFilterHoursAhead, settings.hoursAhead)
|
putInt(keyFilterHoursAhead, settings.hoursAhead)
|
||||||
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
|
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
|
||||||
|
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
||||||
|
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
||||||
|
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
||||||
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
|
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
|
||||||
_passesSettings.value = settings
|
_passesSettings.value = settings
|
||||||
}
|
}
|
||||||
@@ -138,9 +156,20 @@ class SettingsRepo(
|
|||||||
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
|
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
|
||||||
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
|
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
|
||||||
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
|
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
|
||||||
|
val 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 selectedModesString = preferences.getString(keySelectedModes, null)
|
||||||
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
|
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
|
||||||
return PassesSettings(showDeepSpace, hoursAhead, minElevation, selectedModes)
|
return PassesSettings(
|
||||||
|
showDeepSpace,
|
||||||
|
hoursAhead,
|
||||||
|
minElevation,
|
||||||
|
aosStartMinute,
|
||||||
|
aosEndMinute,
|
||||||
|
invertAosTimeWindow,
|
||||||
|
selectedModes
|
||||||
|
)
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -282,6 +311,10 @@ class SettingsRepo(
|
|||||||
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
||||||
|
|
||||||
override fun updateRCSettings(settings: RCSettings) {
|
override fun updateRCSettings(settings: RCSettings) {
|
||||||
|
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
|
||||||
|
Constants.FREQ_OFFSET_MIN_HZ,
|
||||||
|
Constants.FREQ_OFFSET_MAX_HZ
|
||||||
|
)
|
||||||
preferences.edit {
|
preferences.edit {
|
||||||
putBoolean(keyRotatorState, settings.rotatorState)
|
putBoolean(keyRotatorState, settings.rotatorState)
|
||||||
putString(keyRotatorAddress, settings.rotatorAddress)
|
putString(keyRotatorAddress, settings.rotatorAddress)
|
||||||
@@ -291,6 +324,7 @@ class SettingsRepo(
|
|||||||
putString(keyFrequencyAddress, settings.frequencyAddress)
|
putString(keyFrequencyAddress, settings.frequencyAddress)
|
||||||
putString(keyFrequencyPort, settings.frequencyPort)
|
putString(keyFrequencyPort, settings.frequencyPort)
|
||||||
putString(keyFrequencyFormat, settings.frequencyFormat)
|
putString(keyFrequencyFormat, settings.frequencyFormat)
|
||||||
|
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
|
||||||
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
||||||
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
||||||
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
||||||
@@ -299,7 +333,7 @@ class SettingsRepo(
|
|||||||
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
||||||
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
||||||
}
|
}
|
||||||
_rcSettings.value = settings
|
_rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRCSettings(): RCSettings = RCSettings(
|
private fun getRCSettings(): RCSettings = RCSettings(
|
||||||
@@ -311,6 +345,8 @@ class SettingsRepo(
|
|||||||
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
|
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
|
||||||
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
|
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
|
||||||
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
|
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),
|
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
|
||||||
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
||||||
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
||||||
@@ -338,8 +374,8 @@ class SettingsRepo(
|
|||||||
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
||||||
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
||||||
putString(keySstvMode, new.sstvMode)
|
putString(keySstvMode, new.sstvMode)
|
||||||
putString(keyMapSource, MapSource.normalize(new.mapSource))
|
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
||||||
putString(keyTiandituKey, new.tiandituKey)
|
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||||
}
|
}
|
||||||
new
|
new
|
||||||
}
|
}
|
||||||
@@ -355,8 +391,8 @@ class SettingsRepo(
|
|||||||
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
||||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||||
mapSource = MapSource.normalize(preferences.getString(keyMapSource, null) ?: MapSource.TIANDITU_VECTOR),
|
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
||||||
tiandituKey = preferences.getString(keyTiandituKey, null) ?: ""
|
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -365,21 +401,108 @@ class SettingsRepo(
|
|||||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
|
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
|
||||||
|
|
||||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
|
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
|
||||||
|
// Normalize the enabled lists so they are positionally aligned with the URL lists.
|
||||||
|
// Missing entries default to enabled (true), keeping the persisted "one flag per URL"
|
||||||
|
// invariant intact even when a default empty list is used to construct the model.
|
||||||
|
val normalized = settings.copy(
|
||||||
|
satelliteEnabled = alignFlags(settings.satelliteUrls, settings.satelliteEnabled),
|
||||||
|
transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled)
|
||||||
|
)
|
||||||
preferences.edit {
|
preferences.edit {
|
||||||
putBoolean(keyUseCustomTle, settings.useCustomTLE)
|
putString(keySatelliteUrls, normalized.satelliteUrls.joinToString(separatorUrl))
|
||||||
putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers)
|
putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
|
||||||
putString(keyTleUrl, settings.tleUrl)
|
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
|
||||||
putString(keyTransceiversUrl, settings.transceiversUrl)
|
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
|
||||||
|
putBoolean(keySatnogsTleSourceMigration, true)
|
||||||
}
|
}
|
||||||
_dataSourcesSettings.value = settings
|
_dataSourcesSettings.value = normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
||||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
|
satelliteUrls = getDataSourceUrls(
|
||||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
|
key = keySatelliteUrls,
|
||||||
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
|
defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
|
||||||
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
|
legacyEnabledKey = keyUseCustomTle,
|
||||||
|
legacyUrlKey = keyTleUrl
|
||||||
|
).migrateSatnogsTleSource(),
|
||||||
|
transceiversUrls = getDataSourceUrls(
|
||||||
|
key = keyTransceiversUrls,
|
||||||
|
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
|
||||||
|
legacyEnabledKey = keyUseCustomTransceivers,
|
||||||
|
legacyUrlKey = keyTransceiversUrl
|
||||||
|
),
|
||||||
|
satelliteEnabled = readEnabledFlags(keySatelliteEnabled),
|
||||||
|
transceiversEnabled = readEnabledFlags(keyTransceiversEnabled)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Read the persisted per-source enabled flags (empty when never stored). */
|
||||||
|
private fun readEnabledFlags(key: String): List<Boolean> {
|
||||||
|
return preferences.getString(key, null)
|
||||||
|
?.split(separatorComma)
|
||||||
|
?.mapNotNull { it.trim() }
|
||||||
|
?.filter { it == "true" || it == "false" }
|
||||||
|
?.map { it == "true" }
|
||||||
|
?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the flags positionally aligned with the URL list, defaulting to enabled. */
|
||||||
|
private fun alignFlags(urls: List<String>, flags: List<Boolean>): List<Boolean> {
|
||||||
|
if (flags.size >= urls.size) return flags.take(urls.size)
|
||||||
|
return flags + List(urls.size - flags.size) { true }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDataSourceUrls(
|
||||||
|
key: String,
|
||||||
|
defaultUrls: List<String>,
|
||||||
|
legacyEnabledKey: String,
|
||||||
|
legacyUrlKey: String
|
||||||
|
): List<String> {
|
||||||
|
val stored = preferences.getString(key, null)
|
||||||
|
?.split(separatorUrl)
|
||||||
|
?.map { it.trim() }
|
||||||
|
?.filter { it.isNotBlank() }
|
||||||
|
if (stored != null) return stored
|
||||||
|
val legacyCustomUrl = preferences.getString(legacyUrlKey, null)?.trim().orEmpty()
|
||||||
|
return if (preferences.getBoolean(legacyEnabledKey, false) && legacyCustomUrl.isNotBlank()) {
|
||||||
|
(defaultUrls + legacyCustomUrl).distinct()
|
||||||
|
} else {
|
||||||
|
defaultUrls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<String>.migrateSatnogsTleSource(): List<String> {
|
||||||
|
if (preferences.getBoolean(keySatnogsTleSourceMigration, false)) return this
|
||||||
|
val satnogsTleUrl = Sources.satelliteDataUrls["SatNOGS"].orEmpty()
|
||||||
|
if (satnogsTleUrl.isBlank() || containsSourceUrl(satnogsTleUrl)) return this
|
||||||
|
val celestrakSatnogsIndex = indexOfFirst { isSameSourceUrl(it, legacyCelestrakSatnogsUrl) }
|
||||||
|
if (celestrakSatnogsIndex < 0) return this
|
||||||
|
|
||||||
|
val migrated = toMutableList().apply { add(celestrakSatnogsIndex + 1, satnogsTleUrl) }
|
||||||
|
preferences.edit {
|
||||||
|
putString(keySatelliteUrls, migrated.joinToString(separatorUrl))
|
||||||
|
putBoolean(keySatnogsTleSourceMigration, true)
|
||||||
|
}
|
||||||
|
return migrated
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<String>.containsSourceUrl(url: String): Boolean = any { isSameSourceUrl(it, url) }
|
||||||
|
|
||||||
|
private fun isSameSourceUrl(first: String, second: String): Boolean =
|
||||||
|
normalizeSourceUrl(first).equals(normalizeSourceUrl(second), ignoreCase = true)
|
||||||
|
|
||||||
|
private fun normalizeSourceUrl(url: String): String {
|
||||||
|
val trimmed = url.trim()
|
||||||
|
return if (trimmed.startsWith("http", ignoreCase = true)) trimmed else "https://$trimmed"
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # Data sources status
|
||||||
|
private val _dataSourcesStatus = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||||
|
override val dataSourcesStatus: StateFlow<Map<String, Int>> = _dataSourcesStatus
|
||||||
|
|
||||||
|
override fun updateDataSourcesStatus(status: Map<String, Int>) {
|
||||||
|
_dataSourcesStatus.value = status
|
||||||
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Radio control settings
|
//region # Radio control settings
|
||||||
@@ -390,6 +513,7 @@ class SettingsRepo(
|
|||||||
private val keyTxRadioName = "txRadioName"
|
private val keyTxRadioName = "txRadioName"
|
||||||
private val keyRxRadioName = "rxRadioName"
|
private val keyRxRadioName = "rxRadioName"
|
||||||
private val keyRadioBaudRate = "radioBaudRate"
|
private val keyRadioBaudRate = "radioBaudRate"
|
||||||
|
private val keyRadioSplitMode = "radioSplitMode"
|
||||||
|
|
||||||
private val _radioControlSettings = MutableStateFlow(getRadioControlSettings())
|
private val _radioControlSettings = MutableStateFlow(getRadioControlSettings())
|
||||||
override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings
|
override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings
|
||||||
@@ -403,18 +527,71 @@ class SettingsRepo(
|
|||||||
putString(keyTxRadioName, settings.txRadioName)
|
putString(keyTxRadioName, settings.txRadioName)
|
||||||
putString(keyRxRadioName, settings.rxRadioName)
|
putString(keyRxRadioName, settings.rxRadioName)
|
||||||
putInt(keyRadioBaudRate, settings.baudRate)
|
putInt(keyRadioBaudRate, settings.baudRate)
|
||||||
|
putBoolean(keyRadioSplitMode, settings.splitMode)
|
||||||
}
|
}
|
||||||
_radioControlSettings.value = settings
|
_radioControlSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings(
|
private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings(
|
||||||
enabled = preferences.getBoolean(keyRadioControlEnabled, false),
|
enabled = preferences.getBoolean(keyRadioControlEnabled, false),
|
||||||
radioModel = preferences.getString(keyRadioModel, null) ?: "Yaesu FT-817/818",
|
radioModel = preferences.getString(keyRadioModel, null) ?: RadioControlSettings.MODEL_YAESU_FT817,
|
||||||
txRadioAddress = preferences.getString(keyTxRadioAddress, null) ?: "",
|
txRadioAddress = preferences.getString(keyTxRadioAddress, null) ?: "",
|
||||||
rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "",
|
rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "",
|
||||||
txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio",
|
txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio",
|
||||||
rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio",
|
rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio",
|
||||||
baudRate = preferences.getInt(keyRadioBaudRate, 4800)
|
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
|
||||||
|
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
//region # Per-satellite calculator offset settings
|
||||||
|
private val keySatelliteOffsets = "satelliteOffsets"
|
||||||
|
private val keyLegacySatelliteOffsetPrefix = "offset_khz_"
|
||||||
|
|
||||||
|
override fun getSatelliteOffset(catnum: Int): String {
|
||||||
|
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||||
|
val stored = try {
|
||||||
|
JSONObject(json).optString(catnum.toString(), "")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
if (stored.isNotEmpty()) return stored
|
||||||
|
|
||||||
|
// Lazy migration from the fork's old UI-layer implementation, which stored
|
||||||
|
// one SharedPreferences entry per satellite directly from TransceiversPage.
|
||||||
|
val legacyKey = "$keyLegacySatelliteOffsetPrefix$catnum"
|
||||||
|
val legacy = preferences.getString(legacyKey, "").orEmpty()
|
||||||
|
if (legacy.isNotEmpty()) {
|
||||||
|
setSatelliteOffset(catnum, legacy)
|
||||||
|
}
|
||||||
|
return legacy
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (offset.isEmpty()) "{}" else """{"$catnum": "$offset"}"""
|
||||||
|
}
|
||||||
|
preferences.edit {
|
||||||
|
putString(keySatelliteOffsets, updated)
|
||||||
|
remove("$keyLegacySatelliteOffsetPrefix$catnum")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # AMSAT status report settings
|
||||||
|
private val keyAmSatCallsign = "amSatCallsign"
|
||||||
|
|
||||||
|
override fun getAmSatCallsign(): String {
|
||||||
|
return preferences.getString(keyAmSatCallsign, "").orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setAmSatCallsign(callsign: String) {
|
||||||
|
preferences.edit { putString(keyAmSatCallsign, callsign.trim().uppercase(Locale.US)) }
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
}
|
}
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.LatestRelease
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class UpdateRepository(
|
||||||
|
private val remoteSource: IRemoteSource
|
||||||
|
) : IUpdateRepository {
|
||||||
|
|
||||||
|
override suspend fun getLatestRelease(): LatestRelease? = withContext(Dispatchers.IO) {
|
||||||
|
val result = remoteSource.getNetworkStream(LATEST_RELEASE_URL)
|
||||||
|
val stream = result.stream ?: return@withContext null
|
||||||
|
try {
|
||||||
|
val json = stream.bufferedReader().use { it.readText() }
|
||||||
|
parseRelease(json)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("UpdateRepository parse failure: $e")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun downloadApk(url: String, dest: File): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
if (url.isBlank()) return@withContext false
|
||||||
|
val result = remoteSource.getNetworkStream(url)
|
||||||
|
val stream = result.stream ?: return@withContext false
|
||||||
|
try {
|
||||||
|
dest.outputStream().use { out -> stream.use { it.copyTo(out) } }
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("UpdateRepository download failure: $e")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRelease(json: String): LatestRelease? {
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
val tag = obj.optString("tag_name")
|
||||||
|
if (tag.isBlank()) return null
|
||||||
|
val assets = obj.optJSONArray("assets")
|
||||||
|
var apkUrl: String? = null
|
||||||
|
if (assets != null) {
|
||||||
|
for (i in 0 until assets.length()) {
|
||||||
|
val asset = assets.optJSONObject(i) ?: continue
|
||||||
|
val name = asset.optString("name", "")
|
||||||
|
if (name.endsWith(".apk")) {
|
||||||
|
apkUrl = asset.optString("browser_download_url", "").ifBlank { null }
|
||||||
|
if (apkUrl != null) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return LatestRelease(
|
||||||
|
versionTag = tag,
|
||||||
|
title = obj.optString("name", ""),
|
||||||
|
body = obj.optString("body", ""),
|
||||||
|
apkUrl = apkUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val LATEST_RELEASE_URL = "https://api.github.com/repos/atsunatsu/Look4Sat/releases/latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,11 +20,13 @@ package com.rtbishop.look4sat.core.data.source
|
|||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import java.io.ByteArrayInputStream
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
class RemoteSource(
|
class RemoteSource(
|
||||||
@@ -43,15 +45,74 @@ class RemoteSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
override suspend fun getNetworkStream(url: String): NetworkResult = withContext(dispatcher) {
|
||||||
try {
|
try {
|
||||||
val networkRequest = Request.Builder().url(url).build()
|
val networkRequest = Request.Builder().url(url).build()
|
||||||
httpClient.newCall(networkRequest).execute().use { response ->
|
val response = httpClient.newCall(networkRequest).execute()
|
||||||
if (!response.isSuccessful) return@withContext null
|
if (!response.isSuccessful) {
|
||||||
ByteArrayInputStream(response.body.bytes())
|
val code = response.code
|
||||||
|
response.close()
|
||||||
|
return@withContext NetworkResult(code, null)
|
||||||
}
|
}
|
||||||
|
// Return the body stream directly as the caller is responsible for closing it.
|
||||||
|
// Closing the stream returns the connection to OkHttp's pool.
|
||||||
|
val body = response.body
|
||||||
|
if (body == null) {
|
||||||
|
response.close()
|
||||||
|
return@withContext NetworkResult(response.code, null)
|
||||||
|
}
|
||||||
|
NetworkResult(response.code, body.byteStream().buffered())
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
println("RemoteSource network stream exception: $exception")
|
println("RemoteSource network stream exception: $exception")
|
||||||
|
NetworkResult(NetworkResult.CONNECTION_ERROR, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAmSatCatalog(): String? = withContext(dispatcher) {
|
||||||
|
try {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://www.amsat.org/status/api/v1/catalog.php")
|
||||||
|
.header("User-Agent", "Look4Sat")
|
||||||
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@use null
|
||||||
|
response.body.string()
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
println("RemoteSource getAmSatCatalog exception: $exception")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = withContext(dispatcher) {
|
||||||
|
try {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit")
|
||||||
|
.header("User-Agent", "Look4Sat")
|
||||||
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@use null
|
||||||
|
response.body.string()
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
println("RemoteSource getAmSatReports exception: $exception")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = withContext(dispatcher) {
|
||||||
|
try {
|
||||||
|
val body = payloadJson.toRequestBody("application/json; charset=utf-8".toMediaType())
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://www.amsat.org/status/api/v1/reports.php")
|
||||||
|
.header("User-Agent", "Look4Sat")
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
response.code to response.body.string()
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
println("RemoteSource submitAmSatReport exception: $exception")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-19
@@ -1,30 +1,29 @@
|
|||||||
package com.rtbishop.look4sat.core.data
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.data.framework.Ft817CatProtocol
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import kotlin.test.assertContentEquals
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertNotNull
|
|
||||||
import kotlin.test.assertNull
|
|
||||||
|
|
||||||
class Ft817CatProtocolTest {
|
class Ft817CatProtocolTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeFrequencyBcd_145500000() {
|
fun encodeFrequencyBcd_145500000() {
|
||||||
val bcd = Ft817CatProtocol.encodeFrequencyBcd(145500000L)
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(145500000L)
|
||||||
assertContentEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00), bcd)
|
assertArrayEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeFrequencyBcd_435100000() {
|
fun encodeFrequencyBcd_435100000() {
|
||||||
val bcd = Ft817CatProtocol.encodeFrequencyBcd(435100000L)
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(435100000L)
|
||||||
assertContentEquals(byteArrayOf(0x43, 0x51, 0x00, 0x00), bcd)
|
assertArrayEquals(byteArrayOf(0x43, 0x51, 0x00, 0x00), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeFrequencyBcd_7074000() {
|
fun encodeFrequencyBcd_7074000() {
|
||||||
val bcd = Ft817CatProtocol.encodeFrequencyBcd(7074000L)
|
val bcd = Ft817CatProtocol.encodeFrequencyBcd(7074000L)
|
||||||
assertContentEquals(byteArrayOf(0x00, 0x70, 0x74, 0x00), bcd)
|
assertArrayEquals(byteArrayOf(0x00, 0x70, 0x74, 0x00), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -42,21 +41,21 @@ class Ft817CatProtocolTest {
|
|||||||
val cmd = Ft817CatProtocol.buildSetFreqCommand(145500000L)
|
val cmd = Ft817CatProtocol.buildSetFreqCommand(145500000L)
|
||||||
assertEquals(5, cmd.size)
|
assertEquals(5, cmd.size)
|
||||||
assertEquals(0x01.toByte(), cmd[4])
|
assertEquals(0x01.toByte(), cmd[4])
|
||||||
assertContentEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01), cmd)
|
assertArrayEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun buildSetModeCommand_usb() {
|
fun buildSetModeCommand_usb() {
|
||||||
val cmd = Ft817CatProtocol.buildSetModeCommand("USB")
|
val cmd = Ft817CatProtocol.buildSetModeCommand("USB")
|
||||||
assertNotNull(cmd)
|
assertNotNull(cmd)
|
||||||
assertContentEquals(byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x07), cmd)
|
assertArrayEquals(byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x07), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun buildSetModeCommand_fm() {
|
fun buildSetModeCommand_fm() {
|
||||||
val cmd = Ft817CatProtocol.buildSetModeCommand("FM")
|
val cmd = Ft817CatProtocol.buildSetModeCommand("FM")
|
||||||
assertNotNull(cmd)
|
assertNotNull(cmd)
|
||||||
assertContentEquals(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x07), cmd)
|
assertArrayEquals(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x07), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -67,19 +66,19 @@ class Ft817CatProtocolTest {
|
|||||||
@Test
|
@Test
|
||||||
fun encodeCtcssTone_67_0() {
|
fun encodeCtcssTone_67_0() {
|
||||||
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(67.0)
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(67.0)
|
||||||
assertContentEquals(byteArrayOf(0x06, 0x70), bcd)
|
assertArrayEquals(byteArrayOf(0x06, 0x70), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeCtcssTone_74_4() {
|
fun encodeCtcssTone_74_4() {
|
||||||
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(74.4)
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(74.4)
|
||||||
assertContentEquals(byteArrayOf(0x07, 0x44), bcd)
|
assertArrayEquals(byteArrayOf(0x07, 0x44), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodeCtcssTone_141_3() {
|
fun encodeCtcssTone_141_3() {
|
||||||
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(141.3)
|
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(141.3)
|
||||||
assertContentEquals(byteArrayOf(0x14, 0x13), bcd)
|
assertArrayEquals(byteArrayOf(0x14, 0x13), bcd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -87,13 +86,13 @@ class Ft817CatProtocolTest {
|
|||||||
val cmd = Ft817CatProtocol.buildSetCtcssToneCommand(67.0)
|
val cmd = Ft817CatProtocol.buildSetCtcssToneCommand(67.0)
|
||||||
assertEquals(5, cmd.size)
|
assertEquals(5, cmd.size)
|
||||||
assertEquals(0x0B.toByte(), cmd[4])
|
assertEquals(0x0B.toByte(), cmd[4])
|
||||||
assertContentEquals(byteArrayOf(0x06, 0x70, 0x00, 0x00, 0x0B), cmd)
|
assertArrayEquals(byteArrayOf(0x06, 0x70, 0x00, 0x00, 0x0B), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun buildCtcssModeCommand_enable() {
|
fun buildCtcssModeCommand_enable() {
|
||||||
val cmd = Ft817CatProtocol.buildCtcssModeCommand(true)
|
val cmd = Ft817CatProtocol.buildCtcssModeCommand(true)
|
||||||
assertContentEquals(byteArrayOf(0x2A, 0x00, 0x00, 0x00, 0x0A), cmd)
|
assertArrayEquals(byteArrayOf(0x2A, 0x00, 0x00, 0x00, 0x0A), cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -108,6 +107,7 @@ class Ft817CatProtocolTest {
|
|||||||
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01)
|
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01)
|
||||||
val result = Ft817CatProtocol.parseReadResponse(response)
|
val result = Ft817CatProtocol.parseReadResponse(response)
|
||||||
assertNotNull(result)
|
assertNotNull(result)
|
||||||
|
result ?: return
|
||||||
assertEquals(145500000L, result.first)
|
assertEquals(145500000L, result.first)
|
||||||
assertEquals("USB", result.second)
|
assertEquals("USB", result.second)
|
||||||
}
|
}
|
||||||
@@ -117,6 +117,7 @@ class Ft817CatProtocolTest {
|
|||||||
val response = byteArrayOf(0x14, 0x60, 0x00, 0x00, 0x08)
|
val response = byteArrayOf(0x14, 0x60, 0x00, 0x00, 0x08)
|
||||||
val result = Ft817CatProtocol.parseReadResponse(response)
|
val result = Ft817CatProtocol.parseReadResponse(response)
|
||||||
assertNotNull(result)
|
assertNotNull(result)
|
||||||
|
result ?: return
|
||||||
assertEquals(146000000L, result.first)
|
assertEquals(146000000L, result.first)
|
||||||
assertEquals("FM", result.second)
|
assertEquals("FM", result.second)
|
||||||
}
|
}
|
||||||
@@ -135,7 +136,7 @@ class Ft817CatProtocolTest {
|
|||||||
@Test
|
@Test
|
||||||
fun buildPttCommands() {
|
fun buildPttCommands() {
|
||||||
val on = Ft817CatProtocol.buildPttOnCommand()
|
val on = Ft817CatProtocol.buildPttOnCommand()
|
||||||
assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x08), on)
|
assertArrayEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x08), on)
|
||||||
|
|
||||||
val off = Ft817CatProtocol.buildPttOffCommand()
|
val off = Ft817CatProtocol.buildPttOffCommand()
|
||||||
assertEquals(0x88.toByte(), off[4])
|
assertEquals(0x88.toByte(), off[4])
|
||||||
@@ -144,6 +145,6 @@ class Ft817CatProtocolTest {
|
|||||||
@Test
|
@Test
|
||||||
fun buildReadCommand() {
|
fun buildReadCommand() {
|
||||||
val cmd = Ft817CatProtocol.buildReadFreqModeCommand()
|
val cmd = Ft817CatProtocol.buildReadFreqModeCommand()
|
||||||
assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x03), cmd)
|
assertArrayEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x03), cmd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
class AmSatRepositoryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fetchStatusReturnsSeededCacheUntilCacheIsCleared() = runTest {
|
||||||
|
val remoteSource = FakeAmSatRemoteSource()
|
||||||
|
val repository = AmSatRepository(remoteSource)
|
||||||
|
val cachedPage = SatStatusPage(
|
||||||
|
fetchedAtUtcMs = 123L,
|
||||||
|
statuses = listOf(SatStatus(name = "AO-7", days = emptyList())),
|
||||||
|
reports = emptyMap()
|
||||||
|
)
|
||||||
|
repository.seedStatusCache(cachedPage)
|
||||||
|
|
||||||
|
val firstPage = repository.fetchStatus()
|
||||||
|
val secondPage = repository.fetchStatus()
|
||||||
|
|
||||||
|
assertSame(cachedPage, firstPage)
|
||||||
|
assertSame(cachedPage, secondPage)
|
||||||
|
assertSame(cachedPage, repository.getCachedStatus())
|
||||||
|
assertEquals(0, remoteSource.catalogRequests)
|
||||||
|
assertEquals(0, remoteSource.reportRequests)
|
||||||
|
|
||||||
|
repository.clearStatusCache()
|
||||||
|
assertNull(repository.getCachedStatus())
|
||||||
|
repository.fetchStatus()
|
||||||
|
|
||||||
|
assertEquals(1, remoteSource.catalogRequests)
|
||||||
|
assertEquals(1, remoteSource.reportRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun forceRefreshBypassesCachedPage() = runTest {
|
||||||
|
val remoteSource = FakeAmSatRemoteSource()
|
||||||
|
val repository = AmSatRepository(remoteSource)
|
||||||
|
repository.seedStatusCache(
|
||||||
|
SatStatusPage(
|
||||||
|
fetchedAtUtcMs = 123L,
|
||||||
|
statuses = listOf(SatStatus(name = "AO-7", days = emptyList())),
|
||||||
|
reports = emptyMap()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.fetchStatus(forceRefresh = true)
|
||||||
|
|
||||||
|
assertEquals(1, remoteSource.catalogRequests)
|
||||||
|
assertEquals(1, remoteSource.reportRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fetchStatusSharesStartupPrefetchRequest() = runTest {
|
||||||
|
val remoteSource = FakeAmSatRemoteSource(responseDelayMillis = 50)
|
||||||
|
val repository = AmSatRepository(remoteSource)
|
||||||
|
|
||||||
|
val prefetch = async { repository.prefetchStatus() }
|
||||||
|
val pageFetch = async { repository.fetchStatus() }
|
||||||
|
prefetch.await()
|
||||||
|
val page = pageFetch.await()
|
||||||
|
|
||||||
|
assertSame(page, repository.getCachedStatus())
|
||||||
|
assertEquals(1, remoteSource.catalogRequests)
|
||||||
|
assertEquals(1, remoteSource.reportRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
|
||||||
|
val cacheField = AmSatRepository::class.java.getDeclaredField("statusCache")
|
||||||
|
cacheField.isAccessible = true
|
||||||
|
cacheField.set(this, page)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L) : IRemoteSource {
|
||||||
|
var catalogRequests = 0
|
||||||
|
var reportRequests = 0
|
||||||
|
|
||||||
|
override suspend fun getFileStream(uri: String): InputStream? = null
|
||||||
|
|
||||||
|
override suspend fun getNetworkStream(url: String): NetworkResult = NetworkResult(404, null)
|
||||||
|
|
||||||
|
override suspend fun getAmSatCatalog(): String? {
|
||||||
|
if (responseDelayMillis > 0L) delay(responseDelayMillis)
|
||||||
|
catalogRequests += 1
|
||||||
|
return """{"data":[{"name":"AO-7"}]}"""
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAmSatReports(hours: Int, limit: Int): String? {
|
||||||
|
if (responseDelayMillis > 0L) delay(responseDelayMillis)
|
||||||
|
reportRequests += 1
|
||||||
|
return """{"data":[]}"""
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
||||||
|
}
|
||||||
+280
@@ -0,0 +1,280 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatItem
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class DatabaseRepoTest {
|
||||||
|
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
private val dataParser = DataParser(dispatcher)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
|
||||||
|
val uri = "content://look4sat/import/satellites"
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
fileStreams[uri] = { validCsvStream() }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo()
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
|
||||||
|
repository.updateTLEFromFile(uri)
|
||||||
|
|
||||||
|
assertEquals(1, localSource.insertedEntries.size)
|
||||||
|
assertEquals(25544, localSource.insertedEntries.first().catnum)
|
||||||
|
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
||||||
|
assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `manual satellite import keeps tle support`() = runTest(dispatcher) {
|
||||||
|
val uri = "content://look4sat/import/legacy"
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
fileStreams[uri] = { validTleStream() }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo()
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
|
||||||
|
repository.updateTLEFromFile(uri)
|
||||||
|
|
||||||
|
assertEquals(1, localSource.insertedEntries.size)
|
||||||
|
assertEquals(25544, localSource.insertedEntries.first().catnum)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `custom data source imports omm csv from web`() = runTest(dispatcher) {
|
||||||
|
val customCsvUrl = "https://example.com/custom-omm.csv"
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
networkStreams[customCsvUrl] = { validCsvStream() }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo(
|
||||||
|
dataSources = DataSourcesSettings(
|
||||||
|
satelliteUrls = listOf(customCsvUrl),
|
||||||
|
transceiversUrls = emptyList()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
|
||||||
|
repository.updateFromRemote()
|
||||||
|
|
||||||
|
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
||||||
|
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `remote update imports satellites from real SatNOGS TLE source`() = runTest(dispatcher) {
|
||||||
|
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
networkStreams[satnogsUrl] = { jamxTleStream() }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo(
|
||||||
|
dataSources = DataSourcesSettings(
|
||||||
|
satelliteUrls = listOf(satnogsUrl),
|
||||||
|
transceiversUrls = emptyList()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
|
||||||
|
repository.updateFromRemote()
|
||||||
|
|
||||||
|
assertTrue(localSource.insertedEntries.any { it.name == "JAMX-0825b" && it.catnum == 98248 })
|
||||||
|
assertEquals(listOf(98248), settingsRepo.satelliteTypeIdsByType["SatNOGS"])
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validCsvStream(): InputStream = """
|
||||||
|
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||||
|
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
|
||||||
|
private fun validTleStream(): InputStream = """
|
||||||
|
ISS (ZARYA)
|
||||||
|
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||||
|
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
|
||||||
|
private fun jamxTleStream(): InputStream = """
|
||||||
|
JAMX-0825b
|
||||||
|
1 98248U 26237.16675926 .00015724 00000-0 97477-3 0 00013
|
||||||
|
2 98248 097.5373 310.9694 0011309 278.1232 340.7230 15.09766181000012
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeRemoteSource : IRemoteSource {
|
||||||
|
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||||
|
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||||
|
|
||||||
|
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
||||||
|
|
||||||
|
override suspend fun getNetworkStream(url: String): NetworkResult =
|
||||||
|
networkStreams[url]?.let { NetworkResult(200, it()) } ?: NetworkResult(404, null)
|
||||||
|
|
||||||
|
override suspend fun getAmSatCatalog(): String? = null
|
||||||
|
|
||||||
|
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||||
|
|
||||||
|
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeLocalSource : ILocalSource {
|
||||||
|
val insertedEntries = mutableListOf<OrbitalData>()
|
||||||
|
private val insertedRadios = mutableListOf<SatRadio>()
|
||||||
|
|
||||||
|
override suspend fun getEntriesTotal(): Int = insertedEntries.size
|
||||||
|
|
||||||
|
override suspend fun getEntriesList(): List<SatItem> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun insertEntries(entries: List<OrbitalData>) {
|
||||||
|
insertedEntries += entries
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteEntries() {
|
||||||
|
insertedEntries.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun getRadiosTotal(): Int = insertedRadios.size
|
||||||
|
|
||||||
|
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun insertRadios(radios: List<SatRadio>) {
|
||||||
|
insertedRadios += radios
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteRadios() {
|
||||||
|
insertedRadios.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo {
|
||||||
|
|
||||||
|
override val appVersionName: String = "test"
|
||||||
|
|
||||||
|
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
|
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
|
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||||
|
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
|
||||||
|
)
|
||||||
|
|
||||||
|
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||||
|
|
||||||
|
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||||
|
|
||||||
|
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||||
|
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||||
|
OtherSettings(false, false, false, false, false, false, false, false)
|
||||||
|
)
|
||||||
|
|
||||||
|
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
|
||||||
|
|
||||||
|
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||||
|
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||||
|
)
|
||||||
|
|
||||||
|
val satelliteTypeIdsByType = mutableMapOf<String, List<Int>>()
|
||||||
|
|
||||||
|
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||||
|
|
||||||
|
override fun setSelectedTypes(types: List<String>) = Unit
|
||||||
|
|
||||||
|
override fun setPassesSettings(settings: PassesSettings) = Unit
|
||||||
|
|
||||||
|
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
|
||||||
|
|
||||||
|
override fun setStationPosition(): Boolean = true
|
||||||
|
|
||||||
|
override fun setStationPosition(locator: String): Boolean = true
|
||||||
|
|
||||||
|
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
|
||||||
|
|
||||||
|
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
||||||
|
satelliteTypeIdsByType[type] = ids
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateDatabaseState(state: DatabaseState) {
|
||||||
|
databaseState.value = state
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateRCSettings(settings: RCSettings) = Unit
|
||||||
|
|
||||||
|
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
|
||||||
|
|
||||||
|
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
|
||||||
|
dataSourcesSettings.value = settings
|
||||||
|
}
|
||||||
|
|
||||||
|
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||||
|
|
||||||
|
override fun updateDataSourcesStatus(status: Map<String, Int>) {
|
||||||
|
(dataSourcesStatus as? MutableStateFlow)?.value = status
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||||
|
|
||||||
|
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||||
|
|
||||||
|
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||||
|
|
||||||
|
override fun getAmSatCallsign(): String = ""
|
||||||
|
|
||||||
|
override fun setAmSatCallsign(callsign: String) = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||||
|
return DataSourcesSettings(
|
||||||
|
satelliteUrls = emptyList(),
|
||||||
|
transceiversUrls = emptyList()
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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.cw
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bayesian Morse timing decoder.
|
||||||
|
* Replaces hard thresholds with probability-based decision making.
|
||||||
|
*
|
||||||
|
* Inspired by VE3NEA's CW Skimmer approach:
|
||||||
|
* "Instead of making a hard decision at every input sample whether the signal
|
||||||
|
* is present or not, compute the probability that the signal is present."
|
||||||
|
*
|
||||||
|
* Uses Gaussian probability density centered on expected durations:
|
||||||
|
* P(dit | duration) = exp(-(duration - dotMs)^2 / (2 * variance^2))
|
||||||
|
* P(dash | duration) = exp(-(duration - 3*dotMs)^2 / (2 * variance^2))
|
||||||
|
*/
|
||||||
|
internal class CwBayesianDecoder {
|
||||||
|
|
||||||
|
// Morse timing parameters
|
||||||
|
private var dotDurationMs = 60f // initial 20 WPM
|
||||||
|
private var speedWpm = 20f
|
||||||
|
|
||||||
|
// Current symbol being accumulated
|
||||||
|
private var currentSymbol = StringBuilder()
|
||||||
|
private var textBuffer = StringBuilder()
|
||||||
|
|
||||||
|
// Recent dit lengths for speed estimation
|
||||||
|
private val recentDits = mutableListOf<Float>()
|
||||||
|
|
||||||
|
// Output
|
||||||
|
private var _decodedText = ""
|
||||||
|
val decodedText: String get() = _decodedText
|
||||||
|
|
||||||
|
/** Gaussian probability. */
|
||||||
|
private fun gaussianProb(durationMs: Float, expectedMs: Float, varianceMs: Float): Float {
|
||||||
|
if (varianceMs <= 0f) return 0f
|
||||||
|
val diff = durationMs - expectedMs
|
||||||
|
return kotlin.math.exp(-(diff * diff) / (2 * varianceMs * varianceMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Process a tone duration. Returns the symbol type with highest probability. */
|
||||||
|
fun processTone(durationMs: Float): ToneResult {
|
||||||
|
val ditProb = gaussianProb(durationMs, dotDurationMs, dotDurationMs * 0.4f)
|
||||||
|
val dashProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
|
||||||
|
|
||||||
|
return if (ditProb > dashProb && ditProb > 0.05f) {
|
||||||
|
currentSymbol.append('0')
|
||||||
|
recentDits.add(durationMs)
|
||||||
|
updateSpeed()
|
||||||
|
ToneResult('0', ditProb)
|
||||||
|
} else if (dashProb > 0.05f) {
|
||||||
|
currentSymbol.append('1')
|
||||||
|
ToneResult('1', dashProb)
|
||||||
|
} else {
|
||||||
|
ToneResult(null, 0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Process a gap duration. Returns decoded character or null. */
|
||||||
|
fun processGap(durationMs: Float): Char? {
|
||||||
|
if (currentSymbol.isEmpty()) {
|
||||||
|
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
|
||||||
|
if (wordProb > 0.2f) {
|
||||||
|
textBuffer.append(' ')
|
||||||
|
_decodedText = textBuffer.toString()
|
||||||
|
return ' '
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val interCharProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
|
||||||
|
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
|
||||||
|
|
||||||
|
if (wordProb > interCharProb && wordProb > 0.2f) {
|
||||||
|
val char = flushSymbol()
|
||||||
|
textBuffer.append(' ')
|
||||||
|
_decodedText = textBuffer.toString()
|
||||||
|
return char
|
||||||
|
}
|
||||||
|
if (interCharProb > 0.15f) {
|
||||||
|
val char = flushSymbol()
|
||||||
|
_decodedText = textBuffer.toString()
|
||||||
|
return char
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flushSymbol(): Char? {
|
||||||
|
if (currentSymbol.isEmpty()) return null
|
||||||
|
val morse = currentSymbol.toString()
|
||||||
|
currentSymbol.clear()
|
||||||
|
val char = morseToChar(morse)
|
||||||
|
if (char != null) textBuffer.append(char)
|
||||||
|
return char
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateSpeed() {
|
||||||
|
if (recentDits.size < 3) return
|
||||||
|
val sorted = recentDits.sorted()
|
||||||
|
val median = sorted[sorted.size / 2]
|
||||||
|
if (median > 0f) {
|
||||||
|
dotDurationMs = dotDurationMs * 0.7f + median * 0.3f
|
||||||
|
val wpm = 60.0f / (50.0f * dotDurationMs / 1000.0f)
|
||||||
|
if (wpm in 5f..55f) speedWpm = wpm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSpeed(): Float = speedWpm
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
dotDurationMs = 60f
|
||||||
|
speedWpm = 20f
|
||||||
|
recentDits.clear()
|
||||||
|
currentSymbol.clear()
|
||||||
|
textBuffer.clear()
|
||||||
|
_decodedText = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val MORSE_TABLE = mapOf(
|
||||||
|
"01" to 'A', "1000" to 'B', "1010" to 'C', "100" to 'D', "0" to 'E',
|
||||||
|
"0010" to 'F', "110" to 'G', "0000" to 'H', "00" to 'I', "0111" to 'J',
|
||||||
|
"101" to 'K', "0100" to 'L', "11" to 'M', "10" to 'N', "111" to 'O',
|
||||||
|
"0110" to 'P', "1101" to 'Q', "010" to 'R', "000" to 'S', "1" to 'T',
|
||||||
|
"001" to 'U', "0001" to 'V', "011" to 'W', "1001" to 'X', "1011" to 'Y',
|
||||||
|
"1100" to 'Z', "01111" to '1', "00111" to '2', "00011" to '3',
|
||||||
|
"00001" to '4', "00000" to '5', "10000" to '6', "11000" to '7',
|
||||||
|
"11100" to '8', "11110" to '9', "11111" to '0',
|
||||||
|
"010101" to '.', "110011" to ',', "001100" to '?', "011110" to '\'',
|
||||||
|
"101011" to '!', "10010" to '/', "10110" to '(', "101101" to ')',
|
||||||
|
"01000" to '&', "111000" to ':', "101010" to ';', "10001" to '=',
|
||||||
|
"01010" to '+', "100001" to '-', "001101" to '_', "010010" to '"',
|
||||||
|
"0001001" to '$', "011010" to '@'
|
||||||
|
)
|
||||||
|
fun morseToChar(morse: String): Char? = MORSE_TABLE[morse]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ToneResult(val symbol: Char?, val probability: Float)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/*
|
||||||
|
* 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.cw
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-channel CW signal tracker.
|
||||||
|
* Monitors the spectrogram for active frequency bins and extracts
|
||||||
|
* energy envelopes for each detected signal.
|
||||||
|
*
|
||||||
|
* Inspired by CW Skimmer's multi-channel approach:
|
||||||
|
* tracks all active signals in the passband simultaneously,
|
||||||
|
* selects the best one for decoded output.
|
||||||
|
*/
|
||||||
|
internal class CwChannelTracker(
|
||||||
|
private val spectrogram: CwSpectrogram,
|
||||||
|
private val maxChannels: Int = 3
|
||||||
|
) {
|
||||||
|
data class Channel(
|
||||||
|
val bin: Int,
|
||||||
|
val frequency: Float,
|
||||||
|
var active: Boolean = false,
|
||||||
|
var energy: Float = 0f,
|
||||||
|
val history: MutableList<Float> = mutableListOf(),
|
||||||
|
var confidence: Float = 0f
|
||||||
|
)
|
||||||
|
|
||||||
|
private val channels = Array(maxChannels) { Channel(0, 0f) }
|
||||||
|
|
||||||
|
/** Scan the current spectrogram column and update channel tracking. */
|
||||||
|
fun update(): List<Channel> {
|
||||||
|
val col = spectrogram.getCurrentColumn()
|
||||||
|
val peaks = findPeaks(col, threshold = 0.3f, minDistance = 2)
|
||||||
|
|
||||||
|
// Update existing channels
|
||||||
|
for (ch in channels) {
|
||||||
|
if (ch.active) {
|
||||||
|
if (peaks.contains(ch.bin)) {
|
||||||
|
ch.energy = col[ch.bin]
|
||||||
|
ch.history.add(ch.energy)
|
||||||
|
if (ch.history.size > 40) ch.history.removeAt(0)
|
||||||
|
ch.confidence = computeConfidence(ch.history)
|
||||||
|
} else {
|
||||||
|
// Signal lost — decay confidence
|
||||||
|
ch.history.add(0f)
|
||||||
|
if (ch.history.size > 40) ch.history.removeAt(0)
|
||||||
|
ch.confidence *= 0.9f
|
||||||
|
if (ch.confidence < 0.1f) ch.active = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign new peaks to inactive channels
|
||||||
|
var peakIdx = 0
|
||||||
|
for (ch in channels) {
|
||||||
|
if (!ch.active && peakIdx < peaks.size) {
|
||||||
|
val bin = peaks[peakIdx]
|
||||||
|
val freq = spectrogram.binToFreq(bin)
|
||||||
|
// Re-initialize channel
|
||||||
|
channels[peakIdx] = Channel(bin, freq, true, col[bin], mutableListOf(), 0.5f)
|
||||||
|
peakIdx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return channels.filter { it.active }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find peak bins in the spectrum. */
|
||||||
|
private fun findPeaks(spectrum: FloatArray, threshold: Float, minDistance: Int): List<Int> {
|
||||||
|
val peaks = mutableListOf<Int>()
|
||||||
|
for (i in 1 until spectrum.size - 1) {
|
||||||
|
if (spectrum[i] > spectrum[i - 1] && spectrum[i] > spectrum[i + 1] && spectrum[i] > threshold) {
|
||||||
|
if (peaks.isEmpty() || i - peaks.last() >= minDistance) {
|
||||||
|
peaks.add(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return peaks.sortedByDescending { spectrum[it] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute confidence from energy history. Lower variance = higher confidence. */
|
||||||
|
private fun computeConfidence(history: List<Float>): Float {
|
||||||
|
if (history.size < 10) return 0.3f
|
||||||
|
val recent = history.takeLast(10)
|
||||||
|
val mean = recent.average().toFloat()
|
||||||
|
val variance = recent.map { (it - mean) * (it - mean) }.average().toFloat()
|
||||||
|
return if (mean > 0f) (mean / (mean + variance + 0.1f)).coerceIn(0f, 1f) else 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the channel with highest confidence. */
|
||||||
|
fun getBestChannel(): Channel? {
|
||||||
|
return channels.filter { it.active }.maxByOrNull { it.confidence }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
for (i in channels.indices) {
|
||||||
|
channels[i] = Channel(0, 0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/*
|
||||||
|
* 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.cw
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CW (Morse code) decoder v3 — Spectrogram-based multi-channel Bayesian decoder.
|
||||||
|
*
|
||||||
|
* Architecture inspired by Morse Expert / CW Skimmer (VE3NEA):
|
||||||
|
* 1. FFT spectrogram creates a frequency×time matrix
|
||||||
|
* 2. Multi-channel peak detector finds all active signals
|
||||||
|
* 3. Per-channel energy envelope extraction
|
||||||
|
* 4. Bayesian probability for symbol timing (Gaussian likelihood)
|
||||||
|
* 5. Best channel selected for output
|
||||||
|
*
|
||||||
|
* Timing analysis is performed per spectrogram column (hop).
|
||||||
|
* Each column represents hopSize/sampleRate seconds of audio.
|
||||||
|
*/
|
||||||
|
class CwDecoder(
|
||||||
|
val sampleRate: Int = 8000,
|
||||||
|
cwToneFreq: Float = -1f // ignored in v3 (auto-detect via spectrogram)
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val FFT_SIZE = 256
|
||||||
|
private const val HOP_SIZE = 64
|
||||||
|
}
|
||||||
|
|
||||||
|
private val spectrogram = CwSpectrogram(
|
||||||
|
fftSize = FFT_SIZE,
|
||||||
|
hopSize = HOP_SIZE,
|
||||||
|
sampleRate = sampleRate,
|
||||||
|
minBin = 6,
|
||||||
|
maxBin = 38,
|
||||||
|
historyCols = 40
|
||||||
|
)
|
||||||
|
private val channelTracker = CwChannelTracker(spectrogram, maxChannels = 3)
|
||||||
|
private val bayesianDecoder = CwBayesianDecoder()
|
||||||
|
|
||||||
|
// Timing state per channel
|
||||||
|
private data class ChannelTiming(
|
||||||
|
var isSignal: Boolean = false,
|
||||||
|
var toneTicks: Int = 0,
|
||||||
|
var gapTicks: Int = 0
|
||||||
|
)
|
||||||
|
private val timingStates = Array(3) { ChannelTiming() }
|
||||||
|
|
||||||
|
// Time per spectrogram column in milliseconds
|
||||||
|
private val tickMs = 1000f * HOP_SIZE / sampleRate
|
||||||
|
|
||||||
|
// Output flows
|
||||||
|
private val _decodedTextFlow = MutableStateFlow("")
|
||||||
|
val decodedTextFlow: StateFlow<String> = _decodedTextFlow
|
||||||
|
|
||||||
|
private val _signalStrength = MutableStateFlow(0f)
|
||||||
|
val signalStrength: StateFlow<Float> = _signalStrength
|
||||||
|
|
||||||
|
private val _estimatedPitch = MutableStateFlow<Float?>(null)
|
||||||
|
val estimatedPitch: StateFlow<Float?> = _estimatedPitch
|
||||||
|
|
||||||
|
private val _estimatedSpeed = MutableStateFlow<Float?>(null)
|
||||||
|
val estimatedSpeed: StateFlow<Float?> = _estimatedSpeed
|
||||||
|
|
||||||
|
private var frameCount = 0
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (cwToneFreq > 0f) {
|
||||||
|
_estimatedPitch.value = cwToneFreq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun processBuffer(buffer: FloatArray) {
|
||||||
|
// 1. Feed samples to spectrogram
|
||||||
|
spectrogram.addSamples(buffer)
|
||||||
|
|
||||||
|
// 2. Get number of new columns generated
|
||||||
|
val newCols = spectrogram.getNewColumns()
|
||||||
|
if (newCols == 0) return
|
||||||
|
|
||||||
|
// 3. Update channel tracker (uses latest column for peak detection)
|
||||||
|
val activeChannels = channelTracker.update()
|
||||||
|
|
||||||
|
// 4. Process each new column for timing analysis
|
||||||
|
// Columns are indexed 0..historyCols-1, where historyCols-1 is the newest
|
||||||
|
val baseIdx = (spectrogram.historyCols - newCols).coerceAtLeast(0)
|
||||||
|
for (colOffset in 0 until newCols) {
|
||||||
|
val col = spectrogram.getColumn(baseIdx + colOffset)
|
||||||
|
|
||||||
|
for ((idx, channel) in activeChannels.withIndex()) {
|
||||||
|
if (idx >= timingStates.size) break
|
||||||
|
val state = timingStates[idx]
|
||||||
|
val energy = if (channel.bin in col.indices) col[channel.bin] else 0f
|
||||||
|
|
||||||
|
// Adaptive threshold
|
||||||
|
val threshold = 0.3f + (energy - 0.3f) * 0.3f
|
||||||
|
|
||||||
|
if (energy > threshold) {
|
||||||
|
if (!state.isSignal) {
|
||||||
|
if (state.gapTicks > 0) {
|
||||||
|
val gapMs = state.gapTicks * tickMs
|
||||||
|
bayesianDecoder.processGap(gapMs)
|
||||||
|
}
|
||||||
|
state.gapTicks = 0
|
||||||
|
state.isSignal = true
|
||||||
|
}
|
||||||
|
state.toneTicks++
|
||||||
|
} else {
|
||||||
|
if (state.isSignal) {
|
||||||
|
if (state.toneTicks > 0) {
|
||||||
|
val toneMs = state.toneTicks * tickMs
|
||||||
|
bayesianDecoder.processTone(toneMs)
|
||||||
|
}
|
||||||
|
state.toneTicks = 0
|
||||||
|
state.isSignal = false
|
||||||
|
}
|
||||||
|
state.gapTicks++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Update outputs
|
||||||
|
frameCount++
|
||||||
|
if (frameCount % 5 == 0) {
|
||||||
|
val bestChannel = channelTracker.getBestChannel()
|
||||||
|
if (bestChannel != null) {
|
||||||
|
_estimatedPitch.value = bestChannel.frequency
|
||||||
|
_signalStrength.value = bestChannel.confidence
|
||||||
|
_estimatedSpeed.value = bayesianDecoder.getSpeed()
|
||||||
|
}
|
||||||
|
_decodedTextFlow.value = bayesianDecoder.decodedText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetDecoder() {
|
||||||
|
spectrogram.reset()
|
||||||
|
channelTracker.reset()
|
||||||
|
bayesianDecoder.reset()
|
||||||
|
for (state in timingStates) {
|
||||||
|
state.isSignal = false
|
||||||
|
state.toneTicks = 0
|
||||||
|
state.gapTicks = 0
|
||||||
|
}
|
||||||
|
frameCount = 0
|
||||||
|
_decodedTextFlow.value = ""
|
||||||
|
_signalStrength.value = 0f
|
||||||
|
_estimatedPitch.value = null
|
||||||
|
_estimatedSpeed.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/*
|
||||||
|
* 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.cw
|
||||||
|
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DSP utilities for CW (Morse code) decoding.
|
||||||
|
* Pure Kotlin, no NDK required.
|
||||||
|
*/
|
||||||
|
internal object CwDsp {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Design a simple bandpass FIR filter coefficients using windowed sinc method.
|
||||||
|
* @param lowCutoff lower cutoff frequency (Hz) as fraction of sampleRate
|
||||||
|
* @param highCutoff upper cutoff frequency (Hz) as fraction of sampleRate
|
||||||
|
* @param taps filter length (must be odd)
|
||||||
|
*/
|
||||||
|
fun bandpassFir(lowCutoff: Double, highCutoff: Double, taps: Int): FloatArray {
|
||||||
|
val n = if (taps % 2 == 0) taps + 1 else taps
|
||||||
|
val half = n / 2
|
||||||
|
val coeffs = FloatArray(n)
|
||||||
|
for (i in 0 until n) {
|
||||||
|
val idx = i - half
|
||||||
|
if (idx == 0) {
|
||||||
|
coeffs[i] = (2.0 * (highCutoff - lowCutoff)).toFloat()
|
||||||
|
} else {
|
||||||
|
val x = PI * idx
|
||||||
|
coeffs[i] = ((sin(2 * highCutoff * x) - sin(2 * lowCutoff * x)) / x).toFloat()
|
||||||
|
}
|
||||||
|
// Hamming window
|
||||||
|
coeffs[i] = (coeffs[i] * (0.54 - 0.46 * cos(2 * PI * i / (n - 1)))).toFloat()
|
||||||
|
}
|
||||||
|
// Normalize
|
||||||
|
val sum = coeffs.sum()
|
||||||
|
if (sum != 0f) for (i in 0 until n) coeffs[i] /= sum
|
||||||
|
return coeffs
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply FIR filter to a buffer. */
|
||||||
|
fun applyFir(buffer: FloatArray, coeffs: FloatArray): FloatArray {
|
||||||
|
val out = FloatArray(buffer.size)
|
||||||
|
for (i in buffer.indices) {
|
||||||
|
var sum = 0f
|
||||||
|
for (j in coeffs.indices) {
|
||||||
|
val idx = i - j
|
||||||
|
if (idx >= 0) sum += buffer[idx] * coeffs[j]
|
||||||
|
}
|
||||||
|
out[i] = sum
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simple envelope detector: abs + low-pass smoothing. */
|
||||||
|
fun envelope(signal: FloatArray, alpha: Float = 0.1f): FloatArray {
|
||||||
|
val env = FloatArray(signal.size)
|
||||||
|
var s = 0f
|
||||||
|
for (i in signal.indices) {
|
||||||
|
s = alpha * kotlin.math.abs(signal[i]) + (1 - alpha) * s
|
||||||
|
env[i] = s
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Estimate noise floor from envelope for adaptive thresholding. */
|
||||||
|
fun noiseFloor(env: FloatArray, fraction: Float = 0.3f): Float {
|
||||||
|
val sorted = env.sortedArray()
|
||||||
|
val median = sorted[sorted.size / 2]
|
||||||
|
return median + (sorted[sorted.size * 9 / 10] - median) * fraction
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simple Goertzel to detect a specific tone frequency. */
|
||||||
|
fun goertzel(buffer: FloatArray, targetFreq: Float, sampleRate: Int): Float {
|
||||||
|
val omega = 2.0 * PI * targetFreq / sampleRate
|
||||||
|
val coeff = 2.0 * cos(omega)
|
||||||
|
var s0 = 0.0; var s1 = 0.0; var s2 = 0.0
|
||||||
|
for (sample in buffer) {
|
||||||
|
s0 = sample.toDouble() + coeff * s1 - s2
|
||||||
|
s2 = s1; s1 = s0
|
||||||
|
}
|
||||||
|
val power = s2 * s2 + s1 * s1 - coeff * s1 * s2
|
||||||
|
return sqrt(kotlin.math.abs(power)).toFloat()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
* 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.cw
|
||||||
|
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Radix-2 FFT for real-valued input.
|
||||||
|
* Produces magnitude spectrum for the first N/2+1 bins.
|
||||||
|
* Used by CwSpectrogram for time-frequency analysis.
|
||||||
|
*/
|
||||||
|
internal class CwFFT(private val n: Int) {
|
||||||
|
init {
|
||||||
|
require(n > 0 && n and (n - 1) == 0) { "FFT size must be power of 2, got $n" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val cosTable = FloatArray(n / 2)
|
||||||
|
private val sinTable = FloatArray(n / 2)
|
||||||
|
|
||||||
|
init {
|
||||||
|
for (i in 0 until n / 2) {
|
||||||
|
val angle = -2.0 * kotlin.math.PI * i / n
|
||||||
|
cosTable[i] = cos(angle).toFloat()
|
||||||
|
sinTable[i] = kotlin.math.sin(angle).toFloat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute magnitude spectrum for real input. Returns array of size n/2+1. */
|
||||||
|
fun magnitudeSpectrum(input: FloatArray): FloatArray {
|
||||||
|
require(input.size == n) { "Input size must be $n, got ${input.size}" }
|
||||||
|
|
||||||
|
val real = input.copyOf()
|
||||||
|
val imag = FloatArray(n)
|
||||||
|
|
||||||
|
// Bit-reversal permutation
|
||||||
|
var j = 0
|
||||||
|
for (i in 1 until n) {
|
||||||
|
var bit = n shr 1
|
||||||
|
while (j and bit != 0) { j = j xor bit; bit = bit shr 1 }
|
||||||
|
j = j xor bit
|
||||||
|
if (i < j) {
|
||||||
|
var tmp = real[i]; real[i] = real[j]; real[j] = tmp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Radix-2 Cooley-Tukey FFT
|
||||||
|
var len = 2
|
||||||
|
while (len <= n) {
|
||||||
|
val half = len / 2
|
||||||
|
val step = n / len
|
||||||
|
for (i in 0 until n step len) {
|
||||||
|
for (k in 0 until half) {
|
||||||
|
val tReal = real[i + k + half] * cosTable[k * step] - imag[i + k + half] * sinTable[k * step]
|
||||||
|
val tImag = real[i + k + half] * sinTable[k * step] + imag[i + k + half] * cosTable[k * step]
|
||||||
|
real[i + k + half] = real[i + k] - tReal
|
||||||
|
imag[i + k + half] = imag[i + k] - tImag
|
||||||
|
real[i + k] += tReal
|
||||||
|
imag[i + k] += tImag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
len = len shl 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Magnitude spectrum (first N/2+1 bins)
|
||||||
|
val mag = FloatArray(n / 2 + 1)
|
||||||
|
for (i in 0..n / 2) {
|
||||||
|
mag[i] = sqrt(real[i] * real[i] + imag[i] * imag[i]) / n
|
||||||
|
}
|
||||||
|
return mag
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* 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.cw
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-order IIR filters.
|
||||||
|
* Ported from ggmorse/src/filter.h
|
||||||
|
*/
|
||||||
|
internal class CwFilter {
|
||||||
|
private var z1 = 0f
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PI_F = 3.141592653589793f
|
||||||
|
}
|
||||||
|
|
||||||
|
fun highPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
|
||||||
|
val rc = 1.0f / (2f * PI_F * cutoffHz)
|
||||||
|
val dt = 1.0f / sampleRate
|
||||||
|
val alpha = dt / (rc + dt)
|
||||||
|
z1 = alpha * (z1 + sample - z1)
|
||||||
|
return sample - z1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun lowPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
|
||||||
|
val rc = 1.0f / (2f * PI_F * cutoffHz)
|
||||||
|
val dt = 1.0f / sampleRate
|
||||||
|
val alpha = dt / (rc + dt)
|
||||||
|
z1 += alpha * (sample - z1)
|
||||||
|
return z1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() { z1 = 0f }
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/*
|
||||||
|
* 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.cw
|
||||||
|
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Running Goertzel filter for CW tone detection.
|
||||||
|
* Tracks a specific frequency over time with a sliding window.
|
||||||
|
* Ported from ggmorse/src/goertzel.h
|
||||||
|
*/
|
||||||
|
internal class CwGoertzel {
|
||||||
|
private var s1 = 0.0
|
||||||
|
private var s2 = 0.0
|
||||||
|
private var coeff = 0.0
|
||||||
|
|
||||||
|
fun init(sampleRate: Float, targetFreq: Float) {
|
||||||
|
val omega = 2.0 * kotlin.math.PI * targetFreq / sampleRate
|
||||||
|
coeff = 2.0 * cos(omega)
|
||||||
|
s1 = 0.0
|
||||||
|
s2 = 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun process(sample: Float) {
|
||||||
|
val s0 = sample.toDouble() + coeff * s1 - s2
|
||||||
|
s2 = s1
|
||||||
|
s1 = s0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPower(): Float {
|
||||||
|
return sqrt(s2 * s2 + s1 * s1 - coeff * s1 * s2).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
s1 = 0.0
|
||||||
|
s2 = 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* 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.cw
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple linear resampler.
|
||||||
|
* Downsamples from input sample rate to output sample rate.
|
||||||
|
* Ported from ggmorse/src/resampler.h
|
||||||
|
*/
|
||||||
|
internal class CwResampler(private val inputRate: Float, private val outputRate: Float) {
|
||||||
|
private val ratio = inputRate / outputRate
|
||||||
|
private var lastSample = 0f
|
||||||
|
|
||||||
|
fun process(input: FloatArray): FloatArray {
|
||||||
|
if (ratio <= 0f || input.isEmpty()) return input
|
||||||
|
val outputLen = (input.size / ratio).toInt() + 1
|
||||||
|
val output = FloatArray(outputLen)
|
||||||
|
var idx = 0f
|
||||||
|
for (i in output.indices) {
|
||||||
|
val intIdx = idx.toInt()
|
||||||
|
val frac = idx - intIdx
|
||||||
|
if (intIdx + 1 < input.size) {
|
||||||
|
output[i] = input[intIdx] * (1 - frac) + input[intIdx + 1] * frac
|
||||||
|
} else if (intIdx < input.size) {
|
||||||
|
output[i] = input[intIdx] * (1 - frac) + lastSample * frac
|
||||||
|
} else {
|
||||||
|
output[i] = lastSample
|
||||||
|
}
|
||||||
|
idx += ratio
|
||||||
|
}
|
||||||
|
lastSample = input.lastOrNull() ?: lastSample
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
lastSample = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* 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.cw
|
||||||
|
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight pitch detector using DFT at specific frequency bins.
|
||||||
|
* Only scans [200, 1200] Hz in configurable steps — much faster than full FFT.
|
||||||
|
* Ported from ggmorse/src/stfft.h (simplified for CW use case).
|
||||||
|
*/
|
||||||
|
internal class CwPitchDetector(
|
||||||
|
private val sampleRate: Float,
|
||||||
|
private val minFreq: Float = 200f,
|
||||||
|
private val maxFreq: Float = 1200f,
|
||||||
|
private val stepHz: Float = 10f
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Find the dominant pitch frequency in the buffer.
|
||||||
|
* Returns null if no significant pitch found.
|
||||||
|
*/
|
||||||
|
fun findPitch(buffer: FloatArray): Float? {
|
||||||
|
if (buffer.isEmpty()) return null
|
||||||
|
var bestFreq = 0f
|
||||||
|
var bestPower = 0f
|
||||||
|
var freq = minFreq
|
||||||
|
while (freq <= maxFreq) {
|
||||||
|
var real = 0.0
|
||||||
|
var imag = 0.0
|
||||||
|
val omega = 2.0 * kotlin.math.PI * freq / sampleRate
|
||||||
|
for (i in buffer.indices) {
|
||||||
|
real += buffer[i] * cos(omega * i)
|
||||||
|
imag += buffer[i] * -sin(omega * i)
|
||||||
|
}
|
||||||
|
val power = (real * real + imag * imag).toFloat()
|
||||||
|
if (power > bestPower) {
|
||||||
|
bestPower = power
|
||||||
|
bestFreq = freq
|
||||||
|
}
|
||||||
|
freq += stepHz
|
||||||
|
}
|
||||||
|
return if (bestPower > 0.001f) bestFreq else null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/*
|
||||||
|
* 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.cw
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sliding-window spectrogram for CW decoding.
|
||||||
|
* Maintains a time-frequency matrix updated with each audio frame.
|
||||||
|
*
|
||||||
|
* FFT size: 256, hop size: 64, sample rate: 4000 (or native)
|
||||||
|
* Frequency bins: 6..38 (187-1187 Hz, covers typical CW range)
|
||||||
|
* History: 40 columns (320 ms window)
|
||||||
|
* Time resolution: 64/4000 = 16 ms, Frequency resolution: 4000/256 = 15.625 Hz
|
||||||
|
*/
|
||||||
|
internal class CwSpectrogram(
|
||||||
|
private val fftSize: Int = 256,
|
||||||
|
private val hopSize: Int = 64,
|
||||||
|
private val sampleRate: Int = 4000,
|
||||||
|
private val minBin: Int = 6,
|
||||||
|
private val maxBin: Int = 38,
|
||||||
|
val historyCols: Int = 40
|
||||||
|
) {
|
||||||
|
private val fft = CwFFT(fftSize)
|
||||||
|
val numBins: Int get() = maxBin - minBin + 1
|
||||||
|
|
||||||
|
// Hanning window
|
||||||
|
private val hanning = FloatArray(fftSize) {
|
||||||
|
(0.5 - 0.5 * kotlin.math.cos(2.0 * kotlin.math.PI * it / (fftSize - 1))).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spectrogram data: [timeCol][freqBin]
|
||||||
|
private val spectrogram = Array(historyCols) { FloatArray(numBins) }
|
||||||
|
private var currentCol = 0
|
||||||
|
private var samplesBuffered = 0
|
||||||
|
private val buffer = FloatArray(fftSize)
|
||||||
|
|
||||||
|
// Per-bin running energy for normalization
|
||||||
|
private val binEnergy = FloatArray(numBins) { 1f }
|
||||||
|
private val alpha = 0.95f
|
||||||
|
|
||||||
|
// Counter for new columns generated since last check
|
||||||
|
private var newColumnCount = 0
|
||||||
|
|
||||||
|
/** Add audio samples, compute FFTs for each complete hop. */
|
||||||
|
fun addSamples(samples: FloatArray) {
|
||||||
|
var offset = 0
|
||||||
|
while (offset < samples.size) {
|
||||||
|
val needed = fftSize - samplesBuffered
|
||||||
|
val copyLen = minOf(needed, samples.size - offset)
|
||||||
|
System.arraycopy(samples, offset, buffer, samplesBuffered, copyLen)
|
||||||
|
samplesBuffered += copyLen
|
||||||
|
offset += copyLen
|
||||||
|
|
||||||
|
if (samplesBuffered >= fftSize) {
|
||||||
|
processFrame()
|
||||||
|
newColumnCount++
|
||||||
|
// Shift buffer: keep last (fftSize - hopSize) samples
|
||||||
|
System.arraycopy(buffer, hopSize, buffer, 0, fftSize - hopSize)
|
||||||
|
samplesBuffered = fftSize - hopSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get number of new columns generated since the last call to this method. */
|
||||||
|
fun getNewColumns(): Int {
|
||||||
|
val count = newColumnCount
|
||||||
|
newColumnCount = 0
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processFrame() {
|
||||||
|
// Apply Hanning window
|
||||||
|
val windowed = FloatArray(fftSize) { buffer[it] * hanning[it] }
|
||||||
|
|
||||||
|
// Compute FFT magnitude spectrum
|
||||||
|
val mag = fft.magnitudeSpectrum(windowed)
|
||||||
|
|
||||||
|
// Update spectrogram column
|
||||||
|
val col = spectrogram[currentCol]
|
||||||
|
for (b in 0 until numBins) {
|
||||||
|
val binIdx = minBin + b
|
||||||
|
val rawMag = mag[binIdx]
|
||||||
|
// Running energy normalization
|
||||||
|
binEnergy[b] = alpha * binEnergy[b] + (1 - alpha) * rawMag
|
||||||
|
col[b] = if (binEnergy[b] > 1e-6f) rawMag / binEnergy[b] else 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
currentCol = (currentCol + 1) % historyCols
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the current spectrogram as a 2D array in chronological order. */
|
||||||
|
fun getSpectrogram(): Array<FloatArray> {
|
||||||
|
val result = Array(historyCols) { i ->
|
||||||
|
val srcIdx = (currentCol + i) % historyCols
|
||||||
|
spectrogram[srcIdx].copyOf()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the most recent column (current energy across all frequencies). */
|
||||||
|
fun getCurrentColumn(): FloatArray {
|
||||||
|
val prevCol = (currentCol - 1 + historyCols) % historyCols
|
||||||
|
return spectrogram[prevCol].copyOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a column by index from the history (0 = oldest, historyCols-1 = newest). */
|
||||||
|
fun getColumn(index: Int): FloatArray {
|
||||||
|
val clamped = index.coerceIn(0, historyCols - 1)
|
||||||
|
val srcIdx = (currentCol - historyCols + clamped + historyCols) % historyCols
|
||||||
|
return spectrogram[srcIdx].copyOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the frequency bin with peak energy. Returns -1 if no significant signal. */
|
||||||
|
fun findPeakBin(): Int {
|
||||||
|
val col = getCurrentColumn()
|
||||||
|
var maxBin = -1
|
||||||
|
var maxVal = 0f
|
||||||
|
for (i in col.indices) {
|
||||||
|
if (col[i] > maxVal) {
|
||||||
|
maxVal = col[i]
|
||||||
|
maxBin = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (maxVal > 0.3f) maxBin else -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get energy at a specific bin over the last N columns in chronological order. */
|
||||||
|
fun getBinEnergy(bin: Int, numCols: Int): FloatArray {
|
||||||
|
val clamped = minOf(numCols, historyCols)
|
||||||
|
val result = FloatArray(clamped)
|
||||||
|
for (i in 0 until clamped) {
|
||||||
|
val colIdx = (currentCol - clamped + i + historyCols) % historyCols
|
||||||
|
result[i] = spectrogram[colIdx][bin]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the bin index for a frequency in Hz. */
|
||||||
|
fun freqToBin(freqHz: Float): Int {
|
||||||
|
val bin = (freqHz * fftSize / sampleRate).toInt()
|
||||||
|
return (bin - minBin).coerceIn(0, numBins - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the center frequency for a bin. */
|
||||||
|
fun binToFreq(bin: Int): Float {
|
||||||
|
return (minBin + bin).toFloat() * sampleRate / fftSize
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
for (col in spectrogram) col.fill(0f)
|
||||||
|
currentCol = 0
|
||||||
|
samplesBuffered = 0
|
||||||
|
buffer.fill(0f)
|
||||||
|
binEnergy.fill(1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.model
|
||||||
|
|
||||||
|
/** One public AMSAT satellite status report submission. */
|
||||||
|
data class AmSatReportSubmission(
|
||||||
|
val name: String,
|
||||||
|
val report: String,
|
||||||
|
val callsign: String,
|
||||||
|
val gridSquare: String,
|
||||||
|
val reportedAtUtcMillis: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Result of submitting an AMSAT satellite status report. */
|
||||||
|
data class AmSatReportSubmitResult(
|
||||||
|
val success: Boolean,
|
||||||
|
val message: String = "",
|
||||||
|
val reportId: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.model
|
||||||
|
|
||||||
|
object Constants {
|
||||||
|
const val FREQ_OFFSET_MIN_HZ = -50_000L
|
||||||
|
const val FREQ_OFFSET_MAX_HZ = 50_000L
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.model
|
||||||
|
|
||||||
|
/** Latest release info fetched from the fork's GitHub releases API. */
|
||||||
|
data class LatestRelease(
|
||||||
|
val versionTag: String, // e.g. "v4.4.6-ba7opf.6"
|
||||||
|
val title: String, // release name, may be empty
|
||||||
|
val body: String, // release notes / update description
|
||||||
|
val apkUrl: String? // first .apk asset download URL, null if absent
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.model
|
||||||
|
|
||||||
|
/** One satellite status report (AMSAT site tooltip data) */
|
||||||
|
data class SatReport(
|
||||||
|
val id: String, // 报告 ID(a885153)
|
||||||
|
val statusText: String, // Heard / Telemetry Only / Not Heard ...
|
||||||
|
val call: String, // 呼号
|
||||||
|
val grid: String, // 网格坐标(可为空)
|
||||||
|
val dateUtc: String, // 2026-08-04
|
||||||
|
val timeUtc: String // 2:46-:59 UTC
|
||||||
|
)
|
||||||
|
|
||||||
|
/** State of one 2-hour slot */
|
||||||
|
data class SatSlot(
|
||||||
|
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
|
||||||
|
val count: Int, // 报告数量(0 = 无)
|
||||||
|
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One satellite day (12 two-hour slots) */
|
||||||
|
data class SatDay(
|
||||||
|
val dateLabel: String, // "Aug 4"
|
||||||
|
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One satellite, 3 days of state */
|
||||||
|
data class SatStatus(
|
||||||
|
val name: String, // "AO-123_[FM]"
|
||||||
|
val days: List<SatDay> // 3 days (newest first)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Overall page parse result */
|
||||||
|
data class SatStatusPage(
|
||||||
|
val fetchedAtUtcMs: Long,
|
||||||
|
val statuses: List<SatStatus>,
|
||||||
|
val reports: Map<String, SatReport> // id → 报告
|
||||||
|
)
|
||||||
@@ -27,6 +27,9 @@ data class PassesSettings(
|
|||||||
val showDeepSpace: Boolean = true,
|
val showDeepSpace: Boolean = true,
|
||||||
val hoursAhead: Int,
|
val hoursAhead: Int,
|
||||||
val minElevation: Double,
|
val minElevation: Double,
|
||||||
|
val aosStartMinute: Int = 0,
|
||||||
|
val aosEndMinute: Int = 23 * 60 + 59,
|
||||||
|
val invertAosTimeWindow: Boolean = false,
|
||||||
val selectedModes: List<String>
|
val selectedModes: List<String>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,6 +42,7 @@ data class RCSettings(
|
|||||||
val frequencyAddress: String,
|
val frequencyAddress: String,
|
||||||
val frequencyPort: String,
|
val frequencyPort: String,
|
||||||
val frequencyFormat: String,
|
val frequencyFormat: String,
|
||||||
|
val frequencyOffsetHz: Long = 0L,
|
||||||
val bluetoothRotatorState: Boolean,
|
val bluetoothRotatorState: Boolean,
|
||||||
val bluetoothRotatorFormat: String,
|
val bluetoothRotatorFormat: String,
|
||||||
val bluetoothRotatorName: String,
|
val bluetoothRotatorName: String,
|
||||||
@@ -58,29 +62,19 @@ data class OtherSettings(
|
|||||||
val shouldSeeWarning: Boolean,
|
val shouldSeeWarning: Boolean,
|
||||||
val shouldSeeWhatsNew: Boolean,
|
val shouldSeeWhatsNew: Boolean,
|
||||||
val sstvMode: String = "Auto",
|
val sstvMode: String = "Auto",
|
||||||
val mapSource: String = MapSource.TIANDITU_VECTOR,
|
val lowElevation: Double = 15.0,
|
||||||
val tiandituKey: String = ""
|
val highElevation: Double = 45.0
|
||||||
)
|
)
|
||||||
|
|
||||||
object MapSource {
|
|
||||||
const val OSM = "osm"
|
|
||||||
const val TIANDITU = "tianditu"
|
|
||||||
const val TIANDITU_VECTOR = "tianditu_vector"
|
|
||||||
const val TIANDITU_IMAGE = "tianditu_image"
|
|
||||||
|
|
||||||
fun normalize(source: String): String = when (source) {
|
|
||||||
TIANDITU -> TIANDITU_VECTOR
|
|
||||||
OSM, TIANDITU_VECTOR, TIANDITU_IMAGE -> source
|
|
||||||
else -> TIANDITU_VECTOR
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class DataSourcesSettings(
|
data class DataSourcesSettings(
|
||||||
val useCustomTLE: Boolean,
|
val satelliteUrls: List<String>,
|
||||||
val useCustomTransceivers: Boolean,
|
val transceiversUrls: List<String>,
|
||||||
val tleUrl: String,
|
val satelliteEnabled: List<Boolean> = emptyList(),
|
||||||
val transceiversUrl: String
|
val transceiversEnabled: List<Boolean> = emptyList()
|
||||||
)
|
) {
|
||||||
|
fun isSatelliteEnabled(index: Int): Boolean = satelliteEnabled.getOrElse(index) { true }
|
||||||
|
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
|
||||||
|
}
|
||||||
|
|
||||||
data class RadioControlSettings(
|
data class RadioControlSettings(
|
||||||
val enabled: Boolean,
|
val enabled: Boolean,
|
||||||
@@ -89,12 +83,20 @@ data class RadioControlSettings(
|
|||||||
val rxRadioAddress: String,
|
val rxRadioAddress: String,
|
||||||
val txRadioName: String,
|
val txRadioName: String,
|
||||||
val rxRadioName: String,
|
val rxRadioName: String,
|
||||||
val baudRate: Int
|
val baudRate: Int,
|
||||||
|
/** IC-705 only: use single-radio split-VFO mode instead of two radios. */
|
||||||
|
val splitMode: Boolean = false
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val SUPPORTED_RADIOS = listOf(
|
const val MODEL_YAESU_FT817 = "Yaesu FT-817/818"
|
||||||
"Yaesu FT-817/818",
|
const val MODEL_YAESU_FT857 = "Yaesu FT-857/897"
|
||||||
"Yaesu FT-857/897"
|
const val MODEL_ICOM_IC705 = "Icom IC-705"
|
||||||
)
|
|
||||||
|
val SUPPORTED_RADIOS = listOf(MODEL_YAESU_FT817, MODEL_YAESU_FT857, MODEL_ICOM_IC705)
|
||||||
|
|
||||||
|
/** Baud rates available for Yaesu radios. */
|
||||||
|
val BAUD_RATES_YAESU = listOf(4800, 9600, 38400)
|
||||||
|
/** Baud rates available for Icom IC-705 (higher speeds supported via CI-V USB/BT). */
|
||||||
|
val BAUD_RATES_ICOM = listOf(4800, 9600, 19200, 38400, 57600, 115200)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+7
-3
@@ -424,11 +424,15 @@ object CelestialComputer {
|
|||||||
}
|
}
|
||||||
if (sunrise == 0.0) sunrise = daynum
|
if (sunrise == 0.0) sunrise = daynum
|
||||||
|
|
||||||
// Phase 4: fast-forward through the day until sun drops back below threshold
|
// Phase 4: fast-forward through the day until sun drops back below threshold.
|
||||||
daynum = sunrise
|
// 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))
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
guard = 0
|
guard = 0
|
||||||
while (sunPos.elevation > -threshold && guard++ < 500) {
|
while (sunPos.elevation >= -threshold && guard++ < 500) {
|
||||||
daynum += 0.008
|
daynum += 0.008
|
||||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
}
|
}
|
||||||
|
|||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
|
|
||||||
|
/** AMSAT satellite status data source */
|
||||||
|
interface IAmSatRepository {
|
||||||
|
/** Cached AMSAT status page for the current foreground session, if any. */
|
||||||
|
fun getCachedStatus(): SatStatusPage?
|
||||||
|
|
||||||
|
/** Fetch and parse the AMSAT status page; null on failure. */
|
||||||
|
suspend fun fetchStatus(forceRefresh: Boolean = false): SatStatusPage?
|
||||||
|
|
||||||
|
/** Warm the foreground-session cache without forcing a network reload. */
|
||||||
|
suspend fun prefetchStatus()
|
||||||
|
|
||||||
|
/** Clear the foreground-session status cache. */
|
||||||
|
fun clearStatusCache()
|
||||||
|
|
||||||
|
/** Submit a public AMSAT satellite status report. */
|
||||||
|
suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult
|
||||||
|
}
|
||||||
+2
-2
@@ -18,8 +18,8 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.repository
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
interface IDatabaseRepo {
|
interface IDatabaseRepo {
|
||||||
suspend fun updateTLEFromFile(uri: String)
|
suspend fun updateTLEFromFile(uri: String): Int
|
||||||
suspend fun updateTransceiversFromFile(uri: String)
|
suspend fun updateTransceiversFromFile(uri: String): Int
|
||||||
suspend fun updateFromRemote()
|
suspend fun updateFromRemote()
|
||||||
suspend fun clearAllData()
|
suspend fun clearAllData()
|
||||||
}
|
}
|
||||||
+25
-1
@@ -16,12 +16,13 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.domain.repository
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
interface IMainContainer {
|
interface IMainContainer {
|
||||||
val appScope: CoroutineScope
|
val appScope: CoroutineScope
|
||||||
@@ -29,7 +30,11 @@ interface IMainContainer {
|
|||||||
val selectionRepo: ISelectionRepo
|
val selectionRepo: ISelectionRepo
|
||||||
val satelliteRepo: ISatelliteRepo
|
val satelliteRepo: ISatelliteRepo
|
||||||
val databaseRepo: IDatabaseRepo
|
val databaseRepo: IDatabaseRepo
|
||||||
|
val amSatRepo: IAmSatRepository
|
||||||
|
val updateRepo: IUpdateRepository
|
||||||
val radioTrackingService: IRadioTrackingService
|
val radioTrackingService: IRadioTrackingService
|
||||||
|
val mutualPassData: StateFlow<MutualPassData>
|
||||||
|
fun setMutualPassData(data: MutualPassData)
|
||||||
fun provideAddToCalendar(): IAddToCalendar
|
fun provideAddToCalendar(): IAddToCalendar
|
||||||
fun provideShowToast(): IShowToast
|
fun provideShowToast(): IShowToast
|
||||||
fun provideBluetoothReporter(): IReporter
|
fun provideBluetoothReporter(): IReporter
|
||||||
@@ -41,6 +46,25 @@ interface IMainContainer {
|
|||||||
fun provideSaveImage(): ISaveImage
|
fun provideSaveImage(): ISaveImage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class MutualPassData(
|
||||||
|
val samples: List<Pair<Long, Pair<Double, Double>>> = emptyList(),
|
||||||
|
val trackSamples: List<TrackSampleData> = emptyList(),
|
||||||
|
val startTime: Long = 0L,
|
||||||
|
val endTime: Long = 0L,
|
||||||
|
val maxElev: Double = 10.0,
|
||||||
|
val labelA: String = "你",
|
||||||
|
val labelB: String = "友台"
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Minimal track sample for cross-module sharing (angles in degrees). */
|
||||||
|
data class TrackSampleData(
|
||||||
|
val time: Long = 0L,
|
||||||
|
val azimuthA: Double,
|
||||||
|
val elevationA: Double,
|
||||||
|
val azimuthB: Double,
|
||||||
|
val elevationB: Double
|
||||||
|
)
|
||||||
|
|
||||||
interface IContainerProvider {
|
interface IContainerProvider {
|
||||||
fun getMainContainer(): IMainContainer
|
fun getMainContainer(): IMainContainer
|
||||||
}
|
}
|
||||||
+47
@@ -38,4 +38,51 @@ interface IRadioController {
|
|||||||
suspend fun pttOn(): Boolean
|
suspend fun pttOn(): Boolean
|
||||||
|
|
||||||
suspend fun pttOff(): Boolean
|
suspend fun pttOff(): Boolean
|
||||||
|
|
||||||
|
// ── Extended operations (IC-705 / CI-V) ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select the band matching [frequencyHz] via the band stacking register.
|
||||||
|
* Must be called before [setFrequency] and [setMode] when first tracking.
|
||||||
|
* Default: no-op (Yaesu radios auto-switch band via frequency).
|
||||||
|
*/
|
||||||
|
suspend fun setBand(frequencyHz: Long): Boolean = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select the active VFO.
|
||||||
|
* @param vfoA true → VFO-A (main/RX), false → VFO-B (sub/TX in split).
|
||||||
|
*/
|
||||||
|
suspend fun setVfo(vfoA: Boolean): Boolean = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable SPLIT mode (TX on sub-VFO, RX on main VFO).
|
||||||
|
* Default: not supported.
|
||||||
|
*/
|
||||||
|
suspend fun setSplitMode(enabled: Boolean): Boolean = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the frequency of the currently active VFO (IC-705: CMD 0x25 sub 0x00).
|
||||||
|
* Default: delegates to [setFrequency].
|
||||||
|
*/
|
||||||
|
suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = setFrequency(frequencyHz)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the frequency of the inactive/TX VFO (IC-705: CMD 0x25 sub 0x01).
|
||||||
|
* Sent every tracking cycle alongside [setWorkingFrequency] in split mode.
|
||||||
|
* Default: delegates to [setWorkingFrequency].
|
||||||
|
*/
|
||||||
|
suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = setWorkingFrequency(frequencyHz)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the frequency of the currently active VFO (IC-705: CMD 0x25 sub 0x00).
|
||||||
|
* Default: delegates to [readFrequencyAndMode].
|
||||||
|
*/
|
||||||
|
suspend fun readWorkingFrequency(): Long? = readFrequencyAndMode()?.first
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the frequency of the inactive/TX VFO (IC-705: CMD 0x25 sub 0x01).
|
||||||
|
* Used for tuning detection in split mode.
|
||||||
|
* Default: delegates to [readWorkingFrequency].
|
||||||
|
*/
|
||||||
|
suspend fun readTxVfoFrequency(): Long? = readWorkingFrequency()
|
||||||
}
|
}
|
||||||
+9
-1
@@ -44,7 +44,15 @@ interface ISatelliteRepo {
|
|||||||
suspend fun initRepository()
|
suspend fun initRepository()
|
||||||
|
|
||||||
/** Recalculate passes with the given filter parameters. */
|
/** Recalculate passes with the given filter parameters. */
|
||||||
suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>)
|
suspend fun calculatePasses(
|
||||||
|
time: Long,
|
||||||
|
hoursAhead: Int,
|
||||||
|
minElevation: Double,
|
||||||
|
aosStartMinute: Int,
|
||||||
|
aosEndMinute: Int,
|
||||||
|
invertAosTimeWindow: Boolean,
|
||||||
|
modes: List<String>
|
||||||
|
)
|
||||||
|
|
||||||
/** Get the current position of a single satellite. */
|
/** Get the current position of a single satellite. */
|
||||||
suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos
|
suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos
|
||||||
|
|||||||
+15
@@ -73,8 +73,23 @@ interface ISettingsRepo {
|
|||||||
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
//region # Data sources status
|
||||||
|
val dataSourcesStatus: StateFlow<Map<String, Int>>
|
||||||
|
fun updateDataSourcesStatus(status: Map<String, Int>)
|
||||||
|
//endregion
|
||||||
|
|
||||||
//region # Radio control settings
|
//region # Radio control settings
|
||||||
val radioControlSettings: StateFlow<RadioControlSettings>
|
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||||
fun updateRadioControlSettings(settings: RadioControlSettings)
|
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
//region # Per-satellite calculator offset settings
|
||||||
|
fun getSatelliteOffset(catnum: Int): String
|
||||||
|
fun setSatelliteOffset(catnum: Int, offset: String)
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # AMSAT status report settings
|
||||||
|
fun getAmSatCallsign(): String
|
||||||
|
fun setAmSatCallsign(callsign: String)
|
||||||
|
//endregion
|
||||||
}
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.LatestRelease
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
interface IUpdateRepository {
|
||||||
|
/** Fetch the latest release of the fork repo from GitHub; null on failure. */
|
||||||
|
suspend fun getLatestRelease(): LatestRelease?
|
||||||
|
|
||||||
|
/** Download the release APK from [url] into [dest]; true on success. */
|
||||||
|
suspend fun downloadApk(url: String, dest: File): Boolean
|
||||||
|
}
|
||||||
+12
-1
@@ -19,7 +19,18 @@ package com.rtbishop.look4sat.core.domain.source
|
|||||||
|
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
|
/** Result of a network download: the HTTP status code and the response body stream. */
|
||||||
|
data class NetworkResult(val code: Int, val stream: InputStream?) {
|
||||||
|
companion object {
|
||||||
|
/** Code used when a request fails before any HTTP response is received. */
|
||||||
|
const val CONNECTION_ERROR = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface IRemoteSource {
|
interface IRemoteSource {
|
||||||
suspend fun getFileStream(uri: String): InputStream?
|
suspend fun getFileStream(uri: String): InputStream?
|
||||||
suspend fun getNetworkStream(url: String): InputStream?
|
suspend fun getNetworkStream(url: String): NetworkResult
|
||||||
|
suspend fun getAmSatCatalog(): String?
|
||||||
|
suspend fun getAmSatReports(hours: Int, limit: Int): String?
|
||||||
|
suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>?
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,7 @@ package com.rtbishop.look4sat.core.domain.source
|
|||||||
object Sources {
|
object Sources {
|
||||||
val satelliteDataUrls = mapOf(
|
val satelliteDataUrls = mapOf(
|
||||||
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||||
|
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
|
||||||
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
||||||
"Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv",
|
"Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv",
|
||||||
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
|
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
|
||||||
@@ -35,7 +36,7 @@ object Sources {
|
|||||||
"OneWeb" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=oneweb&FORMAT=csv",
|
"OneWeb" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=oneweb&FORMAT=csv",
|
||||||
"Orbcomm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=orbcomm&FORMAT=csv",
|
"Orbcomm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=orbcomm&FORMAT=csv",
|
||||||
"Resource" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=resource&FORMAT=csv",
|
"Resource" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=resource&FORMAT=csv",
|
||||||
"SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv",
|
"CelesTrak SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv",
|
||||||
"Science" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=science&FORMAT=csv",
|
"Science" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=science&FORMAT=csv",
|
||||||
"Spire" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=spire&FORMAT=csv",
|
"Spire" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=spire&FORMAT=csv",
|
||||||
"Starlink" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=csv",
|
"Starlink" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=csv",
|
||||||
@@ -46,9 +47,11 @@ object Sources {
|
|||||||
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
||||||
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
|
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
|
||||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||||
|
"ARISS" to "https://live.ariss.org/iss.txt",
|
||||||
"Other" to "" // key for sats filter
|
"Other" to "" // key for sats filter
|
||||||
)
|
)
|
||||||
val transceiversDataUrls = mapOf(
|
val transceiversDataUrls = mapOf(
|
||||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
|
||||||
|
"R4UAB" to "https://r4uab.ru/transmitters.json"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -20,39 +20,94 @@ package com.rtbishop.look4sat.core.domain.sstv
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.math.PI
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.round
|
import kotlin.math.round
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
class SstvFrame(
|
class SstvFrame(
|
||||||
|
val scopePixels: IntArray?,
|
||||||
|
val scopeWidth: Int,
|
||||||
|
val scopeHeight: Int,
|
||||||
val imagePixels: IntArray?,
|
val imagePixels: IntArray?,
|
||||||
val imageWidth: Int,
|
val imageWidth: Int,
|
||||||
val imageHeight: Int,
|
val imageHeight: Int,
|
||||||
val modeName: String
|
val modeName: String,
|
||||||
|
val imageComplete: Boolean,
|
||||||
|
val inputRms: Float,
|
||||||
|
val appliedGain: Float,
|
||||||
|
val syncHitRate: Float,
|
||||||
|
val predictedLineBursts: Int,
|
||||||
|
val maxPredictedStreak: Int,
|
||||||
|
val timingErrorSamples: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoder quality metrics for diagnostics and logging. Useful for profiling decode
|
||||||
|
* performance on noisy recordings.
|
||||||
|
*/
|
||||||
|
data class SstvQualityMetrics(
|
||||||
|
val syncHitRate: Float,
|
||||||
|
val predictedLineBursts: Int,
|
||||||
|
val maxPredictedStreak: Int,
|
||||||
|
val timingErrorSamples: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class LineRecoveryStrategy {
|
||||||
|
Look4SatLimited,
|
||||||
|
Robot36Compatible
|
||||||
|
}
|
||||||
|
|
||||||
|
class SstvDiagnosticsHandle internal constructor(
|
||||||
|
val enabled: Boolean,
|
||||||
|
val metrics: StateFlow<SstvQualityMetrics?>
|
||||||
)
|
)
|
||||||
|
|
||||||
class SstvDecoder(
|
class SstvDecoder(
|
||||||
sampleRate: Int = 44100,
|
sampleRate: Int = 44100,
|
||||||
scopeWidth: Int = 320,
|
scopeWidth: Int = 320,
|
||||||
scopeHeight: Int = 256,
|
scopeHeight: Int = 256,
|
||||||
private val channelSelect: Int = 0
|
private val channelSelect: Int = 0,
|
||||||
|
targetRmsLevel: Float = 0.25f,
|
||||||
|
private val includeScopeData: Boolean = false,
|
||||||
|
private val enableRmsNormalization: Boolean = true,
|
||||||
|
preFilterCutoffHz: Double = 500.0,
|
||||||
|
enablePreFilter: Boolean = true,
|
||||||
|
private val enableDiagnosticsHandle: Boolean = false,
|
||||||
|
lineRecoveryStrategy: LineRecoveryStrategy = LineRecoveryStrategy.Look4SatLimited
|
||||||
) {
|
) {
|
||||||
private val scopeBuffer = PixelBuffer(scopeWidth, scopeHeight * 2)
|
private val scopeBuffer = PixelBuffer(scopeWidth, scopeHeight * 2)
|
||||||
private val imageBuffer = PixelBuffer(scopeWidth, scopeHeight)
|
private val imageBuffer = PixelBuffer(scopeWidth, scopeHeight)
|
||||||
private val decoder = DecoderEngine(scopeBuffer, imageBuffer, "Raw", sampleRate)
|
private val decoder = DecoderEngine(scopeBuffer, imageBuffer, "Raw", sampleRate, lineRecoveryStrategy)
|
||||||
private val _frames = MutableSharedFlow<SstvFrame>(
|
private val _frames = MutableSharedFlow<SstvFrame>(
|
||||||
replay = 1,
|
replay = 1,
|
||||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||||
)
|
)
|
||||||
|
private var lastInputRms = 0f
|
||||||
|
private var lastAppliedGain = 1f
|
||||||
|
private val _qualityMetrics = MutableStateFlow<SstvQualityMetrics?>(null)
|
||||||
|
private val preFilter = if (enablePreFilter) HighPassFilter(preFilterCutoffHz, sampleRate.toDouble()) else null
|
||||||
|
private val diagnosticsHandle = SstvDiagnosticsHandle(enableDiagnosticsHandle, _qualityMetrics)
|
||||||
val frames: SharedFlow<SstvFrame> = _frames
|
val frames: SharedFlow<SstvFrame> = _frames
|
||||||
val supportedModes: List<String> = decoder.allModes.map { it.name }
|
val supportedModes: List<String> = decoder.allModes.map { it.name }
|
||||||
|
|
||||||
suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) {
|
suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) {
|
||||||
// Normalize to a fixed RMS before processing so that both direct audio
|
// Optional pre-filtering: remove DC offset and subsonic noise that can mask
|
||||||
// coupling and air-coupled microphone input decode with equal reliability.
|
// weak signals and corrupt the RMS normalization baseline.
|
||||||
normalise(samples)
|
preFilter?.apply(samples)
|
||||||
|
|
||||||
|
// Optional RMS normalization: bring input to a consistent level so the
|
||||||
|
// FM demodulator operates in a predictable region. However, this amplifies
|
||||||
|
// noise proportionally. Aggressive RMS targets (e.g., 0.25) can hurt weak
|
||||||
|
// signals by boosting noise floor. Safer defaults: 0.35-0.50 for noisy inputs.
|
||||||
|
// Disable entirely for direct line-level inputs (e.g., receiver discriminator).
|
||||||
|
val gain = if (enableRmsNormalization) normalise(samples) else GainInfo(0f, 1f)
|
||||||
|
lastInputRms = gain.inputRms
|
||||||
|
lastAppliedGain = gain.appliedGain
|
||||||
val hasNewLines = decoder.process(samples, channelSelect)
|
val hasNewLines = decoder.process(samples, channelSelect)
|
||||||
if (hasNewLines) emitFrame()
|
if (hasNewLines) emitFrame()
|
||||||
}
|
}
|
||||||
@@ -62,36 +117,134 @@ class SstvDecoder(
|
|||||||
fun clearPixels() {
|
fun clearPixels() {
|
||||||
imageBuffer.line = -1
|
imageBuffer.line = -1
|
||||||
imageBuffer.pixels.fill(0)
|
imageBuffer.pixels.fill(0)
|
||||||
|
decoder.resetQuality()
|
||||||
|
if (enableDiagnosticsHandle) _qualityMetrics.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDiagnosticsHandle(): SstvDiagnosticsHandle? = diagnosticsHandle.takeIf { it.enabled }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export quality metrics for logging/diagnostics. Useful for profiling decode
|
||||||
|
* performance on noisy recordings. Returns null before first frame is emitted.
|
||||||
|
*/
|
||||||
|
@Suppress("unused")
|
||||||
|
fun getQualityMetrics(): SstvQualityMetrics? {
|
||||||
|
if (enableDiagnosticsHandle) return _qualityMetrics.value
|
||||||
|
val q = decoder.quality()
|
||||||
|
return SstvQualityMetrics(
|
||||||
|
syncHitRate = q.syncHitRate,
|
||||||
|
predictedLineBursts = q.predictedLineBursts,
|
||||||
|
maxPredictedStreak = q.maxPredictedStreak,
|
||||||
|
timingErrorSamples = q.timingErrorSamples
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun emitFrame() {
|
private fun emitFrame() {
|
||||||
val imageWidth = imageBuffer.width
|
val imageWidth = imageBuffer.width
|
||||||
val imageHeight = imageBuffer.height
|
val imageHeight = imageBuffer.height
|
||||||
|
val quality = decoder.quality()
|
||||||
|
if (enableDiagnosticsHandle) {
|
||||||
|
_qualityMetrics.value = SstvQualityMetrics(
|
||||||
|
syncHitRate = quality.syncHitRate,
|
||||||
|
predictedLineBursts = quality.predictedLineBursts,
|
||||||
|
maxPredictedStreak = quality.maxPredictedStreak,
|
||||||
|
timingErrorSamples = quality.timingErrorSamples
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val imageComplete = imageBuffer.line >= imageHeight && imageBuffer.line > 0
|
||||||
// Copy only the active image region — imageBuffer.pixels is pre-allocated
|
// Copy only the active image region — imageBuffer.pixels is pre-allocated
|
||||||
// at the maximum possible size (PD-290: 800×616), so we must not copyOf()
|
// at the maximum possible size (PD-290: 800×616), so we must not copyOf()
|
||||||
// the entire array and send padding pixels to the observer.
|
// the entire array and send padding pixels to the observer.
|
||||||
val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf(imageWidth * imageHeight) else null
|
val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf(imageWidth * imageHeight) else null
|
||||||
val modeName = decoder.currentMode.name
|
val modeName = decoder.currentMode.name
|
||||||
_frames.tryEmit(SstvFrame(imagePixels, imageWidth, imageHeight, modeName))
|
val scopePixels = if (includeScopeData) scopeBuffer.pixels.copyOf() else null
|
||||||
|
val scopeWidth = if (includeScopeData) scopeBuffer.width else 0
|
||||||
|
val scopeHeight = if (includeScopeData) scopeBuffer.height else 0
|
||||||
|
_frames.tryEmit(
|
||||||
|
SstvFrame(
|
||||||
|
scopePixels = scopePixels,
|
||||||
|
scopeWidth = scopeWidth,
|
||||||
|
scopeHeight = scopeHeight,
|
||||||
|
imagePixels = imagePixels,
|
||||||
|
imageWidth = imageWidth,
|
||||||
|
imageHeight = imageHeight,
|
||||||
|
modeName = modeName,
|
||||||
|
imageComplete = imageComplete,
|
||||||
|
inputRms = lastInputRms,
|
||||||
|
appliedGain = lastAppliedGain,
|
||||||
|
syncHitRate = quality.syncHitRate,
|
||||||
|
predictedLineBursts = quality.predictedLineBursts,
|
||||||
|
maxPredictedStreak = quality.maxPredictedStreak,
|
||||||
|
timingErrorSamples = quality.timingErrorSamples
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Target RMS level for the normalizer. 0.25 leaves headroom while keeping the
|
// Target RMS level for the normalizer. 0.25 leaves headroom while keeping the
|
||||||
// FM demodulator well above its noise floor regardless of input gain.
|
// FM demodulator well above its noise floor regardless of input gain.
|
||||||
private val targetRms = 0.25f
|
// TUNING GUIDE:
|
||||||
|
// - 0.20-0.25: Aggressive, best for clean direct-coupled inputs, worst for mic noise
|
||||||
|
// - 0.35-0.40: Moderate, good balance for typical phone/mic inputs (RECOMMENDED)
|
||||||
|
// - 0.50-0.60: Conservative, best for noisy environments, reduces amplitude resolution
|
||||||
|
// Disable RMS normalization entirely if using a professional receiver discriminator output.
|
||||||
|
private val targetRms = targetRmsLevel.coerceIn(0.05f, 0.8f)
|
||||||
|
|
||||||
|
private class GainInfo(
|
||||||
|
val inputRms: Float,
|
||||||
|
val appliedGain: Float
|
||||||
|
)
|
||||||
|
|
||||||
// Bring the buffer to a fixed RMS so that microphone and direct-coupled inputs
|
// Bring the buffer to a fixed RMS so that microphone and direct-coupled inputs
|
||||||
// both decode reliably. The guard prevents amplifying pure silence into noise.
|
// both decode reliably. The guard prevents amplifying pure silence into noise.
|
||||||
private fun normalise(buffer: FloatArray) {
|
private fun normalise(buffer: FloatArray): GainInfo {
|
||||||
var sumSq = 0f
|
var sumSq = 0f
|
||||||
for (s in buffer) sumSq += s * s
|
for (s in buffer) sumSq += s * s
|
||||||
val rms = sqrt(sumSq / buffer.size)
|
val rms = sqrt(sumSq / buffer.size)
|
||||||
if (rms > 1e-6f) {
|
if (rms > 1e-6f) {
|
||||||
val gain = targetRms / rms
|
val gain = targetRms / rms
|
||||||
for (i in buffer.indices) buffer[i] *= gain
|
for (i in buffer.indices) buffer[i] *= gain
|
||||||
|
return GainInfo(rms, gain)
|
||||||
|
}
|
||||||
|
return GainInfo(rms, 1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple high-pass filter to remove DC offset and subsonic interference before RMS
|
||||||
|
* normalization. Improves noise floor estimation and prevents low-freq noise from
|
||||||
|
* corrupting the gain calculation.
|
||||||
|
*
|
||||||
|
* Design: First-order butterworth (pole at cutoff frequency). Fast, minimal latency,
|
||||||
|
* suitable for real-time preprocessing.
|
||||||
|
*/
|
||||||
|
internal class HighPassFilter(cutoffHz: Double, sampleRateHz: Double) {
|
||||||
|
private val alpha: Float
|
||||||
|
private var prevInput = 0f
|
||||||
|
private var prevOutput = 0f
|
||||||
|
|
||||||
|
init {
|
||||||
|
// First-order pole placement: alpha = wc / (wc + ws) where wc = 2*pi*fc, ws = 2*pi*fs
|
||||||
|
val omega = 2f * PI.toFloat() * (cutoffHz / sampleRateHz).toFloat()
|
||||||
|
alpha = omega / (omega + 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun apply(buffer: FloatArray) {
|
||||||
|
for (i in buffer.indices) {
|
||||||
|
val input = buffer[i]
|
||||||
|
prevOutput = alpha * (prevOutput + input - prevInput)
|
||||||
|
buffer[i] = prevOutput
|
||||||
|
prevInput = input
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal class DecoderQuality(
|
||||||
|
val syncHitRate: Float,
|
||||||
|
val predictedLineBursts: Int,
|
||||||
|
val maxPredictedStreak: Int,
|
||||||
|
val timingErrorSamples: Int
|
||||||
|
)
|
||||||
|
|
||||||
internal enum class SyncPulseWidth { FiveMs, NineMs, TwentyMs }
|
internal enum class SyncPulseWidth { FiveMs, NineMs, TwentyMs }
|
||||||
|
|
||||||
internal class SyncPulseDetector(sampleRate: Int) {
|
internal class SyncPulseDetector(sampleRate: Int) {
|
||||||
@@ -197,7 +350,8 @@ internal class DecoderEngine(
|
|||||||
private val scopeBuffer: PixelBuffer,
|
private val scopeBuffer: PixelBuffer,
|
||||||
private val imageBuffer: PixelBuffer,
|
private val imageBuffer: PixelBuffer,
|
||||||
rawName: String,
|
rawName: String,
|
||||||
sampleRate: Int
|
sampleRate: Int,
|
||||||
|
lineRecoveryStrategy: LineRecoveryStrategy
|
||||||
) {
|
) {
|
||||||
private val pixelBuffer = PixelBuffer(800, 2)
|
private val pixelBuffer = PixelBuffer(800, 2)
|
||||||
private val detector = SyncPulseDetector(sampleRate)
|
private val detector = SyncPulseDetector(sampleRate)
|
||||||
@@ -237,6 +391,19 @@ internal class DecoderEngine(
|
|||||||
private var lastSync = 0
|
private var lastSync = 0
|
||||||
private var curLineSamples: Int
|
private var curLineSamples: Int
|
||||||
private var lastOffset = 0f
|
private var lastOffset = 0f
|
||||||
|
private var syncChecks = 0
|
||||||
|
private var syncHits = 0
|
||||||
|
private var predictedLineBursts = 0
|
||||||
|
private var predictedStreak = 0
|
||||||
|
private var maxPredictedStreak = 0
|
||||||
|
private var timingErrorSamples = 0
|
||||||
|
|
||||||
|
// Look4Sat strategy limits synthetic lines to avoid visible vertical collapse.
|
||||||
|
// Robot36 strategy preserves legacy behavior by allowing unlimited synthesis.
|
||||||
|
private val maxConsecutivePredictedLines = when (lineRecoveryStrategy) {
|
||||||
|
LineRecoveryStrategy.Look4SatLimited -> 2
|
||||||
|
LineRecoveryStrategy.Robot36Compatible -> Int.MAX_VALUE
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
imageBuffer.line = -1
|
imageBuffer.line = -1
|
||||||
@@ -286,6 +453,11 @@ internal class DecoderEngine(
|
|||||||
fun process(recordBuffer: FloatArray, channelSelect: Int): Boolean {
|
fun process(recordBuffer: FloatArray, channelSelect: Int): Boolean {
|
||||||
var newLines = false
|
var newLines = false
|
||||||
val detected = detector.process(recordBuffer, channelSelect)
|
val detected = detector.process(recordBuffer, channelSelect)
|
||||||
|
syncChecks++
|
||||||
|
if (detected) {
|
||||||
|
syncHits++
|
||||||
|
predictedStreak = 0
|
||||||
|
}
|
||||||
var syncIdx = sample + detector.pulseOffset
|
var syncIdx = sample + detector.pulseOffset
|
||||||
val channels = if (channelSelect > 0) 2 else 1
|
val channels = if (channelSelect > 0) 2 else 1
|
||||||
for (j in 0 until recordBuffer.size / channels) {
|
for (j in 0 until recordBuffer.size / channels) {
|
||||||
@@ -308,20 +480,10 @@ internal class DecoderEngine(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (handleHeader()) {
|
} else if (handleHeader()) {
|
||||||
|
predictedStreak = 0
|
||||||
newLines = true
|
newLines = true
|
||||||
} else if (sample > lastSync + (curLineSamples * 5) / 4) {
|
} else if (sample > lastSync + (curLineSamples * 5) / 4) {
|
||||||
copyLines(
|
newLines = decodePredictedLine()
|
||||||
currentMode.decodeScanLine(
|
|
||||||
pixelBuffer,
|
|
||||||
scratch,
|
|
||||||
scanLineBuffer,
|
|
||||||
scopeBuffer.width,
|
|
||||||
lastSync,
|
|
||||||
curLineSamples,
|
|
||||||
lastOffset
|
|
||||||
)
|
|
||||||
)
|
|
||||||
lastSync += curLineSamples; newLines = true
|
|
||||||
}
|
}
|
||||||
return newLines
|
return newLines
|
||||||
}
|
}
|
||||||
@@ -337,6 +499,25 @@ internal class DecoderEngine(
|
|||||||
lockMode = false
|
lockMode = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun quality(): DecoderQuality {
|
||||||
|
val hitRate = if (syncChecks > 0) syncHits.toFloat() / syncChecks else 0f
|
||||||
|
return DecoderQuality(
|
||||||
|
syncHitRate = hitRate,
|
||||||
|
predictedLineBursts = predictedLineBursts,
|
||||||
|
maxPredictedStreak = maxPredictedStreak,
|
||||||
|
timingErrorSamples = timingErrorSamples
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetQuality() {
|
||||||
|
syncChecks = 0
|
||||||
|
syncHits = 0
|
||||||
|
predictedLineBursts = 0
|
||||||
|
predictedStreak = 0
|
||||||
|
maxPredictedStreak = 0
|
||||||
|
timingErrorSamples = 0
|
||||||
|
}
|
||||||
|
|
||||||
private fun mean(a: IntArray): Double = a.sumOf { it.toDouble() } / a.size
|
private fun mean(a: IntArray): Double = a.sumOf { it.toDouble() } / a.size
|
||||||
private fun stdDev(a: IntArray, m: Double): Double {
|
private fun stdDev(a: IntArray, m: Double): Double {
|
||||||
var s = 0.0; for (v in a) s += (v - m) * (v - m); return sqrt(s / a.size)
|
var s = 0.0; for (v in a) s += (v - m) * (v - m); return sqrt(s / a.size)
|
||||||
@@ -426,6 +607,29 @@ internal class DecoderEngine(
|
|||||||
if (finish) drawLines(0xff000000.toInt(), 10)
|
if (finish) drawLines(0xff000000.toInt(), 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun decodePredictedLine(): Boolean {
|
||||||
|
// Avoid long streaks of synthetic lines; once we exceed the cap we wait for
|
||||||
|
// real sync to reduce visible vertical compression on weak/noisy signals.
|
||||||
|
if (predictedStreak >= maxConsecutivePredictedLines) return false
|
||||||
|
val expectedSync = lastSync + curLineSamples
|
||||||
|
timingErrorSamples = sample - expectedSync
|
||||||
|
val decoded = currentMode.decodeScanLine(
|
||||||
|
pixelBuffer,
|
||||||
|
scratch,
|
||||||
|
scanLineBuffer,
|
||||||
|
scopeBuffer.width,
|
||||||
|
lastSync,
|
||||||
|
curLineSamples,
|
||||||
|
lastOffset
|
||||||
|
)
|
||||||
|
copyLines(decoded)
|
||||||
|
lastSync = expectedSync
|
||||||
|
predictedLineBursts++
|
||||||
|
predictedStreak++
|
||||||
|
if (predictedStreak > maxPredictedStreak) maxPredictedStreak = predictedStreak
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
private fun drawLines(color: Int, count: Int) {
|
private fun drawLines(color: Int, count: Int) {
|
||||||
repeat(count) {
|
repeat(count) {
|
||||||
scopeBuffer.pixels.fill(
|
scopeBuffer.pixels.fill(
|
||||||
@@ -542,6 +746,7 @@ internal class DecoderEngine(
|
|||||||
lineLen: IntArray,
|
lineLen: IntArray,
|
||||||
latest: Int
|
latest: Int
|
||||||
): Boolean {
|
): Boolean {
|
||||||
|
predictedStreak = 0
|
||||||
for (i in 1 until syncPulses.size) syncPulses[i - 1] = syncPulses[i]
|
for (i in 1 until syncPulses.size) syncPulses[i - 1] = syncPulses[i]
|
||||||
syncPulses[syncPulses.size - 1] = latest
|
syncPulses[syncPulses.size - 1] = latest
|
||||||
for (i in 1 until lineLen.size) lineLen[i - 1] = lineLen[i]
|
for (i in 1 until lineLen.size) lineLen[i - 1] = lineLen[i]
|
||||||
|
|||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.utility
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
||||||
|
*
|
||||||
|
* The full physical path:
|
||||||
|
*
|
||||||
|
* TX→RX (uplink → downlink):
|
||||||
|
* ① 地面发射 f_tx
|
||||||
|
* ② 卫星收到 f_tx × (c - v) / c (上行多普勒)
|
||||||
|
* ③ 卫星转发 = passband映射(②) (在卫星上做映射)
|
||||||
|
* ④ 地面听到 ③ × (c - v) / c (下行多普勒)
|
||||||
|
*
|
||||||
|
* RX→TX (downlink → uplink):
|
||||||
|
* ④ 地面听到 f_rx
|
||||||
|
* ③ 卫星转发 = f_rx × (c + v) / c (逆下行多普勒)
|
||||||
|
* ② 卫星收到 = 逆passband映射(③)
|
||||||
|
* ① 地面应发射 = ② × (c + v) / c (逆上行多普勒)
|
||||||
|
*
|
||||||
|
* Addresses GitHub issue #91 (Custom frequency Doppler correction).
|
||||||
|
*/
|
||||||
|
object DopplerFrequencyCalculator {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a downlink frequency (what the user hears), compute the
|
||||||
|
* uplink frequency the user should transmit.
|
||||||
|
* Full path: ④→③→②→①
|
||||||
|
*/
|
||||||
|
fun computeUplinkFromDownlink(
|
||||||
|
downlinkHz: Long,
|
||||||
|
transponder: SatRadio,
|
||||||
|
orbitalPos: OrbitalPos
|
||||||
|
): Long? {
|
||||||
|
if (!isLinearTransponder(transponder)) return null
|
||||||
|
// ④→③ 逆下行多普勒:卫星转发的频率
|
||||||
|
val satTx = orbitalPos.getUplinkFreq(downlinkHz)
|
||||||
|
// ③→② 逆 passband 映射
|
||||||
|
val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null
|
||||||
|
// ②→① 逆上行多普勒:地面应发射的频率
|
||||||
|
return orbitalPos.getUplinkFreq(satRx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a downlink frequency (what the user hears), compute the
|
||||||
|
* uplink frequency the user should transmit, with an offset applied
|
||||||
|
* to the downlink (in Hz).
|
||||||
|
* Full path: ④→③→②→①
|
||||||
|
*
|
||||||
|
* The user-entered downlink frequency already includes the offset, so subtract
|
||||||
|
* it before the inverse downlink Doppler.
|
||||||
|
*/
|
||||||
|
fun computeUplinkFromDownlinkWithOffset(
|
||||||
|
downlinkHz: Long,
|
||||||
|
transponder: SatRadio,
|
||||||
|
orbitalPos: OrbitalPos,
|
||||||
|
offsetHz: Long
|
||||||
|
): Long? {
|
||||||
|
if (!isLinearTransponder(transponder)) return null
|
||||||
|
// ④→③ 逆下行多普勒:卫星转发的频率(含 offset)
|
||||||
|
val satTxWithOffset = orbitalPos.getUplinkFreq(downlinkHz)
|
||||||
|
// ③ 去掉 offset(offset 在卫星本地频率域)
|
||||||
|
val satTx = satTxWithOffset - offsetHz
|
||||||
|
// ③→② 逆 passband 映射
|
||||||
|
val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null
|
||||||
|
// ②→① 逆上行多普勒:地面应发射的频率
|
||||||
|
return orbitalPos.getUplinkFreq(satRx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an uplink frequency (what the user transmits), compute the
|
||||||
|
* downlink frequency the user will hear.
|
||||||
|
* Full path: ①→②→③→④
|
||||||
|
*/
|
||||||
|
fun computeDownlinkFromUplink(
|
||||||
|
uplinkHz: Long,
|
||||||
|
transponder: SatRadio,
|
||||||
|
orbitalPos: OrbitalPos
|
||||||
|
): Long? {
|
||||||
|
if (!isLinearTransponder(transponder)) return null
|
||||||
|
// ①→② 上行多普勒:卫星收到的频率
|
||||||
|
val satRx = orbitalPos.getDownlinkFreq(uplinkHz)
|
||||||
|
// ②→③ passband 映射
|
||||||
|
val satTx = TransponderMapper.mapUplinkToDownlink(satRx, transponder) ?: return null
|
||||||
|
// ③→④ 下行多普勒:地面听到的
|
||||||
|
return orbitalPos.getDownlinkFreq(satTx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an uplink frequency (what the user transmits), compute the
|
||||||
|
* downlink frequency the user will hear, with an offset applied
|
||||||
|
* to the downlink (in Hz).
|
||||||
|
* Full path: ①→②→③→④
|
||||||
|
*/
|
||||||
|
fun computeDownlinkFromUplinkWithOffset(
|
||||||
|
uplinkHz: Long,
|
||||||
|
transponder: SatRadio,
|
||||||
|
orbitalPos: OrbitalPos,
|
||||||
|
offsetHz: Long
|
||||||
|
): Long? {
|
||||||
|
if (!isLinearTransponder(transponder)) return null
|
||||||
|
// ①→② 上行多普勒:卫星收到的频率
|
||||||
|
val satRx = orbitalPos.getDownlinkFreq(uplinkHz)
|
||||||
|
// ②→③ passband 映射
|
||||||
|
val satTx = TransponderMapper.mapUplinkToDownlink(satRx, transponder) ?: return null
|
||||||
|
// ③ 加上 offset(offset 在卫星本地频率域)
|
||||||
|
// ③→④ 下行多普勒:地面听到的
|
||||||
|
return orbitalPos.getDownlinkFreq(satTx + offsetHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if this transponder supports linear passband mapping. */
|
||||||
|
fun isLinearTransponder(transponder: SatRadio): Boolean {
|
||||||
|
val upLow = transponder.uplinkLow
|
||||||
|
val upHigh = transponder.uplinkHigh
|
||||||
|
val downLow = transponder.downlinkLow
|
||||||
|
val downHigh = transponder.downlinkHigh
|
||||||
|
return upLow != null && upHigh != null && downLow != null && downHigh != null
|
||||||
|
&& upLow != upHigh && downLow != downHigh
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for the radio entry that should drive the standalone Calculator page.
|
||||||
|
*
|
||||||
|
* A frequency range alone is not enough: some non-user-facing or drifting data entries
|
||||||
|
* can also have low/high frequencies. The calculator is meant for the named linear
|
||||||
|
* transponder entry, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
|
||||||
|
*/
|
||||||
|
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
|
||||||
|
if (!isLinearTransponder(transponder)) return false
|
||||||
|
|
||||||
|
val info = transponder.info.lowercase(Locale.ENGLISH)
|
||||||
|
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
||||||
|
.joinToString(separator = " ")
|
||||||
|
.lowercase(Locale.ENGLISH)
|
||||||
|
val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
|
||||||
|
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
|
||||||
|
info.contains("xponder") || info.contains("xpdr")
|
||||||
|
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
|
||||||
|
|
||||||
|
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) ||
|
||||||
|
(hasLinearName && hasLinearMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes duplicate transponder entries that describe the same physical
|
||||||
|
* transponder with different mode labels (e.g. SatNOGS lists AO-7's Mode A
|
||||||
|
* as both "Lin SSB" and "Lin CW", and JO-97's U/V transponder as both
|
||||||
|
* "CW Transponder" and "SSB Transponder").
|
||||||
|
*
|
||||||
|
* Entries sharing the same uplink/downlink frequency range are considered
|
||||||
|
* the same transponder. The non-CW entry is preferred because its invert
|
||||||
|
* flag is more reliable (e.g. JO-97's CW entry wrongly has invert=false).
|
||||||
|
*/
|
||||||
|
fun deduplicateTransponders(radios: List<SatRadio>): List<SatRadio> {
|
||||||
|
return radios.groupBy { radio ->
|
||||||
|
listOf(radio.uplinkLow, radio.uplinkHigh, radio.downlinkLow, radio.downlinkHigh)
|
||||||
|
}.values.map { group ->
|
||||||
|
group.firstOrNull { it.downlinkMode?.equals("CW", ignoreCase = true) != true } ?: group.first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
-12
@@ -19,20 +19,43 @@ package com.rtbishop.look4sat.core.domain.utility
|
|||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Maidenhead locator (4, 6 or 8 chars) to the position of its
|
||||||
|
* square / subsquare / extended-square centre. Longer input is truncated to
|
||||||
|
* 8 chars (the Maidenhead maximum); malformed or out-of-range input yields
|
||||||
|
* null.
|
||||||
|
*/
|
||||||
fun qthToPosition(locator: String): GeoPos? {
|
fun qthToPosition(locator: String): GeoPos? {
|
||||||
val trimmedQth = locator.take(6)
|
val trimmedQth = locator.trim().uppercase().take(8)
|
||||||
if (!isValidLocator(trimmedQth)) return null
|
if (trimmedQth.length !in listOf(4, 6, 8) || !isValidLocator(trimmedQth)) return null
|
||||||
val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20
|
val lonFirst = (trimmedQth[0].code - 65) * 20
|
||||||
val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10
|
val latFirst = (trimmedQth[1].code - 65) * 10
|
||||||
val lonSecond = trimmedQth[2].toString().toInt() * 2
|
val lonSecond = trimmedQth[2].toString().toInt() * 2
|
||||||
val latSecond = trimmedQth[3].toString().toInt()
|
val latSecond = trimmedQth[3].toString().toInt()
|
||||||
val lonThird = (((trimmedQth[4].lowercaseChar().code - 97) / 12.0) + (1.0 / 24.0)) - 180
|
// Start from the 4-char square centre, refine for 6/8-char subsquares.
|
||||||
val latThird = (((trimmedQth[5].lowercaseChar().code - 97) / 24.0) + (1.0 / 48.0)) - 90
|
var longitude = lonFirst + lonSecond + 1.0
|
||||||
val longitude = (lonFirst + lonSecond + lonThird).round(4)
|
var latitude = latFirst + latSecond + 0.5
|
||||||
val latitude = (latFirst + latSecond + latThird).round(4)
|
if (trimmedQth.length >= 6) {
|
||||||
return GeoPos(latitude, longitude)
|
val lonSub = (trimmedQth[4].code - 65) * 5.0 / 60.0
|
||||||
|
val latSub = (trimmedQth[5].code - 65) * 2.5 / 60.0
|
||||||
|
if (lonSub < 0.0 || lonSub > 115.0 / 60.0 || latSub < 0.0 || latSub > 57.5 / 60.0) return null
|
||||||
|
longitude = lonFirst + lonSecond + lonSub + 2.5 / 60.0
|
||||||
|
latitude = latFirst + latSecond + latSub + 1.25 / 60.0
|
||||||
|
if (trimmedQth.length >= 8) {
|
||||||
|
val lonExt = (trimmedQth[6].code - 48) * 30.0 / 3600.0
|
||||||
|
val latExt = (trimmedQth[7].code - 48) * 15.0 / 3600.0
|
||||||
|
if (lonExt < 0.0 || lonExt > 270.0 / 3600.0 || latExt < 0.0 || latExt > 135.0 / 3600.0) return null
|
||||||
|
longitude += lonExt + 15.0 / 3600.0
|
||||||
|
latitude += latExt + 7.5 / 3600.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return GeoPos((latitude - 90.0).round(4), (longitude - 180.0).round(4))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a position to its 6-char Maidenhead locator, upper-cased (field,
|
||||||
|
* square and subsquare letters). Returns null for out-of-range positions.
|
||||||
|
*/
|
||||||
fun positionToQth(latitude: Double, longitude: Double): String? {
|
fun positionToQth(latitude: Double, longitude: Double): String? {
|
||||||
if (!isValidPosition(latitude, longitude)) return null
|
if (!isValidPosition(latitude, longitude)) return null
|
||||||
val newLongitude = if (longitude > 180.0) longitude else longitude + 180
|
val newLongitude = if (longitude > 180.0) longitude else longitude + 180
|
||||||
@@ -41,8 +64,8 @@ fun positionToQth(latitude: Double, longitude: Double): String? {
|
|||||||
val latFirst = (65 + (newLatitude / 10)).toInt().toChar()
|
val latFirst = (65 + (newLatitude / 10)).toInt().toChar()
|
||||||
val lonSecond = ((newLongitude / 2) % 10).toInt()
|
val lonSecond = ((newLongitude / 2) % 10).toInt()
|
||||||
val latSecond = (newLatitude % 10).toInt()
|
val latSecond = (newLatitude % 10).toInt()
|
||||||
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar()
|
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().uppercaseChar()
|
||||||
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar()
|
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().uppercaseChar()
|
||||||
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
|
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,5 +74,11 @@ private fun isValidPosition(lat: Double, lon: Double): Boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun isValidLocator(locator: String): Boolean {
|
private fun isValidLocator(locator: String): Boolean {
|
||||||
return locator.matches("[a-xA-X][a-xA-X]\\d\\d[a-xA-X][a-xA-X]".toRegex())
|
// 4 chars: [A-X][A-X]\d\d ; 6 chars: + [A-X][A-X] ; 8 chars: + \d\d
|
||||||
|
return when (locator.length) {
|
||||||
|
4 -> Regex("[A-X]{2}\\d{2}").matches(locator)
|
||||||
|
6 -> Regex("[A-X]{2}\\d{2}[A-X]{2}").matches(locator)
|
||||||
|
8 -> Regex("[A-X]{2}\\d{2}[A-X]{2}\\d{2}").matches(locator)
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.utility
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares two version strings of the form "<major>.<minor>.<patch>[-<build>]" used by the
|
||||||
|
* BA7OPF fork releases (e.g. "4.4.6-ba7opf.6" or "v4.4.6-ba7opf.7").
|
||||||
|
*
|
||||||
|
* The comparison is done on the numeric version segments first; when the base versions are
|
||||||
|
* equal, the trailing build number (if any) decides. This lets a "4.4.6-ba7opf.7" release be
|
||||||
|
* detected as newer than "4.4.6-ba7opf.6" while "4.5.0" trumps any "4.4.x" build.
|
||||||
|
*/
|
||||||
|
object VersionComparator {
|
||||||
|
|
||||||
|
/** True when [candidate] is a newer version than [current]. */
|
||||||
|
fun isNewer(candidate: String, current: String): Boolean {
|
||||||
|
val cand = parse(candidate)
|
||||||
|
val curr = parse(current)
|
||||||
|
val maxLen = maxOf(cand.first.size, curr.first.size)
|
||||||
|
for (i in 0 until maxLen) {
|
||||||
|
val c = cand.first.getOrElse(i) { 0 }
|
||||||
|
val k = curr.first.getOrElse(i) { 0 }
|
||||||
|
if (c != k) return c > k
|
||||||
|
}
|
||||||
|
return cand.second > curr.second
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a version string into (numeric base segments, trailing build number).
|
||||||
|
* "v4.4.6-ba7opf.7" -> ([4,4,6], 7); "4.4.6" -> ([4,4,6], 0); "4.4.6-ba7opf" -> ([4,4,6], 0).
|
||||||
|
*/
|
||||||
|
fun parse(version: String): Pair<List<Int>, Int> {
|
||||||
|
val cleaned = version.trim().removePrefix("v")
|
||||||
|
val dashIndex = cleaned.indexOf('-')
|
||||||
|
val basePart = if (dashIndex >= 0) cleaned.substring(0, dashIndex) else cleaned
|
||||||
|
val suffixPart = if (dashIndex >= 0) cleaned.substring(dashIndex + 1) else ""
|
||||||
|
val base = basePart.split('.').mapNotNull { it.toIntOrNull() }
|
||||||
|
// The build number is the trailing numeric segment of the suffix (e.g. "ba7opf.7" -> 7).
|
||||||
|
val build = suffixPart
|
||||||
|
.split('.')
|
||||||
|
.lastOrNull()
|
||||||
|
?.toIntOrNull()
|
||||||
|
?: 0
|
||||||
|
return base to build
|
||||||
|
}
|
||||||
|
}
|
||||||
+275
@@ -0,0 +1,275 @@
|
|||||||
|
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.Test
|
||||||
|
|
||||||
|
class DopplerFrequencyCalculatorTest {
|
||||||
|
|
||||||
|
private fun linearTransponder(
|
||||||
|
uuid: String = "linear",
|
||||||
|
upLow: Long = 145_000_000L,
|
||||||
|
upHigh: Long = 145_500_000L,
|
||||||
|
downLow: Long = 435_000_000L,
|
||||||
|
downHigh: Long? = 435_500_000L,
|
||||||
|
inverted: Boolean = false,
|
||||||
|
info: String = "Linear Transponder",
|
||||||
|
downlinkMode: String? = "USB",
|
||||||
|
uplinkMode: String? = "LSB"
|
||||||
|
) = SatRadio(
|
||||||
|
uuid = uuid, info = info, isAlive = true,
|
||||||
|
downlinkLow = downLow, downlinkHigh = downHigh,
|
||||||
|
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
|
||||||
|
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun fmTransponder() = SatRadio(
|
||||||
|
uuid = "fm", info = "FM Repeater", isAlive = true,
|
||||||
|
downlinkLow = 435_600_000L, downlinkHigh = null,
|
||||||
|
downlinkMode = "FM", uplinkLow = 145_900_000L, uplinkHigh = null,
|
||||||
|
uplinkMode = "FM", isInverted = false, catnum = 99999
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun pos(distanceRateKmS: Double = 0.0) = OrbitalPos().apply {
|
||||||
|
this.distanceRate = distanceRateKmS
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isLinearTransponder_returnsTrueForLinear() {
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isLinearTransponder(linearTransponder()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isLinearTransponder_returnsFalseForFM() {
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(fmTransponder()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isLinearTransponder_returnsFalseForNullDownlinkHigh() {
|
||||||
|
val xpdr = linearTransponder(downHigh = null)
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsTrueForLinearTransponderName() {
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(linearTransponder()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsTrueForSsbTransponderName() {
|
||||||
|
val xpdr = linearTransponder(info = "Mode V/U SSB Transponder", downlinkMode = "USB", uplinkMode = "LSB")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(xpdr))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsFalseForRangeEntryWithoutTransponderName() {
|
||||||
|
val driftingRangeEntry = linearTransponder(info = "Upper side band (drifting)")
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsTrueForAbbreviatedLinName() {
|
||||||
|
// AO-7 style: "Mode V/A (A) Lin SSB" — "Lin" abbreviation, no "transponder" word
|
||||||
|
val ao7Entry = linearTransponder(info = "Mode V/A (A) Lin SSB", downlinkMode = "USB", uplinkMode = "USB")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7Entry))
|
||||||
|
val ao7CwEntry = linearTransponder(info = "Mode V/A (A) Lin CW", downlinkMode = "CW", uplinkMode = "CW")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7CwEntry))
|
||||||
|
val ao7ModeBEntry = linearTransponder(info = "Mode U/V (B) Lin", downlinkMode = "USB", uplinkMode = "LSB")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7ModeBEntry))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsTrueForLinearWithoutTransponderWord() {
|
||||||
|
// AO-73 style: "Mode U/V Linear" — has "Linear" but no "transponder"
|
||||||
|
val ao73Entry = linearTransponder(info = "Mode U/V Linear", downlinkMode = "USB", uplinkMode = "LSB")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao73Entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsFalseForDownlinkContainingLinInsideWord() {
|
||||||
|
// "Downlink" contains "lin" but is not a linear-transponder name
|
||||||
|
val downlinkEntry = linearTransponder(info = "Mode U Downlink", downlinkMode = "FM", uplinkMode = "FM")
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(downlinkEntry))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deduplicateTransponders_mergesSameFrequencyRange() {
|
||||||
|
// AO-7's Mode A: same range, SSB and CW entries
|
||||||
|
val ssb = linearTransponder(
|
||||||
|
uuid = "ssb-uuid", info = "Mode V/A (A) Lin SSB",
|
||||||
|
downlinkMode = "USB", uplinkMode = "USB"
|
||||||
|
)
|
||||||
|
val cw = linearTransponder(
|
||||||
|
uuid = "cw-uuid", info = "Mode V/A (A) Lin CW",
|
||||||
|
downlinkMode = "CW", uplinkMode = "CW"
|
||||||
|
)
|
||||||
|
val modeB = linearTransponder(
|
||||||
|
uuid = "modeb-uuid", info = "Mode U/V (B) Lin",
|
||||||
|
upLow = 432_125_000L, upHigh = 432_175_000L,
|
||||||
|
downLow = 145_925_000L, downHigh = 145_975_000L,
|
||||||
|
downlinkMode = "USB", uplinkMode = "LSB"
|
||||||
|
)
|
||||||
|
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(ssb, cw, modeB))
|
||||||
|
assertEquals(2, result.size)
|
||||||
|
// SSB entry should be preferred over CW (same range)
|
||||||
|
assertEquals("ssb-uuid", result[0].uuid)
|
||||||
|
assertEquals("modeb-uuid", result[1].uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deduplicateTransponders_prefersNonCwEntry() {
|
||||||
|
// JO-97: CW entry has invert=false (wrong), SSB has invert=true (correct)
|
||||||
|
val cw = linearTransponder(
|
||||||
|
uuid = "cw-uuid", info = "U/V CW Transponder",
|
||||||
|
downlinkMode = "CW", uplinkMode = "CW",
|
||||||
|
upLow = 435_100_000L, upHigh = 435_120_000L,
|
||||||
|
downLow = 145_855_000L, downHigh = 145_875_000L
|
||||||
|
)
|
||||||
|
val ssb = linearTransponder(
|
||||||
|
uuid = "ssb-uuid", info = "U/V SSB Transponder",
|
||||||
|
downlinkMode = "USB", uplinkMode = "LSB",
|
||||||
|
upLow = 435_100_000L, upHigh = 435_120_000L,
|
||||||
|
downLow = 145_855_000L, downHigh = 145_875_000L,
|
||||||
|
inverted = true
|
||||||
|
)
|
||||||
|
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(cw, ssb))
|
||||||
|
assertEquals(1, result.size)
|
||||||
|
assertEquals("ssb-uuid", result[0].uuid)
|
||||||
|
// Verify the correct invert flag is preserved
|
||||||
|
assertTrue(result[0].isInverted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deduplicateTransponders_preservesUniqueEntries() {
|
||||||
|
val t1 = linearTransponder(uuid = "t1", upLow = 145_000_000L, upHigh = 145_500_000L,
|
||||||
|
downLow = 435_000_000L, downHigh = 435_500_000L)
|
||||||
|
val t2 = linearTransponder(uuid = "t2", upLow = 435_000_000L, upHigh = 435_500_000L,
|
||||||
|
downLow = 145_000_000L, downHigh = 145_500_000L)
|
||||||
|
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(t1, t2))
|
||||||
|
assertEquals(2, result.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_linear_noDoppler() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||||
|
assertNotNull(uplink)
|
||||||
|
assertTrue(uplink!! > 0)
|
||||||
|
// With zero Doppler, result equals mapDownlinkToUplink output
|
||||||
|
assertEquals(145_200_000L, uplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeDownlinkFromUplink_linear_noDoppler() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_200_000L, xpdr, orbitalPos)
|
||||||
|
assertNotNull(downlink)
|
||||||
|
assertEquals(435_200_000L, downlink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() {
|
||||||
|
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(7.0) // ~7 km/s receding
|
||||||
|
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() {
|
||||||
|
val orbitalPos = pos()
|
||||||
|
val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos)
|
||||||
|
assertNull(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeDownlinkFromUplink_fm_transponder_returnsNull() {
|
||||||
|
val orbitalPos = pos()
|
||||||
|
val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos)
|
||||||
|
assertNull(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeDownlinkFromUplink_withPositiveOffset_addsOffsetToDownlink() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||||
|
uplinkHz = 145_200_000L,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = 2_500L
|
||||||
|
)
|
||||||
|
assertEquals(435_202_500L, downlink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_withPositiveOffset_subtractsOffsetBeforeMapping() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||||
|
downlinkHz = 435_202_500L,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = 2_500L
|
||||||
|
)
|
||||||
|
assertEquals(145_200_000L, uplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeOffsetRoundTrip_handlesNegativeOffset() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||||
|
uplinkHz = 145_200_000L,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = -2_500L
|
||||||
|
)
|
||||||
|
assertEquals(435_197_500L, downlink)
|
||||||
|
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||||
|
downlinkHz = downlink!!,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = -2_500L
|
||||||
|
)
|
||||||
|
assertEquals(145_200_000L, uplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_invertedTransponder() {
|
||||||
|
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||||
|
assertNotNull(uplink)
|
||||||
|
// 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
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(originalDownlink, xpdr, orbitalPos)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,3 @@
|
|||||||
/*
|
|
||||||
* 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
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||||
@@ -22,25 +5,34 @@ import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class QthConverterTest {
|
class QthConverterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Given valid QTH returns correct POS`() {
|
fun `Given valid QTH returns correct POS`() {
|
||||||
|
// 8-char locator -> centre of the extended square (higher precision)
|
||||||
var result = qthToPosition("io91VL39FX")
|
var result = qthToPosition("io91VL39FX")
|
||||||
assert(result?.latitude == 51.4792 && result.longitude == -0.2083)
|
assert(result?.latitude == 51.5188 && result.longitude == -0.1792)
|
||||||
result = qthToPosition("gf15vc")
|
result = qthToPosition("gf15vc")
|
||||||
assert(result?.latitude == -34.8958 && result.longitude == -56.2083)
|
assert(result?.latitude == -34.8958 && result.longitude == -56.2083)
|
||||||
|
// 4-char locator -> centre of the 2deg x 1deg square
|
||||||
|
result = qthToPosition("JN58")
|
||||||
|
assert(result?.latitude == 48.5 && result.longitude == 11.0)
|
||||||
|
// lowercase input is accepted and normalised
|
||||||
|
result = qthToPosition("ol63pd")
|
||||||
|
assert(result?.latitude == 23.1458 && result.longitude == 113.2917)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Given invalid QTH returns null`() {
|
fun `Given invalid QTH returns null`() {
|
||||||
assert(qthToPosition("ZZ00zz") == null)
|
assert(qthToPosition("ZZ00zz") == null)
|
||||||
assert(qthToPosition("JN58") == null)
|
assert(qthToPosition("JN5") == null) // odd length
|
||||||
|
assert(qthToPosition("JN58Z") == null) // 5 chars
|
||||||
|
assert(qthToPosition("JN58ZA") == null) // Z out of A-X range
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Given valid POS returns correct QTH`() {
|
fun `Given valid POS returns correct QTH`() {
|
||||||
assert(positionToQth(51.4878, -0.2146) == "IO91vl")
|
assert(positionToQth(51.4878, -0.2146) == "IO91VL")
|
||||||
assert(positionToQth(48.1466, 11.6083) == "JN58td")
|
assert(positionToQth(48.1466, 11.6083) == "JN58TD")
|
||||||
|
assert(positionToQth(23.13, 113.26) == "OL63PD")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class VersionComparatorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `newer build number wins when base version equal`() {
|
||||||
|
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.7", "4.4.6-ba7opf.6"))
|
||||||
|
assertTrue(VersionComparator.isNewer("v4.4.6-ba7opf.6", "4.4.6-ba7opf.5"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `newer base version wins regardless of build number`() {
|
||||||
|
assertTrue(VersionComparator.isNewer("4.5.0", "4.4.6-ba7opf.99"))
|
||||||
|
assertTrue(VersionComparator.isNewer("4.4.7-ba7opf.1", "4.4.6-ba7opf.99"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `older versions are not newer`() {
|
||||||
|
assertFalse(VersionComparator.isNewer("4.4.6-ba7opf.5", "4.4.6-ba7opf.6"))
|
||||||
|
assertFalse(VersionComparator.isNewer("4.4.5", "4.4.6-ba7opf.1"))
|
||||||
|
assertFalse(VersionComparator.isNewer("4.4.6-ba7opf.6", "4.4.6-ba7opf.6"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `v prefix is ignored`() {
|
||||||
|
assertTrue(VersionComparator.isNewer("v4.4.7", "4.4.6"))
|
||||||
|
assertFalse(VersionComparator.isNewer("v4.4.6", "4.4.6-ba7opf.1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non numeric suffix without number treated as zero build`() {
|
||||||
|
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.1", "4.4.6-ba7opf"))
|
||||||
|
assertFalse(VersionComparator.isNewer("4.4.6-ba7opf", "4.4.6-ba7opf.1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse extracts base and build number`() {
|
||||||
|
assertEquals(listOf(4, 4, 6) to 7, VersionComparator.parse("v4.4.6-ba7opf.7"))
|
||||||
|
assertEquals(listOf(4, 4, 6) to 0, VersionComparator.parse("4.4.6"))
|
||||||
|
assertEquals(listOf(4, 4, 6) to 0, VersionComparator.parse("4.4.6-ba7opf"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
/*
|
||||||
|
* 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.cw
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
class CwDecoderTest {
|
||||||
|
|
||||||
|
// --- Morse table ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun morseToChar_basicLetters() {
|
||||||
|
assertEquals('A', CwBayesianDecoder.morseToChar("01"))
|
||||||
|
assertEquals('S', CwBayesianDecoder.morseToChar("000"))
|
||||||
|
assertEquals('O', CwBayesianDecoder.morseToChar("111"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun morseToChar_numbers() {
|
||||||
|
assertEquals('1', CwBayesianDecoder.morseToChar("01111"))
|
||||||
|
assertEquals('0', CwBayesianDecoder.morseToChar("11111"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun morseToChar_unknown_returnsNull() {
|
||||||
|
assertNull(CwBayesianDecoder.morseToChar("......."))
|
||||||
|
assertNull(CwBayesianDecoder.morseToChar(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FFT ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fft_magnitudeSpectrum_detectsTone() {
|
||||||
|
val fft = CwFFT(256)
|
||||||
|
val sampleRate = 8000f
|
||||||
|
val freq = 700f
|
||||||
|
val buffer = FloatArray(256) { (sin(2.0 * PI * freq * it / sampleRate)).toFloat() }
|
||||||
|
val mag = fft.magnitudeSpectrum(buffer)
|
||||||
|
// Peak should be at bin around 700 * 256 / 8000 ≈ 22.4
|
||||||
|
var maxBin = 0
|
||||||
|
var maxVal = 0f
|
||||||
|
for (i in mag.indices) {
|
||||||
|
if (mag[i] > maxVal) { maxVal = mag[i]; maxBin = i }
|
||||||
|
}
|
||||||
|
assertTrue("Peak bin $maxBin should be near 22", maxBin in 18..26)
|
||||||
|
assertTrue("Peak value $maxVal should be positive", maxVal > 0.01f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fft_magnitudeSpectrum_silence_isFlat() {
|
||||||
|
val fft = CwFFT(256)
|
||||||
|
val buffer = FloatArray(256) { 0f }
|
||||||
|
val mag = fft.magnitudeSpectrum(buffer)
|
||||||
|
for (v in mag) assertEquals("Silence spectrum should be 0, got $v", 0f, v, 1e-6f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fft_rejectsWrongSize() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { CwFFT(100) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Spectrogram ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun spectrogram_addSamples_updatesEnergy() {
|
||||||
|
val spec = CwSpectrogram(sampleRate = 8000)
|
||||||
|
val freq = 700f
|
||||||
|
// Feed multiple frames to stabilize energy normalization
|
||||||
|
for (i in 0..5) {
|
||||||
|
val buffer = FloatArray(256) { (sin(2.0 * PI * freq * it / 8000.0)).toFloat() }
|
||||||
|
spec.addSamples(buffer)
|
||||||
|
}
|
||||||
|
val col = spec.getCurrentColumn()
|
||||||
|
val peakBin = spec.findPeakBin()
|
||||||
|
assertTrue("Peak bin $peakBin should be >= 0", peakBin >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun spectrogram_findPeakBin_returnsValidBin() {
|
||||||
|
val spec = CwSpectrogram(sampleRate = 8000)
|
||||||
|
// Add multiple frames of 700 Hz tone
|
||||||
|
for (i in 0..5) {
|
||||||
|
val buffer = FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() }
|
||||||
|
spec.addSamples(buffer)
|
||||||
|
}
|
||||||
|
val peakBin = spec.findPeakBin()
|
||||||
|
assertTrue("Peak bin should be >= 0, got $peakBin", peakBin >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun spectrogram_freqToBin_roundtrip() {
|
||||||
|
val spec = CwSpectrogram(sampleRate = 8000)
|
||||||
|
val freq = 700f
|
||||||
|
val bin = spec.freqToBin(freq)
|
||||||
|
val backFreq = spec.binToFreq(bin)
|
||||||
|
assertTrue("Freq $freq → bin $bin → freq $backFreq", backFreq > 600f && backFreq < 800f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun spectrogram_getBinEnergy_returnsCorrectLength() {
|
||||||
|
val spec = CwSpectrogram(sampleRate = 8000)
|
||||||
|
val energy = spec.getBinEnergy(0, 10)
|
||||||
|
assertEquals(10, energy.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun spectrogram_reset() {
|
||||||
|
val spec = CwSpectrogram(sampleRate = 8000)
|
||||||
|
spec.addSamples(FloatArray(256) { 1f })
|
||||||
|
spec.reset()
|
||||||
|
assertEquals(-1, spec.findPeakBin())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bayesian decoder ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bayesian_processTone_dit() {
|
||||||
|
val decoder = CwBayesianDecoder()
|
||||||
|
// At 20 WPM, dot = 60 ms
|
||||||
|
val result = decoder.processTone(60f)
|
||||||
|
assertEquals('0', result.symbol)
|
||||||
|
assertTrue("Dit probability should be positive", result.probability > 0.1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bayesian_processTone_dash() {
|
||||||
|
val decoder = CwBayesianDecoder()
|
||||||
|
// Dash = 3 * dot = 180 ms
|
||||||
|
val result = decoder.processTone(180f)
|
||||||
|
assertEquals('1', result.symbol)
|
||||||
|
assertTrue("Dash probability should be positive", result.probability > 0.1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bayesian_processTone_unknown_returnsNull() {
|
||||||
|
val decoder = CwBayesianDecoder()
|
||||||
|
// Very long tone — low probability for both dit and dash
|
||||||
|
val result = decoder.processTone(5000f)
|
||||||
|
assertNull(result.symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bayesian_processGap_interChar_returnsChar() {
|
||||||
|
val decoder = CwBayesianDecoder()
|
||||||
|
decoder.processTone(60f) // dit
|
||||||
|
decoder.processTone(60f) // dit
|
||||||
|
decoder.processTone(60f) // dit
|
||||||
|
// 3 dots = "000" = 'S'
|
||||||
|
val char = decoder.processGap(180f) // 3 * dot = inter-char gap
|
||||||
|
assertEquals('S', char)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bayesian_processGap_wordGap_addsSpace() {
|
||||||
|
val decoder = CwBayesianDecoder()
|
||||||
|
decoder.processTone(60f) // dit = 'E'
|
||||||
|
decoder.processGap(180f) // inter-char gap
|
||||||
|
// Now word gap
|
||||||
|
val space = decoder.processGap(420f) // 7 * dot
|
||||||
|
assertEquals(' ', space)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bayesian_decodedText_accumulates() {
|
||||||
|
val decoder = CwBayesianDecoder()
|
||||||
|
decoder.processTone(60f) // dit = 'E'
|
||||||
|
decoder.processGap(180f) // inter-char
|
||||||
|
assertTrue(decoder.decodedText.isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bayesian_reset() {
|
||||||
|
val decoder = CwBayesianDecoder()
|
||||||
|
decoder.processTone(60f)
|
||||||
|
decoder.reset()
|
||||||
|
assertEquals("", decoder.decodedText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bayesian_getSpeed() {
|
||||||
|
val decoder = CwBayesianDecoder()
|
||||||
|
// Send 3 dits at 20 WPM (60 ms each)
|
||||||
|
decoder.processTone(60f)
|
||||||
|
decoder.processTone(60f)
|
||||||
|
decoder.processTone(60f)
|
||||||
|
val speed = decoder.getSpeed()
|
||||||
|
assertTrue("Speed should be ~20 WPM, got $speed", speed > 15f && speed < 30f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Channel tracker ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun channelTracker_initialState() {
|
||||||
|
val spec = CwSpectrogram(sampleRate = 8000)
|
||||||
|
val tracker = CwChannelTracker(spec)
|
||||||
|
val channels = tracker.update()
|
||||||
|
assertTrue("No channels should be active initially", channels.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun channelTracker_detectsTone() {
|
||||||
|
val spec = CwSpectrogram(sampleRate = 8000)
|
||||||
|
// Feed a tone
|
||||||
|
for (i in 0..5) {
|
||||||
|
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
|
||||||
|
}
|
||||||
|
val tracker = CwChannelTracker(spec)
|
||||||
|
val channels = tracker.update()
|
||||||
|
assertTrue("Should detect at least 1 channel", channels.isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun channelTracker_bestChannel() {
|
||||||
|
val spec = CwSpectrogram(sampleRate = 8000)
|
||||||
|
for (i in 0..5) {
|
||||||
|
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
|
||||||
|
}
|
||||||
|
val tracker = CwChannelTracker(spec)
|
||||||
|
tracker.update()
|
||||||
|
val best = tracker.getBestChannel()
|
||||||
|
assertNotNull("Best channel should exist", best)
|
||||||
|
if (best != null) assertTrue(best.frequency in 600f..800f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun channelTracker_reset() {
|
||||||
|
val spec = CwSpectrogram(sampleRate = 8000)
|
||||||
|
for (i in 0..5) {
|
||||||
|
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
|
||||||
|
}
|
||||||
|
val tracker = CwChannelTracker(spec)
|
||||||
|
tracker.update()
|
||||||
|
tracker.reset()
|
||||||
|
assertNull(tracker.getBestChannel())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Full decoder ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decoder_initialState() {
|
||||||
|
val decoder = CwDecoder()
|
||||||
|
assertEquals("", decoder.decodedTextFlow.value)
|
||||||
|
assertEquals(0f, decoder.signalStrength.value, 0.001f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decoder_processSilence_doesNotCrash() {
|
||||||
|
val decoder = CwDecoder()
|
||||||
|
decoder.processBuffer(FloatArray(256) { 0f })
|
||||||
|
assertEquals("", decoder.decodedTextFlow.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decoder_processNoise_doesNotCrash() {
|
||||||
|
val decoder = CwDecoder()
|
||||||
|
decoder.processBuffer(FloatArray(256) { (Math.random() * 2 - 1).toFloat() * 0.1f })
|
||||||
|
assertNotNull(decoder.decodedTextFlow.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decoder_processTone_doesNotCrash() {
|
||||||
|
val decoder = CwDecoder()
|
||||||
|
for (i in 0..20) {
|
||||||
|
decoder.processBuffer(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
|
||||||
|
}
|
||||||
|
assertNotNull(decoder.decodedTextFlow.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decoder_reset() {
|
||||||
|
val decoder = CwDecoder()
|
||||||
|
decoder.processBuffer(FloatArray(256) { 1f })
|
||||||
|
decoder.resetDecoder()
|
||||||
|
assertEquals("", decoder.decodedTextFlow.value)
|
||||||
|
assertEquals(0f, decoder.signalStrength.value, 0.001f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decoder_withFixedPitch() {
|
||||||
|
val decoder = CwDecoder(sampleRate = 8000, cwToneFreq = 700f)
|
||||||
|
assertEquals(700f, decoder.estimatedPitch.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
+127
-42
@@ -39,18 +39,26 @@ import androidx.compose.material3.CardDefaults
|
|||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ElevatedButton
|
import androidx.compose.material3.ElevatedButton
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.hideFromAccessibility
|
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||||
@@ -64,10 +72,11 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.TextUnit
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.ui.unit.Velocity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
|
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
@@ -148,16 +157,13 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
.padding(start = 6.dp, top = 1.dp, end = 6.dp, bottom = 0.dp)
|
.padding(start = 6.dp, top = 1.dp, end = 6.dp, bottom = 0.dp)
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
|
||||||
text = "${stringResource(R.string.pass_satId, pass.catNum)} - ",
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
Text(
|
||||||
text = pass.name,
|
text = pass.name,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(end = 6.dp)
|
.padding(end = 6.dp)
|
||||||
.infiniteMarquee(),
|
.infiniteMarquee(),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
@@ -274,59 +280,125 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass(
|
|||||||
altitude = 0, maxElevation = 0.0, orbitalObject = NearEarthObject(defaultOrbitalData), progress = 0f
|
altitude = 0, maxElevation = 0.0, orbitalObject = NearEarthObject(defaultOrbitalData), progress = 0f
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun InfoDialog(
|
||||||
|
title: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onAccept: () -> Unit,
|
||||||
|
extraAction: (@Composable () -> Unit)? = null,
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
extraAction?.let {
|
||||||
|
it()
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SharedDialog(
|
fun SharedDialog(
|
||||||
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
DialogShell(title = title, titleFontSize = 16, onDismissRequest = onCancel) {
|
SharedDialog(title = title, onDismissRequest = onCancel, onCancel = onCancel, onAccept = onAccept) { _ ->
|
||||||
content()
|
content()
|
||||||
Row(modifier = Modifier.padding(start = it, bottom = it, end = it)) {
|
|
||||||
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InfoDialog(title: String, text: String, onDismiss: () -> Unit) {
|
fun SharedDialog(
|
||||||
DialogShell(title = title, titleFontSize = 18, onDismissRequest = {}) {
|
title: String,
|
||||||
Text(
|
onDismissRequest: () -> Unit,
|
||||||
text = text,
|
onCancel: (() -> Unit)? = null,
|
||||||
fontSize = 16.sp,
|
onAccept: (() -> Unit)? = null,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
titleFontSize: Int = 16,
|
||||||
modifier = Modifier.padding(horizontal = it)
|
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
|
||||||
)
|
content: @Composable (padding: Dp) -> Unit
|
||||||
Row(modifier = Modifier.padding(start = it, bottom = it, end = it)) {
|
) {
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
DialogShell(onDismissRequest = onDismissRequest) { padding ->
|
||||||
CardButton(onClick = onDismiss, text = stringResource(R.string.btn_accept))
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = padding, top = padding, end = padding)
|
||||||
|
) {
|
||||||
|
if (onCancel != null) {
|
||||||
|
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
fontSize = titleFontSize.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
textAlign = titleTextAlign,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp)
|
||||||
|
)
|
||||||
|
if (onAccept != null) {
|
||||||
|
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
content(padding)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun DialogShell(
|
private fun DialogShell(
|
||||||
title: String,
|
|
||||||
titleFontSize: Int,
|
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
content: @Composable (padding: androidx.compose.ui.unit.Dp) -> Unit
|
content: @Composable (padding: Dp) -> Unit
|
||||||
) {
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
val padding = LocalSpacing.current.large
|
||||||
Dialog(onDismissRequest = onDismissRequest) {
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
ElevatedCard {
|
val stopSheetFling = remember {
|
||||||
Column(
|
object : NestedScrollConnection {
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||||
verticalArrangement = Arrangement.spacedBy(padding)
|
return available
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
fontSize = titleFontSize.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(start = padding, top = padding, end = padding)
|
|
||||||
)
|
|
||||||
content(padding)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onPostScroll(
|
||||||
|
consumed: Offset,
|
||||||
|
available: Offset,
|
||||||
|
source: NestedScrollSource
|
||||||
|
): Offset = Offset.Zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
sheetState = sheetState,
|
||||||
|
dragHandle = null,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
scrimColor = Color.Black.copy(alpha = 0.64f)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(padding),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.nestedScroll(stopSheetFling)
|
||||||
|
) {
|
||||||
|
content(padding)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,13 +463,26 @@ fun TopBar(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun elevationColor(elevation: Double): Color {
|
fun elevationColor(elevation: Double): Color {
|
||||||
|
val thresholds = LocalElevationThresholds.current
|
||||||
return when {
|
return when {
|
||||||
elevation < 15.0 -> Color(0xFFEF5350) // soft red for low elevation
|
elevation < thresholds.low -> ElevationLowColor // soft red for low elevation
|
||||||
elevation < 45.0 -> MaterialTheme.colorScheme.primary // accent yellow for normal
|
elevation < thresholds.high -> MaterialTheme.colorScheme.primary // accent yellow for normal
|
||||||
else -> Color(0xFF66BB6A) // soft green for high elevation
|
else -> ElevationHighColor // soft green for high elevation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** User-configurable elevation highlight thresholds (in degrees). */
|
||||||
|
data class ElevationThresholds(val low: Double = 15.0, val high: Double = 45.0)
|
||||||
|
|
||||||
|
/** Provided at the app root from settings; defaults keep the original 15°/45° behavior. */
|
||||||
|
val LocalElevationThresholds = compositionLocalOf { ElevationThresholds() }
|
||||||
|
|
||||||
|
/** Soft red used for elevations below the low threshold. */
|
||||||
|
val ElevationLowColor = Color(0xFFEF5350)
|
||||||
|
|
||||||
|
/** Soft green used for elevations above the high threshold. */
|
||||||
|
val ElevationHighColor = Color(0xFF66BB6A)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OutlinedText(
|
fun OutlinedText(
|
||||||
text: String,
|
text: String,
|
||||||
|
|||||||
+8
-4
@@ -79,6 +79,10 @@ private val lightScheme = lightColorScheme(
|
|||||||
onSecondary = Color(0xFFFFFFFF),
|
onSecondary = Color(0xFFFFFFFF),
|
||||||
secondaryContainer = Color(0xFFF1E1BB),
|
secondaryContainer = Color(0xFFF1E1BB),
|
||||||
onSecondaryContainer = Color(0xFF221B04),
|
onSecondaryContainer = Color(0xFF221B04),
|
||||||
|
tertiary = Color(0xFF3C6FE0), // AMSAT Active (darker for light theme)
|
||||||
|
onTertiary = Color(0xFFFFFFFF),
|
||||||
|
tertiaryContainer = Color(0xFFE09800), // AMSAT Telemetry (darker)
|
||||||
|
onTertiaryContainer = Color(0xFF000000),
|
||||||
background = Color(0xFFFFF8F0),
|
background = Color(0xFFFFF8F0),
|
||||||
onBackground = Color(0xFF1E1B13),
|
onBackground = Color(0xFF1E1B13),
|
||||||
surface = Color(0xFFFFF8F0),
|
surface = Color(0xFFFFF8F0),
|
||||||
@@ -101,10 +105,10 @@ private val darkScheme = darkColorScheme(
|
|||||||
onSecondary = Color(0xFF000000),
|
onSecondary = Color(0xFF000000),
|
||||||
secondaryContainer = Color(0xFF404040), // navBar indicator,
|
secondaryContainer = Color(0xFF404040), // navBar indicator,
|
||||||
onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon
|
onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon
|
||||||
// tertiary = Color(0xFF121212),
|
tertiary = Color(0xFF648FFF), // AMSAT Active (from amsat.org/status)
|
||||||
// onTertiary = Color(0xFF121212),
|
onTertiary = Color(0xFF000000),
|
||||||
// tertiaryContainer = Color(0xFF121212),
|
tertiaryContainer = Color(0xFFFFB000), // AMSAT Telemetry
|
||||||
// onTertiaryContainer = Color(0xFF121212),
|
onTertiaryContainer = Color(0xFF000000),
|
||||||
background = Color(0xFF121212),
|
background = Color(0xFF121212),
|
||||||
onBackground = Color(0xFFE0E0E0),
|
onBackground = Color(0xFFE0E0E0),
|
||||||
surface = Color(0xFF202020), // card background
|
surface = Color(0xFF202020), // card background
|
||||||
|
|||||||
+5
-2
@@ -30,10 +30,13 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
|||||||
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
|
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
data object Mutual : Screen(R.drawable.ic_radio_tower, R.string.nav_mutual)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object AMSAT : Screen(R.drawable.ic_satellite_alt, R.string.nav_amsat)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<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="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M240,320q-33,0 -56.5,-23.5T160,240q0,-33 23.5,-56.5T240,160q33,0 56.5,23.5T320,240q0,33 -23.5,56.5T240,320zM240,560q-33,0 -56.5,-23.5T160,480q0,-33 23.5,-56.5T240,400q33,0 56.5,23.5T320,480q0,33 -23.5,56.5T240,560zM240,800q-33,0 -56.5,-23.5T160,720q0,-33 23.5,-56.5T240,640q33,0 56.5,23.5T320,720q0,33 -23.5,56.5T240,800zM720,320q-33,0 -56.5,-23.5T640,240q0,-33 23.5,-56.5T720,160q33,0 56.5,23.5T800,240q0,33 -23.5,56.5T720,320zM720,560q-33,0 -56.5,-23.5T640,480q0,-33 23.5,-56.5T720,400q33,0 56.5,23.5T800,480q0,33 -23.5,56.5T720,560zM720,800q-33,0 -56.5,-23.5T640,720q0,-33 23.5,-56.5T720,640q33,0 56.5,23.5T800,720q0,33 -23.5,56.5T720,800z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="96"
|
||||||
|
android:viewportHeight="96">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeWidth="6"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M 8 76 C 8 44, 21 24, 39 24 C 57 24, 70 44, 70 76" />
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeWidth="6"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M 30 76 C 30 54, 44 42, 62 42 C 80 42, 94 54, 94 76" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="M12,10A2,2 0 0,1 14,12C14,12.5 13.82,12.94 13.53,13.29L16.7,22H14.57L12,14.93L9.43,22H7.3L10.47,13.29C10.18,12.94 10,12.5 10,12A2,2 0 0,1 12,10M12,8A4,4 0 0,0 8,12C8,12.5 8.1,13 8.28,13.46L7.4,15.86C6.53,14.81 6,13.47 6,12A6,6 0 0,1 12,6A6,6 0 0,1 18,12C18,13.47 17.47,14.81 16.6,15.86L15.72,13.46C15.9,13 16,12.5 16,12A4,4 0 0,0 12,8M12,4A8,8 0 0,0 4,12C4,14.36 5,16.5 6.64,17.94L5.92,19.94C3.54,18.11 2,15.23 2,12A10,10 0 0,1 12,2A10,10 0 0,1 22,12C22,15.23 20.46,18.11 18.08,19.94L17.36,17.94C19,16.5 20,14.36 20,12A8,8 0 0,0 12,4Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?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"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
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,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- AMSAT tab icon provided by rt-bishop (upstream author), original SVG asset -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<group
|
||||||
|
android:scaleX="0.242"
|
||||||
|
android:scaleY="0.242"
|
||||||
|
android:translateX="0.3"
|
||||||
|
android:translateY="-230.5">
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="m 21.275553,967.35933 a 2.0000048,2.0000048 0 0 0 -1.1875,0.59375 l -13.5000003,13.5 a 2.0000048,2.0000048 0 0 0 0,2.8125 l 18.5625003,18.59382 a 2.0000048,2.0000048 0 0 0 1.2813,0.5625 l 5.5312,0.4375 -4.0312,4.0312 a 2.0000048,2.0000048 0 0 0 0,2.8126 l 13.6874,13.7187 a 2.0000048,2.0000048 0 0 0 2.8438,0 l 4.0312,-4.0312 0.4376,5.5312 a 2.0000048,2.0000048 0 0 0 0.5624,1.2813 l 18.5938,18.5625 a 2.0000048,2.0000048 0 0 0 2.8438,0 l 13.468705,-13.4688 a 2.0000048,2.0000048 0 0 0 0,-2.8437 l -18.562505,-18.5938 a 2.0000048,2.0000048 0 0 0 -1.2812,-0.5625 l -5.5313,-0.4375 4.0313,-4.0313 a 2.0000048,2.0000048 0 0 0 0,-2.8437 l -13.6876,-13.68757 a 2.0000048,2.0000048 0 0 0 -2.8437,0 l -4.0313,4.03125 -0.4374,-5.53125 a 2.0000048,2.0000048 0 0 0 -0.5626,-1.28125 l -18.5937,-18.5625 a 2.0000048,2.0000048 0 0 0 -1.4375,-0.59375 2.0000048,2.0000048 0 0 0 -0.1875,0 z m 0.2187,4.8125 16.6563,16.65625 0.5625,7.46875 -3.7812,3.78127 -7.4688,-0.59377 -16.6562,-16.625 z m 34.5626,41.46887 7.4687,0.5937 16.656305,16.625 -10.687605,10.6875 -16.625,-16.6562 -0.5937,-7.4688 z" />
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="m 50.940553,966.18283 a 2.000005,2.000005 0 1 0 0,3.99969 c 7.9999,0 15.9734,3.03851 22.0884,9.15344 6.103605,6.10359 9.143805,14.08909 9.143805,22.07844 a 2.000005,2.000005 0 1 0 3.9994,0 c 0,-9.00692 -3.4462,-18.03775 -10.315,-24.90657 -6.877605,-6.8775 -15.900305,-10.325 -24.916605,-10.325 z" />
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="m 50.940553,958.36533 a 2.000005,2.000005 0 1 0 0,3.99969 c 10.0021,0 19.9781,3.79987 27.613705,11.43562 7.6352,7.63517 11.4357,17.62207 11.4357,27.61376 a 2.000005,2.000005 0 1 0 3.9997,0 c 0,-11.00842 -4.207,-22.04171 -12.6072,-30.44188 -8.399505,-8.3996 -19.424005,-12.60719 -30.441905,-12.60719 z" />
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="m 50.940553,973.94033 a 2.000005,2.000005 0 1 0 0,3.99938 c 6.0191,0 12.0105,2.279 16.6031,6.87156 4.5926,4.59255 6.881605,10.59683 6.881605,16.60313 a 2.000005,2.000005 0 1 0 3.9997,0 c 0,-7.02618 -2.6961,-14.07429 -8.053105,-19.43126 -5.3571,-5.35696 -12.398,-8.04281 -19.4313,-8.04281 z" />
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="m 50.940553,981.77783 a 2.000005,2.000005 0 1 0 0,3.99938 c 4.0058,0 7.9968,1.51836 11.0578,4.57937 3.061,3.06099 4.5794,7.05193 4.5794,11.05782 a 2.000005,2.000005 0 1 0 3.9994,0 c 0,-5.02276 -1.9251,-10.06035 -5.7507,-13.88594 -3.8256,-3.82559 -8.8631,-5.75063 -13.8859,-5.75063 z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -31,14 +31,17 @@
|
|||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Filtrar pases</string>
|
<string name="pass_filter_title">Filtrar pases</string>
|
||||||
<string name="pass_filter_elev">Elevación mínima</string>
|
<string name="pass_filter_elev">Elevación mínima</string>
|
||||||
<string name="pass_filter_hours">Horas por delante</string>
|
<string name="pass_filter_hours">Tiempo por delante</string>
|
||||||
|
<string name="pass_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">Tipo de modulación</string>
|
||||||
<string name="pass_elevation">Elevación: %.1f°</string>
|
<string name="pass_elevation">Elevación: %.1f°</string>
|
||||||
<string name="pass_altitude">Altitud: %.0f km</string>
|
<string name="pass_altitude">Altitud: %.0f km</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
Asegúrate de que tus filtros sean correctos y hayas seleccionado satélites</string>
|
Asegúrate de que tus filtros sean correctos y hayas seleccionado satélites</string>
|
||||||
<string name="pass_error_message">No hay pases de satélite próximos para mostrar</string>
|
<string name="pass_error_message">No hay pases de satélite próximos para mostrar</string>
|
||||||
<string name="pass_welcome_title">Bienvenido a Look4Sat-CNMap\!</string>
|
<string name="pass_welcome_title">Bienvenido a Look4Sat\!</string>
|
||||||
<string name="pass_welcome_message">
|
<string name="pass_welcome_message">
|
||||||
Asegúrate de configurar tu posición mediante GPS, Latitud/Longitud o QTH en Configuración.
|
Asegúrate de configurar tu posición mediante GPS, Latitud/Longitud o QTH en Configuración.
|
||||||
\n\nActualiza la base de datos al menos una vez por semana para obtener predicciones precisas.</string>
|
\n\nActualiza la base de datos al menos una vez por semana para obtener predicciones precisas.</string>
|
||||||
@@ -109,6 +112,13 @@
|
|||||||
<string name="prefs_data_update_success">Actualización completada con éxito</string>
|
<string name="prefs_data_update_success">Actualización completada con éxito</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||||
|
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||||
|
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
|
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||||
|
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</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_data_sources_tle_switch">Custom TLE URL</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_transceivers_switch">Custom transceivers URL</string>
|
||||||
|
|
||||||
@@ -139,6 +149,12 @@
|
|||||||
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</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_sweep">Habilitar animaciones radar</string>
|
||||||
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de 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_highlight_title">Resaltado de elevación</string>
|
||||||
|
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">Media %1$d-%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">Alta > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">Me gustaría dar las gracias a:</string>
|
<string name="prefs_outro_title">Me gustaría dar las gracias a:</string>
|
||||||
<string name="prefs_outro_license">La app viene sin garantías de ningún tipo.</string>
|
<string name="prefs_outro_license">La app viene sin garantías de ningún tipo.</string>
|
||||||
|
|||||||
@@ -30,14 +30,17 @@
|
|||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Фильтровать пролеты</string>
|
<string name="pass_filter_title">Фильтровать пролеты</string>
|
||||||
<string name="pass_filter_elev">Минимальная элевация</string>
|
<string name="pass_filter_elev">Минимальная элевация</string>
|
||||||
<string name="pass_filter_hours">Количество часов</string>
|
<string name="pass_filter_hours">Период расчёта</string>
|
||||||
|
<string name="pass_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_elevation">Элевация: %.1f°</string>
|
||||||
<string name="pass_altitude">Высота: %d км</string>
|
<string name="pass_altitude">Высота: %d км</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
Убедитесь, что ваши фильтры настроены правильно и вы выбрали спутники</string>
|
Убедитесь, что ваши фильтры настроены правильно и вы выбрали спутники</string>
|
||||||
<string name="pass_error_message">Нет предстоящих пролётов спутников</string>
|
<string name="pass_error_message">Нет предстоящих пролётов спутников</string>
|
||||||
<string name="pass_welcome_title">Добро пожаловать в Look4Sat-CNMap\!</string>
|
<string name="pass_welcome_title">Добро пожаловать в Look4Sat\!</string>
|
||||||
<string name="pass_welcome_message">
|
<string name="pass_welcome_message">
|
||||||
Обязательно укажите свое местоположение по GPS, широте/долготе или QTH в настройках.
|
Обязательно укажите свое местоположение по GPS, широте/долготе или QTH в настройках.
|
||||||
\n\nПожалуйста, обновляйте базу данных как минимум раз в неделю, чтобы получать точные прогнозы.</string>
|
\n\nПожалуйста, обновляйте базу данных как минимум раз в неделю, чтобы получать точные прогнозы.</string>
|
||||||
@@ -108,6 +111,13 @@
|
|||||||
<string name="prefs_data_update_success">Обновление прошло успешно</string>
|
<string name="prefs_data_update_success">Обновление прошло успешно</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Свои источники данных</string>
|
<string name="prefs_data_sources_title">Свои источники данных</string>
|
||||||
|
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||||
|
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
|
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||||
|
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</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_data_sources_tle_switch">Custom TLE URL</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_transceivers_switch">Custom Transceivers URL</string>
|
||||||
|
|
||||||
@@ -138,6 +148,12 @@
|
|||||||
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
|
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
|
||||||
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
|
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
|
||||||
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
|
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
|
||||||
|
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
|
||||||
|
|
||||||
|
<string name="prefs_highlight_title">Подсветка высоты</string>
|
||||||
|
<string name="prefs_highlight_low">Низко < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">Средне %1$d-%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">Высоко > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">Я хотел бы сказать спасибо:</string>
|
<string name="prefs_outro_title">Я хотел бы сказать спасибо:</string>
|
||||||
<string name="prefs_outro_license">Это ПО поставляется без гарантий.</string>
|
<string name="prefs_outro_license">Это ПО поставляется без гарантий.</string>
|
||||||
|
|||||||
@@ -31,14 +31,17 @@
|
|||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">පසුකිරිම් පිරිපහදුව</string>
|
<string name="pass_filter_title">පසුකිරිම් පිරිපහදුව</string>
|
||||||
<string name="pass_filter_elev">අවම උත්තෝලනය</string>
|
<string name="pass_filter_elev">අවම උත්තෝලනය</string>
|
||||||
<string name="pass_filter_hours">ඉදිරි පැය</string>
|
<string name="pass_filter_hours">ඉදිරි කාලය</string>
|
||||||
|
<string name="pass_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_elevation">උත්තෝලනය: %.1f°</string>
|
||||||
<string name="pass_altitude">උන්නතාශය: %d km</string>
|
<string name="pass_altitude">උන්නතාශය: %d km</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
ඔබගේ පෙරහන් නිවැරදි බවත් ඔබ චන්ද්රිකා තෝරාගෙන ඇති බවත් සහතික කර ගන්න</string>
|
ඔබගේ පෙරහන් නිවැරදි බවත් ඔබ චන්ද්රිකා තෝරාගෙන ඇති බවත් සහතික කර ගන්න</string>
|
||||||
<string name="pass_error_message">ප්රදර්ශනය කිරීමට ඉදිරියට එන චන්ද්රිකා පසුකර යෑම් නැත.</string>
|
<string name="pass_error_message">ප්රදර්ශනය කිරීමට ඉදිරියට එන චන්ද්රිකා පසුකර යෑම් නැත.</string>
|
||||||
<string name="pass_welcome_title">Look4Sat-CNMap වෙත සාදරයෙන් පිළිගනිමු\!</string>
|
<string name="pass_welcome_title">Look4Sat වෙත සාදරයෙන් පිළිගනිමු\!</string>
|
||||||
<string name="pass_welcome_message">
|
<string name="pass_welcome_message">
|
||||||
සැකසුම් තුළ GPS, Lat/Lon හෝ QTH හරහා ඔබේ ස්ථානය සැකසීමට වග බලා ගන්න.
|
සැකසුම් තුළ GPS, Lat/Lon හෝ QTH හරහා ඔබේ ස්ථානය සැකසීමට වග බලා ගන්න.
|
||||||
\n\nනිවැරදි අනාවැකි ලබා ගැනීම සඳහා අවම වශයෙන් සතිපතා දත්ත සමුදාය යාවත්කාලීන කරන්න.</string>
|
\n\nනිවැරදි අනාවැකි ලබා ගැනීම සඳහා අවම වශයෙන් සතිපතා දත්ත සමුදාය යාවත්කාලීන කරන්න.</string>
|
||||||
@@ -109,6 +112,13 @@
|
|||||||
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
|
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||||
|
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||||
|
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
|
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||||
|
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</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_data_sources_tle_switch">Custom TLE URL</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_transceivers_switch">Custom transceivers URL</string>
|
||||||
|
|
||||||
@@ -139,6 +149,12 @@
|
|||||||
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
|
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
|
||||||
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
|
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
|
||||||
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
|
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
|
||||||
|
<string name="prefs_other_switch_night_mode">රතු රාත්රී පෙරහන සබල කරන්න</string>
|
||||||
|
|
||||||
|
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
|
||||||
|
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">මධ්ය %1$d-%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">ඉහළ > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">මම ස්තුති කිරීමට කැමති:</string>
|
<string name="prefs_outro_title">මම ස්තුති කිරීමට කැමති:</string>
|
||||||
<string name="prefs_outro_license">මෘදුකාංගය වගකීමක් සමග නොලැබේ</string>
|
<string name="prefs_outro_license">මෘදුකාංගය වගකීමක් සමග නොලැබේ</string>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Commmon -->
|
<!-- Commmon -->
|
||||||
<string name="app_name" translatable="false">Look4Sat-CNMap</string>
|
<string name="app_name" translatable="false">Look4Sat</string>
|
||||||
<string name="btn_accept">Kabul et</string>
|
<string name="btn_accept">Kabul et</string>
|
||||||
<string name="btn_cancel">İptal</string>
|
<string name="btn_cancel">İptal</string>
|
||||||
<string name="empty_list_message">Gösterilecek veri bulunamadı</string>
|
<string name="empty_list_message">Gösterilecek veri bulunamadı</string>
|
||||||
@@ -31,7 +31,10 @@
|
|||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Geçişleri filtrele</string>
|
<string name="pass_filter_title">Geçişleri filtrele</string>
|
||||||
<string name="pass_filter_elev">Minimum yükseklik açısı</string>
|
<string name="pass_filter_elev">Minimum yükseklik açısı</string>
|
||||||
<string name="pass_filter_hours">Gösterilecek saat aralığı</string>
|
<string name="pass_filter_hours">Gösterilecek zaman</string>
|
||||||
|
<string name="pass_filter_aos_time">AOS aralığı</string>
|
||||||
|
<string name="pass_filter_invert_time">AOS aralığını tersine çevir</string>
|
||||||
|
<string name="pass_filter_deep_space">DeepSpace (periyot >225dk)</string>
|
||||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||||
<string name="pass_modes_title">Modulasyon türü seçin</string>
|
<string name="pass_modes_title">Modulasyon türü seçin</string>
|
||||||
<string name="pass_satId" translatable="false">%05d</string>
|
<string name="pass_satId" translatable="false">%05d</string>
|
||||||
@@ -42,11 +45,11 @@
|
|||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
Filtrelerinizin doğru olduğundan ve uydu seçtiğinizden emin olun</string>
|
Filtrelerinizin doğru olduğundan ve uydu seçtiğinizden emin olun</string>
|
||||||
<string name="pass_error_message">Gösterilecek yaklaşan uydu geçişi bulunmuyor</string>
|
<string name="pass_error_message">Gösterilecek yaklaşan uydu geçişi bulunmuyor</string>
|
||||||
<string name="pass_welcome_title">Look4Sat-CNMap\'a hoş geldiniz\!</string>
|
<string name="pass_welcome_title">Look4Sat\'a hoş geldiniz\!</string>
|
||||||
<string name="pass_welcome_message">
|
<string name="pass_welcome_message">
|
||||||
Ayarlar\'dan GPS, Enlem/Boylam veya QTH kullanarak konumunuzu belirlediğinizden emin olun.
|
Ayarlar\'dan GPS, Enlem/Boylam veya QTH kullanarak konumunuzu belirlediğinizden emin olun.
|
||||||
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
|
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
|
||||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat-CNMap</string>
|
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* Switched to short-form commands in NetworkReporter, by theojalba
|
* Switched to short-form commands in NetworkReporter, by theojalba
|
||||||
\n* Added CAT radio control for full-duplex SAT operation, by jcmerg
|
\n* Added CAT radio control for full-duplex SAT operation, by jcmerg
|
||||||
@@ -84,8 +87,7 @@
|
|||||||
<!-- Map screen -->
|
<!-- Map screen -->
|
||||||
<string name="map_prev">Önceki</string>
|
<string name="map_prev">Önceki</string>
|
||||||
<string name="map_next">Sonraki</string>
|
<string name="map_next">Sonraki</string>
|
||||||
<string name="map_copyright_osm" translatable="false">© OpenStreetMap contributors</string>
|
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
|
||||||
<string name="map_copyright_tianditu" translatable="false">© Tianditu</string>
|
|
||||||
<string name="map_azimuth">Azimut: %.1f°</string>
|
<string name="map_azimuth">Azimut: %.1f°</string>
|
||||||
<string name="map_elevation">Yükseklik açısı: %.1f°</string>
|
<string name="map_elevation">Yükseklik açısı: %.1f°</string>
|
||||||
<string name="map_altitude">İrtifa: %.0f km</string>
|
<string name="map_altitude">İrtifa: %.0f km</string>
|
||||||
@@ -101,12 +103,12 @@
|
|||||||
<string name="map_visible">Görünür</string>
|
<string name="map_visible">Görünür</string>
|
||||||
|
|
||||||
<!-- Settings screen -->
|
<!-- Settings screen -->
|
||||||
<string name="prefs_app_title" translatable="false">Look4Sat-CNMap v%s</string>
|
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
|
||||||
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=cn.bh2vsq.look4sat</string>
|
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
|
||||||
<string name="prefs_donate_title">Bağış yap</string>
|
<string name="prefs_donate_title">Bağış yap</string>
|
||||||
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
||||||
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
|
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
|
||||||
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/cn.bh2vsq.look4sat/</string>
|
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
|
||||||
<string name="prefs_github_title" translatable="false">GitHub</string>
|
<string name="prefs_github_title" translatable="false">GitHub</string>
|
||||||
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
||||||
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
||||||
@@ -141,6 +143,12 @@
|
|||||||
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
|
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Özel veri kaynakları</string>
|
<string name="prefs_data_sources_title">Özel veri kaynakları</string>
|
||||||
|
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||||
|
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
|
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</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_data_sources_tle_switch">Özel TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Özel transceiver URL</string>
|
<string name="prefs_data_sources_transceivers_switch">Özel transceiver URL</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
@@ -183,6 +191,12 @@
|
|||||||
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
|
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
|
||||||
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
|
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
|
||||||
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
|
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
|
||||||
|
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string>
|
||||||
|
|
||||||
|
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
|
||||||
|
<string name="prefs_highlight_low">Düşük < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">Orta %1$d-%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">Yüksek > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">Teşekkürler</string>
|
<string name="prefs_outro_title">Teşekkürler</string>
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
@@ -191,7 +205,8 @@
|
|||||||
\n• Dave Moten (predict4java)
|
\n• Dave Moten (predict4java)
|
||||||
\n• Alexandru Csete (Gpredict)
|
\n• Alexandru Csete (Gpredict)
|
||||||
\n• Dr T.S. Kelso (Celestrak)
|
\n• Dr T.S. Kelso (Celestrak)
|
||||||
\n• Libre Space Foundation (SatNOGS)</string>
|
\n• Libre Space Foundation (SatNOGS)
|
||||||
|
\n• BG7NTA (CW decoding feature)</string>
|
||||||
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
|
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -30,15 +30,18 @@
|
|||||||
|
|
||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Фільтр прольотів</string>
|
<string name="pass_filter_title">Фільтр прольотів</string>
|
||||||
<string name="pass_filter_elev">Мінімальне піднесення</string>
|
<string name="pass_filter_elev">Мінімальне піднесень</string>
|
||||||
<string name="pass_filter_hours">Кількість годин</string>
|
<string name="pass_filter_hours">Час вперед</string>
|
||||||
|
<string name="pass_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_elevation">Піднес.: %.1f°</string>
|
||||||
<string name="pass_altitude">Висота: %.0f км</string>
|
<string name="pass_altitude">Висота: %.0f км</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
Переконайтеся, що ваші фільтри правильні, і ви вибрали супутники</string>
|
Переконайтеся, що ваші фільтри правильні, і ви вибрали супутники</string>
|
||||||
<string name="pass_error_message">Немає майбутніх прольотів супутників</string>
|
<string name="pass_error_message">Немає майбутніх прольотів супутників</string>
|
||||||
<string name="pass_welcome_title">Ласкаво просимо до Look4Sat-CNMap\!</string>
|
<string name="pass_welcome_title">Ласкаво просимо до Look4Sat\!</string>
|
||||||
<string name="pass_welcome_message">
|
<string name="pass_welcome_message">
|
||||||
Обов’язково встановіть своє місцезнаходження за допомогою GPS, широти/довготи або QTH у налаштуваннях.
|
Обов’язково встановіть своє місцезнаходження за допомогою GPS, широти/довготи або QTH у налаштуваннях.
|
||||||
\n\nБудь ласка, оновлюйте базу даних принаймні раз на тиждень, щоб отримувати точні прогнози.</string>
|
\n\nБудь ласка, оновлюйте базу даних принаймні раз на тиждень, щоб отримувати точні прогнози.</string>
|
||||||
@@ -66,7 +69,7 @@
|
|||||||
<string name="map_prev">Попередній</string>
|
<string name="map_prev">Попередній</string>
|
||||||
<string name="map_next">Наступний</string>
|
<string name="map_next">Наступний</string>
|
||||||
<string name="map_azimuth">Азимут: %.1f°</string>
|
<string name="map_azimuth">Азимут: %.1f°</string>
|
||||||
<string name="map_elevation">Піднесення: %.1f°</string>
|
<string name="map_elevation">Піднесень: %.1f°</string>
|
||||||
<string name="map_altitude">Висота: %.0f км</string>
|
<string name="map_altitude">Висота: %.0f км</string>
|
||||||
<string name="map_distance">Дистанція: %.0f км</string>
|
<string name="map_distance">Дистанція: %.0f км</string>
|
||||||
<string name="map_latitude">Широта: %.1f°</string>
|
<string name="map_latitude">Широта: %.1f°</string>
|
||||||
@@ -109,6 +112,13 @@
|
|||||||
<string name="prefs_data_update_success">Оновлення успішне</string>
|
<string name="prefs_data_update_success">Оновлення успішне</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||||
|
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||||
|
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
|
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||||
|
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_label" translatable="false">Transceivers data</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_data_sources_tle_switch">Custom TLE URL</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_transceivers_switch">Custom transceivers URL</string>
|
||||||
|
|
||||||
@@ -139,6 +149,12 @@
|
|||||||
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
|
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
|
||||||
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
|
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
|
||||||
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
|
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
|
||||||
|
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
|
||||||
|
|
||||||
|
<string name="prefs_highlight_title">Підсвічування висоти</string>
|
||||||
|
<string name="prefs_highlight_low">Низько < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">Середньо %1$d-%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">Високо > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">Я хотів би подякувати:</string>
|
<string name="prefs_outro_title">Я хотів би подякувати:</string>
|
||||||
<string name="prefs_outro_license">Ця програма поставляється без жодних гарантій</string>
|
<string name="prefs_outro_license">Ця програма поставляється без жодних гарантій</string>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<string name="nav_sat">卫星</string>
|
<string name="nav_sat">卫星</string>
|
||||||
<string name="nav_pass">过境</string>
|
<string name="nav_pass">过境</string>
|
||||||
<string name="nav_radar">雷达</string>
|
<string name="nav_radar">雷达</string>
|
||||||
|
<string name="nav_mutual">匹配</string>
|
||||||
<string name="nav_map">地图</string>
|
<string name="nav_map">地图</string>
|
||||||
<string name="nav_prefs">设置</string>
|
<string name="nav_prefs">设置</string>
|
||||||
|
|
||||||
@@ -26,15 +27,53 @@
|
|||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">筛选过境</string>
|
<string name="pass_filter_title">筛选过境</string>
|
||||||
<string name="pass_filter_elev">仰角值</string>
|
<string name="pass_filter_elev">仰角值</string>
|
||||||
<string name="pass_filter_hours">小时数</string>
|
<string name="pass_filter_hours">提前时间</string>
|
||||||
|
<string name="pass_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_elevation">仰角:%.1f°</string>
|
||||||
<string name="pass_altitude">高度:%d km</string>
|
<string name="pass_altitude">高度:%d km</string>
|
||||||
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
|
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
|
||||||
<string name="pass_error_message">没有即将过境的卫星</string>
|
<string name="pass_error_message">没有即将过境的卫星</string>
|
||||||
<string name="pass_welcome_title">欢迎来到 Look4Sat-CNMap\!</string>
|
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</string>
|
||||||
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
||||||
|
|
||||||
|
<!-- AMSAT Status screen -->
|
||||||
|
<string name="nav_amsat">AMSAT</string>
|
||||||
|
<string name="amsat_title">AMSAT 卫星状态</string>
|
||||||
|
<string name="amsat_refresh">刷新</string>
|
||||||
|
<string name="amsat_retry">重试</string>
|
||||||
|
<string name="amsat_updated">更新于:</string>
|
||||||
|
<string name="amsat_active">活跃</string>
|
||||||
|
<string name="amsat_tlm">遥测/信标</string>
|
||||||
|
<string name="amsat_not_heard">未听到</string>
|
||||||
|
<string name="amsat_conflict">冲突</string>
|
||||||
|
<string name="amsat_name">名称</string>
|
||||||
|
<string name="amsat_load_failed">加载失败 - 检查网络后重试</string>
|
||||||
|
<string name="amsat_no_reports">此时间段无报告</string>
|
||||||
|
<string name="amsat_close">关闭</string>
|
||||||
|
<string name="amsat_upload">上传</string>
|
||||||
|
<string name="amsat_upload_back">返回</string>
|
||||||
|
<string name="amsat_upload_report">上传报告到 AMSAT</string>
|
||||||
|
<string name="amsat_upload_status">状态</string>
|
||||||
|
<string name="amsat_upload_callsign">呼号</string>
|
||||||
|
<string name="amsat_upload_grid">网格</string>
|
||||||
|
<string name="amsat_upload_time_now">时间:当前 UTC %s</string>
|
||||||
|
<string name="amsat_upload_submit">提交到 AMSAT</string>
|
||||||
|
<string name="amsat_upload_confirm">确认上传</string>
|
||||||
|
<string name="amsat_upload_confirm_title">确认上传到 AMSAT</string>
|
||||||
|
<string name="amsat_upload_confirm_message">这条公开报告将上传到 AMSAT。\n卫星:%1$s\n状态:%2$s\n呼号:%3$s\n网格:%4$s</string>
|
||||||
|
<string name="amsat_upload_grid_none">未填写</string>
|
||||||
|
<string name="amsat_upload_success">报告已上传</string>
|
||||||
|
<string name="amsat_upload_failed">上传失败:%s</string>
|
||||||
|
<string name="amsat_upload_callsign_required">请填写呼号</string>
|
||||||
|
<string name="amsat_upload_grid_invalid">网格应为 4 或 6 位 Maidenhead 定位符</string>
|
||||||
|
<string name="amsat_upload_active">活跃</string>
|
||||||
|
<string name="amsat_upload_tlm">遥测/信标</string>
|
||||||
|
<string name="amsat_upload_not_heard">未听到</string>
|
||||||
|
<string name="amsat_upload_crew_active">ISS 语音</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
<string name="radar_back">后退</string>
|
<string name="radar_back">后退</string>
|
||||||
<string name="radar_notify">通知</string>
|
<string name="radar_notify">通知</string>
|
||||||
@@ -54,6 +93,17 @@
|
|||||||
<string name="radar_string_yes">是</string>
|
<string name="radar_string_yes">是</string>
|
||||||
<string name="radar_visible">日照中</string>
|
<string name="radar_visible">日照中</string>
|
||||||
|
|
||||||
|
<string name="radar_doppler_calc">多普勒频率计算器</string>
|
||||||
|
<string name="radar_doppler_tx_hint">输入上行频率 (MHz)</string>
|
||||||
|
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
|
||||||
|
<string name="radar_doppler_offset_hint">偏移 (kHz)</string>
|
||||||
|
<string name="radar_doppler_info">线性转发器:输入一个频率即可算出另一个</string>
|
||||||
|
|
||||||
|
<string name="radar_cw_decoder">CW 解码器</string>
|
||||||
|
<string name="radar_cw_start">开始</string>
|
||||||
|
<string name="radar_cw_stop">停止</string>
|
||||||
|
<string name="radar_cw_reset">清空</string>
|
||||||
|
|
||||||
<!-- Map screen -->
|
<!-- Map screen -->
|
||||||
<string name="map_prev">上一个</string>
|
<string name="map_prev">上一个</string>
|
||||||
<string name="map_next">下一个</string>
|
<string name="map_next">下一个</string>
|
||||||
@@ -77,7 +127,7 @@
|
|||||||
<string name="prefs_updated_title">更新:%s</string>
|
<string name="prefs_updated_title">更新:%s</string>
|
||||||
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
|
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
|
||||||
|
|
||||||
<string name="prefs_loc_title">站位</string>
|
<string name="prefs_loc_title">站点位置</string>
|
||||||
<string name="prefs_loc_gps_title">GPS 定位</string>
|
<string name="prefs_loc_gps_title">GPS 定位</string>
|
||||||
<string name="prefs_loc_gps_error">检查您的位置权限</string>
|
<string name="prefs_loc_gps_error">检查您的位置权限</string>
|
||||||
<string name="prefs_loc_input_title">输入位置</string>
|
<string name="prefs_loc_input_title">输入位置</string>
|
||||||
@@ -85,11 +135,11 @@
|
|||||||
<string name="prefs_loc_qth_title">QTH 网格</string>
|
<string name="prefs_loc_qth_title">QTH 网格</string>
|
||||||
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
|
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
|
||||||
<string name="prefs_loc_success">位置更新成功</string>
|
<string name="prefs_loc_success">位置更新成功</string>
|
||||||
<string name="prefs_station_title">站位设置</string>
|
<string name="prefs_station_title">站点位置设置</string>
|
||||||
<string name="prefs_station_lat_text">输入站位的纬度</string>
|
<string name="prefs_station_lat_text">输入站点的纬度</string>
|
||||||
<string name="prefs_station_lon_text">输入站位的经度</string>
|
<string name="prefs_station_lon_text">输入站点的经度</string>
|
||||||
<string name="prefs_locator_title">QTH 网格</string>
|
<string name="prefs_locator_title">QTH 网格</string>
|
||||||
<string name="prefs_locator_text">使用梅登黑德网格设置站位</string>
|
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">卫星数据更新</string>
|
<string name="prefs_data_title">卫星数据更新</string>
|
||||||
<string name="prefs_data_entries">卫星:%s</string>
|
<string name="prefs_data_entries">卫星:%s</string>
|
||||||
@@ -100,13 +150,21 @@
|
|||||||
<string name="prefs_data_clear_success">数据清空成功</string>
|
<string name="prefs_data_clear_success">数据清空成功</string>
|
||||||
<string name="prefs_data_update_success">更新成功</string>
|
<string name="prefs_data_update_success">更新成功</string>
|
||||||
|
|
||||||
|
<string name="prefs_data_import_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</string>
|
||||||
|
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">自定义数据源</string>
|
<string name="prefs_data_sources_title">自定义数据源</string>
|
||||||
|
<string name="prefs_data_sources_restore">恢复默认数据源</string>
|
||||||
<string name="prefs_data_sources_tle_switch">自定义TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">自定义TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string>
|
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string>
|
||||||
|
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||||
|
<string name="prefs_data_sources_satellites_label">卫星数据</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_label">收发器数据</string>
|
||||||
|
|
||||||
<string name="prefs_data_output_title">数据输出</string>
|
<string name="prefs_data_output_title">数据输出</string>
|
||||||
<string name="prefs_net_output">网络</string>
|
<string name="prefs_net_output">网络</string>
|
||||||
<string name="prefs_bt_output">蓝牙</string>
|
<string name="prefs_bt_output">蓝牙</string>
|
||||||
|
<string name="prefs_cat_output">CAT</string>
|
||||||
|
|
||||||
<string name="prefs_net_title">网络数据输出</string>
|
<string name="prefs_net_title">网络数据输出</string>
|
||||||
<string name="prefs_net_rotator_switch">启用方位俯仰角输出</string>
|
<string name="prefs_net_rotator_switch">启用方位俯仰角输出</string>
|
||||||
@@ -115,6 +173,8 @@
|
|||||||
<string name="prefs_net_frequency_switch">启用多普勒频率输出</string>
|
<string name="prefs_net_frequency_switch">启用多普勒频率输出</string>
|
||||||
<string name="prefs_net_frequency_address_hint">IP:端口</string>
|
<string name="prefs_net_frequency_address_hint">IP:端口</string>
|
||||||
<string name="prefs_net_frequency_format_hint">数据格式</string>
|
<string name="prefs_net_frequency_format_hint">数据格式</string>
|
||||||
|
<string name="prefs_net_frequency_offset_hint" translatable="false">频率偏移 (Hz)</string>
|
||||||
|
<string name="prefs_net_frequency_offset_help" translatable="false">范围:-50000 到 50000 Hz。正值提高上报频率,负值降低上报频率。</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">蓝牙数据输出</string>
|
<string name="prefs_bt_title">蓝牙数据输出</string>
|
||||||
<string name="prefs_bt_rotator_switch">启用方位俯仰角输出</string>
|
<string name="prefs_bt_rotator_switch">启用方位俯仰角输出</string>
|
||||||
@@ -126,6 +186,16 @@
|
|||||||
<string name="prefs_bt_perm_error">请检查蓝牙权限</string>
|
<string name="prefs_bt_perm_error">请检查蓝牙权限</string>
|
||||||
<string name="prefs_net_perm_error">请检查网络权限</string>
|
<string name="prefs_net_perm_error">请检查网络权限</string>
|
||||||
|
|
||||||
|
<!-- Radio Control -->
|
||||||
|
<string name="nav_radiocontrol">电台控制</string>
|
||||||
|
<string name="rc_settings_title">CAT 电台控制</string>
|
||||||
|
<string name="rc_radio_model">电台型号</string>
|
||||||
|
<string name="rc_tx_device_hint">发射电台蓝牙地址</string>
|
||||||
|
<string name="rc_rx_device_hint">接收电台蓝牙地址</string>
|
||||||
|
<string name="rc_tx_name_hint">发射电台名称</string>
|
||||||
|
<string name="rc_rx_name_hint">接收电台名称</string>
|
||||||
|
<string name="rc_enable_switch">启用 CAT 控制</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">其他设置</string>
|
<string name="prefs_other_title">其他设置</string>
|
||||||
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
||||||
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
||||||
@@ -133,6 +203,11 @@
|
|||||||
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
|
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
|
||||||
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
|
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
|
||||||
|
|
||||||
|
<string name="prefs_highlight_title">仰角高亮</string>
|
||||||
|
<string name="prefs_highlight_low">低 < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">中 %1$d-%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">高 > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">我要感谢:</string>
|
<string name="prefs_outro_title">我要感谢:</string>
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
• Look4Sat 所有用户及贡献者!
|
• Look4Sat 所有用户及贡献者!
|
||||||
@@ -140,7 +215,53 @@
|
|||||||
\n• Dave Moten (predict4java)
|
\n• Dave Moten (predict4java)
|
||||||
\n• Alexandru Csete (Gpredict)
|
\n• Alexandru Csete (Gpredict)
|
||||||
\n• Dr T.S. Kelso (Celestrak)
|
\n• Dr T.S. Kelso (Celestrak)
|
||||||
\n• Libre Space Foundation (SatNOGS)</string>
|
\n• Libre Space Foundation (SatNOGS)
|
||||||
|
\n• xdsopl 和 Robot36 贡献者!
|
||||||
|
\n• BG7NTA(CW解码功能)</string>
|
||||||
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
||||||
|
|
||||||
|
<!-- 匹配页 Mutual match page -->
|
||||||
|
<string name="mutual_title">过境匹配</string>
|
||||||
|
<string name="mutual_station_you">你的位置</string>
|
||||||
|
<string name="mutual_station_opposite">友台位置</string>
|
||||||
|
<string name="mutual_label_you">你</string>
|
||||||
|
<string name="mutual_label_opposite">友台</string>
|
||||||
|
<string name="mutual_status_calculating">正在计算双方共同可见窗口…</string>
|
||||||
|
<string name="mutual_status_error">请修正提示后重新查询</string>
|
||||||
|
<string name="mutual_status_matches">找到 %d 个匹配 · 点击卡片展开曲线</string>
|
||||||
|
<string name="mutual_status_none">未找到共同可见窗口,调整时间或仰角后重试</string>
|
||||||
|
<string name="mutual_status_idle">输入双方位置后点击查询</string>
|
||||||
|
<string name="mutual_chip_calculating">计算中</string>
|
||||||
|
<string name="mutual_chip_error">有错误</string>
|
||||||
|
<string name="mutual_chip_matches">%d 个匹配</string>
|
||||||
|
<string name="mutual_chip_none">无匹配</string>
|
||||||
|
<string name="mutual_chip_idle">待查询</string>
|
||||||
|
<string name="mutual_use_current">填入当前位置</string>
|
||||||
|
<string name="mutual_lat">纬度</string>
|
||||||
|
<string name="mutual_lon">经度</string>
|
||||||
|
<string name="mutual_grid">网格</string>
|
||||||
|
<string name="mutual_grid_hint">支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度</string>
|
||||||
|
<string name="mutual_min_elevation">最小仰角:%d°</string>
|
||||||
|
<string name="mutual_time_range">时间范围</string>
|
||||||
|
<string name="mutual_calculating">计算中…</string>
|
||||||
|
<string name="mutual_query">查询匹配</string>
|
||||||
|
<string name="mutual_elevation_you">你:%d°</string>
|
||||||
|
<string name="mutual_elevation_opposite">友台:%d°</string>
|
||||||
|
<string name="mutual_both_tracks">双方轨迹</string>
|
||||||
|
<string name="mutual_open_radar">查看雷达</string>
|
||||||
|
<string name="mutual_station_a">站点A</string>
|
||||||
|
<string name="mutual_station_b">站点B</string>
|
||||||
|
<string name="update_check_title">检查更新</string>
|
||||||
|
<string name="update_check_button">检查更新</string>
|
||||||
|
<string name="update_check_current">当前版本:%s</string>
|
||||||
|
<string name="update_check_latest">最新版本:%s</string>
|
||||||
|
<string name="update_check_up_to_date">已是最新版本</string>
|
||||||
|
<string name="update_check_available">发现新版本</string>
|
||||||
|
<string name="update_check_release_notes">更新内容</string>
|
||||||
|
<string name="update_check_download">下载并安装</string>
|
||||||
|
<string name="update_check_downloading">正在下载…</string>
|
||||||
|
<string name="update_check_error">检查更新失败,请检查网络连接。</string>
|
||||||
|
<string name="update_check_download_error">下载失败,请重试。</string>
|
||||||
|
<string name="update_check_no_apk">该版本未附带 APK 安装包。</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Commmon -->
|
<!-- Commmon -->
|
||||||
<string name="app_name" translatable="false">Look4Sat-CNMap</string>
|
<string name="app_name" translatable="false">Look4Sat</string>
|
||||||
<string name="btn_accept">Accept</string>
|
<string name="btn_accept">Accept</string>
|
||||||
<string name="btn_cancel">Cancel</string>
|
<string name="btn_cancel">Cancel</string>
|
||||||
<string name="empty_list_message">No data to show</string>
|
<string name="empty_list_message">No data to show</string>
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
<string name="nav_sat">Satellites</string>
|
<string name="nav_sat">Satellites</string>
|
||||||
<string name="nav_pass">Passes</string>
|
<string name="nav_pass">Passes</string>
|
||||||
<string name="nav_radar">Radar</string>
|
<string name="nav_radar">Radar</string>
|
||||||
|
<string name="nav_mutual">Match</string>
|
||||||
<string name="nav_map">Map</string>
|
<string name="nav_map">Map</string>
|
||||||
<string name="nav_prefs">Settings</string>
|
<string name="nav_prefs">Settings</string>
|
||||||
|
|
||||||
@@ -31,7 +32,9 @@
|
|||||||
<!-- Passes screen -->
|
<!-- Passes screen -->
|
||||||
<string name="pass_filter_title">Filter passes</string>
|
<string name="pass_filter_title">Filter passes</string>
|
||||||
<string name="pass_filter_elev">Minimal elevation</string>
|
<string name="pass_filter_elev">Minimal elevation</string>
|
||||||
<string name="pass_filter_hours">Hours ahead</string>
|
<string name="pass_filter_hours">Time ahead</string>
|
||||||
|
<string name="pass_filter_aos_time">AOS window</string>
|
||||||
|
<string name="pass_filter_invert_time">Invert AOS window</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace (period >225min)</string>
|
<string name="pass_filter_deep_space">DeepSpace (period >225min)</string>
|
||||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||||
<string name="pass_modes_title">Select modulation type</string>
|
<string name="pass_modes_title">Select modulation type</string>
|
||||||
@@ -43,16 +46,51 @@
|
|||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
Make sure your filters are correct and you have selected satellites</string>
|
Make sure your filters are correct and you have selected satellites</string>
|
||||||
<string name="pass_error_message">There are no upcoming satellite passes to display</string>
|
<string name="pass_error_message">There are no upcoming satellite passes to display</string>
|
||||||
<string name="pass_welcome_title">Welcome to Look4Sat-CNMap\!</string>
|
<string name="pass_welcome_title">Welcome to Look4Sat\!</string>
|
||||||
<string name="pass_welcome_message">
|
<string name="pass_welcome_message">
|
||||||
Make sure to set your position via GPS, Lat/Lon or QTH in Settings.
|
Make sure to set your position via GPS, Lat/Lon or QTH in Settings.
|
||||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
||||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat-CNMap</string>
|
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* Added required tweaks to support Android 17 (API 37)
|
* Added required tweaks to support Android 17 (API 37)
|
||||||
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
|
<!-- AMSAT Status screen -->
|
||||||
|
<string name="nav_amsat">AMSAT</string>
|
||||||
|
<string name="amsat_title">AMSAT Satellite Status</string>
|
||||||
|
<string name="amsat_refresh">Refresh</string>
|
||||||
|
<string name="amsat_retry">Retry</string>
|
||||||
|
<string name="amsat_updated">Updated:</string>
|
||||||
|
<string name="amsat_active">Active</string>
|
||||||
|
<string name="amsat_tlm">TLM/Beacon</string>
|
||||||
|
<string name="amsat_not_heard">Not Heard</string>
|
||||||
|
<string name="amsat_conflict">Conflicting</string>
|
||||||
|
<string name="amsat_name">Name</string>
|
||||||
|
<string name="amsat_load_failed">Load failed - check network and retry</string>
|
||||||
|
<string name="amsat_no_reports">No reports for this period</string>
|
||||||
|
<string name="amsat_close">Close</string>
|
||||||
|
<string name="amsat_upload">Upload</string>
|
||||||
|
<string name="amsat_upload_back">Back</string>
|
||||||
|
<string name="amsat_upload_report">Upload report to AMSAT</string>
|
||||||
|
<string name="amsat_upload_status">Status</string>
|
||||||
|
<string name="amsat_upload_callsign">Callsign</string>
|
||||||
|
<string name="amsat_upload_grid">Grid</string>
|
||||||
|
<string name="amsat_upload_time_now">Time: current UTC %s</string>
|
||||||
|
<string name="amsat_upload_submit">Submit to AMSAT</string>
|
||||||
|
<string name="amsat_upload_confirm">Upload</string>
|
||||||
|
<string name="amsat_upload_confirm_title">Confirm AMSAT upload</string>
|
||||||
|
<string name="amsat_upload_confirm_message">This public report will be uploaded to AMSAT.\nSatellite: %1$s\nStatus: %2$s\nCallsign: %3$s\nGrid: %4$s</string>
|
||||||
|
<string name="amsat_upload_grid_none">Not provided</string>
|
||||||
|
<string name="amsat_upload_success">Report uploaded</string>
|
||||||
|
<string name="amsat_upload_failed">Upload failed: %s</string>
|
||||||
|
<string name="amsat_upload_callsign_required">Callsign is required</string>
|
||||||
|
<string name="amsat_upload_grid_invalid">Grid must be a 4 or 6 character Maidenhead locator</string>
|
||||||
|
<string name="amsat_upload_active">Active</string>
|
||||||
|
<string name="amsat_upload_tlm">TLM/Beacon</string>
|
||||||
|
<string name="amsat_upload_not_heard">Not Heard</string>
|
||||||
|
<string name="amsat_upload_crew_active">Crew Active</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
<string name="radar_back">Back</string>
|
<string name="radar_back">Back</string>
|
||||||
<string name="radar_notify">Notify</string>
|
<string name="radar_notify">Notify</string>
|
||||||
@@ -78,12 +116,21 @@
|
|||||||
<string name="radar_string_no">No</string>
|
<string name="radar_string_no">No</string>
|
||||||
<string name="radar_string_yes">Yes</string>
|
<string name="radar_string_yes">Yes</string>
|
||||||
<string name="radar_visible">Visible</string>
|
<string name="radar_visible">Visible</string>
|
||||||
|
<string name="radar_doppler_calc">Doppler Frequency Calculator</string>
|
||||||
|
<string name="radar_doppler_tx_hint">Enter TX freq (MHz)</string>
|
||||||
|
<string name="radar_doppler_rx_hint">Enter RX freq (MHz)</string>
|
||||||
|
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
|
||||||
|
<string name="radar_doppler_info">For linear transponders, type one frequency to see the other</string>
|
||||||
|
|
||||||
|
<string name="radar_cw_decoder">CW Decoder</string>
|
||||||
|
<string name="radar_cw_start">Start</string>
|
||||||
|
<string name="radar_cw_stop">Stop</string>
|
||||||
|
<string name="radar_cw_reset">Clear</string>
|
||||||
|
|
||||||
<!-- Map screen -->
|
<!-- Map screen -->
|
||||||
<string name="map_prev">Prev</string>
|
<string name="map_prev">Prev</string>
|
||||||
<string name="map_next">Next</string>
|
<string name="map_next">Next</string>
|
||||||
<string name="map_copyright_osm" translatable="false">© OpenStreetMap contributors</string>
|
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
|
||||||
<string name="map_copyright_tianditu" translatable="false">© Tianditu</string>
|
|
||||||
<string name="map_azimuth">Azimuth: %.1f°</string>
|
<string name="map_azimuth">Azimuth: %.1f°</string>
|
||||||
<string name="map_elevation">Elevation: %.1f°</string>
|
<string name="map_elevation">Elevation: %.1f°</string>
|
||||||
<string name="map_altitude">Altitude: %.0f km</string>
|
<string name="map_altitude">Altitude: %.0f km</string>
|
||||||
@@ -99,12 +146,12 @@
|
|||||||
<string name="map_visible">Visible</string>
|
<string name="map_visible">Visible</string>
|
||||||
|
|
||||||
<!-- Settings screen -->
|
<!-- Settings screen -->
|
||||||
<string name="prefs_app_title" translatable="false">Look4Sat-CNMap v%s</string>
|
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
|
||||||
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=cn.bh2vsq.look4sat</string>
|
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
|
||||||
<string name="prefs_donate_title">Donate</string>
|
<string name="prefs_donate_title">Donate</string>
|
||||||
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
||||||
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
|
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
|
||||||
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/cn.bh2vsq.look4sat/</string>
|
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
|
||||||
<string name="prefs_github_title" translatable="false">GitHub</string>
|
<string name="prefs_github_title" translatable="false">GitHub</string>
|
||||||
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
||||||
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
||||||
@@ -137,24 +184,22 @@
|
|||||||
<string name="prefs_data_clear">Clear</string>
|
<string name="prefs_data_clear">Clear</string>
|
||||||
<string name="prefs_data_clear_success">Data was cleared successfully</string>
|
<string name="prefs_data_clear_success">Data was cleared successfully</string>
|
||||||
<string name="prefs_data_update_success">Update completed successfully</string>
|
<string name="prefs_data_update_success">Update completed successfully</string>
|
||||||
|
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||||
|
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||||
|
<string name="prefs_data_sources_restore">Restore default sources</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</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_transceivers_switch">Custom transceivers URL</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
|
<string name="prefs_data_sources_satellites_label">Satellites data</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_label">Transceivers data</string>
|
||||||
|
|
||||||
<string name="prefs_data_output_title">Data output</string>
|
<string name="prefs_data_output_title">Data output</string>
|
||||||
<string name="prefs_net_output">Network</string>
|
<string name="prefs_net_output">Network</string>
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
<string name="prefs_cat_output">CAT</string>
|
<string name="prefs_cat_output">CAT</string>
|
||||||
|
|
||||||
<string name="prefs_map_title">Map settings</string>
|
|
||||||
<string name="prefs_map_source_osm" translatable="false">OSM</string>
|
|
||||||
<string name="prefs_map_source_tianditu_vector" translatable="false">天地图-矢量</string>
|
|
||||||
<string name="prefs_map_source_tianditu_image" translatable="false">天地图-影像</string>
|
|
||||||
<string name="prefs_map_tianditu_key" translatable="false">天地图密钥</string>
|
|
||||||
<string name="prefs_map_save_key">保存密钥</string>
|
|
||||||
|
|
||||||
<!-- Radio Control -->
|
<!-- Radio Control -->
|
||||||
<string name="nav_radiocontrol">Radio Control</string>
|
<string name="nav_radiocontrol">Radio Control</string>
|
||||||
<string name="rc_settings_title">CAT Radio Control</string>
|
<string name="rc_settings_title">CAT Radio Control</string>
|
||||||
@@ -172,6 +217,8 @@
|
|||||||
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
<string name="prefs_net_frequency_switch">Enable frequency output</string>
|
||||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||||
<string name="prefs_net_frequency_format_hint">Format</string>
|
<string name="prefs_net_frequency_format_hint">Format</string>
|
||||||
|
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
|
||||||
|
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
|
||||||
|
|
||||||
<string name="prefs_bt_title">Bluetooth data output</string>
|
<string name="prefs_bt_title">Bluetooth data output</string>
|
||||||
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
|
||||||
@@ -190,6 +237,11 @@
|
|||||||
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
|
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
|
||||||
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
|
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
|
||||||
|
|
||||||
|
<string name="prefs_highlight_title">Elevation highlight</string>
|
||||||
|
<string name="prefs_highlight_low">Low < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">Mid %1$d–%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">High > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">I would like to say thanks to</string>
|
<string name="prefs_outro_title">I would like to say thanks to</string>
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
* Look4Sat users and contributors!
|
* Look4Sat users and contributors!
|
||||||
@@ -199,7 +251,52 @@
|
|||||||
\n* Dr T.S. Kelso (Celestrak)
|
\n* Dr T.S. Kelso (Celestrak)
|
||||||
\n* Libre Space Foundation (SatNOGS)
|
\n* Libre Space Foundation (SatNOGS)
|
||||||
\n* xdsopl and Robot36 contributors!
|
\n* xdsopl and Robot36 contributors!
|
||||||
|
\n* BG7NTA (CW decoding feature)
|
||||||
</string>
|
</string>
|
||||||
<string name="prefs_outro_license">The app comes with no warranty</string>
|
<string name="prefs_outro_license">The app comes with no warranty</string>
|
||||||
|
|
||||||
|
<!-- Mutual match page -->
|
||||||
|
<string name="mutual_title">Pass Match</string>
|
||||||
|
<string name="mutual_station_you">Your Station</string>
|
||||||
|
<string name="mutual_station_opposite">Opposite Station</string>
|
||||||
|
<string name="mutual_label_you">You</string>
|
||||||
|
<string name="mutual_label_opposite">Opposite</string>
|
||||||
|
<string name="mutual_status_calculating">Calculating mutual visibility windows…</string>
|
||||||
|
<string name="mutual_status_error">Fix the reported issues and retry</string>
|
||||||
|
<string name="mutual_status_matches">%d matches found · tap a card to expand</string>
|
||||||
|
<string name="mutual_status_none">No mutual windows found. Adjust time or min elevation and retry</string>
|
||||||
|
<string name="mutual_status_idle">Enter both stations, then tap Query</string>
|
||||||
|
<string name="mutual_chip_calculating">Calculating</string>
|
||||||
|
<string name="mutual_chip_error">Error</string>
|
||||||
|
<string name="mutual_chip_matches">%d matches</string>
|
||||||
|
<string name="mutual_chip_none">No matches</string>
|
||||||
|
<string name="mutual_chip_idle">Idle</string>
|
||||||
|
<string name="mutual_use_current">Use current</string>
|
||||||
|
<string name="mutual_lat">Lat</string>
|
||||||
|
<string name="mutual_lon">Lon</string>
|
||||||
|
<string name="mutual_grid">Grid</string>
|
||||||
|
<string name="mutual_grid_hint">Supports 4/6/8-char Maidenhead grid; or enter lat/lon directly</string>
|
||||||
|
<string name="mutual_min_elevation">Min elevation: %d°</string>
|
||||||
|
<string name="mutual_time_range">Time range</string>
|
||||||
|
<string name="mutual_calculating">Calculating…</string>
|
||||||
|
<string name="mutual_query">Query Match</string>
|
||||||
|
<string name="mutual_elevation_you">You: %d°</string>
|
||||||
|
<string name="mutual_elevation_opposite">Opposite: %d°</string>
|
||||||
|
<string name="mutual_both_tracks">Both tracks</string>
|
||||||
|
<string name="mutual_open_radar">Open Radar</string>
|
||||||
|
<string name="mutual_station_a">Station A</string>
|
||||||
|
<string name="mutual_station_b">Station B</string>
|
||||||
|
<string name="update_check_title">Check for updates</string>
|
||||||
|
<string name="update_check_button">Check for updates</string>
|
||||||
|
<string name="update_check_current">Current version: %s</string>
|
||||||
|
<string name="update_check_latest">Latest version: %s</string>
|
||||||
|
<string name="update_check_up_to_date">You are up to date</string>
|
||||||
|
<string name="update_check_available">New version available</string>
|
||||||
|
<string name="update_check_release_notes">Release notes</string>
|
||||||
|
<string name="update_check_download">Download & install</string>
|
||||||
|
<string name="update_check_downloading">Downloading…</string>
|
||||||
|
<string name="update_check_error">Failed to check for updates. Check your connection.</string>
|
||||||
|
<string name="update_check_download_error">Download failed. Try again.</string>
|
||||||
|
<string name="update_check_no_apk">No APK attached to this release.</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import com.android.build.api.dsl.LibraryExtension
|
||||||
|
import com.android.build.api.variant.CanProduceConsumerProguardFiles
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.convention.featurePlugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.rtbishop.look4sat.feature.cw"
|
||||||
|
compileOptions {
|
||||||
|
encoding = "UTF-8"
|
||||||
|
}
|
||||||
|
// 照搬 Morse Expert 1.15: native 解码器仅 armeabi-v7a
|
||||||
|
defaultConfig {
|
||||||
|
ndk {
|
||||||
|
abiFilters += listOf("armeabi-v7a")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CW 类保持规则(JNI 按类名注册 + 照搬混淆类保逻辑), 由 app 的 R8 消费
|
||||||
|
// AGP 9: consumer 规则走 variant 级 CanProduceConsumerProguardFiles
|
||||||
|
androidComponents {
|
||||||
|
onVariants(selector().all()) { variant ->
|
||||||
|
(variant as? CanProduceConsumerProguardFiles)?.consumerProguardFiles?.add(
|
||||||
|
project.layout.projectDirectory.file("proguard-rules.pro")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 照搬的 Java 源码含中文注释, 强制 UTF-8 编译(compileOptions 在部分 AGP 版本不生效)
|
||||||
|
tasks.withType<JavaCompile>().configureEach {
|
||||||
|
options.encoding = "UTF-8"
|
||||||
|
}
|
||||||
Vendored
+31
@@ -0,0 +1,31 @@
|
|||||||
|
# Look4Sat Pro — CW 解码模块(照搬 Morse Expert 1.15)混淆规则
|
||||||
|
# 所有 CW 类保持原名: pas.* 通过 RegisterNatives 按类名注册 JNI(混淆即崩);
|
||||||
|
# 其余为照搬的混淆名类(命名已无混淆意义, 保持以保逻辑完整)。
|
||||||
|
|
||||||
|
# JNI 引擎(RegisterNatives 按类名/方法名查表)
|
||||||
|
-keep class pas.** { *; }
|
||||||
|
|
||||||
|
# 照搬的解码/UI 类(全部保持)
|
||||||
|
-keep class com.ve3nea.morse_expert.** { *; }
|
||||||
|
-keep class H2.** { *; }
|
||||||
|
-keep class I2.b { *; }
|
||||||
|
-keep class J2.** { *; }
|
||||||
|
-keep class k3.** { *; }
|
||||||
|
-keep class i3.** { *; }
|
||||||
|
-keep class g3.** { *; }
|
||||||
|
-keep class j3.** { *; }
|
||||||
|
-keep class s.** { *; }
|
||||||
|
-keep class d1.AbstractC1518b { *; }
|
||||||
|
-keep class B0.b { *; }
|
||||||
|
-keep class B.RunnableC0001b { *; }
|
||||||
|
-keep class D.n { *; }
|
||||||
|
-keep class E2.g { *; }
|
||||||
|
-keep class F2.a { *; }
|
||||||
|
-keep class I0.d { *; }
|
||||||
|
-keep class K1.a { *; }
|
||||||
|
-keep class j1.C1646n { *; }
|
||||||
|
|
||||||
|
# sun.misc stub(Android 无此类, 仅编译期; 防止 R8 异常处理)
|
||||||
|
-dontwarn sun.misc.**
|
||||||
|
-keep class sun.misc.Unsafe { *; }
|
||||||
|
-keep class sun.misc.Cleaner { *; }
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package B;
|
||||||
|
|
||||||
|
import android.text.SpannableStringBuilder;
|
||||||
|
import com.ve3nea.morse_expert.DecodedTextView;
|
||||||
|
import com.ve3nea.morse_expert.MainActivity;
|
||||||
|
import H2.b;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 照搬自 Morse Expert 1.15 B.RunnableC0001b, 类名保持混淆原名。
|
||||||
|
* R8 合并 Runnable 手术: 构造按 smali 真实签名 5 参 (Object,Object,int,int,boolean),
|
||||||
|
* 字段 = i(Object)/h(Object)/g(int)/f(int); run() 仅保留 case 4(f==2 对应 pswitch_4:
|
||||||
|
* 解码文本 UI 更新, H2.b 调用), 其余 case(权限/Intent/通知等库代码)已裁剪。
|
||||||
|
* 注: jadx 4 参构造为错误还原, 已按 smali 修正。
|
||||||
|
*/
|
||||||
|
public final class RunnableC0001b implements Runnable {
|
||||||
|
public final /* synthetic */ int f;
|
||||||
|
|
||||||
|
/* renamed from: g, reason: collision with root package name */
|
||||||
|
public final int g;
|
||||||
|
|
||||||
|
/* renamed from: h, reason: collision with root package name */
|
||||||
|
public final Object h;
|
||||||
|
|
||||||
|
/* renamed from: i, reason: collision with root package name */
|
||||||
|
public final Object i;
|
||||||
|
|
||||||
|
public /* synthetic */ RunnableC0001b(Object obj, Object obj2, int i4, int i5, boolean z) {
|
||||||
|
this.i = obj;
|
||||||
|
this.h = obj2;
|
||||||
|
this.g = i4;
|
||||||
|
this.f = i5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override // java.lang.Runnable
|
||||||
|
public final void run() {
|
||||||
|
if (this.f == 2) {
|
||||||
|
MainActivity mainActivity = ((b) this.i).f620p;
|
||||||
|
if (mainActivity != null) {
|
||||||
|
SpannableStringBuilder spannableStringBuilder = (SpannableStringBuilder) this.h;
|
||||||
|
int i5 = this.g;
|
||||||
|
DecodedTextView decodedTextView = (DecodedTextView) mainActivity.f11035D.f11888g;
|
||||||
|
if (!decodedTextView.f11032n && !decodedTextView.f11033o.get()) {
|
||||||
|
decodedTextView.beginBatchEdit();
|
||||||
|
try {
|
||||||
|
int length2 = decodedTextView.length();
|
||||||
|
int min = Math.min(length2, i5);
|
||||||
|
if (min > 0) {
|
||||||
|
decodedTextView.getEditableText().delete(length2 - min, length2);
|
||||||
|
}
|
||||||
|
decodedTextView.q(spannableStringBuilder);
|
||||||
|
decodedTextView.endBatchEdit();
|
||||||
|
return;
|
||||||
|
} catch (Throwable th) {
|
||||||
|
decodedTextView.endBatchEdit();
|
||||||
|
throw th;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package B0;
|
||||||
|
|
||||||
|
import android.widget.TextView;
|
||||||
|
import com.ve3nea.morse_expert.MainActivity;
|
||||||
|
import com.ve3nea.morse_expert.ScaleView;
|
||||||
|
import java.util.Locale;
|
||||||
|
import d1.AbstractC1518b;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 照搬自 Morse Expert 1.15 B0.b, 类名保持混淆原名。
|
||||||
|
* R8 合并 Runnable 手术: 构造按 smali 真实签名 (H2/b, String, [I) -> f=4;
|
||||||
|
* 只保留 case 4(状态栏更新: dBFS/Hz/dB/WPM + ScaleView 频率标记, H2.b 调用),
|
||||||
|
* 其余 case(WorkManager/动画等库代码)已裁剪。
|
||||||
|
* r3 = scaleView.getHeight()(smali 证实, jadx 寄存器未展开)。
|
||||||
|
*/
|
||||||
|
public final class b implements Runnable {
|
||||||
|
public final /* synthetic */ int f;
|
||||||
|
|
||||||
|
/* renamed from: g, reason: collision with root package name */
|
||||||
|
public final Object g;
|
||||||
|
|
||||||
|
/* renamed from: h, reason: collision with root package name */
|
||||||
|
public final Object h;
|
||||||
|
|
||||||
|
/* renamed from: i, reason: collision with root package name */
|
||||||
|
public final Object i;
|
||||||
|
|
||||||
|
public b(H2.b bVar, String str, int[] iArr) {
|
||||||
|
this.f = 4;
|
||||||
|
this.g = bVar;
|
||||||
|
this.h = iArr;
|
||||||
|
this.i = str;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override // java.lang.Runnable
|
||||||
|
public final void run() {
|
||||||
|
if (this.f == 4) {
|
||||||
|
MainActivity mainActivity = ((H2.b) this.g).f621q;
|
||||||
|
if (mainActivity != null) {
|
||||||
|
String str2 = (String) this.i;
|
||||||
|
int[] iArr = (int[]) this.h;
|
||||||
|
String concat;
|
||||||
|
String format = String.format(Locale.US, " %3d dBFS", Integer.valueOf(iArr[0]));
|
||||||
|
if (iArr[5] == 0) {
|
||||||
|
concat = format.concat(" Idle ");
|
||||||
|
} else {
|
||||||
|
concat = format.concat(String.format(Locale.US, " %4d Hz %2d dB %2d WPM ", Integer.valueOf(iArr[1]), Integer.valueOf(iArr[2]), Integer.valueOf(iArr[3])));
|
||||||
|
}
|
||||||
|
((TextView) mainActivity.f11035D.f11890i).setText(AbstractC1518b.e(concat, str2));
|
||||||
|
ScaleView scaleView = (ScaleView) mainActivity.f11035D.f11889h;
|
||||||
|
int i4 = iArr[1];
|
||||||
|
scaleView.f11041g = iArr[5];
|
||||||
|
// r3 = scaleView.getHeight()(smali 证实)
|
||||||
|
scaleView.f = (scaleView.getHeight() - 1) - Math.round(((Math.round(i4 * 0.128f) - H2.b.f606z) * scaleView.getHeight()) / H2.b.f603B);
|
||||||
|
scaleView.invalidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package D;
|
||||||
|
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.opengl.GLES20;
|
||||||
|
import android.opengl.GLUtils;
|
||||||
|
import com.ve3nea.morse_expert.WaterfallSurfaceView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 照搬自 Morse Expert 1.15, 类名保持混淆原名。
|
||||||
|
* R8 合并 Runnable 手术: 只保留 case 1(瀑布图频谱->OpenGL 纹理, H2.b 调用),
|
||||||
|
* 其余 case(库/广告)已裁剪。
|
||||||
|
*/
|
||||||
|
public final /* synthetic */ class n implements Runnable {
|
||||||
|
public final /* synthetic */ int f;
|
||||||
|
|
||||||
|
/* renamed from: g, reason: collision with root package name */
|
||||||
|
public final /* synthetic */ Object f224g;
|
||||||
|
|
||||||
|
/* renamed from: h, reason: collision with root package name */
|
||||||
|
public final /* synthetic */ Object f225h;
|
||||||
|
|
||||||
|
public /* synthetic */ n(Object obj, int i4, Object obj2) {
|
||||||
|
this.f = i4;
|
||||||
|
this.f224g = obj;
|
||||||
|
this.f225h = obj2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override // java.lang.Runnable
|
||||||
|
public final void run() {
|
||||||
|
int i4 = this.f;
|
||||||
|
Object obj = this.f225h;
|
||||||
|
Object obj2 = this.f224g;
|
||||||
|
if (i4 == 1) {
|
||||||
|
float[] fArr = (float[]) obj;
|
||||||
|
J2.b bVar = ((WaterfallSurfaceView) obj2).f;
|
||||||
|
Bitmap bitmap = bVar.f733h;
|
||||||
|
int width = bVar.f730d.getWidth();
|
||||||
|
double d4 = width;
|
||||||
|
double b4 = (bVar.b() + 4.0d) % d4;
|
||||||
|
double d5 = bVar.f737l - b4;
|
||||||
|
if (d5 > d4 / 2.0d) {
|
||||||
|
d5 -= d4;
|
||||||
|
} else if (d5 < (-width) / 2.0d) {
|
||||||
|
d5 += d4;
|
||||||
|
}
|
||||||
|
if (Math.abs(d5) > 8.0d) {
|
||||||
|
bVar.f737l = (int) Math.floor(b4);
|
||||||
|
} else {
|
||||||
|
bVar.f732g = (d5 * 1.0E-5d) + bVar.f732g;
|
||||||
|
}
|
||||||
|
int height = bitmap.getHeight() - 1;
|
||||||
|
for (int i5 = 0; i5 <= height; i5++) {
|
||||||
|
J2.a aVar = bVar.f734i;
|
||||||
|
int round = Math.round((fArr[i5] * 4.0f) + 30.0f);
|
||||||
|
aVar.getClass();
|
||||||
|
bitmap.setPixel(0, height - i5, aVar.f727a[Math.max(0, Math.min(255, round))]);
|
||||||
|
}
|
||||||
|
int i6 = bVar.f737l;
|
||||||
|
GLES20.glBindTexture(3553, bVar.f728a[0]);
|
||||||
|
J2.b.a();
|
||||||
|
GLUtils.texSubImage2D(3553, 0, i6, 0, bitmap);
|
||||||
|
J2.b.a();
|
||||||
|
int i7 = bVar.f737l + 1;
|
||||||
|
bVar.f737l = i7;
|
||||||
|
bVar.f737l = i7 % width;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package E2;
|
||||||
|
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
import android.view.View;
|
||||||
|
import com.ve3nea.morse_expert.MainActivity;
|
||||||
|
import com.ve3nea.morse_expert.ScaleView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 照搬自 Morse Expert 1.15 (com.ve3nea.morse_expert), 类名保持混淆原名。
|
||||||
|
* R8 合并类手术: 只保留 case 1(瀑布图触摸选频率=应用逻辑),
|
||||||
|
* 其余 case(广告 P4 / appcompat 焦点 / 弹窗)为库代码, 已裁剪。
|
||||||
|
*/
|
||||||
|
public final class g implements View.OnTouchListener {
|
||||||
|
public final /* synthetic */ int f;
|
||||||
|
|
||||||
|
/* renamed from: g, reason: collision with root package name */
|
||||||
|
public final /* synthetic */ Object f411g;
|
||||||
|
|
||||||
|
public /* synthetic */ g(Object obj, int i4) {
|
||||||
|
this.f = i4;
|
||||||
|
this.f411g = obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override // android.view.View.OnTouchListener
|
||||||
|
public final boolean onTouch(View view, MotionEvent motionEvent) {
|
||||||
|
int i4 = this.f;
|
||||||
|
Object obj = this.f411g;
|
||||||
|
if (i4 == 1) {
|
||||||
|
MainActivity mainActivity = (MainActivity) obj;
|
||||||
|
if (motionEvent.getActionMasked() == 0 && mainActivity.f11037F != null) {
|
||||||
|
// smali 证实: p1 先被重赋值为 f11035D.h(ScaleView)再 getHeight;
|
||||||
|
// 公式 = round((f606z + ((scaleH-1-y)*f603B/scaleH)) / 0.128f)
|
||||||
|
ScaleView scaleView = (ScaleView) mainActivity.f11035D.f11889h;
|
||||||
|
float y3 = motionEvent.getY();
|
||||||
|
int round = Math.round((H2.b.f606z + ((((scaleView.getHeight() - 1) - y3) * H2.b.f603B) / scaleView.getHeight())) / 0.128f);
|
||||||
|
mainActivity.f11037F.f622r.set(round);
|
||||||
|
Log.i("Waterfall", String.format("Touch at %d Hz`", Integer.valueOf(round)));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package F2;
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.feature.cw.R;
|
||||||
|
import android.view.ActionMode;
|
||||||
|
import android.view.Menu;
|
||||||
|
import android.view.MenuItem;
|
||||||
|
import com.ve3nea.morse_expert.DecodedTextView;
|
||||||
|
|
||||||
|
/* loaded from: classes.dex */
|
||||||
|
public final class a implements ActionMode.Callback {
|
||||||
|
|
||||||
|
/* renamed from: a, reason: collision with root package name */
|
||||||
|
public final /* synthetic */ DecodedTextView f564a;
|
||||||
|
|
||||||
|
public a(DecodedTextView decodedTextView) {
|
||||||
|
this.f564a = decodedTextView;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override // android.view.ActionMode.Callback
|
||||||
|
public final boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) {
|
||||||
|
int itemId = menuItem.getItemId();
|
||||||
|
DecodedTextView decodedTextView = this.f564a;
|
||||||
|
if (itemId != 73) {
|
||||||
|
if (itemId != 74) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
decodedTextView.s();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
decodedTextView.r();
|
||||||
|
actionMode.finish();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override // android.view.ActionMode.Callback
|
||||||
|
public final boolean onCreateActionMode(ActionMode actionMode, Menu menu) {
|
||||||
|
this.f564a.f11032n = true;
|
||||||
|
// 原: if (menu.findItem(R.id.shareText) == null) 检查(shareText 为原 APK 资源, 简化后始终添加)
|
||||||
|
menu.add(0, 74, 0, "Share").setIcon(R.drawable.ic_baseline_share_24);
|
||||||
|
menu.add(0, 73, 0, "Save").setIcon(R.drawable.ic_baseline_save_24);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override // android.view.ActionMode.Callback
|
||||||
|
public final void onDestroyActionMode(ActionMode actionMode) {
|
||||||
|
this.f564a.f11032n = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override // android.view.ActionMode.Callback
|
||||||
|
public final boolean onPrepareActionMode(ActionMode actionMode, Menu menu) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package H2;
|
||||||
|
|
||||||
|
import android.media.AudioRecord;
|
||||||
|
import android.util.Log;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
/* loaded from: classes.dex */
|
||||||
|
public final class a implements Runnable {
|
||||||
|
public final int f;
|
||||||
|
|
||||||
|
/* renamed from: g, reason: collision with root package name */
|
||||||
|
public final AudioRecord f596g;
|
||||||
|
|
||||||
|
/* renamed from: h, reason: collision with root package name */
|
||||||
|
public final ExecutorService f597h;
|
||||||
|
|
||||||
|
/* renamed from: i, reason: collision with root package name */
|
||||||
|
public final AtomicBoolean f598i;
|
||||||
|
|
||||||
|
/* renamed from: j, reason: collision with root package name */
|
||||||
|
public final short[] f599j;
|
||||||
|
|
||||||
|
/* renamed from: k, reason: collision with root package name */
|
||||||
|
public final float[] f600k;
|
||||||
|
|
||||||
|
/* renamed from: l, reason: collision with root package name */
|
||||||
|
public b f601l;
|
||||||
|
|
||||||
|
public a() {
|
||||||
|
int minBufferSize = AudioRecord.getMinBufferSize(8000, 16, 2);
|
||||||
|
int i4 = b.f604C;
|
||||||
|
this.f = i4;
|
||||||
|
ExecutorService newSingleThreadExecutor = Executors.newSingleThreadExecutor();
|
||||||
|
this.f597h = newSingleThreadExecutor;
|
||||||
|
AtomicBoolean atomicBoolean = new AtomicBoolean(false);
|
||||||
|
this.f598i = atomicBoolean;
|
||||||
|
this.f599j = new short[i4];
|
||||||
|
this.f600k = new float[i4];
|
||||||
|
AudioRecord audioRecord = new AudioRecord(1, 8000, 16, 2, minBufferSize * 4);
|
||||||
|
this.f596g = audioRecord;
|
||||||
|
if (audioRecord.getState() == 1) {
|
||||||
|
audioRecord.startRecording();
|
||||||
|
if (audioRecord.getState() != 1) {
|
||||||
|
Log.e("AudioRecord", "cannot start");
|
||||||
|
}
|
||||||
|
atomicBoolean.set(true);
|
||||||
|
newSingleThreadExecutor.execute(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Log.e("AudioInput", "Failed to initialize");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override // java.lang.Runnable
|
||||||
|
public final void run() {
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
if (this.f598i.get()) {
|
||||||
|
float[] fArr = this.f600k;
|
||||||
|
AudioRecord audioRecord = this.f596g;
|
||||||
|
short[] sArr = this.f599j;
|
||||||
|
int i4 = this.f;
|
||||||
|
if (audioRecord.read(sArr, 0, i4) == i4) {
|
||||||
|
for (int i5 = 0; i5 < i4; i5++) {
|
||||||
|
fArr[i5] = sArr[i5] / 32768.0f;
|
||||||
|
}
|
||||||
|
b bVar = this.f601l;
|
||||||
|
if (bVar != null) {
|
||||||
|
bVar.a(fArr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TimeUnit.MILLISECONDS.sleep(100L);
|
||||||
|
}
|
||||||
|
} catch (Exception e4) {
|
||||||
|
Log.e("Audio thread", "Exception: ", e4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
package H2;
|
||||||
|
|
||||||
|
import B.RunnableC0001b;
|
||||||
|
import D.n;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.media.MediaScannerConnection;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.text.SpannableStringBuilder;
|
||||||
|
import android.text.format.DateFormat;
|
||||||
|
import android.text.style.ForegroundColorSpan;
|
||||||
|
import android.util.Log;
|
||||||
|
import com.ve3nea.morse_expert.MainActivity;
|
||||||
|
import com.ve3nea.morse_expert.WaterfallSurfaceView;
|
||||||
|
import i3.d;
|
||||||
|
import java.io.BufferedOutputStream;
|
||||||
|
import java.io.DataOutputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import k3.s;
|
||||||
|
import pas.decoder;
|
||||||
|
import pas.nativedecoder;
|
||||||
|
|
||||||
|
/* loaded from: classes.dex */
|
||||||
|
public final class b {
|
||||||
|
|
||||||
|
/* renamed from: A, reason: collision with root package name */
|
||||||
|
public static final int f602A;
|
||||||
|
|
||||||
|
/* renamed from: B, reason: collision with root package name */
|
||||||
|
public static final int f603B;
|
||||||
|
|
||||||
|
/* renamed from: C, reason: collision with root package name */
|
||||||
|
public static final int f604C;
|
||||||
|
|
||||||
|
/* renamed from: D, reason: collision with root package name */
|
||||||
|
public static final d f605D;
|
||||||
|
|
||||||
|
/* renamed from: z, reason: collision with root package name */
|
||||||
|
public static final int f606z;
|
||||||
|
|
||||||
|
/* renamed from: a, reason: collision with root package name */
|
||||||
|
public final float[] f607a;
|
||||||
|
|
||||||
|
/* renamed from: b, reason: collision with root package name */
|
||||||
|
public final float[] f608b;
|
||||||
|
public final float[] c;
|
||||||
|
|
||||||
|
/* renamed from: d, reason: collision with root package name */
|
||||||
|
public final float[] f609d;
|
||||||
|
|
||||||
|
/* renamed from: e, reason: collision with root package name */
|
||||||
|
public final nativedecoder.TNativeDecoder f610e;
|
||||||
|
public int f;
|
||||||
|
|
||||||
|
/* renamed from: g, reason: collision with root package name */
|
||||||
|
public int f611g;
|
||||||
|
|
||||||
|
/* renamed from: h, reason: collision with root package name */
|
||||||
|
public int f612h;
|
||||||
|
|
||||||
|
/* renamed from: i, reason: collision with root package name */
|
||||||
|
public decoder.TDecoderState f613i;
|
||||||
|
|
||||||
|
/* renamed from: j, reason: collision with root package name */
|
||||||
|
public String f614j;
|
||||||
|
|
||||||
|
/* renamed from: k, reason: collision with root package name */
|
||||||
|
public final c f615k;
|
||||||
|
|
||||||
|
/* renamed from: l, reason: collision with root package name */
|
||||||
|
public int f616l;
|
||||||
|
|
||||||
|
/* renamed from: m, reason: collision with root package name */
|
||||||
|
public final I2.b f617m;
|
||||||
|
|
||||||
|
/* renamed from: n, reason: collision with root package name */
|
||||||
|
public final Handler f618n;
|
||||||
|
|
||||||
|
/* renamed from: o, reason: collision with root package name */
|
||||||
|
public WaterfallSurfaceView f619o;
|
||||||
|
|
||||||
|
/* renamed from: p, reason: collision with root package name */
|
||||||
|
public MainActivity f620p;
|
||||||
|
|
||||||
|
/* renamed from: q, reason: collision with root package name */
|
||||||
|
public MainActivity f621q;
|
||||||
|
|
||||||
|
/* renamed from: r, reason: collision with root package name */
|
||||||
|
public final AtomicInteger f622r;
|
||||||
|
|
||||||
|
/* renamed from: s, reason: collision with root package name */
|
||||||
|
public final AtomicBoolean f623s;
|
||||||
|
|
||||||
|
/* renamed from: t, reason: collision with root package name */
|
||||||
|
public final Context f624t;
|
||||||
|
|
||||||
|
/* renamed from: u, reason: collision with root package name */
|
||||||
|
public int f625u;
|
||||||
|
|
||||||
|
/* renamed from: v, reason: collision with root package name */
|
||||||
|
public final AtomicBoolean f626v;
|
||||||
|
|
||||||
|
/* renamed from: w, reason: collision with root package name */
|
||||||
|
public DataOutputStream f627w;
|
||||||
|
|
||||||
|
/* renamed from: x, reason: collision with root package name */
|
||||||
|
public int f628x;
|
||||||
|
|
||||||
|
/* renamed from: y, reason: collision with root package name */
|
||||||
|
public String f629y;
|
||||||
|
|
||||||
|
/* JADX WARN: Removed duplicated region for block: B:14:0x012d */
|
||||||
|
/* JADX WARN: Type inference failed for: r0v4, types: [i3.d, java.lang.Object] */
|
||||||
|
static {
|
||||||
|
boolean z3;
|
||||||
|
float f;
|
||||||
|
float f4;
|
||||||
|
float f5;
|
||||||
|
int round = Math.round(25.6f);
|
||||||
|
f606z = round;
|
||||||
|
int round2 = Math.round(153.6f);
|
||||||
|
f602A = round2;
|
||||||
|
f603B = round2 - round;
|
||||||
|
f604C = Math.round(85.333336f);
|
||||||
|
i3.d obj = new i3.d();
|
||||||
|
if (2048 > 1073741824) {
|
||||||
|
z3 = true;
|
||||||
|
} else {
|
||||||
|
z3 = false;
|
||||||
|
}
|
||||||
|
obj.f11709l = z3;
|
||||||
|
int i4 = (int) 1024;
|
||||||
|
obj.f11700a = i4;
|
||||||
|
obj.f11701b = 1024L;
|
||||||
|
if (!z3) {
|
||||||
|
obj.f11708k = 1;
|
||||||
|
int[] iArr = new int[((int) g3.c.a((1 << (((int) (g3.c.d(((float) 1024) + 0.5f) / g3.c.d(2.0d))) / 2)) + 2)) + 2];
|
||||||
|
obj.c = iArr;
|
||||||
|
float[] fArr = new float[i4];
|
||||||
|
obj.f11703e = fArr;
|
||||||
|
int i5 = (i4 * 2) >> 2;
|
||||||
|
obj.f11704g = i5;
|
||||||
|
iArr[0] = i5;
|
||||||
|
iArr[1] = 1;
|
||||||
|
if (i5 > 2) {
|
||||||
|
int i6 = i5 >> 1;
|
||||||
|
float f6 = 0.7853982f / i6;
|
||||||
|
float f7 = 2.0f * f6;
|
||||||
|
float b4 = (float) g3.c.b(i6 * f6); // r9 = i6 (smali)
|
||||||
|
fArr[0] = 1.0f;
|
||||||
|
fArr[1] = b4;
|
||||||
|
if (i6 == 4) {
|
||||||
|
double d4 = f7;
|
||||||
|
f4 = 0.5f;
|
||||||
|
fArr[2] = (float) g3.c.b(d4);
|
||||||
|
fArr[3] = (float) g3.c.f(d4);
|
||||||
|
} else {
|
||||||
|
f4 = 0.5f;
|
||||||
|
if (i6 > 4) {
|
||||||
|
iArr[2] = 0;
|
||||||
|
iArr[3] = 16;
|
||||||
|
int i7 = i5;
|
||||||
|
int i8 = 2;
|
||||||
|
f5 = 1.0f;
|
||||||
|
while (i7 > 32) {
|
||||||
|
int i9 = i8 << 1;
|
||||||
|
int i10 = i8 << 4;
|
||||||
|
for (int i11 = i8; i11 < i9; i11++) {
|
||||||
|
int i12 = iArr[i11] << 2;
|
||||||
|
iArr[i8 + i11] = i12;
|
||||||
|
iArr[i9 + i11] = i12 + i10;
|
||||||
|
}
|
||||||
|
i7 >>= 2;
|
||||||
|
i8 = i9;
|
||||||
|
}
|
||||||
|
fArr[2] = 0.5f / ((float) g3.c.b(f7));
|
||||||
|
fArr[3] = 0.5f / ((float) g3.c.b(6.0f * f6));
|
||||||
|
for (int i13 = 4; i13 < i6; i13 += 4) {
|
||||||
|
float f8 = i13 * f6;
|
||||||
|
double d5 = f8;
|
||||||
|
fArr[i13] = (float) g3.c.b(d5);
|
||||||
|
fArr[i13 + 1] = (float) g3.c.f(d5);
|
||||||
|
double d6 = 3.0f * f8;
|
||||||
|
fArr[i13 + 2] = (float) g3.c.b(d6);
|
||||||
|
fArr[i13 + 3] = -((float) g3.c.f(d6));
|
||||||
|
}
|
||||||
|
int i14 = 0;
|
||||||
|
while (i6 > 2) {
|
||||||
|
int i15 = i14 + i6;
|
||||||
|
i6 >>= 1;
|
||||||
|
fArr[i15] = f5;
|
||||||
|
fArr[i15 + 1] = b4;
|
||||||
|
if (i6 == 4) {
|
||||||
|
float f9 = fArr[i14 + 4];
|
||||||
|
float f10 = fArr[i14 + 5];
|
||||||
|
fArr[i15 + 2] = f9;
|
||||||
|
fArr[i15 + 3] = f10;
|
||||||
|
} else if (i6 > 4) {
|
||||||
|
float f11 = fArr[i14 + 4];
|
||||||
|
float f12 = fArr[i14 + 6];
|
||||||
|
fArr[i15 + 2] = f4 / f11;
|
||||||
|
fArr[i15 + 3] = f4 / f12;
|
||||||
|
for (int i16 = 4; i16 < i6; i16 += 4) {
|
||||||
|
int i17 = (i16 * 2) + i14;
|
||||||
|
int i18 = i15 + i16;
|
||||||
|
float f13 = fArr[i17];
|
||||||
|
float f14 = fArr[i17 + 1];
|
||||||
|
float f15 = fArr[i17 + 2];
|
||||||
|
float f16 = fArr[i17 + 3];
|
||||||
|
fArr[i18] = f13;
|
||||||
|
fArr[i18 + 1] = f14;
|
||||||
|
fArr[i18 + 2] = f15;
|
||||||
|
fArr[i18 + 3] = f16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i14 = i15;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f5 = 1.0f;
|
||||||
|
int i142 = 0;
|
||||||
|
while (i6 > 2) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int i19 = i4 >> 2;
|
||||||
|
obj.f11706i = i19;
|
||||||
|
iArr[1] = i19;
|
||||||
|
if (i19 > 1) {
|
||||||
|
int i20 = i19 >> 1;
|
||||||
|
float f17 = 0.7853982f / i20;
|
||||||
|
float b5 = (float) g3.c.b(i20 * f17); // r6 = i20 (smali)
|
||||||
|
fArr[i5] = b5;
|
||||||
|
fArr[i5 + i20] = b5 * 0.5f;
|
||||||
|
for (int i21 = 1; i21 < i20; i21++) {
|
||||||
|
double d7 = i21 * f17;
|
||||||
|
fArr[i5 + i21] = ((float) g3.c.b(d7)) * 0.5f;
|
||||||
|
fArr[(i5 + i19) - i21] = ((float) g3.c.f(d7)) * 0.5f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
obj.f11708k = 1;
|
||||||
|
s sVar = new s(((long) g3.c.a((1 << ((int) (((long) (g3.c.d(((float) 1024) + 0.5f) / g3.c.d(2.0d))) / 2))) + 2)) + 2, true);
|
||||||
|
obj.f11702d = sVar;
|
||||||
|
k3.d dVar = new k3.d(1024L, true);
|
||||||
|
obj.f = dVar;
|
||||||
|
obj.f11705h = 512L;
|
||||||
|
sVar.c(0L, 512L);
|
||||||
|
sVar.c(1L, 1L);
|
||||||
|
if (512 > 2) {
|
||||||
|
long j4 = 512 >> 1;
|
||||||
|
float f18 = 0.7853982f / ((float) j4);
|
||||||
|
float f19 = f18 * 2.0f;
|
||||||
|
float b6 = (float) g3.c.b(j4 * f18); // r14 = j4 (smali 同模式)
|
||||||
|
dVar.c(0L, 1.0f);
|
||||||
|
dVar.c(1L, b6);
|
||||||
|
if (j4 == 4) {
|
||||||
|
f = 0.5f;
|
||||||
|
double d8 = f19;
|
||||||
|
dVar.c(2L, (float) g3.c.b(d8));
|
||||||
|
dVar.c(3L, (float) g3.c.f(d8));
|
||||||
|
} else {
|
||||||
|
f = 0.5f;
|
||||||
|
if (j4 > 4) {
|
||||||
|
sVar.c(2L, 0L);
|
||||||
|
sVar.c(3L, 16L);
|
||||||
|
long j5 = 2;
|
||||||
|
long j6 = 512;
|
||||||
|
while (j6 > 32) {
|
||||||
|
long j7 = j5 << 1;
|
||||||
|
long j8 = j5 << 4;
|
||||||
|
long j9 = j5;
|
||||||
|
while (j9 < j7) {
|
||||||
|
long b7 = sVar.b(j9) << 2;
|
||||||
|
sVar.c(j5 + j9, b7);
|
||||||
|
sVar.c(j7 + j9, b7 + j8);
|
||||||
|
j9++;
|
||||||
|
f19 = f19;
|
||||||
|
}
|
||||||
|
j6 >>= 2;
|
||||||
|
j5 = j7;
|
||||||
|
}
|
||||||
|
dVar.c(2L, 0.5f / ((float) g3.c.b(f19)));
|
||||||
|
dVar.c(3L, 0.5f / ((float) g3.c.b(6.0f * f18)));
|
||||||
|
long j10 = 4;
|
||||||
|
while (j10 < j4) {
|
||||||
|
float f20 = ((float) j10) * f18;
|
||||||
|
double d9 = f20;
|
||||||
|
dVar.c(j10, (float) g3.c.b(d9));
|
||||||
|
dVar.c(j10 + 1, (float) g3.c.f(d9));
|
||||||
|
double d10 = 3.0f * f20;
|
||||||
|
long j11 = j10;
|
||||||
|
dVar.c(j10 + 2, (float) g3.c.b(d10));
|
||||||
|
dVar.c(j11 + 3, -((float) g3.c.f(d10)));
|
||||||
|
j10 = j11 + 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
long j12 = 0;
|
||||||
|
long j13 = 2;
|
||||||
|
while (j4 > j13) {
|
||||||
|
long j14 = j12 + j4;
|
||||||
|
j4 >>= 1;
|
||||||
|
dVar.c(j14, 1.0f);
|
||||||
|
dVar.c(j14 + 1, b6);
|
||||||
|
if (j4 == 4) {
|
||||||
|
float b8 = dVar.b(j12 + 4);
|
||||||
|
float b9 = dVar.b(j12 + 5);
|
||||||
|
j13 = 2;
|
||||||
|
dVar.c(j14 + 2, b8);
|
||||||
|
dVar.c(j14 + 3, b9);
|
||||||
|
} else {
|
||||||
|
j13 = 2;
|
||||||
|
if (j4 > 4) {
|
||||||
|
float b10 = dVar.b(j12 + 4);
|
||||||
|
float b11 = dVar.b(6 + j12);
|
||||||
|
dVar.c(j14 + 2, f / b10);
|
||||||
|
dVar.c(j14 + 3, f / b11);
|
||||||
|
long j15 = 4;
|
||||||
|
while (j15 < j4) {
|
||||||
|
long j16 = (j15 * 2) + j12;
|
||||||
|
float f21 = b6;
|
||||||
|
long j17 = j14 + j15;
|
||||||
|
long j18 = j12;
|
||||||
|
float b12 = dVar.b(j16);
|
||||||
|
long j19 = j14;
|
||||||
|
float b13 = dVar.b(j16 + 1);
|
||||||
|
float b14 = dVar.b(j16 + 2);
|
||||||
|
float b15 = dVar.b(j16 + 3);
|
||||||
|
dVar.c(j17, b12);
|
||||||
|
dVar.c(j17 + 1, b13);
|
||||||
|
dVar.c(j17 + 2, b14);
|
||||||
|
dVar.c(j17 + 3, b15);
|
||||||
|
j15 += 4;
|
||||||
|
b6 = f21;
|
||||||
|
j12 = j18;
|
||||||
|
j14 = j19;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b6 = b6;
|
||||||
|
j12 = j14;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
long j20 = 256;
|
||||||
|
obj.f11707j = 256L;
|
||||||
|
long j21 = 1;
|
||||||
|
sVar.c(1L, 256L);
|
||||||
|
if (256 > 1) {
|
||||||
|
long j22 = 256 >> 1;
|
||||||
|
float f22 = 0.7853982f / ((float) j22);
|
||||||
|
long j23 = 512;
|
||||||
|
dVar.c(512L, (float) g3.c.b(j22 * f22)); // r1 = j22 (smali)
|
||||||
|
dVar.c(512 + j22, dVar.b(512L) * 0.5f);
|
||||||
|
long j24 = 1;
|
||||||
|
while (j24 < j22) {
|
||||||
|
long j25 = j20;
|
||||||
|
long j26 = j21;
|
||||||
|
double d11 = ((float) j24) * f22;
|
||||||
|
long j27 = j23;
|
||||||
|
dVar.c(j23 + j24, ((float) g3.c.b(d11)) * 0.5f);
|
||||||
|
dVar.c((j27 + j25) - j24, ((float) g3.c.f(d11)) * 0.5f);
|
||||||
|
j24 += j26;
|
||||||
|
j20 = j25;
|
||||||
|
j21 = j26;
|
||||||
|
j23 = j27;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f605D = obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
public b(Context context) {
|
||||||
|
float[] fArr = new float[1024];
|
||||||
|
fArr[512] = 1.0f;
|
||||||
|
for (int i4 = 1; i4 < 512; i4++) {
|
||||||
|
double d4 = (i4 * 6.283185307179586d) / 1024;
|
||||||
|
float cos = (float) ((Math.cos(d4 * 2.0d) * 0.08d) + (0.42d - (Math.cos(d4) * 0.5d)));
|
||||||
|
fArr[i4] = cos;
|
||||||
|
double d5 = 0.003125f * 6.283185307179586d * (i4 - 512);
|
||||||
|
float sin = (float) ((Math.sin(d5) / d5) * cos);
|
||||||
|
fArr[i4] = sin;
|
||||||
|
fArr[1024 - i4] = sin;
|
||||||
|
}
|
||||||
|
this.f607a = fArr;
|
||||||
|
this.f608b = new float[1024];
|
||||||
|
this.c = new float[1024];
|
||||||
|
this.f609d = new float[f603B];
|
||||||
|
this.f610e = nativedecoder.TNativeDecoder.Create();
|
||||||
|
this.f614j = "";
|
||||||
|
this.f615k = new c(4000, 0);
|
||||||
|
I2.b bVar = new I2.b();
|
||||||
|
this.f617m = bVar;
|
||||||
|
this.f618n = new Handler(Looper.getMainLooper());
|
||||||
|
this.f622r = new AtomicInteger();
|
||||||
|
this.f623s = new AtomicBoolean(false);
|
||||||
|
this.f625u = 0;
|
||||||
|
this.f626v = new AtomicBoolean(false);
|
||||||
|
this.f628x = 0;
|
||||||
|
this.f624t = context;
|
||||||
|
bVar.b(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JADX WARN: Removed duplicated region for block: B:57:0x01e2 */
|
||||||
|
/* JADX WARN: Removed duplicated region for block: B:63:0x020a A[LOOP:6: B:62:0x0208->B:63:0x020a, LOOP_END] */
|
||||||
|
/* JADX WARN: Removed duplicated region for block: B:66:0x0216 A[LOOP:7: B:65:0x0214->B:66:0x0216, LOOP_END] */
|
||||||
|
/* JADX WARN: Removed duplicated region for block: B:70:0x0232 */
|
||||||
|
/* JADX WARN: Removed duplicated region for block: B:73:? A[RETURN, SYNTHETIC] */
|
||||||
|
/*
|
||||||
|
Code decompiled incorrectly, please refer to instructions dump.
|
||||||
|
*/
|
||||||
|
public final void a(float[] fArr) {
|
||||||
|
c cVar;
|
||||||
|
float[] fArr2;
|
||||||
|
float[] fArr3;
|
||||||
|
b bVar;
|
||||||
|
Exception exc;
|
||||||
|
int i4;
|
||||||
|
float f;
|
||||||
|
int i5;
|
||||||
|
WaterfallSurfaceView waterfallSurfaceView;
|
||||||
|
int length = fArr.length;
|
||||||
|
int i6 = 0;
|
||||||
|
while (true) {
|
||||||
|
cVar = this.f615k;
|
||||||
|
if (i6 >= length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cVar.c(Math.abs(fArr[i6]));
|
||||||
|
i6++;
|
||||||
|
}
|
||||||
|
this.f616l = Math.round(((float) Math.log10(Math.max(-0.5d, cVar.f633e))) * 20.0f);
|
||||||
|
int i7 = f604C;
|
||||||
|
int i8 = 1024 - i7;
|
||||||
|
float[] fArr4 = this.f608b;
|
||||||
|
System.arraycopy(fArr4, i7, fArr4, 0, i8);
|
||||||
|
System.arraycopy(fArr, 0, fArr4, i8, i7);
|
||||||
|
int i9 = 0;
|
||||||
|
while (true) {
|
||||||
|
fArr2 = this.c;
|
||||||
|
if (i9 >= 1024) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
fArr2[i9] = fArr4[i9] * this.f607a[i9];
|
||||||
|
i9++;
|
||||||
|
}
|
||||||
|
f605D.k(fArr2, 0);
|
||||||
|
int i10 = f606z;
|
||||||
|
int i11 = i10;
|
||||||
|
while (true) {
|
||||||
|
int i12 = f602A;
|
||||||
|
float f4 = 0.0f;
|
||||||
|
fArr3 = this.f609d;
|
||||||
|
if (i11 < i12) {
|
||||||
|
int i13 = i11 * 2;
|
||||||
|
float f5 = fArr2[i13];
|
||||||
|
float f6 = fArr2[i13 + 1];
|
||||||
|
float f7 = ((f6 * f6) + (f5 * f5)) * 0.001f;
|
||||||
|
if (!Float.isNaN(f7)) {
|
||||||
|
f4 = f7;
|
||||||
|
}
|
||||||
|
fArr3[i11 - i10] = f4;
|
||||||
|
i11++;
|
||||||
|
} else {
|
||||||
|
// smali: 循环内无 try; 录音写入 try 在循环后(见下)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DataOutputStream dataOutputStream = this.f627w;
|
||||||
|
Context context = this.f624t;
|
||||||
|
AtomicBoolean atomicBoolean = this.f626v;
|
||||||
|
try { // smali try_start_0: 录音写入整块(原 jadx 误把 try 放 break 处)
|
||||||
|
if (dataOutputStream == null && atomicBoolean.get()) {
|
||||||
|
File file = new File(context.getExternalFilesDir(null), ((String) DateFormat.format("yyyy-MM-dd_HH_mm_ss", new Date())) + "_spectra.bin");
|
||||||
|
this.f629y = file.getAbsoluteFile().toString();
|
||||||
|
this.f627w = new DataOutputStream(new BufferedOutputStream(new FileOutputStream(file, false)));
|
||||||
|
this.f628x = 0;
|
||||||
|
this.f614j = " Recording...";
|
||||||
|
}
|
||||||
|
if (this.f627w != null) {
|
||||||
|
for (float f8 : fArr3) {
|
||||||
|
this.f627w.writeFloat(f8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (atomicBoolean.get()) {
|
||||||
|
int i14 = this.f628x + 1;
|
||||||
|
this.f628x = i14;
|
||||||
|
if (i14 >= 2048) {
|
||||||
|
atomicBoolean.set(false);
|
||||||
|
this.f627w.close();
|
||||||
|
this.f627w = null;
|
||||||
|
this.f614j = "";
|
||||||
|
MediaScannerConnection.scanFile(context, new String[]{this.f629y}, new String[]{"application/x-binary"}, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException e4) {
|
||||||
|
Log.e("File write", "Exception: ", e4);
|
||||||
|
}
|
||||||
|
nativedecoder.TNativeDecoder tNativeDecoder = this.f610e;
|
||||||
|
try {
|
||||||
|
this.f613i = tNativeDecoder.ProcessSpectrum(fArr3);
|
||||||
|
int i15 = this.f625u + 1;
|
||||||
|
this.f625u = i15;
|
||||||
|
Handler handler = this.f618n;
|
||||||
|
if (i15 == 15) {
|
||||||
|
try {
|
||||||
|
this.f625u = 0;
|
||||||
|
this.f = tNativeDecoder.getPitch();
|
||||||
|
this.f611g = tNativeDecoder.getSnr();
|
||||||
|
this.f612h = tNativeDecoder.getWpm();
|
||||||
|
handler.post(new B0.b(this, this.f614j, new int[]{this.f616l, this.f, this.f611g, this.f612h, tNativeDecoder.getSignalCount(), this.f613i.Value}));
|
||||||
|
AtomicInteger atomicInteger = this.f622r;
|
||||||
|
int i16 = atomicInteger.get();
|
||||||
|
if (i16 != 0) {
|
||||||
|
tNativeDecoder.LockToPitch(i16);
|
||||||
|
atomicInteger.set(0);
|
||||||
|
}
|
||||||
|
tNativeDecoder.setHamMode(this.f623s.get());
|
||||||
|
} catch (Exception e5) {
|
||||||
|
// jadx 幻觉区(空循环/空 if 为 catch 错位还原)已裁剪; 原 smali: 记录异常后跳回正常流程尾部
|
||||||
|
exc = e5;
|
||||||
|
bVar = this;
|
||||||
|
Log.e("Audio Processor", "Exception", exc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int charCount = tNativeDecoder.getCharCount();
|
||||||
|
if (charCount > 0) {
|
||||||
|
SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder();
|
||||||
|
int i18 = 0;
|
||||||
|
while (i18 < charCount) {
|
||||||
|
decoder.TCharInfo GetChar = tNativeDecoder.GetChar(i18);
|
||||||
|
spannableStringBuilder.append(GetChar.getChar());
|
||||||
|
ForegroundColorSpan foregroundColorSpan = new ForegroundColorSpan(this.f617m.a(GetChar));
|
||||||
|
int i19 = i18 + 1;
|
||||||
|
spannableStringBuilder.setSpan(foregroundColorSpan, i18, i19, 33);
|
||||||
|
i18 = i19;
|
||||||
|
}
|
||||||
|
bVar = this;
|
||||||
|
try {
|
||||||
|
handler.post(new RunnableC0001b(bVar, spannableStringBuilder, tNativeDecoder.getDeleteCount(), 2, false));
|
||||||
|
} catch (Exception e6) {
|
||||||
|
// jadx 幻觉区已裁剪(同 catch e5); 原 smali: 记录异常后跳回正常流程尾部
|
||||||
|
exc = e6;
|
||||||
|
Log.e("Audio Processor", "Exception", exc);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bVar = this;
|
||||||
|
}
|
||||||
|
} catch (Exception e7) {
|
||||||
|
// jadx 幻觉(e 变量)已裁剪
|
||||||
|
bVar = this;
|
||||||
|
}
|
||||||
|
i4 = f603B;
|
||||||
|
int i1722 = i4 / 2;
|
||||||
|
float[] copyOfRange22 = Arrays.copyOfRange(fArr3, i1722 - 20, i1722 + 20);
|
||||||
|
Arrays.sort(copyOfRange22);
|
||||||
|
f = copyOfRange22[20];
|
||||||
|
if (f > 0.0f) {
|
||||||
|
for (int i20 = 0; i20 < i4; i20++) {
|
||||||
|
fArr3[i20] = fArr3[i20] / f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
float[] fArr522 = (float[]) fArr3.clone();
|
||||||
|
new c(12, 1).a(fArr522);
|
||||||
|
new c(12, 0).a(fArr522);
|
||||||
|
for (i5 = 0; i5 < i4; i5++) {
|
||||||
|
fArr3[i5] = fArr3[i5] - fArr522[i5];
|
||||||
|
}
|
||||||
|
for (int i21 = 0; i21 < i4; i21++) {
|
||||||
|
fArr3[i21] = ((float) Math.log10(Math.max(-0.5d, fArr3[i21]) + 1.0d)) * 10.0f;
|
||||||
|
}
|
||||||
|
waterfallSurfaceView = bVar.f619o;
|
||||||
|
if (waterfallSurfaceView != null) {
|
||||||
|
waterfallSurfaceView.queueEvent(new n(waterfallSurfaceView, 1, (float[]) fArr3.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package H2;
|
||||||
|
|
||||||
|
/* loaded from: classes.dex */
|
||||||
|
public final class c {
|
||||||
|
|
||||||
|
/* renamed from: a, reason: collision with root package name */
|
||||||
|
public final int f630a;
|
||||||
|
|
||||||
|
/* renamed from: b, reason: collision with root package name */
|
||||||
|
public float[] f631b;
|
||||||
|
public int c;
|
||||||
|
|
||||||
|
/* renamed from: d, reason: collision with root package name */
|
||||||
|
public float f632d;
|
||||||
|
|
||||||
|
/* renamed from: e, reason: collision with root package name */
|
||||||
|
public float f633e;
|
||||||
|
public final /* synthetic */ int f;
|
||||||
|
|
||||||
|
public c(int i4, int i5) {
|
||||||
|
this.f = i5;
|
||||||
|
this.f630a = i4 - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public final void a(float[] fArr) {
|
||||||
|
int i4 = this.f630a / 2;
|
||||||
|
int length = fArr.length;
|
||||||
|
if (length > i4) {
|
||||||
|
this.f631b = null;
|
||||||
|
for (int i5 = 0; i5 < i4; i5++) {
|
||||||
|
c(fArr[i5]);
|
||||||
|
}
|
||||||
|
for (int i6 = i4; i6 < length; i6++) {
|
||||||
|
fArr[i6 - i4] = c(fArr[i6]);
|
||||||
|
}
|
||||||
|
for (int i7 = length; i7 < length + i4; i7++) {
|
||||||
|
fArr[i7 - i4] = c(fArr[length - 1]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Sliding Filter error: input too short");
|
||||||
|
}
|
||||||
|
|
||||||
|
public final float b(float f, float f4) {
|
||||||
|
switch (this.f) {
|
||||||
|
case 0:
|
||||||
|
return Math.max(f, f4);
|
||||||
|
default:
|
||||||
|
return Math.min(f, f4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public final float c(float f) {
|
||||||
|
float[] fArr = this.f631b;
|
||||||
|
int i4 = this.f630a;
|
||||||
|
if (fArr == null) {
|
||||||
|
this.f631b = new float[i4];
|
||||||
|
for (int i5 = 0; i5 < i4; i5++) {
|
||||||
|
this.f631b[i5] = f;
|
||||||
|
}
|
||||||
|
this.f632d = f;
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
float b4 = b(this.f632d, f);
|
||||||
|
this.f632d = b4;
|
||||||
|
this.f633e = b(this.f631b[this.c], b4);
|
||||||
|
float[] fArr2 = this.f631b;
|
||||||
|
int i6 = this.c;
|
||||||
|
int i7 = i6 + 1;
|
||||||
|
this.c = i7;
|
||||||
|
fArr2[i6] = f;
|
||||||
|
if (i7 == i4) {
|
||||||
|
int length = fArr2.length;
|
||||||
|
while (true) {
|
||||||
|
this.c = length - 1;
|
||||||
|
int i8 = this.c;
|
||||||
|
if (i8 <= 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
float[] fArr3 = this.f631b;
|
||||||
|
fArr3[i8 - 1] = b(fArr3[i8 - 1], fArr3[i8]);
|
||||||
|
length = this.c;
|
||||||
|
}
|
||||||
|
this.c = 0;
|
||||||
|
this.f632d = f;
|
||||||
|
}
|
||||||
|
return this.f633e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package I0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 照搬自 Morse Expert 1.15 I0.d, 类名保持混淆原名。
|
||||||
|
* R8 合并类手术: 只保留 (i3.d, int, int, float[]) FFT 任务构造;
|
||||||
|
* run() 按 smali 原样还原: R8 已将任务体优化为死代码(aget 后 throw null,
|
||||||
|
* 永不执行), 保留原语义(行为与原 APK 完全一致)。
|
||||||
|
* SystemForegroundService(work 库)case 已裁剪。
|
||||||
|
*/
|
||||||
|
public final class d implements Runnable {
|
||||||
|
public final /* synthetic */ int f = 0;
|
||||||
|
|
||||||
|
/* renamed from: g, reason: collision with root package name */
|
||||||
|
public final /* synthetic */ int f653g;
|
||||||
|
|
||||||
|
/* renamed from: h, reason: collision with root package name */
|
||||||
|
public final /* synthetic */ int f654h;
|
||||||
|
|
||||||
|
/* renamed from: i, reason: collision with root package name */
|
||||||
|
public final /* synthetic */ Object f655i;
|
||||||
|
|
||||||
|
/* renamed from: j, reason: collision with root package name */
|
||||||
|
public final /* synthetic */ Object f656j;
|
||||||
|
|
||||||
|
public d(i3.d dVar, int i4, int i5, float[] fArr) {
|
||||||
|
this.f656j = dVar;
|
||||||
|
this.f653g = i4;
|
||||||
|
this.f654h = i5;
|
||||||
|
this.f655i = fArr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override // java.lang.Runnable
|
||||||
|
public final void run() {
|
||||||
|
int i6 = this.f654h;
|
||||||
|
int i7 = this.f653g;
|
||||||
|
if (i7 >= i6) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
float f = ((float[]) this.f655i)[i7 * 2];
|
||||||
|
// R8 原代码: aget 后 throw null(死代码, 永不执行) —— 照搬语义, 不执行任何计算
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package I2;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import pas.decoder;
|
||||||
|
|
||||||
|
/* loaded from: classes.dex */
|
||||||
|
public final class b {
|
||||||
|
|
||||||
|
/* renamed from: b, reason: collision with root package name */
|
||||||
|
public static final String[] f663b = {"bg_color", "text_color", "text_color_weak", "call_color", "call_color_weak", "cq_color", "cq_color_weak", "rst_color", "rst_color_weak"};
|
||||||
|
public static final String[] c = {"Background color", "Text color", "Text color, weak", "Callsign color", "Callsign color, weak", "CQ color", "CQ color, weak", "RST color", "RST color, weak"};
|
||||||
|
|
||||||
|
/* renamed from: d, reason: collision with root package name */
|
||||||
|
public static final int[] f664d = {-3084048, -16777216, -5592406, -65536, -30584, -16776961, -7829249, -65281, -30465};
|
||||||
|
|
||||||
|
/* renamed from: a, reason: collision with root package name */
|
||||||
|
public final int[] f665a = (int[]) f664d.clone();
|
||||||
|
|
||||||
|
public final int a(decoder.TCharInfo tCharInfo) {
|
||||||
|
boolean lowSnr = tCharInfo.getLowSnr();
|
||||||
|
int[] iArr = this.f665a;
|
||||||
|
if (lowSnr) {
|
||||||
|
int i4 = tCharInfo.getWordType().Value;
|
||||||
|
if (i4 != 0) {
|
||||||
|
if (i4 != 1) {
|
||||||
|
if (i4 != 2) {
|
||||||
|
if (i4 != 3) {
|
||||||
|
if (i4 != 4) {
|
||||||
|
if (i4 == 5) {
|
||||||
|
return iArr[4];
|
||||||
|
}
|
||||||
|
return -16777216;
|
||||||
|
}
|
||||||
|
return iArr[2];
|
||||||
|
}
|
||||||
|
return iArr[6];
|
||||||
|
}
|
||||||
|
return iArr[8];
|
||||||
|
}
|
||||||
|
return iArr[2];
|
||||||
|
}
|
||||||
|
return iArr[2];
|
||||||
|
}
|
||||||
|
int i5 = tCharInfo.getWordType().Value;
|
||||||
|
if (i5 != 0) {
|
||||||
|
if (i5 != 1) {
|
||||||
|
if (i5 != 2) {
|
||||||
|
if (i5 != 3) {
|
||||||
|
if (i5 != 4) {
|
||||||
|
if (i5 != 5) {
|
||||||
|
return -16777216;
|
||||||
|
}
|
||||||
|
return iArr[3];
|
||||||
|
}
|
||||||
|
return iArr[1];
|
||||||
|
}
|
||||||
|
return iArr[5];
|
||||||
|
}
|
||||||
|
return iArr[7];
|
||||||
|
}
|
||||||
|
return iArr[1];
|
||||||
|
}
|
||||||
|
return iArr[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
public final void b(Context context) {
|
||||||
|
SharedPreferences sharedPreferences = context.getSharedPreferences(context.getPackageName() + "_preferences", 0); // 原 c0.x.a(context)
|
||||||
|
for (int i4 = 0; i4 < 9; i4++) {
|
||||||
|
this.f665a[i4] = sharedPreferences.getInt(f663b[i4], f664d[i4]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package J2;
|
||||||
|
|
||||||
|
/* loaded from: classes.dex */
|
||||||
|
public final class a {
|
||||||
|
|
||||||
|
/* renamed from: b, reason: collision with root package name */
|
||||||
|
public static final int[] f726b = {16, 17749, 65535, 16776960, 16711680};
|
||||||
|
|
||||||
|
/* renamed from: a, reason: collision with root package name */
|
||||||
|
public final int[] f727a;
|
||||||
|
|
||||||
|
public a() {
|
||||||
|
int[] iArr = new int[256];
|
||||||
|
this.f727a = iArr;
|
||||||
|
int i4 = 4;
|
||||||
|
float f = 255.0f / 4;
|
||||||
|
int[] iArr2 = f726b;
|
||||||
|
int i5 = 0;
|
||||||
|
int i6 = -16777216;
|
||||||
|
iArr[0] = iArr2[0] | (-16777216);
|
||||||
|
int i7 = 1;
|
||||||
|
while (i7 <= i4) {
|
||||||
|
int round = Math.round(i7 * f);
|
||||||
|
iArr[round] = iArr2[i7] | i6;
|
||||||
|
int i8 = round - i5;
|
||||||
|
int i9 = iArr[i5];
|
||||||
|
int i10 = (i9 >> 16) & 255;
|
||||||
|
int i11 = (i9 >> 8) & 255;
|
||||||
|
int i12 = i9 & 255;
|
||||||
|
float f4 = i8;
|
||||||
|
// r10 = iArr2[i7] (下一个锚点色, smali: aget v10, v4, v8)
|
||||||
|
float f5 = (((iArr2[i7] >> 16) & 255) - i10) / f4;
|
||||||
|
float f6 = (((iArr2[i7] >> 8) & 255) - i11) / f4;
|
||||||
|
float f7 = ((iArr2[i7] & 255) - i12) / f4;
|
||||||
|
for (int i13 = 1; i13 < i8; i13++) {
|
||||||
|
float f8 = i13;
|
||||||
|
iArr[i5 + i13] = ((Math.round(f5 * f8) + i10) << 16) | (-16777216) | ((Math.round(f6 * f8) + i11) << 8) | (Math.round(f8 * f7) + i12);
|
||||||
|
}
|
||||||
|
i7++;
|
||||||
|
i5 = round;
|
||||||
|
i4 = 4;
|
||||||
|
i6 = -16777216;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package J2;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.opengl.GLES20;
|
||||||
|
import android.opengl.GLSurfaceView;
|
||||||
|
import android.opengl.GLUtils;
|
||||||
|
import android.os.SystemClock;
|
||||||
|
import android.util.Log;
|
||||||
|
import java.nio.Buffer;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
import java.nio.FloatBuffer;
|
||||||
|
import javax.microedition.khronos.egl.EGLConfig;
|
||||||
|
import javax.microedition.khronos.opengles.GL10;
|
||||||
|
|
||||||
|
/* loaded from: classes.dex */
|
||||||
|
public final class b implements GLSurfaceView.Renderer {
|
||||||
|
|
||||||
|
/* renamed from: b, reason: collision with root package name */
|
||||||
|
public FloatBuffer f729b;
|
||||||
|
public int c;
|
||||||
|
|
||||||
|
/* renamed from: d, reason: collision with root package name */
|
||||||
|
public final Bitmap f730d;
|
||||||
|
public final Context f;
|
||||||
|
|
||||||
|
/* renamed from: g, reason: collision with root package name */
|
||||||
|
public double f732g;
|
||||||
|
|
||||||
|
/* renamed from: h, reason: collision with root package name */
|
||||||
|
public final Bitmap f733h;
|
||||||
|
|
||||||
|
/* renamed from: i, reason: collision with root package name */
|
||||||
|
public final a f734i;
|
||||||
|
|
||||||
|
/* renamed from: j, reason: collision with root package name */
|
||||||
|
public long f735j;
|
||||||
|
|
||||||
|
/* renamed from: k, reason: collision with root package name */
|
||||||
|
public double f736k;
|
||||||
|
|
||||||
|
/* renamed from: l, reason: collision with root package name */
|
||||||
|
public int f737l;
|
||||||
|
|
||||||
|
/* renamed from: a, reason: collision with root package name */
|
||||||
|
public final int[] f728a = new int[1];
|
||||||
|
|
||||||
|
/* renamed from: e, reason: collision with root package name */
|
||||||
|
public final float[] f731e = {-1.0f, -1.0f, 0.0f, 1.0f, -1.0f, 1.0f, 0.0f, 0.0f, 1.0f, -1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 0.0f};
|
||||||
|
|
||||||
|
public b(Context context, int i4) {
|
||||||
|
Bitmap.Config config = Bitmap.Config.ARGB_8888;
|
||||||
|
Bitmap createBitmap = Bitmap.createBitmap(2048, i4, config);
|
||||||
|
this.f730d = createBitmap;
|
||||||
|
createBitmap.eraseColor(16);
|
||||||
|
this.f732g = 0.09375d;
|
||||||
|
this.f734i = new a();
|
||||||
|
this.f735j = 0L;
|
||||||
|
this.f736k = 0.0d;
|
||||||
|
this.f737l = -1;
|
||||||
|
this.f = context;
|
||||||
|
this.f733h = Bitmap.createBitmap(1, i4, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void a() {
|
||||||
|
int glGetError = GLES20.glGetError();
|
||||||
|
if (glGetError != 0) {
|
||||||
|
StackTraceElement stackTraceElement = Thread.currentThread().getStackTrace()[3];
|
||||||
|
Log.e(stackTraceElement.getFileName() + ":" + stackTraceElement.getLineNumber(), ", GLES20 error: " + glGetError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public final double b() {
|
||||||
|
long uptimeMillis = SystemClock.uptimeMillis();
|
||||||
|
double width = (((uptimeMillis - this.f735j) * this.f732g) + this.f736k) % this.f730d.getWidth();
|
||||||
|
this.f735j = uptimeMillis;
|
||||||
|
this.f736k = width;
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
public final void c(GL10 gl10) {
|
||||||
|
GLES20.glUseProgram(this.c);
|
||||||
|
a();
|
||||||
|
GLES20.glActiveTexture(33984);
|
||||||
|
a();
|
||||||
|
GLES20.glBindTexture(3553, this.f728a[0]);
|
||||||
|
a();
|
||||||
|
GLES20.glUniform1i(GLES20.glGetUniformLocation(this.c, "uTexture"), 0);
|
||||||
|
a();
|
||||||
|
int glGetAttribLocation = GLES20.glGetAttribLocation(this.c, "aPosition");
|
||||||
|
a();
|
||||||
|
int glGetAttribLocation2 = GLES20.glGetAttribLocation(this.c, "aTexPos");
|
||||||
|
a();
|
||||||
|
this.f729b.position(0);
|
||||||
|
a();
|
||||||
|
GLES20.glVertexAttribPointer(glGetAttribLocation, 2, 5126, false, 16, (Buffer) this.f729b);
|
||||||
|
a();
|
||||||
|
GLES20.glEnableVertexAttribArray(glGetAttribLocation);
|
||||||
|
a();
|
||||||
|
this.f729b.position(2);
|
||||||
|
a();
|
||||||
|
GLES20.glVertexAttribPointer(glGetAttribLocation2, 2, 5126, false, 16, (Buffer) this.f729b);
|
||||||
|
a();
|
||||||
|
GLES20.glEnableVertexAttribArray(glGetAttribLocation2);
|
||||||
|
a();
|
||||||
|
GLES20.glDrawArrays(5, 0, 4);
|
||||||
|
a();
|
||||||
|
}
|
||||||
|
|
||||||
|
public final void d(GL10 gl10, int i4, int i5) {
|
||||||
|
GLES20.glViewport(0, 0, i4, i5);
|
||||||
|
a();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override // android.opengl.GLSurfaceView.Renderer
|
||||||
|
public final void onDrawFrame(GL10 gl10) {
|
||||||
|
float b4 = (float) (b() / this.f730d.getWidth());
|
||||||
|
float f = 0.5f + b4;
|
||||||
|
float[] fArr = this.f731e;
|
||||||
|
fArr[6] = f;
|
||||||
|
fArr[2] = f;
|
||||||
|
float f4 = b4 + 1.0f;
|
||||||
|
fArr[14] = f4;
|
||||||
|
fArr[10] = f4;
|
||||||
|
this.f729b.position(0);
|
||||||
|
this.f729b.put(this.f731e);
|
||||||
|
c(gl10);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override // android.opengl.GLSurfaceView.Renderer
|
||||||
|
public final void onSurfaceChanged(GL10 gl10, int i4, int i5) {
|
||||||
|
d(gl10, i4, i5);
|
||||||
|
float width = 1.0f - (((this.f730d.getWidth() * 0.7f) / (i4 / this.f.getResources().getDisplayMetrics().density)) * 2.0f);
|
||||||
|
float[] fArr = this.f731e;
|
||||||
|
fArr[4] = width;
|
||||||
|
fArr[0] = width;
|
||||||
|
this.f729b.position(0);
|
||||||
|
this.f729b.put(this.f731e);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override // android.opengl.GLSurfaceView.Renderer
|
||||||
|
public final void onSurfaceCreated(GL10 gl10, EGLConfig eGLConfig) {
|
||||||
|
int glCreateShader = GLES20.glCreateShader(35633);
|
||||||
|
a();
|
||||||
|
GLES20.glShaderSource(glCreateShader, "attribute vec2 aPosition;\nattribute vec2 aTexPos;\nvarying vec2 vTexPos;\nvoid main() {\n vTexPos = aTexPos;\n gl_Position = vec4(aPosition.xy, 0.0, 1.0);\n}");
|
||||||
|
a();
|
||||||
|
GLES20.glCompileShader(glCreateShader);
|
||||||
|
a();
|
||||||
|
int glCreateShader2 = GLES20.glCreateShader(35632);
|
||||||
|
a();
|
||||||
|
GLES20.glShaderSource(glCreateShader2, "precision mediump float;\nuniform sampler2D uTexture;\nvarying vec2 vTexPos;\nvoid main(void)\n{\n gl_FragColor = texture2D(uTexture, vTexPos);\n}");
|
||||||
|
a();
|
||||||
|
GLES20.glCompileShader(glCreateShader2);
|
||||||
|
a();
|
||||||
|
this.c = GLES20.glCreateProgram();
|
||||||
|
a();
|
||||||
|
GLES20.glAttachShader(this.c, glCreateShader);
|
||||||
|
a();
|
||||||
|
GLES20.glAttachShader(this.c, glCreateShader2);
|
||||||
|
a();
|
||||||
|
GLES20.glLinkProgram(this.c);
|
||||||
|
a();
|
||||||
|
int[] iArr = this.f728a;
|
||||||
|
GLES20.glGenTextures(1, iArr, 0);
|
||||||
|
a();
|
||||||
|
GLES20.glBindTexture(3553, iArr[0]);
|
||||||
|
a();
|
||||||
|
GLES20.glTexParameteri(3553, 10240, 9729);
|
||||||
|
GLES20.glTexParameteri(3553, 10242, 10497);
|
||||||
|
GLES20.glTexParameteri(3553, 10241, 9729);
|
||||||
|
GLES20.glTexParameteri(3553, 10243, 10497);
|
||||||
|
a();
|
||||||
|
GLUtils.texImage2D(3553, 0, this.f730d, 0);
|
||||||
|
a();
|
||||||
|
float[] fArr = this.f731e;
|
||||||
|
ByteBuffer allocateDirect = ByteBuffer.allocateDirect(fArr.length * 4);
|
||||||
|
allocateDirect.order(ByteOrder.nativeOrder());
|
||||||
|
FloatBuffer asFloatBuffer = allocateDirect.asFloatBuffer();
|
||||||
|
this.f729b = asFloatBuffer;
|
||||||
|
asFloatBuffer.put(fArr);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package K1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 照搬自 Morse Expert 1.15 K1.a 精简版: 仅保留 k3 FFT 库引用的内存统计静态字段
|
||||||
|
* (K1.a.c 长整型记账)。原 K1.a(828 行)的其余方法为状态栏/库代码, 已裁剪
|
||||||
|
* (状态栏逻辑已在 MainActivity 内联)。
|
||||||
|
*/
|
||||||
|
public class a {
|
||||||
|
public static long c = 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
/*
|
||||||
|
* CwDecodeScreen.kt — Look4Sat 的 CW 解码页面(Compose 外壳)。
|
||||||
|
*
|
||||||
|
* 照搬自 Morse Expert 1.15 的 com.ve3nea.morse_expert.MainActivity(已改为普通控制器类):
|
||||||
|
* - AndroidView 嵌入原版 activity_main.xml(ConstraintLayout 根, 含 decodedTextView/scaleView/
|
||||||
|
* statusTextView/verticalLayout/waterfallView);
|
||||||
|
* - 生命周期 onCreate/onResume/onPause/onDestroy 全部委托给控制器, 顺序与原 Activity 一致
|
||||||
|
* (onCreate 必须在 onResume 前, onDispose 先 onPause 再 onDestroy);
|
||||||
|
* - 麦克风权限(RECORD_AUDIO)由 Compose 侧管理, 授予后调用 onPermissionGranted()(= v()),
|
||||||
|
* 用 initialized 标记保证只调一次(v() 会 new a() 覆盖 f11036E);
|
||||||
|
* - 原 options_menu 的 清除/暂停/保存/录音/设置 改为顶部一行按钮(图标用模块内原版 drawable)。
|
||||||
|
*
|
||||||
|
* 注意: 控制器 onCreate 内的 setImmersive(window, false) 会把宿主窗口恢复为
|
||||||
|
* decorFitsSystemWindows(true)(原版行为照搬, 会对整个宿主 Activity 生效)。
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.feature.cw
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.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.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
import com.ve3nea.morse_expert.MainActivity
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val activity = remember(context) {
|
||||||
|
context as? Activity ?: error("CwDecodeScreen must be hosted in an Activity")
|
||||||
|
}
|
||||||
|
// 照搬的控制器(普通类, 非 Activity); 每次进入页面新建实例
|
||||||
|
val controller = remember { MainActivity() }
|
||||||
|
// 提前 inflate 原版布局, 供 AndroidView 与控制器 onCreate 共用同一根视图
|
||||||
|
val rootView = remember(context) {
|
||||||
|
LayoutInflater.from(context).inflate(R.layout.activity_main, null) as ConstraintLayout
|
||||||
|
}
|
||||||
|
|
||||||
|
var permissionGranted by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var permanentlyDenied by remember { mutableStateOf(false) }
|
||||||
|
// v() 会 new a() 覆盖 f11036E, 必须保证只初始化一次
|
||||||
|
var initialized by remember { mutableStateOf(false) }
|
||||||
|
var showSettings by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// 生命周期: onCreate 必须在 onResume 前; onDispose 先 onPause 再 onDestroy
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
controller.onCreate(activity, rootView)
|
||||||
|
controller.onResume()
|
||||||
|
onDispose {
|
||||||
|
controller.onPause()
|
||||||
|
controller.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 权限授予后启动解码核心; "首次进入已授权" 与 "弹窗回调授权" 两条路径统一走这里
|
||||||
|
LaunchedEffect(permissionGranted) {
|
||||||
|
if (permissionGranted && !initialized) {
|
||||||
|
controller.onPermissionGranted() // = v(): 创建音频采集 + 解码核心
|
||||||
|
// v() 只创建核心; 页面此刻已处于 resumed, 需再走一次 onResume 立即启动录音
|
||||||
|
// (AudioRecord 是新建的, startRecording 不会重复; GLSurfaceView.onResume 幂等)
|
||||||
|
controller.onResume()
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { granted ->
|
||||||
|
permissionGranted = granted
|
||||||
|
permanentlyDenied = !granted &&
|
||||||
|
!activity.shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原版 tap_back_again_to_close: 单击返回只提示, 2 秒内再按才退出
|
||||||
|
BackHandler {
|
||||||
|
if (!controller.handleBackPress()) navigateUp()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSettings) {
|
||||||
|
CwSettingsDialog(controller = controller, onDismiss = { showSettings = false })
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.systemBarsPadding()
|
||||||
|
) {
|
||||||
|
// 原 options_menu: 暂停/清除/保存(原版图标) + 录音/设置(文字按钮)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
IconButton(onClick = { controller.togglePause() }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_baseline_pause_24),
|
||||||
|
contentDescription = stringResource(R.string.pause)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { controller.clearDecoded() }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_baseline_delete_24),
|
||||||
|
contentDescription = stringResource(R.string.clear)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { controller.saveText() }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_baseline_save_24),
|
||||||
|
contentDescription = stringResource(R.string.save_text)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// recordSignals 需要解码核心(f11037F), 未初始化时禁用
|
||||||
|
TextButton(
|
||||||
|
onClick = { if (initialized) controller.recordSignals() },
|
||||||
|
enabled = initialized
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.record_signals))
|
||||||
|
}
|
||||||
|
TextButton(onClick = { showSettings = true }) {
|
||||||
|
Text(stringResource(R.string.settings))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { rootView },
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
if (!permissionGranted) {
|
||||||
|
// 未授予麦克风权限: 遮罩 + 提示文字 + 请求按钮
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color(0x99000000)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = "Microphone permission is required for CW decoding",
|
||||||
|
color = Color.White,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Button(onClick = {
|
||||||
|
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
}) {
|
||||||
|
Text("Grant permission")
|
||||||
|
}
|
||||||
|
if (permanentlyDenied) {
|
||||||
|
// 勾选了"不再询问": 引导去系统设置开启
|
||||||
|
TextButton(onClick = {
|
||||||
|
activity.startActivity(
|
||||||
|
Intent(
|
||||||
|
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||||
|
Uri.parse("package:${activity.packageName}")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
Text("Open app settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.feature.cw
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.ve3nea.morse_expert.MainActivity
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/** 键名/默认值照搬原 Morse Expert(root_preferences.xml + SettingsActivity 逻辑)。 */
|
||||||
|
private const val KEY_MESSAGE_TYPE = "message_type"
|
||||||
|
private const val KEY_TEXT_FONT_SIZE = "text_font_size"
|
||||||
|
private const val DEFAULT_MESSAGE_TYPE = "general_text"
|
||||||
|
private const val DEFAULT_TEXT_FONT_SIZE = 18
|
||||||
|
private const val MIN_FONT_SIZE = 7
|
||||||
|
private const val MAX_FONT_SIZE = 99
|
||||||
|
|
||||||
|
/** 预置色板(9 色, 3x3 网格);原 ColorPreferenceCompat 用第三方 colorpicker, 不引入。 */
|
||||||
|
private val PaletteColors = listOf(
|
||||||
|
0xFFD0F0F0.toInt(), 0xFF000000.toInt(), 0xFFFFFFFF.toInt(),
|
||||||
|
0xFFAAAAAA.toInt(), 0xFFFF0000.toInt(), 0xFF00FF00.toInt(),
|
||||||
|
0xFF0000FF.toInt(), 0xFFFFFF00.toInt(), 0xFFFF00FF.toInt(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CW 设置弹窗(替代原 Morse Expert 的 SettingsActivity)。
|
||||||
|
* 设置项与键名照搬原 APK: message_type / text_font_size / 9 个颜色
|
||||||
|
* (I2.b.f663b 键名, I2.b.f664d 默认值, I2.b.c 英文标题)。
|
||||||
|
* 存储: SharedPreferences(getPackageName() + "_preferences")。
|
||||||
|
* 点击 OK 保存并调用 controller.onResume() 立即生效; Cancel/点外部仅关闭不保存。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CwSettingsDialog(controller: MainActivity, onDismiss: () -> Unit) {
|
||||||
|
val activity = controller.mActivity ?: return
|
||||||
|
val prefs = remember(activity) {
|
||||||
|
activity.getSharedPreferences(activity.packageName + "_preferences", Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
var messageType by remember {
|
||||||
|
mutableStateOf(prefs.getString(KEY_MESSAGE_TYPE, DEFAULT_MESSAGE_TYPE) ?: DEFAULT_MESSAGE_TYPE)
|
||||||
|
}
|
||||||
|
var fontSize by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
prefs.getInt(KEY_TEXT_FONT_SIZE, DEFAULT_TEXT_FONT_SIZE)
|
||||||
|
.toFloat().coerceIn(MIN_FONT_SIZE.toFloat(), MAX_FONT_SIZE.toFloat())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var colorValues by remember {
|
||||||
|
mutableStateOf(IntArray(9) { i -> prefs.getInt(I2.b.f663b[i], I2.b.f664d[i]) })
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("CW Settings") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(top = 4.dp)
|
||||||
|
) {
|
||||||
|
Text("Message type", style = MaterialTheme.typography.titleSmall)
|
||||||
|
MessageTypeRow("general_text", "General Text", messageType) { messageType = it }
|
||||||
|
MessageTypeRow("ham_radio_qso", "Ham Radio QSO", messageType) { messageType = it }
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
Text("Text font size", style = MaterialTheme.typography.titleSmall)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Slider(
|
||||||
|
value = fontSize,
|
||||||
|
onValueChange = { fontSize = it },
|
||||||
|
valueRange = MIN_FONT_SIZE.toFloat()..MAX_FONT_SIZE.toFloat(),
|
||||||
|
steps = MAX_FONT_SIZE - MIN_FONT_SIZE - 1,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${fontSize.roundToInt()}",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
modifier = Modifier.width(44.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
Text("Colors", style = MaterialTheme.typography.titleSmall)
|
||||||
|
for (i in 0 until 9) {
|
||||||
|
ColorSettingRow(
|
||||||
|
title = I2.b.c[i],
|
||||||
|
value = colorValues[i],
|
||||||
|
onSelect = { selected ->
|
||||||
|
colorValues = colorValues.copyOf().also { it[i] = selected }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
val editor = prefs.edit()
|
||||||
|
editor.putString(KEY_MESSAGE_TYPE, messageType)
|
||||||
|
editor.putInt(KEY_TEXT_FONT_SIZE, fontSize.roundToInt())
|
||||||
|
for (i in 0 until 9) {
|
||||||
|
editor.putInt(I2.b.f663b[i], colorValues[i])
|
||||||
|
}
|
||||||
|
editor.apply()
|
||||||
|
// 原 SettingsActivity 返回时由 MainActivity.onResume() 重新读取全部设置; 照搬逻辑已就位
|
||||||
|
controller.onResume()
|
||||||
|
onDismiss()
|
||||||
|
}) { Text("OK") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MessageTypeRow(option: String, label: String, selected: String, onSelect: (String) -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(6.dp))
|
||||||
|
.clickable { onSelect(option) }
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
RadioButton(selected = option == selected, onClick = { onSelect(option) })
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ColorSettingRow(title: String, value: Int, onSelect: (Int) -> Unit) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(title, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(20.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(Color(value)),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = String.format("#%06X", value and 0xFFFFFF),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
PaletteGrid(selected = value, onSelect = onSelect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PaletteGrid(selected: Int, onSelect: (Int) -> Unit) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
PaletteColors.chunked(3).forEach { rowColors ->
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
rowColors.forEach { color ->
|
||||||
|
val isSelected = color == selected
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.border(
|
||||||
|
width = if (isSelected) 3.dp else 1.dp,
|
||||||
|
color = if (isSelected) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
)
|
||||||
|
.clip(RoundedCornerShape(6.dp))
|
||||||
|
.background(Color(color))
|
||||||
|
.clickable { onSelect(color) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.rtbishop.look4sat.feature.cw.suncompat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编译期 stub:Android 无 sun.misc.Cleaner(照搬 Morse Expert 1.15 k3 FFT 库的清理回调,
|
||||||
|
* 实际 FFT 1024 点不触发 native 路径)。
|
||||||
|
*/
|
||||||
|
public class Cleaner {
|
||||||
|
public static Cleaner create(Object referent, Runnable thunk) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clean() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.rtbishop.look4sat.feature.cw.suncompat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编译期 stub:Android 无 sun.misc.Unsafe(运行时通过反射获取,失败时 k3.r.f12109a 为 null)。
|
||||||
|
* 照搬自 Morse Expert 1.15(k3 FFT 库的大数组 native 路径 >1GB 不触发,实际 FFT 走 float[] 分支)。
|
||||||
|
*/
|
||||||
|
public class Unsafe {
|
||||||
|
public long allocateMemory(long bytes) {
|
||||||
|
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void freeMemory(long address) {
|
||||||
|
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte getByte(long address) {
|
||||||
|
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void putByte(long address, byte value) {
|
||||||
|
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||||
|
}
|
||||||
|
|
||||||
|
public short getShort(long address) {
|
||||||
|
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void putShort(long address, short value) {
|
||||||
|
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getInt(long address) {
|
||||||
|
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void putInt(long address, int value) {
|
||||||
|
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getLong(long address) {
|
||||||
|
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void putLong(long address, long value) {
|
||||||
|
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||||
|
}
|
||||||
|
|
||||||
|
public float getFloat(long address) {
|
||||||
|
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void putFloat(long address, float value) {
|
||||||
|
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getDouble(long address) {
|
||||||
|
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void putDouble(long address, double value) {
|
||||||
|
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMemory(long address, long bytes, byte value) {
|
||||||
|
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void copyMemory(long src, long dst, long len) {
|
||||||
|
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Object getObject(Object o, long offset) {
|
||||||
|
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void putObject(Object o, long offset, Object value) {
|
||||||
|
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||||
|
}
|
||||||
|
|
||||||
|
public long objectFieldOffset(java.lang.reflect.Field field) {
|
||||||
|
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package com.ve3nea.morse_expert;
|
||||||
|
|
||||||
|
import F2.a;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.media.MediaScannerConnection;
|
||||||
|
import android.text.Layout;
|
||||||
|
import android.text.Selection;
|
||||||
|
import android.text.Spannable;
|
||||||
|
import android.text.SpannableStringBuilder;
|
||||||
|
import android.text.TextUtils;
|
||||||
|
import android.text.format.DateFormat;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.ActionMode;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.PrintWriter;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 照搬自 Morse Expert 1.15 DecodedTextView, 类名保持混淆原名。
|
||||||
|
* 适配: 原 extends l.C1702e0(appcompat 混淆类) -> 直接 extends TextView
|
||||||
|
* (C1702e0 仅提供 TextView 兼容功能, 本类未使用其特有方法)。
|
||||||
|
*/
|
||||||
|
public class DecodedTextView extends TextView {
|
||||||
|
|
||||||
|
/* renamed from: m, reason: collision with root package name */
|
||||||
|
public final Context f11031m;
|
||||||
|
|
||||||
|
/* renamed from: n, reason: collision with root package name */
|
||||||
|
public boolean f11032n;
|
||||||
|
|
||||||
|
/* renamed from: o, reason: collision with root package name */
|
||||||
|
public final AtomicBoolean f11033o;
|
||||||
|
|
||||||
|
public DecodedTextView(Context context, AttributeSet attributeSet) {
|
||||||
|
super(context, attributeSet);
|
||||||
|
this.f11032n = false;
|
||||||
|
this.f11033o = new AtomicBoolean(false);
|
||||||
|
this.f11031m = context;
|
||||||
|
setCustomSelectionActionModeCallback(getActionModeCallback());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActionMode.Callback getActionModeCallback() {
|
||||||
|
return new a(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getDecodedText() {
|
||||||
|
int selectionStart = getSelectionStart();
|
||||||
|
int selectionEnd = getSelectionEnd();
|
||||||
|
int max = Math.max(0, Math.min(selectionStart, selectionEnd));
|
||||||
|
int max2 = Math.max(0, Math.max(selectionStart, selectionEnd));
|
||||||
|
if (max2 > max) {
|
||||||
|
return getText().subSequence(max, max2).toString();
|
||||||
|
}
|
||||||
|
return getText().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override // android.widget.TextView
|
||||||
|
public final void onSelectionChanged(int i4, int i5) {
|
||||||
|
super.onSelectionChanged(i4, i5);
|
||||||
|
if (hasSelection()) {
|
||||||
|
this.f11032n = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public final void q(SpannableStringBuilder spannableStringBuilder) {
|
||||||
|
boolean z3;
|
||||||
|
try {
|
||||||
|
CharSequence text = getText();
|
||||||
|
Layout layout = getLayout();
|
||||||
|
int lineCount = getLineCount();
|
||||||
|
int length = text.length();
|
||||||
|
int lineHeight = getLineHeight();
|
||||||
|
int scrollY = getScrollY();
|
||||||
|
if (layout.getLineBottom(lineCount - 1) == getHeight() + scrollY) {
|
||||||
|
z3 = true;
|
||||||
|
} else {
|
||||||
|
z3 = false;
|
||||||
|
}
|
||||||
|
getSelectionStart();
|
||||||
|
getSelectionEnd();
|
||||||
|
hasSelection();
|
||||||
|
if (z3) {
|
||||||
|
Selection.setSelection((Spannable) text, length, length);
|
||||||
|
}
|
||||||
|
append(spannableStringBuilder);
|
||||||
|
int i4 = lineCount - 50;
|
||||||
|
if (i4 > 0) {
|
||||||
|
int i5 = lineHeight * i4;
|
||||||
|
getEditableText().delete(0, layout.getLineEnd(lineCount - 51));
|
||||||
|
if (scrollY >= i5) {
|
||||||
|
scrollBy(0, -i5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e4) {
|
||||||
|
Log.e("printChars()", "Exception: ", e4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public final void r() {
|
||||||
|
String decodedText = getDecodedText();
|
||||||
|
boolean isEmpty = TextUtils.isEmpty(decodedText);
|
||||||
|
Context context = this.f11031m;
|
||||||
|
if (isEmpty) {
|
||||||
|
Toast.makeText(context, "No text to save", 0).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
File file = new File(getContext().getExternalFilesDir(null), ((String) DateFormat.format("yyyy-MM-dd_HH_mm_ss", new Date())) + ".txt");
|
||||||
|
String absolutePath = file.getAbsolutePath();
|
||||||
|
PrintWriter printWriter = new PrintWriter(file);
|
||||||
|
try {
|
||||||
|
printWriter.println(decodedText);
|
||||||
|
printWriter.close();
|
||||||
|
MediaScannerConnection.scanFile(context, new String[]{absolutePath}, new String[]{"text/plain"}, null);
|
||||||
|
Toast.makeText(context, "Text saved to " + absolutePath, 1).show();
|
||||||
|
} catch (Throwable th) {
|
||||||
|
printWriter.close();
|
||||||
|
throw th;
|
||||||
|
}
|
||||||
|
} catch (Exception e4) {
|
||||||
|
Log.e("File Save", "Failed", e4);
|
||||||
|
Toast.makeText(context, "Unable to save: " + e4.getMessage(), 1).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public final void s() {
|
||||||
|
Intent intent = new Intent("android.intent.action.SEND");
|
||||||
|
intent.setFlags(268435456);
|
||||||
|
intent.putExtra("android.intent.extra.TEXT", getDecodedText());
|
||||||
|
intent.setType("text/plain");
|
||||||
|
this.f11031m.startActivity(Intent.createChooser(intent, "Share Decoded Text"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
|
||||||
|
package com.ve3nea.morse_expert;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.view.Window;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||||
|
import com.rtbishop.look4sat.feature.cw.R;
|
||||||
|
import E2.g;
|
||||||
|
import H2.a;
|
||||||
|
import H2.b;
|
||||||
|
import j1.C1646n;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ported verbatim from Morse Expert 1.15 MainActivity (com.ve3nea.morse_expert); obfuscated
|
||||||
|
* class/field names kept as-is. Surgery notes: originally extends e.AbstractActivityC1557l
|
||||||
|
* (obfuscated AppCompat base) -> now a plain controller class, Activity injected via
|
||||||
|
* onCreate(Activity, ConstraintLayout) for Compose AndroidView integration. Ads (d1.*),
|
||||||
|
* billing (I2.a), premium/rate menu removed per user. h3.b.r (recursive findViewById),
|
||||||
|
* K1.a.M (immersive status bar), B.i (permissions) replaced with equivalent standard APIs.
|
||||||
|
* Decoder logic (H2.a audio, H2.b core, pas engine) untouched.
|
||||||
|
*/
|
||||||
|
public class MainActivity {
|
||||||
|
|
||||||
|
public static final /* synthetic */ int f11034K = 0;
|
||||||
|
|
||||||
|
/* renamed from: D, reason: collision with root package name */
|
||||||
|
public C1646n f11035D;
|
||||||
|
|
||||||
|
/* renamed from: E, reason: collision with root package name */
|
||||||
|
public a f11036E;
|
||||||
|
|
||||||
|
/* renamed from: F, reason: collision with root package name */
|
||||||
|
public b f11037F;
|
||||||
|
|
||||||
|
/* renamed from: G, reason: collision with root package name */
|
||||||
|
public long f11038G = 0;
|
||||||
|
|
||||||
|
/* renamed from: J, reason: collision with root package name */
|
||||||
|
public final g f11040J = new g(this, 1);
|
||||||
|
|
||||||
|
/** Injected Activity context (replaces the original this Activity capabilities) */
|
||||||
|
public android.app.Activity mActivity;
|
||||||
|
|
||||||
|
public void onCreate(android.app.Activity activity, ConstraintLayout constraintLayout) {
|
||||||
|
onCreate(activity, constraintLayout, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* applyImmersive=false 用于嵌入小容器(如转发器 CW 面板):
|
||||||
|
* 不触碰宿主窗口的系统栏标志(避免影响整个 Activity)。
|
||||||
|
*/
|
||||||
|
public void onCreate(android.app.Activity activity, ConstraintLayout constraintLayout, boolean applyImmersive) {
|
||||||
|
this.mActivity = activity;
|
||||||
|
if (applyImmersive) {
|
||||||
|
// K1.a.M(getWindow(), false): immersive status bar (nav/fullscreen flags, ported as-is)
|
||||||
|
setImmersive(activity.getWindow(), false);
|
||||||
|
}
|
||||||
|
View inflate = constraintLayout;
|
||||||
|
int i4 = R.id.decodedTextView;
|
||||||
|
DecodedTextView decodedTextView = (DecodedTextView) findViewRecursive(inflate, R.id.decodedTextView);
|
||||||
|
if (decodedTextView != null) {
|
||||||
|
i4 = R.id.scaleView;
|
||||||
|
ScaleView scaleView = (ScaleView) findViewRecursive(inflate, R.id.scaleView);
|
||||||
|
if (scaleView != null) {
|
||||||
|
i4 = R.id.statusTextView;
|
||||||
|
TextView textView = (TextView) findViewRecursive(inflate, R.id.statusTextView);
|
||||||
|
if (textView != null) {
|
||||||
|
i4 = R.id.verticalLayout;
|
||||||
|
LinearLayout linearLayout = (LinearLayout) findViewRecursive(inflate, R.id.verticalLayout);
|
||||||
|
if (linearLayout != null) {
|
||||||
|
i4 = R.id.waterfallView;
|
||||||
|
WaterfallSurfaceView waterfallSurfaceView = (WaterfallSurfaceView) findViewRecursive(inflate, R.id.waterfallView);
|
||||||
|
if (waterfallSurfaceView != null) {
|
||||||
|
this.f11035D = new C1646n(constraintLayout, decodedTextView, scaleView, textView, linearLayout, waterfallSurfaceView);
|
||||||
|
((WaterfallSurfaceView) this.f11035D.f11892k).setOnTouchListener(this.f11040J);
|
||||||
|
// Originally: permission check then v(); permission flow handled by the Compose page
|
||||||
|
// Originally: ad banner mounting (removed)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new NullPointerException("Missing required view with ID: " + inflate.getResources().getResourceName(i4));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Original h3.b.r: recursive findViewById (ported behavior) */
|
||||||
|
private static View findViewRecursive(View view, int id) {
|
||||||
|
if (view instanceof ViewGroup) {
|
||||||
|
ViewGroup viewGroup = (ViewGroup) view;
|
||||||
|
int childCount = viewGroup.getChildCount();
|
||||||
|
for (int i = 0; i < childCount; i++) {
|
||||||
|
View found = viewGroup.getChildAt(i).findViewById(id);
|
||||||
|
if (found != null) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Original K1.a.M(window, false): immersive (hide nav/fullscreen flags, ported as-is) */
|
||||||
|
private static void setImmersive(Window window, boolean immersive) {
|
||||||
|
if (Build.VERSION.SDK_INT >= 30) {
|
||||||
|
window.setDecorFitsSystemWindows(!immersive);
|
||||||
|
if (immersive) {
|
||||||
|
window.getDecorView().setSystemUiVisibility(
|
||||||
|
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_FULLSCREEN);
|
||||||
|
} else {
|
||||||
|
window.getDecorView().setSystemUiVisibility(
|
||||||
|
View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
View decorView = window.getDecorView();
|
||||||
|
int systemUiVisibility = decorView.getSystemUiVisibility();
|
||||||
|
decorView.setSystemUiVisibility(immersive ? (systemUiVisibility & (-1793)) : (systemUiVisibility | 1792));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Original v(): audio capture + decoder core init (logic unchanged) */
|
||||||
|
public void v() {
|
||||||
|
this.f11036E = new a();
|
||||||
|
b bVar = new b(mActivity.getApplicationContext());
|
||||||
|
this.f11037F = bVar;
|
||||||
|
this.f11036E.f601l = bVar;
|
||||||
|
bVar.f619o = (WaterfallSurfaceView) this.f11035D.f11892k;
|
||||||
|
bVar.f620p = this;
|
||||||
|
bVar.f621q = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Original onResume: restore decoding (message type/font/background/record start/waterfall) */
|
||||||
|
public void onResume() {
|
||||||
|
android.content.SharedPreferences prefs = mActivity.getSharedPreferences(
|
||||||
|
mActivity.getPackageName() + "_preferences", Context.MODE_PRIVATE);
|
||||||
|
String messageType = prefs.getString("message_type", "ham_radio_qso");
|
||||||
|
b bVar = this.f11037F;
|
||||||
|
if (bVar != null) {
|
||||||
|
bVar.f623s.set(messageType.equals("ham_radio_qso"));
|
||||||
|
}
|
||||||
|
((DecodedTextView) this.f11035D.f11888g).setTextSize(Math.max(7, prefs.getInt("text_font_size", 18)));
|
||||||
|
b bVar2 = this.f11037F;
|
||||||
|
if (bVar2 != null) {
|
||||||
|
bVar2.f617m.b(mActivity);
|
||||||
|
((DecodedTextView) this.f11035D.f11888g).setBackgroundColor(this.f11037F.f617m.f665a[0]);
|
||||||
|
}
|
||||||
|
a aVar = this.f11036E;
|
||||||
|
if (aVar != null) {
|
||||||
|
android.media.AudioRecord audioRecord = aVar.f596g;
|
||||||
|
audioRecord.startRecording();
|
||||||
|
if (audioRecord.getState() != 1) {
|
||||||
|
android.util.Log.e("AudioRecord", "cannot start");
|
||||||
|
}
|
||||||
|
aVar.f598i.set(true);
|
||||||
|
}
|
||||||
|
((WaterfallSurfaceView) this.f11035D.f11892k).onResume();
|
||||||
|
// Originally: ad lifecycle (removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Original onPause: stop recording + waterfall pause */
|
||||||
|
public void onPause() {
|
||||||
|
a aVar = this.f11036E;
|
||||||
|
if (aVar != null) {
|
||||||
|
aVar.f598i.set(false);
|
||||||
|
android.media.AudioRecord audioRecord = aVar.f596g;
|
||||||
|
audioRecord.stop();
|
||||||
|
if (audioRecord.getState() != 1) {
|
||||||
|
android.util.Log.e("AudioRecord", "cannot stop");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
((WaterfallSurfaceView) this.f11035D.f11892k).onPause();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Original onDestroy: release audio/decoder (billing cleanup removed) */
|
||||||
|
public void onDestroy() {
|
||||||
|
a aVar = this.f11036E;
|
||||||
|
if (aVar != null) {
|
||||||
|
aVar.f598i.set(false);
|
||||||
|
try {
|
||||||
|
aVar.f596g.stop();
|
||||||
|
aVar.f596g.release();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
aVar.f597h.shutdownNow();
|
||||||
|
}
|
||||||
|
this.f11036E = null;
|
||||||
|
this.f11037F = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Menu: clear decoded text (original clear_mnu) */
|
||||||
|
public void clearDecoded() {
|
||||||
|
((DecodedTextView) this.f11035D.f11888g).setText("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Menu: pause/resume decoding (original pause_mnu, red/white icon toggle logic) */
|
||||||
|
public boolean togglePause() {
|
||||||
|
java.util.concurrent.atomic.AtomicBoolean atomicBoolean = ((DecodedTextView) this.f11035D.f11888g).f11033o;
|
||||||
|
atomicBoolean.set(!atomicBoolean.get());
|
||||||
|
return atomicBoolean.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Menu: save text (original save_mnu -> DecodedTextView.r()) */
|
||||||
|
public void saveText() {
|
||||||
|
((DecodedTextView) this.f11035D.f11888g).r();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Menu: record signals (original record_signals_mnu) */
|
||||||
|
public void recordSignals() {
|
||||||
|
this.f11037F.f626v.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Original tap_back_again_to_close hint */
|
||||||
|
public boolean handleBackPress() {
|
||||||
|
if (System.currentTimeMillis() < this.f11038G + 2000) {
|
||||||
|
return true; // double-back: exit
|
||||||
|
}
|
||||||
|
this.f11038G = System.currentTimeMillis();
|
||||||
|
Toast.makeText(mActivity, R.string.tap_back_again_to_close, 0).show();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start decoding after permission granted (original onRequestPermissionsResult success) */
|
||||||
|
public void onPermissionGranted() {
|
||||||
|
v();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.ve3nea.morse_expert;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.graphics.Path;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
/* loaded from: classes.dex */
|
||||||
|
public class ScaleView extends View {
|
||||||
|
public int f;
|
||||||
|
|
||||||
|
/* renamed from: g, reason: collision with root package name */
|
||||||
|
public int f11041g;
|
||||||
|
|
||||||
|
/* renamed from: h, reason: collision with root package name */
|
||||||
|
public final Paint f11042h;
|
||||||
|
|
||||||
|
/* renamed from: i, reason: collision with root package name */
|
||||||
|
public final Paint f11043i;
|
||||||
|
|
||||||
|
/* renamed from: j, reason: collision with root package name */
|
||||||
|
public final Paint f11044j;
|
||||||
|
|
||||||
|
/* renamed from: k, reason: collision with root package name */
|
||||||
|
public final Paint f11045k;
|
||||||
|
|
||||||
|
public ScaleView(Context context, AttributeSet attributeSet) {
|
||||||
|
super(context, attributeSet);
|
||||||
|
Paint paint = new Paint();
|
||||||
|
this.f11042h = paint;
|
||||||
|
paint.setColor(-16711936);
|
||||||
|
Paint paint2 = new Paint();
|
||||||
|
this.f11043i = paint2;
|
||||||
|
paint2.setColor(-16744448);
|
||||||
|
Paint paint3 = new Paint();
|
||||||
|
this.f11044j = paint3;
|
||||||
|
paint3.setColor(-256);
|
||||||
|
Paint paint4 = new Paint();
|
||||||
|
this.f11045k = paint4;
|
||||||
|
paint4.setColor(-8355840);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override // android.view.View
|
||||||
|
public final void onDraw(Canvas canvas) {
|
||||||
|
Paint paint;
|
||||||
|
super.onDraw(canvas);
|
||||||
|
int i4 = this.f11041g;
|
||||||
|
if (i4 != 1) {
|
||||||
|
if (i4 != 2) {
|
||||||
|
if (i4 != 3) {
|
||||||
|
if (i4 != 4) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
paint = this.f11044j;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
paint = this.f11045k;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
paint = this.f11042h;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
paint = this.f11043i;
|
||||||
|
}
|
||||||
|
int round = (int) Math.round(getWidth() * 0.7d);
|
||||||
|
Path path = new Path();
|
||||||
|
path.moveTo(0.0f, this.f);
|
||||||
|
float f = round;
|
||||||
|
path.lineTo(f, this.f - round);
|
||||||
|
path.lineTo(f, this.f + round);
|
||||||
|
path.close();
|
||||||
|
canvas.drawPath(path, paint);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.ve3nea.morse_expert;
|
||||||
|
|
||||||
|
import J2.b;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.opengl.GLSurfaceView;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
|
||||||
|
/* loaded from: classes.dex */
|
||||||
|
public class WaterfallSurfaceView extends GLSurfaceView {
|
||||||
|
public final b f;
|
||||||
|
|
||||||
|
public WaterfallSurfaceView(Context context, AttributeSet attributeSet) {
|
||||||
|
super(context, attributeSet);
|
||||||
|
setEGLContextClientVersion(2);
|
||||||
|
b bVar = new b(context, H2.b.f603B);
|
||||||
|
this.f = bVar;
|
||||||
|
setRenderer(bVar);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package d1;
|
||||||
|
|
||||||
|
/* renamed from: d1.b, reason: case insensitive filesystem */
|
||||||
|
/* 照搬自 Morse Expert 1.15, 混淆名保持。b(Parcel)/c(y2.d) 为库工具已裁剪(L1/y2 库依赖)。 */
|
||||||
|
public abstract /* synthetic */ class AbstractC1518b {
|
||||||
|
public static float a(float f, float f4, k3.d dVar, long j4, float f5, float f6) {
|
||||||
|
dVar.c(j4, f - f4);
|
||||||
|
return f5 + f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String d(String str, int i4) {
|
||||||
|
return str + i4;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String e(String str, String str2) {
|
||||||
|
return str + str2;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String f(String str, String str2, String str3) {
|
||||||
|
return str + str2 + str3;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String g(StringBuilder sb, int i4, String str) {
|
||||||
|
sb.append(i4);
|
||||||
|
sb.append(str);
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String h(StringBuilder sb, String str, String str2) {
|
||||||
|
sb.append(str);
|
||||||
|
sb.append(str2);
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static /* synthetic */ boolean i(Object obj) {
|
||||||
|
return obj != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float j(float f, float f4, k3.d dVar, long j4, float f5, float f6) {
|
||||||
|
dVar.c(j4, f + f4);
|
||||||
|
return f5 + f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String k(String str, String str2) {
|
||||||
|
return str + str2;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float l(float f, float f4, k3.d dVar, long j4, float f5, float f6) {
|
||||||
|
dVar.c(j4, f - f4);
|
||||||
|
return f5 - f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float m(float f, float f4, k3.d dVar, long j4, float f5, float f6) {
|
||||||
|
dVar.c(j4, f + f4);
|
||||||
|
return f5 - f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static /* synthetic */ String n(int i4) {
|
||||||
|
switch (i4) {
|
||||||
|
case 1:
|
||||||
|
return "NONE";
|
||||||
|
case 2:
|
||||||
|
return "LEFT";
|
||||||
|
case 3:
|
||||||
|
return "TOP";
|
||||||
|
case 4:
|
||||||
|
return "RIGHT";
|
||||||
|
case 5:
|
||||||
|
return "BOTTOM";
|
||||||
|
case 6:
|
||||||
|
return "BASELINE";
|
||||||
|
case 7:
|
||||||
|
return "CENTER";
|
||||||
|
case 8:
|
||||||
|
return "CENTER_X";
|
||||||
|
case 9:
|
||||||
|
return "CENTER_Y";
|
||||||
|
default:
|
||||||
|
throw null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user