mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 19:17:21 +00:00
Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9fe0b74e9 | ||
|
|
2c9a5b376f | ||
|
|
422e480b2f | ||
|
|
efd6931abb | ||
|
|
c7ca77a943 | ||
|
|
be6473e692 | ||
|
|
75d162301b | ||
|
|
07c09ec3e9 | ||
|
|
513d50e56a | ||
|
|
366edd8bbb | ||
|
|
13df0dfcec | ||
|
|
eb1a5d51a2 | ||
|
|
4e3547649f | ||
|
|
7abbeb6eb3 | ||
|
|
5c73d5ed7a | ||
|
|
a663ef7f1b | ||
|
|
1f1e1fb144 | ||
|
|
5166daa297 | ||
|
|
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 |
No files matched your search
@@ -4,15 +4,10 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- v**
|
- v**
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag_name:
|
|
||||||
description: 'Release tag name (e.g. v4.4.3)'
|
|
||||||
required: false
|
|
||||||
default: ''
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
TAG_NAME: ${{ github.event.inputs.tag_name || github.ref_name }}
|
TAG_NAME: ${{ github.ref_name }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
@@ -21,40 +16,66 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v4
|
uses: gradle/actions/setup-gradle@v6
|
||||||
|
|
||||||
- name: Assemble APK
|
- name: Assemble Artifacts
|
||||||
run: ./gradlew assembleRelease
|
run: ./gradlew assembleRelease bundleRelease
|
||||||
|
|
||||||
- name: Sign APK
|
- name: Sign APK
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||||
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
|
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
|
||||||
VERSION=${TAG_NAME#v}
|
|
||||||
SIGNED_APK="app/build/outputs/apk/release/Look4Sat-Pro-${VERSION}.apk"
|
|
||||||
BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
|
BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
|
||||||
${BUILD_TOOLS}apksigner sign \
|
${BUILD_TOOLS}apksigner sign \
|
||||||
--ks keystore.jks \
|
--ks keystore.jks \
|
||||||
--ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
|
--ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
|
||||||
--ks-key-alias ${{ secrets.KEY_ALIAS }} \
|
--ks-key-alias ${{ secrets.KEY_ALIAS }} \
|
||||||
--key-pass pass:${{ secrets.KEY_PASSWORD }} \
|
--key-pass pass:${{ secrets.KEY_PASSWORD }} \
|
||||||
--out "$SIGNED_APK" \
|
--out app/build/outputs/apk/release/look4sat.apk \
|
||||||
"$APK"
|
"$APK"
|
||||||
rm keystore.jks
|
rm keystore.jks
|
||||||
echo "SIGNED_APK=$SIGNED_APK" >> "$GITHUB_ENV"
|
|
||||||
|
- name: Sign Bundle
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||||
|
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
||||||
|
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
|
||||||
|
-keystore keystore.jks \
|
||||||
|
-storepass ${{ secrets.KEY_STORE_PASSWORD }} \
|
||||||
|
-keypass ${{ secrets.KEY_PASSWORD }} \
|
||||||
|
"$AAB" ${{ secrets.KEY_ALIAS }}
|
||||||
|
rm keystore.jks
|
||||||
|
|
||||||
|
- name: Setup Ruby
|
||||||
|
uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
ruby-version: '3.4'
|
||||||
|
|
||||||
|
- name: Deploy Bundle to Google Play
|
||||||
|
run: |
|
||||||
|
gem install multi_json
|
||||||
|
gem install fastlane --no-document
|
||||||
|
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
|
||||||
|
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
|
||||||
|
fastlane supply \
|
||||||
|
--aab "$AAB" \
|
||||||
|
--json_key service_account.json \
|
||||||
|
--package_name com.rtbishop.look4sat \
|
||||||
|
--track production \
|
||||||
|
--skip_upload_images true \
|
||||||
|
--skip_upload_screenshots true \
|
||||||
|
rm service_account.json
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
run: |
|
||||||
gh release create $TAG_NAME --title=$TAG_NAME --generate-notes
|
gh release create $TAG_NAME --title=$TAG_NAME --generate-notes
|
||||||
gh release upload $TAG_NAME "$SIGNED_APK"
|
gh release upload $TAG_NAME app/build/outputs/apk/release/look4sat.apk
|
||||||
+4
-1
@@ -18,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
|
||||||
@@ -39,7 +40,8 @@ captures/
|
|||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
# Keystore files
|
# Keystore files
|
||||||
*.jks
|
# Uncomment the following line if you do not want to check your keystore files in.
|
||||||
|
#*.jks
|
||||||
/*.properties
|
/*.properties
|
||||||
/keystore.properties
|
/keystore.properties
|
||||||
/app/keystore.jks
|
/app/keystore.jks
|
||||||
@@ -66,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>
|
||||||
+18
-18
@@ -1,36 +1,36 @@
|
|||||||
|
import java.io.FileInputStream
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.convention.applicationPlugin)
|
alias(libs.plugins.convention.applicationPlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load signing config from keystore.properties (gitignored, never commit credentials)
|
// Release signing credentials live in local.properties (gitignored) or
|
||||||
val keystoreProperties = Properties().apply {
|
// environment variables — never hardcode passwords in VCS.
|
||||||
val propsFile = rootProject.file("keystore.properties")
|
val releaseProps = Properties().apply {
|
||||||
if (propsFile.exists()) propsFile.inputStream().use { load(it) }
|
val propsFile = rootProject.file("local.properties")
|
||||||
|
if (propsFile.exists()) FileInputStream(propsFile).use { load(it) }
|
||||||
}
|
}
|
||||||
|
fun releaseCred(name: String): String =
|
||||||
|
releaseProps.getProperty(name) ?: System.getenv(name) ?: ""
|
||||||
|
|
||||||
android {
|
android {
|
||||||
// CW 解码 native 库仅 armeabi-v7a(照搬 Morse Expert 1.15): 全 ABI 打包会在
|
namespace = libs.versions.packageName.get()
|
||||||
// arm64 设备 loadLibrary 失败, 强制 32 位兼容(用户设备为 32 位软件)
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
ndk {
|
applicationId = "cn.ba7opf.look4sat"
|
||||||
abiFilters += listOf("armeabi-v7a")
|
ndk { abiFilters.add("armeabi-v7a") }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
if (keystoreProperties["storeFile"] != null) {
|
create("release") {
|
||||||
create("release") {
|
storeFile = file(System.getProperty("user.home") + "/my-release-key.jks")
|
||||||
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
|
storePassword = releaseCred("RELEASE_STORE_PASSWORD")
|
||||||
storePassword = keystoreProperties["storePassword"] as String
|
keyAlias = releaseCred("RELEASE_KEY_ALIAS").ifEmpty { "look4sat" }
|
||||||
keyAlias = keystoreProperties["keyAlias"] as String
|
keyPassword = releaseCred("RELEASE_KEY_PASSWORD")
|
||||||
keyPassword = keystoreProperties["keyPassword"] as String
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
signingConfig = signingConfigs.findByName("release")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
<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=".MainApplication"
|
android:name=".MainApplication"
|
||||||
@@ -43,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="com.rtbishop.look4sat.bg7nta" />
|
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>
|
||||||
@@ -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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,10 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.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
|
||||||
|
|||||||
@@ -17,23 +17,14 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat
|
package com.rtbishop.look4sat
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.animateContentSize
|
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
import androidx.compose.animation.core.Spring
|
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
import androidx.compose.animation.core.spring
|
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.expandVertically
|
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
|
||||||
import androidx.compose.animation.scaleOut
|
|
||||||
import androidx.compose.animation.shrinkVertically
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
@@ -51,7 +42,7 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||||
@@ -60,7 +51,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -90,48 +81,60 @@ import com.rtbishop.look4sat.core.presentation.RadarDestination
|
|||||||
import com.rtbishop.look4sat.core.presentation.Screen
|
import com.rtbishop.look4sat.core.presentation.Screen
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
||||||
import com.rtbishop.look4sat.feature.cw.CwDecodeScreen
|
|
||||||
import com.rtbishop.look4sat.feature.map.MapDestination
|
import com.rtbishop.look4sat.feature.map.MapDestination
|
||||||
import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
||||||
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
||||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||||
import com.rtbishop.look4sat.feature.radar.WavelogLogScreen
|
|
||||||
import com.rtbishop.look4sat.feature.status.SatStatusScreen
|
|
||||||
import com.rtbishop.look4sat.feature.roaming.RoamingScreen
|
|
||||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||||
|
import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NavRoot(deeplink: String? = null) {
|
fun NavRoot(deeplink: String? = null) {
|
||||||
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||||
val deeplinkResolver = DeeplinkResolver()
|
val deeplinkResolver = DeeplinkResolver()
|
||||||
|
var openMapRequest by remember { mutableIntStateOf(0) }
|
||||||
LaunchedEffect(deeplink) {
|
LaunchedEffect(deeplink) {
|
||||||
deeplink?.let {
|
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
||||||
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
|
|
||||||
rootBackStack.add(destination)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||||
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
||||||
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
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(
|
NavDisplay(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
backStack = rootBackStack,
|
backStack = rootBackStack,
|
||||||
onBack = navigateBack,
|
onBack = navigateBack,
|
||||||
transitionSpec = { slideInTransition },
|
transitionSpec = { pushTransition },
|
||||||
popTransitionSpec = { slideOutTransition },
|
popTransitionSpec = { popTransition },
|
||||||
predictivePopTransitionSpec = { slideOutTransition },
|
predictivePopTransitionSpec = { popTransition },
|
||||||
entryDecorators = listOf(
|
entryDecorators = listOf(
|
||||||
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
),
|
),
|
||||||
entryProvider = entryProvider {
|
entryProvider = entryProvider {
|
||||||
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
|
entry<Screen.Passes> {
|
||||||
|
MainScreen(
|
||||||
|
navigateToRadar = navigateToRadar,
|
||||||
|
openMapRequest = openMapRequest,
|
||||||
|
onOpenMapRequestHandled = { openMapRequest = 0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
entry<RadarDestination> {
|
entry<RadarDestination> {
|
||||||
Scaffold { innerPadding ->
|
Surface(
|
||||||
RadarDestination(navigateUp = navigateBack)
|
modifier = Modifier.fillMaxSize(),
|
||||||
innerPadding.calculateTopPadding()
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
RadarDestination(navigateUp = navigateBack, navigateToMap = navigateToMap)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,40 +142,31 @@ fun NavRoot(deeplink: String? = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
fun MainScreen(
|
||||||
|
navigateToRadar: () -> Unit = {},
|
||||||
|
openMapRequest: Int = 0,
|
||||||
|
onOpenMapRequestHandled: () -> Unit = {}
|
||||||
|
) {
|
||||||
val backStack = rememberNavBackStack(Screen.Passes)
|
val backStack = rememberNavBackStack(Screen.Passes)
|
||||||
val currentKey = backStack.lastOrNull()
|
val currentKey = backStack.lastOrNull()
|
||||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
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 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 context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||||
// UI 设置: 按 screenOrder 排序(空 = 默认顺序), 再按 hiddenScreens 过滤(设置页固定保留)
|
|
||||||
val allNavItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.CwDecode, Screen.WavelogLog, Screen.AmSat, Screen.Map, Screen.Settings)
|
|
||||||
.sortedBy { screen ->
|
|
||||||
// 未知(新页面如 CwDecode 不在旧持久化顺序里): 用默认顺序位置(漫游↔地图), 再兜底最后
|
|
||||||
val idx = otherSettings.screenOrder.indexOf(screen.screenId)
|
|
||||||
if (idx != -1) idx
|
|
||||||
else com.rtbishop.look4sat.core.presentation.defaultScreenOrder.indexOf(screen.screenId).let {
|
|
||||||
if (it != -1) it else Int.MAX_VALUE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.filter { it.screenId !in otherSettings.hiddenScreens || it is Screen.Settings }
|
|
||||||
// 4.5.1 折叠菜单: 主菜单(底部栏 5 槽) + 更多菜单(溢出页面)
|
|
||||||
// 老用户迁移: 已持久化的 subMenuOrder 不含新页面 WavelogLog → 追加到子菜单尾部
|
|
||||||
val subOrder = (otherSettings.subMenuOrder.ifEmpty { com.rtbishop.look4sat.core.presentation.defaultSubMenuOrder })
|
|
||||||
.let { list -> if ("WavelogLog" in list) list else list + "WavelogLog" }
|
|
||||||
.let { list -> if ("AMSAT" in list) list else list + "AMSAT" }
|
|
||||||
val mainNavItems = remember(allNavItems, subOrder) {
|
|
||||||
allNavItems.filter { it.screenId !in subOrder }.take(5)
|
|
||||||
}
|
|
||||||
val moreNavItems = remember(allNavItems, subOrder) {
|
|
||||||
subOrder.mapNotNull { id -> allNavItems.find { it.screenId == id } }
|
|
||||||
}
|
|
||||||
var moreExpanded by remember { mutableStateOf(false) }
|
|
||||||
// 更多菜单打开时拦截返回: 先关菜单
|
|
||||||
BackHandler(enabled = moreExpanded) { moreExpanded = false }
|
|
||||||
// Activity-scoped so the mutual query results survive navigation to Radar and back
|
// Activity-scoped so the mutual query results survive navigation to Radar and back
|
||||||
val mutualViewModel: MutualViewModel = viewModel(
|
val mutualViewModel: MutualViewModel = viewModel(
|
||||||
viewModelStoreOwner = context as ViewModelStoreOwner,
|
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||||
@@ -187,16 +181,13 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
) {
|
) {
|
||||||
NavigationSuiteScaffold(
|
NavigationSuiteScaffold(
|
||||||
navigationSuiteItems = {
|
navigationSuiteItems = {
|
||||||
mainNavItems.forEach { screen ->
|
navItems.forEach { screen ->
|
||||||
val isSelected = when (currentKey) {
|
val isSelected = when (currentKey) {
|
||||||
is Screen.Satellites -> screen is Screen.Satellites
|
is Screen.Satellites -> screen is Screen.Satellites
|
||||||
is Screen.Passes -> screen is Screen.Passes
|
is Screen.Passes -> screen is Screen.Passes
|
||||||
is Screen.Radar -> screen is Screen.Radar
|
is Screen.Map -> screen is Screen.Passes
|
||||||
|
is Screen.AMSAT -> screen is Screen.AMSAT
|
||||||
is Screen.Mutual -> screen is Screen.Mutual
|
is Screen.Mutual -> screen is Screen.Mutual
|
||||||
is Screen.CwDecode -> screen is Screen.CwDecode
|
|
||||||
is Screen.WavelogLog -> screen is Screen.WavelogLog
|
|
||||||
is Screen.AmSat -> screen is Screen.AmSat
|
|
||||||
is Screen.Map -> screen is Screen.Map
|
|
||||||
is Screen.Settings -> screen is Screen.Settings
|
is Screen.Settings -> screen is Screen.Settings
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
@@ -205,27 +196,12 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
label = { Text(stringResource(screen.titleResId)) },
|
label = { Text(stringResource(screen.titleResId)) },
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (isSelected) return@item
|
if (isSelected && !(currentKey is Screen.Map && screen is Screen.Passes)) return@item
|
||||||
moreExpanded = false
|
|
||||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||||
if (screen !is Screen.Passes) backStack.add(screen)
|
if (screen !is Screen.Passes) backStack.add(screen)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// 更多菜单按钮(固定第 6 槽, 子菜单非空才显示)
|
|
||||||
if (moreNavItems.isNotEmpty()) {
|
|
||||||
item(
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
painterResource(com.rtbishop.look4sat.R.drawable.ic_more),
|
|
||||||
stringResource(com.rtbishop.look4sat.core.presentation.R.string.nav_more)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
label = { Text(stringResource(com.rtbishop.look4sat.core.presentation.R.string.nav_more)) },
|
|
||||||
selected = moreExpanded,
|
|
||||||
onClick = { moreExpanded = !moreExpanded }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
||||||
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||||
@@ -236,137 +212,102 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
else -> NavigationSuiteType.ShortNavigationBarMedium
|
else -> NavigationSuiteType.ShortNavigationBarMedium
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Box {
|
Column {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
NavDisplay(
|
||||||
NavDisplay(
|
backStack = backStack,
|
||||||
backStack = backStack,
|
modifier = Modifier.weight(1f),
|
||||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
onBack = navigateBack,
|
||||||
onBack = navigateBack,
|
transitionSpec = { fadeTransition },
|
||||||
transitionSpec = { fadeTransition },
|
popTransitionSpec = { fadeTransition },
|
||||||
popTransitionSpec = { fadeTransition },
|
predictivePopTransitionSpec = { fadeTransition },
|
||||||
predictivePopTransitionSpec = { fadeTransition },
|
entryDecorators = listOf(
|
||||||
entryDecorators = listOf(
|
// Required for saving Compose state per entry
|
||||||
// Required for saving Compose state per entry
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
rememberSaveableStateHolderNavEntryDecorator(),
|
// Required for ViewModel scoping per entry
|
||||||
// Required for ViewModel scoping per entry
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
rememberViewModelStoreNavEntryDecorator()
|
),
|
||||||
),
|
entryProvider = entryProvider {
|
||||||
entryProvider = entryProvider {
|
entry<Screen.Satellites> {
|
||||||
entry<Screen.Satellites> {
|
SatellitesDestination(navigateUp = navigateBack)
|
||||||
SatellitesDestination(navigateUp = navigateBack)
|
}
|
||||||
}
|
entry<Screen.Passes> {
|
||||||
entry<Screen.Passes> {
|
PassesDestination(
|
||||||
PassesDestination { catNum, aosTime ->
|
navigateToRadar = { catNum, aosTime ->
|
||||||
container.setMutualPassData(MutualPassData())
|
container.setMutualPassData(MutualPassData())
|
||||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
backStack.add(Screen.Radar)
|
navigateToRadar()
|
||||||
// navigateToRadar()
|
},
|
||||||
}
|
navigateToMap = navigateToMap
|
||||||
}
|
)
|
||||||
entry<Screen.Radar> {
|
|
||||||
RadarDestination(navigateUp = navigateBack)
|
|
||||||
}
|
|
||||||
entry<Screen.Map> {
|
|
||||||
MapDestination()
|
|
||||||
}
|
|
||||||
entry<Screen.Mutual> {
|
|
||||||
MutualScreen(
|
|
||||||
viewModel = mutualViewModel,
|
|
||||||
navigateUp = navigateBack,
|
|
||||||
navigateToRadar = { catNum, aosTime, pass ->
|
|
||||||
container.setMutualPassData(pass ?: MutualPassData())
|
|
||||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
|
||||||
backStack.add(Screen.Radar)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
entry<Screen.Roaming> {
|
|
||||||
RoamingScreen()
|
|
||||||
}
|
|
||||||
entry<Screen.CwDecode> {
|
|
||||||
CwDecodeScreen()
|
|
||||||
}
|
|
||||||
entry<Screen.AmSat> {
|
|
||||||
SatStatusScreen(container = container)
|
|
||||||
}
|
|
||||||
entry<Screen.WavelogLog> {
|
|
||||||
WavelogLogScreen(queue = container.wavelogQueue)
|
|
||||||
}
|
|
||||||
entry<Screen.Settings> {
|
|
||||||
SettingsDestination()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
entry<Screen.Map> {
|
||||||
// Radio tracking status banner
|
MapDestination()
|
||||||
if (trackingState.isActive) {
|
}
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
entry<Screen.Mutual> {
|
||||||
val alpha by infiniteTransition.animateFloat(
|
MutualScreen(
|
||||||
initialValue = 1f, targetValue = 0.4f,
|
viewModel = mutualViewModel,
|
||||||
animationSpec = infiniteRepeatable(
|
navigateToRadar = { catNum, aosTime, pass ->
|
||||||
animation = tween(1000, easing = LinearEasing),
|
container.setMutualPassData(pass ?: MutualPassData())
|
||||||
repeatMode = RepeatMode.Reverse
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
), label = "pulseAlpha"
|
navigateToRadar()
|
||||||
)
|
|
||||||
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)
|
|
||||||
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 = stringResource(com.rtbishop.look4sat.core.presentation.R.string.tracking_status, 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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
entry<Screen.AMSAT> { SatStatusDestination() }
|
||||||
|
entry<Screen.Settings> {
|
||||||
|
SettingsDestination()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
// 更多菜单弹出面板(覆盖在内容上, 底部栏上方; spring 弹跳)
|
// Radio tracking status banner
|
||||||
AnimatedVisibility(
|
if (trackingState.isActive) {
|
||||||
visible = moreExpanded,
|
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
||||||
modifier = Modifier.fillMaxSize(),
|
val alpha by infiniteTransition.animateFloat(
|
||||||
enter = expandVertically(
|
initialValue = 1f, targetValue = 0.4f,
|
||||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
|
animationSpec = infiniteRepeatable(
|
||||||
) + fadeIn(),
|
animation = tween(1000, easing = LinearEasing),
|
||||||
exit = shrinkVertically() + fadeOut()
|
repeatMode = RepeatMode.Reverse
|
||||||
) {
|
), label = "pulseAlpha"
|
||||||
MoreMenuPopup(
|
|
||||||
items = moreNavItems,
|
|
||||||
currentKey = currentKey,
|
|
||||||
onDismiss = { moreExpanded = false },
|
|
||||||
onSelect = { screen ->
|
|
||||||
moreExpanded = false
|
|
||||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
|
||||||
if (screen !is Screen.Passes) backStack.add(screen)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
/*
|
|
||||||
* MoreMenuPopup.kt — 底部导航「更多」二级菜单弹出面板(4.5.1)。
|
|
||||||
*
|
|
||||||
* 覆盖在内容区上(底部栏上方, 右对齐), 竖排菜单项(图标+文本+箭头),
|
|
||||||
* 当前页高亮; 点击遮罩关闭, 点击项跳转。弹出/收起动画由调用处
|
|
||||||
* (MainScreen 的 AnimatedVisibility + spring)驱动。
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat
|
|
||||||
|
|
||||||
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.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
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.navigation3.runtime.NavKey
|
|
||||||
import com.rtbishop.look4sat.core.presentation.Screen
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MoreMenuPopup(
|
|
||||||
items: List<Screen>,
|
|
||||||
currentKey: NavKey?,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onSelect: (Screen) -> Unit
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f))
|
|
||||||
.clickable(onClick = onDismiss)
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomEnd)
|
|
||||||
.padding(12.dp),
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
|
||||||
items.forEach { screen ->
|
|
||||||
val isSelected = when (currentKey) {
|
|
||||||
is Screen.Satellites -> screen is Screen.Satellites
|
|
||||||
is Screen.Passes -> screen is Screen.Passes
|
|
||||||
is Screen.Radar -> screen is Screen.Radar
|
|
||||||
is Screen.Mutual -> screen is Screen.Mutual
|
|
||||||
is Screen.CwDecode -> screen is Screen.CwDecode
|
|
||||||
is Screen.WavelogLog -> screen is Screen.WavelogLog
|
|
||||||
is Screen.Map -> screen is Screen.Map
|
|
||||||
is Screen.Settings -> screen is Screen.Settings
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { onSelect(screen) }
|
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(screen.iconResId),
|
|
||||||
contentDescription = stringResource(screen.titleResId),
|
|
||||||
tint = if (isSelected) MaterialTheme.colorScheme.primary
|
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(screen.titleResId),
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
|
||||||
color = if (isSelected) MaterialTheme.colorScheme.primary
|
|
||||||
else MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "›",
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z" />
|
|
||||||
</vector>
|
|
||||||
@@ -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>
|
||||||
-2
@@ -31,12 +31,10 @@ internal class ApplicationPlugin : Plugin<Project> {
|
|||||||
implementation(project(":core:data"))
|
implementation(project(":core:data"))
|
||||||
implementation(project(":core:domain"))
|
implementation(project(":core:domain"))
|
||||||
implementation(project(":core:presentation"))
|
implementation(project(":core:presentation"))
|
||||||
implementation(project(":feature:cw"))
|
|
||||||
implementation(project(":feature:map"))
|
implementation(project(":feature:map"))
|
||||||
implementation(project(":feature:mutual"))
|
implementation(project(":feature:mutual"))
|
||||||
implementation(project(":feature:passes"))
|
implementation(project(":feature:passes"))
|
||||||
implementation(project(":feature:radar"))
|
implementation(project(":feature:radar"))
|
||||||
implementation(project(":feature:roaming"))
|
|
||||||
implementation(project(":feature:satellites"))
|
implementation(project(":feature:satellites"))
|
||||||
implementation(project(":feature:settings"))
|
implementation(project(":feature:settings"))
|
||||||
implementation(project(":feature:status"))
|
implementation(project(":feature:status"))
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ internal fun Project.setupAndroidApp() {
|
|||||||
namespace = libs.versions.packageName.get()
|
namespace = libs.versions.packageName.get()
|
||||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = libs.versions.applicationId.get()
|
applicationId = libs.versions.packageName.get()
|
||||||
minSdk = libs.versions.minSdk.get().toInt()
|
minSdk = libs.versions.minSdk.get().toInt()
|
||||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||||
versionName = libs.versions.appVersionName.get()
|
versionName = libs.versions.appVersionName.get()
|
||||||
|
|||||||
+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())
|
||||||
}
|
}
|
||||||
+15
-25
@@ -35,25 +35,27 @@ 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.repository.LoTWRepository
|
||||||
|
import com.rtbishop.look4sat.core.data.repository.WavelogRepository
|
||||||
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.wavelog.IWavelogQueueStore
|
|
||||||
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
|
||||||
import com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
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
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
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.IWavelogRepository
|
||||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
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
|
||||||
@@ -74,14 +76,17 @@ 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 by lazy { provideRemoteSource() }
|
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 = AmSatRepository(remoteSource)
|
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
|
||||||
|
override val updateRepo by lazy { UpdateRepository(remoteSource) }
|
||||||
|
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
|
||||||
|
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
|
||||||
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)
|
||||||
@@ -102,16 +107,6 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
|
|
||||||
override fun provideSaveImage(): ISaveImage = SaveImage(context)
|
override fun provideSaveImage(): ISaveImage = SaveImage(context)
|
||||||
|
|
||||||
// WaveLog 日志(4.5.2): 本地队列 + 上传器(共享实例)
|
|
||||||
override val wavelogQueue: WavelogQueue by lazy {
|
|
||||||
val prefs = context.getSharedPreferences("wavelog", Context.MODE_PRIVATE)
|
|
||||||
WavelogQueue(object : IWavelogQueueStore {
|
|
||||||
override fun load(): String = prefs.getString("wavelog_queue", "[]") ?: "[]"
|
|
||||||
override fun save(json: String) = prefs.edit().putString("wavelog_queue", json).apply()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
override fun provideWavelogUploader(): WavelogUploader = WavelogUploader(settingsRepo, wavelogQueue)
|
|
||||||
|
|
||||||
override fun provideBluetoothReporter(): IReporter {
|
override fun provideBluetoothReporter(): IReporter {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
val rc = settingsRepo.rcSettings.value
|
val rc = settingsRepo.rcSettings.value
|
||||||
@@ -130,7 +125,8 @@ 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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +161,7 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
private fun provideDatabaseRepo(): IDatabaseRepo {
|
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||||
val dbDispatcher = Dispatchers.Default
|
val dbDispatcher = Dispatchers.Default
|
||||||
val dataParser = DataParser(dbDispatcher)
|
val dataParser = DataParser(dbDispatcher)
|
||||||
|
val remoteSource = provideRemoteSource()
|
||||||
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,14 +172,7 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun provideRemoteSource(): IRemoteSource {
|
private fun provideRemoteSource(): IRemoteSource {
|
||||||
return RemoteSource(
|
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
|
||||||
Dispatchers.IO, context.contentResolver,
|
|
||||||
OkHttpClient.Builder()
|
|
||||||
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
|
||||||
.readTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
|
|
||||||
.writeTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideSatelliteRepo(): ISatelliteRepo {
|
private fun provideSatelliteRepo(): ISatelliteRepo {
|
||||||
@@ -198,6 +188,6 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
val appPrefsFileName = "${context.packageName}_preferences"
|
val appPrefsFileName = "${context.packageName}_preferences"
|
||||||
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
||||||
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
||||||
return SettingsRepo(context, manager, appPreferences, appVersionName)
|
return SettingsRepo(manager, appPreferences, appVersionName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+261
-9
@@ -1,21 +1,273 @@
|
|||||||
package com.rtbishop.look4sat.core.data.repository
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.data.source.AmSatParser
|
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.model.SatStatusPage
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
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
|
||||||
|
|
||||||
/** AMSAT 状态仓库: 抓 HTML → 解析 → SatStatusPage */
|
/** One report from the AMSAT API (data layer model). */
|
||||||
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
|
private data class ApiReport(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val callsign: String,
|
||||||
|
val report: String,
|
||||||
|
val gridSquare: String,
|
||||||
|
val reportedTimeUtcSec: Long
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun fetchStatus(): SatStatusPage? {
|
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
|
||||||
val html = remoteSource.getStatusHtml() ?: return null
|
class AmSatRepository(
|
||||||
if (html.isBlank()) return null
|
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 {
|
return try {
|
||||||
AmSatParser.parse(html, System.currentTimeMillis())
|
fetch.await()
|
||||||
} catch (exception: Exception) {
|
} finally {
|
||||||
println("AmSatRepository parse exception: $exception")
|
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
|
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 utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
|
val labels = (0 until 3).map { d ->
|
||||||
|
utc.timeInMillis = (nowSec - d * 86400L) * 1000
|
||||||
|
formatDayLabel(utc)
|
||||||
|
}
|
||||||
|
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 formatDayLabel(calendar: Calendar): String {
|
||||||
|
return if (Locale.getDefault().language == Locale.CHINESE.language) {
|
||||||
|
"${calendar.get(Calendar.MONTH) + 1}月${calendar.get(Calendar.DAY_OF_MONTH)}日"
|
||||||
|
} else {
|
||||||
|
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||||
|
"${monthAbbr[calendar.get(Calendar.MONTH)]} ${calendar.get(Calendar.DAY_OF_MONTH)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+40
-30
@@ -66,38 +66,45 @@ class DatabaseRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
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.
|
||||||
// 开关开且 URL 非空 -> All 用自定义 URL;否则用默认 URL(在线更新默认源)
|
val tleUrls = settings.satelliteUrls
|
||||||
put("All", if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank())
|
.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) }
|
||||||
dataSourcesSettings.tleUrl else Sources.defaultTleUrl)
|
.map { it to normalizeUrl(it) }
|
||||||
}.filterValues { it.isNotBlank() }
|
.distinctBy { it.second }
|
||||||
val radioUrls = buildMap {
|
val radioUrls = settings.transceiversUrls
|
||||||
putAll(Sources.transceiversDataUrls)
|
.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) }
|
||||||
put("SatNOGS", if (dataSourcesSettings.useCustomTransceivers && dataSourcesSettings.transceiversUrl.isNotBlank())
|
.map { it to normalizeUrl(it) }
|
||||||
dataSourcesSettings.transceiversUrl else Sources.defaultTransceiversUrl)
|
.distinctBy { it.second }
|
||||||
}.filterValues { it.isNotBlank() }
|
val builtinTypesByUrl = Sources.satelliteDataUrls
|
||||||
// launch all network requests concurrently
|
.filterValues { it.isNotBlank() }
|
||||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
.mapValues { normalizeUrl(it.value) }
|
||||||
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
.entries
|
||||||
// 统计成功源数: 0 成功视为更新失败(不刷时间戳, 抛异常让 UI 提示)
|
.associate { (type, url) -> url to type }
|
||||||
|
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 radioJobs = radioUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
|
||||||
val tleResults = tleJobs.awaitAll()
|
val tleResults = tleJobs.awaitAll()
|
||||||
val radioResults = radioJobs.awaitAll()
|
val radioResults = radioJobs.awaitAll()
|
||||||
val successCount = tleResults.count { it.second != null } + radioResults.count { it.second != null }
|
// report the HTTP status code of every source (200, 404, ...)
|
||||||
if (successCount == 0) {
|
settingsRepo.updateDataSourcesStatus(
|
||||||
throw java.io.IOException("All data sources failed to download")
|
(tleResults + radioResults).associate { (url, result) -> url to result.code }
|
||||||
}
|
)
|
||||||
// parse fetched data concurrently and associate with types
|
// parse fetched data concurrently and associate known built-in URLs with existing type filters.
|
||||||
val importedEntries = tleResults.flatMap { (url, stream) ->
|
val importedEntries = tleResults.flatMap { (rawUrl, result) ->
|
||||||
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
val normUrl = normalizeUrl(rawUrl)
|
||||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
val entries = result.stream?.let { parseSatelliteStream(normUrl, unwrapIfZipped(normUrl, it)) }.orEmpty()
|
||||||
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
val type = builtinTypesByUrl[normUrl] ?: customSourceType
|
||||||
}
|
importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum })
|
||||||
}
|
entries
|
||||||
val importedRadios = radioResults.flatMap { (url, stream) ->
|
}.distinctBy { it.catnum }
|
||||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
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)
|
||||||
@@ -110,6 +117,9 @@ class DatabaseRepo(
|
|||||||
setUpdateSuccessful(0L)
|
setUpdateSuccessful(0L)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun normalizeUrl(url: String): String =
|
||||||
|
if (url.startsWith("http", ignoreCase = true)) url else "https://$url"
|
||||||
|
|
||||||
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
|
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
|
||||||
val bufferedStream = stream.buffered()
|
val bufferedStream = stream.buffered()
|
||||||
return when {
|
return when {
|
||||||
|
|||||||
@@ -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.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches confirmed gridsquares directly from ARRL LoTW via the official report endpoint:
|
||||||
|
* GET https://lotw.arrl.org/lotwuser/lotwreport.adi?login=<call>&password=<pwd>
|
||||||
|
* &qso_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes&qso_qslsince=<date>
|
||||||
|
*
|
||||||
|
* Only QSL_RCVD=Y records are returned by LoTW for qso_qsl=yes, so every grid in the
|
||||||
|
* report is a *confirmed* grid (green on the map). GRIDSQUARE may be a 4- or 6-char
|
||||||
|
* value; VUCC_GRIDS ("EN52en,EN53fa") also yields 4-char fields. All values are
|
||||||
|
* truncated/expanded to the 4-char form used by the map overlay.
|
||||||
|
*
|
||||||
|
* ARRL rate-limits the report endpoint (roughly once per hour per account), which is
|
||||||
|
* fine for a manual sync button.
|
||||||
|
*/
|
||||||
|
class LoTWRepository : ILoTWRepository {
|
||||||
|
|
||||||
|
override suspend fun fetchConfirmedGrids(callsign: String, password: String): Set<String>? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val call = callsign.trim().uppercase()
|
||||||
|
val pwd = password.trim()
|
||||||
|
if (call.isBlank() || pwd.isBlank()) return@withContext null
|
||||||
|
// qso_qslsince with an early date forces a FULL confirmed-QSL report.
|
||||||
|
// Without it, LoTW applies a "system supplied default" since-date and
|
||||||
|
// only returns confirmations newer than the account's last query —
|
||||||
|
// subsequent syncs would return an empty/incremental report.
|
||||||
|
val since = "2000-01-01"
|
||||||
|
val query = buildString {
|
||||||
|
append("login=").append(URLEncoder.encode(call, "UTF-8"))
|
||||||
|
append("&password=").append(URLEncoder.encode(pwd, "UTF-8"))
|
||||||
|
append("&qso_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes")
|
||||||
|
append("&qso_qslsince=").append(URLEncoder.encode(since, "UTF-8"))
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val connection = URL("$BASE_URL?$query").openConnection() as HttpURLConnection
|
||||||
|
connection.connectTimeout = 15_000
|
||||||
|
connection.readTimeout = 60_000
|
||||||
|
connection.requestMethod = "GET"
|
||||||
|
val code = connection.responseCode
|
||||||
|
if (code !in 200..299) {
|
||||||
|
connection.disconnect()
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
val body = connection.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
connection.disconnect()
|
||||||
|
if (body.contains(" password=") && !body.startsWith("ARRL")) return@withContext null
|
||||||
|
parseConfirmedGrids(body)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("LoTWRepository fetch failure: $e")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun parseConfirmedGrids(body: String): Set<String>? {
|
||||||
|
// LoTW answers with ADIF text; on bad credentials it returns a short error page
|
||||||
|
// containing "password=?" or an <eoh>-less block. Treat anything without a header
|
||||||
|
// marker as failure so the caller can show a sensible message.
|
||||||
|
// LoTW answers with ADIF text; on bad credentials it returns a short error
|
||||||
|
// page without an <eoh> header terminator. Real reports always carry <eoh>
|
||||||
|
// (LoTW writes it lowercase). Match case-insensitively to be safe.
|
||||||
|
if (!body.contains("<eoh>", ignoreCase = true)) return null
|
||||||
|
val grids = mutableSetOf<String>()
|
||||||
|
for (raw in body.lineSequence()) {
|
||||||
|
val line = raw.trim()
|
||||||
|
val value = when {
|
||||||
|
line.startsWith("<GRIDSQUARE:") -> line.substringAfter('>')
|
||||||
|
line.startsWith("<VUCC_GRIDS:") -> line.substringAfter('>')
|
||||||
|
else -> continue
|
||||||
|
}.substringBefore("E<").trim().uppercase()
|
||||||
|
if (value.length >= 4) grids.add(value.take(4))
|
||||||
|
}
|
||||||
|
return grids
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val BASE_URL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-32
@@ -34,6 +34,7 @@ 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
|
import java.util.TimeZone
|
||||||
@@ -63,19 +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 settings = settingsRepo.passesSettings.value
|
settingsRepo.stationPosition
|
||||||
calculatePasses(
|
) { selectedIds, _ -> selectedIds }
|
||||||
time = System.currentTimeMillis(),
|
.collect { selectedIds ->
|
||||||
hoursAhead = settings.hoursAhead,
|
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||||
minElevation = settings.minElevation,
|
val settings = settingsRepo.passesSettings.value
|
||||||
aosStartMinute = settings.aosStartMinute,
|
calculatePasses(
|
||||||
aosEndMinute = settings.aosEndMinute,
|
time = System.currentTimeMillis(),
|
||||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
hoursAhead = settings.hoursAhead,
|
||||||
modes = settings.selectedModes
|
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 {
|
||||||
@@ -95,8 +100,14 @@ class SatelliteRepo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getRadios(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio> {
|
override suspend fun getRadios(
|
||||||
|
sat: OrbitalObject,
|
||||||
|
pos: GeoPos,
|
||||||
|
radios: List<SatRadio>,
|
||||||
|
time: Long
|
||||||
|
): List<SatRadio> {
|
||||||
return withContext(dispatcher) {
|
return withContext(dispatcher) {
|
||||||
|
val satPos = sat.getPosition(pos, time)
|
||||||
radios.map { transmitter ->
|
radios.map { transmitter ->
|
||||||
transmitter.copy(
|
transmitter.copy(
|
||||||
downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) },
|
downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) },
|
||||||
@@ -236,16 +247,13 @@ class SatelliteRepo(
|
|||||||
if (elevation > maxElevation) maxElevation = elevation
|
if (elevation > maxElevation) maxElevation = elevation
|
||||||
} while (elevation < 0.0)
|
} while (elevation < 0.0)
|
||||||
|
|
||||||
// refine AOS to ~500ms precision via binary search.
|
// refine AOS to ~500ms precision
|
||||||
// Elevation is monotonic across the horizon crossing, so binary search
|
calendarTimeMillis -= 60L * 1000L
|
||||||
// finds the crossing in ~8 SGP4 calls instead of up to 120 linear steps.
|
do {
|
||||||
var aosLo = calendarTimeMillis - 60L * 1000L // elevation < 0 (below horizon)
|
calendarTimeMillis += 500L
|
||||||
var aosHi = calendarTimeMillis // elevation >= 0 (above horizon)
|
elevation = sat.getElevation(pos, calendarTimeMillis)
|
||||||
while (aosHi - aosLo > 500L) {
|
if (elevation > maxElevation) maxElevation = elevation
|
||||||
val mid = (aosLo + aosHi) / 2
|
} while (elevation < 0.0)
|
||||||
if (sat.getElevation(pos, mid) < 0.0) aosLo = mid else aosHi = mid
|
|
||||||
}
|
|
||||||
calendarTimeMillis = aosHi
|
|
||||||
|
|
||||||
// Get full position for AOS data (azimuth, altitude)
|
// Get full position for AOS data (azimuth, altitude)
|
||||||
val aosPos = sat.getFullPosition(pos, calendarTimeMillis)
|
val aosPos = sat.getFullPosition(pos, calendarTimeMillis)
|
||||||
@@ -259,14 +267,13 @@ class SatelliteRepo(
|
|||||||
if (elevation > maxElevation) maxElevation = elevation
|
if (elevation > maxElevation) maxElevation = elevation
|
||||||
} while (elevation > 0.0)
|
} while (elevation > 0.0)
|
||||||
|
|
||||||
// refine LOS to ~500ms precision via binary search (same monotonic argument)
|
// refine LOS to ~500ms precision
|
||||||
var losLo = calendarTimeMillis - 30L * 1000L // elevation > 0 (above horizon)
|
calendarTimeMillis -= 30L * 1000L
|
||||||
var losHi = calendarTimeMillis // elevation <= 0 (below horizon)
|
do {
|
||||||
while (losHi - losLo > 500L) {
|
calendarTimeMillis += 500L
|
||||||
val mid = (losLo + losHi) / 2
|
elevation = sat.getElevation(pos, calendarTimeMillis)
|
||||||
if (sat.getElevation(pos, mid) > 0.0) losLo = mid else losHi = mid
|
if (elevation > maxElevation) maxElevation = elevation
|
||||||
}
|
} while (elevation > 0.0)
|
||||||
calendarTimeMillis = losHi
|
|
||||||
|
|
||||||
// Get full position for LOS data (azimuth, altitude)
|
// Get full position for LOS data (azimuth, altitude)
|
||||||
val losPos = sat.getFullPosition(pos, calendarTimeMillis)
|
val losPos = sat.getFullPosition(pos, calendarTimeMillis)
|
||||||
|
|||||||
+256
-77
@@ -29,7 +29,7 @@ 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
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||||
@@ -38,9 +38,12 @@ 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 context: android.content.Context,
|
|
||||||
private val locationManager: LocationManager,
|
private val locationManager: LocationManager,
|
||||||
private val preferences: SharedPreferences,
|
private val preferences: SharedPreferences,
|
||||||
override val appVersionName: String
|
override val appVersionName: String
|
||||||
@@ -69,6 +72,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"
|
||||||
@@ -78,8 +82,7 @@ class SettingsRepo(
|
|||||||
private val keyStateOfUtc = "stateOfUtc"
|
private val keyStateOfUtc = "stateOfUtc"
|
||||||
private val keyStateOfLightTheme = "stateOfLightTheme"
|
private val keyStateOfLightTheme = "stateOfLightTheme"
|
||||||
private val keyStateOfNightMode = "stateOfNightMode"
|
private val keyStateOfNightMode = "stateOfNightMode"
|
||||||
private val keyHiddenScreens = "hiddenScreens"
|
private val keyStateOfMapGrid = "stateOfMapGrid"
|
||||||
private val keyScreenOrder = "screenOrder"
|
|
||||||
private val keyStationAltitude = "stationAltitude"
|
private val keyStationAltitude = "stationAltitude"
|
||||||
private val keyStationLatitude = "stationLatitude"
|
private val keyStationLatitude = "stationLatitude"
|
||||||
private val keyStationLongitude = "stationLongitude"
|
private val keyStationLongitude = "stationLongitude"
|
||||||
@@ -95,12 +98,15 @@ class SettingsRepo(
|
|||||||
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 keySubMenuOrder = "subMenuOrder"
|
private val keySatelliteUrls = "satelliteUrls"
|
||||||
private val keyWavelogUrl = "wavelogUrl"
|
private val keyTransceiversUrls = "transceiversUrls"
|
||||||
private val keyWavelogApiKey = "wavelogApiKey"
|
private val keySatelliteEnabled = "satelliteEnabled"
|
||||||
private val keyWavelogStationId = "wavelogStationId"
|
private val keyTransceiversEnabled = "transceiversEnabled"
|
||||||
private val keyWavelogAutoUpload = "wavelogAutoUpload"
|
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())
|
||||||
@@ -133,7 +139,73 @@ class SettingsRepo(
|
|||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Passes filter settings
|
//region # Wavelog worked-grids settings
|
||||||
|
private val keyWavelogUrl = "wavelogUrl"
|
||||||
|
private val keyWavelogToken = "wavelogToken"
|
||||||
|
private val keyWorkedGrids = "workedGrids"
|
||||||
|
|
||||||
|
private val _wavelogSettings = MutableStateFlow(getWavelogSettings())
|
||||||
|
override val wavelogSettings: StateFlow<WavelogSettings> = _wavelogSettings
|
||||||
|
|
||||||
|
override fun updateWavelogSettings(settings: WavelogSettings) {
|
||||||
|
preferences.edit {
|
||||||
|
putString(keyWavelogUrl, settings.url.trim())
|
||||||
|
putString(keyWavelogToken, settings.token.trim())
|
||||||
|
}
|
||||||
|
_wavelogSettings.value = settings.copy(url = settings.url.trim(), token = settings.token.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getWavelogSettings(): WavelogSettings = WavelogSettings(
|
||||||
|
url = preferences.getString(keyWavelogUrl, null).orEmpty(),
|
||||||
|
token = preferences.getString(keyWavelogToken, null).orEmpty()
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun getWorkedGrids(): Set<String> {
|
||||||
|
val json = preferences.getString(keyWorkedGrids, null).orEmpty()
|
||||||
|
if (json.isBlank()) return emptySet()
|
||||||
|
return try {
|
||||||
|
val array = org.json.JSONArray(json)
|
||||||
|
(0 until array.length()).mapNotNull { i ->
|
||||||
|
array.optString(i).takeIf { it.isNotBlank() }
|
||||||
|
}.toSet()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptySet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setWorkedGrids(grids: Set<String>) {
|
||||||
|
val array = org.json.JSONArray()
|
||||||
|
grids.sorted().forEach { array.put(it) }
|
||||||
|
preferences.edit { putString(keyWorkedGrids, array.toString()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoTW credentials (stored locally on the device only)
|
||||||
|
private val keyLoTWCall = "lotwCallsign"
|
||||||
|
private val keyLoTWPass = "lotwPassword"
|
||||||
|
|
||||||
|
override val lotwSettings: kotlinx.coroutines.flow.StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
|
||||||
|
get() = _lotwSettings
|
||||||
|
private val _lotwSettings = MutableStateFlow(getLoTWSettings())
|
||||||
|
|
||||||
|
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) {
|
||||||
|
preferences.edit {
|
||||||
|
putString(keyLoTWCall, settings.callsign.trim().uppercase())
|
||||||
|
putString(keyLoTWPass, settings.password)
|
||||||
|
}
|
||||||
|
_lotwSettings.value = settings.copy(
|
||||||
|
callsign = settings.callsign.trim().uppercase(),
|
||||||
|
password = settings.password
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getLoTWSettings(): com.rtbishop.look4sat.core.domain.model.LoTWSettings =
|
||||||
|
com.rtbishop.look4sat.core.domain.model.LoTWSettings(
|
||||||
|
callsign = preferences.getString(keyLoTWCall, null).orEmpty(),
|
||||||
|
password = preferences.getString(keyLoTWPass, null).orEmpty()
|
||||||
|
)
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # Transceivers settings
|
||||||
private val _passesSettings = MutableStateFlow(getPassesSettings())
|
private val _passesSettings = MutableStateFlow(getPassesSettings())
|
||||||
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
|
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
|
||||||
|
|
||||||
@@ -187,41 +259,23 @@ class SettingsRepo(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GPS 定位: 一次性 getCurrentLocation(GPS 优先, 15 秒超时), 拿到位置才返回 true */
|
override fun setStationPosition(): Boolean {
|
||||||
override suspend fun setStationPosition(): Boolean {
|
|
||||||
// 权限前置: 无定位权限直接失败(不吞异常)
|
|
||||||
if (androidx.core.content.ContextCompat.checkSelfPermission(
|
|
||||||
context, android.Manifest.permission.ACCESS_FINE_LOCATION
|
|
||||||
) != android.content.pm.PackageManager.PERMISSION_GRANTED
|
|
||||||
) {
|
|
||||||
println("GPS: no fine location permission")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (!LocationManagerCompat.isLocationEnabled(locationManager)) return false
|
if (!LocationManagerCompat.isLocationEnabled(locationManager)) return false
|
||||||
return kotlinx.coroutines.suspendCancellableCoroutine { cont ->
|
try {
|
||||||
val signal = android.os.CancellationSignal()
|
|
||||||
val handler = android.os.Handler(android.os.Looper.getMainLooper())
|
|
||||||
val executor = java.util.concurrent.Executor { handler.post(it) }
|
|
||||||
// 15 秒超时
|
|
||||||
val timeout = handler.postDelayed({
|
|
||||||
signal.cancel()
|
|
||||||
if (cont.isActive) cont.resume(false) { }
|
|
||||||
}, 15_000L)
|
|
||||||
val listener = androidx.core.util.Consumer<Location> { location ->
|
|
||||||
handler.removeCallbacksAndMessages(null)
|
|
||||||
setStationPosition(location.latitude, location.longitude, location.altitude)
|
|
||||||
if (cont.isActive) cont.resume(true) { }
|
|
||||||
}
|
|
||||||
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
|
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
|
||||||
val provider = if (hasGps) providerGps else providerNet
|
val hasNet = LocationManagerCompat.hasProvider(locationManager, providerNet)
|
||||||
try {
|
val provider = if (hasGps) providerGps else if (hasNet) providerNet else providerDef
|
||||||
LocationManagerCompat.getCurrentLocation(locationManager, provider, signal, executor, listener)
|
val location = locationManager.getLastKnownLocation(providerDef)
|
||||||
} catch (exception: SecurityException) {
|
if (location == null || System.currentTimeMillis() - location.time > 600_000L) {
|
||||||
handler.removeCallbacksAndMessages(null)
|
println("Requesting location for $provider provider")
|
||||||
if (cont.isActive) cont.resume(false) { }
|
locationManager.requestLocationUpdates(provider, 0L, 0f, this)
|
||||||
|
} else {
|
||||||
|
setStationPosition(location.latitude, location.longitude, location.altitude)
|
||||||
}
|
}
|
||||||
cont.invokeOnCancellation { signal.cancel(); handler.removeCallbacksAndMessages(null) }
|
} catch (exception: SecurityException) {
|
||||||
|
println("No permissions were given - $exception")
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setStationPosition(locator: String): Boolean {
|
override fun setStationPosition(locator: String): Boolean {
|
||||||
@@ -240,8 +294,8 @@ class SettingsRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setStationPosition(latitude: Double, longitude: Double, altitude: Double, locator: String) {
|
private fun setStationPosition(latitude: Double, longitude: Double, altitude: Double, locator: String) {
|
||||||
val newLat = latitude.round(5)
|
val newLat = latitude.round(4)
|
||||||
val newLon = longitude.round(5)
|
val newLon = longitude.round(4)
|
||||||
val newAlt = altitude.round(1)
|
val newAlt = altitude.round(1)
|
||||||
val timestamp = System.currentTimeMillis()
|
val timestamp = System.currentTimeMillis()
|
||||||
println("Received new Position($newLat, $newLon, $newAlt) & Locator $locator")
|
println("Received new Position($newLat, $newLon, $newAlt) & Locator $locator")
|
||||||
@@ -325,6 +379,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)
|
||||||
@@ -334,6 +392,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)
|
||||||
@@ -342,7 +401,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(
|
||||||
@@ -354,6 +413,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",
|
||||||
@@ -378,18 +439,12 @@ class SettingsRepo(
|
|||||||
putBoolean(keyStateOfUtc, new.stateOfUtc)
|
putBoolean(keyStateOfUtc, new.stateOfUtc)
|
||||||
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
|
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
|
||||||
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
|
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
|
||||||
|
putBoolean(keyStateOfMapGrid, new.stateOfMapGrid)
|
||||||
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
||||||
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
||||||
putString(keySstvMode, new.sstvMode)
|
putString(keySstvMode, new.sstvMode)
|
||||||
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
||||||
putLong(keyHighElevation, new.highElevation.toRawBits())
|
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||||
putStringSet(keyHiddenScreens, new.hiddenScreens.toSet())
|
|
||||||
putString(keyScreenOrder, new.screenOrder.joinToString(","))
|
|
||||||
putString(keySubMenuOrder, new.subMenuOrder.joinToString(","))
|
|
||||||
putString(keyWavelogUrl, new.wavelogUrl)
|
|
||||||
putString(keyWavelogApiKey, new.wavelogApiKey)
|
|
||||||
putString(keyWavelogStationId, new.wavelogStationId)
|
|
||||||
putBoolean(keyWavelogAutoUpload, new.wavelogAutoUpload)
|
|
||||||
}
|
}
|
||||||
new
|
new
|
||||||
}
|
}
|
||||||
@@ -402,18 +457,12 @@ class SettingsRepo(
|
|||||||
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
|
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
|
||||||
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
|
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
|
||||||
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
||||||
|
stateOfMapGrid = preferences.getBoolean(keyStateOfMapGrid, false),
|
||||||
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
||||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||||
hiddenScreens = preferences.getStringSet(keyHiddenScreens, emptySet())?.toList() ?: emptyList(),
|
|
||||||
screenOrder = preferences.getString(keyScreenOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
|
|
||||||
subMenuOrder = preferences.getString(keySubMenuOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
|
|
||||||
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
||||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
|
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
|
||||||
wavelogUrl = preferences.getString(keyWavelogUrl, null) ?: "",
|
|
||||||
wavelogApiKey = preferences.getString(keyWavelogApiKey, null) ?: "",
|
|
||||||
wavelogStationId = preferences.getString(keyWavelogStationId, null) ?: "",
|
|
||||||
wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false)
|
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -422,28 +471,107 @@ 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 {
|
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
||||||
// 4.4.8 修复: 旧版 example.com 占位 URL 视为未配置 -> 替换为真实默认 URL 且开关强制关闭,
|
satelliteUrls = getDataSourceUrls(
|
||||||
// 否则在线更新的 All/SatNOGS 源会指向错误地址导致更新失败
|
key = keySatelliteUrls,
|
||||||
val storedTleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl
|
defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
|
||||||
val storedTxUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl) ?: Sources.defaultTransceiversUrl
|
legacyEnabledKey = keyUseCustomTle,
|
||||||
val tleUrl = if (storedTleUrl == "https://example.com/tle.txt") Sources.defaultTleUrl else storedTleUrl
|
legacyUrlKey = keyTleUrl
|
||||||
val txUrl = if (storedTxUrl == "https://example.com/radio.json") Sources.defaultTransceiversUrl else storedTxUrl
|
).migrateSatnogsTleSource(),
|
||||||
return DataSourcesSettings(
|
transceiversUrls = getDataSourceUrls(
|
||||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false) && tleUrl != Sources.defaultTleUrl,
|
key = keyTransceiversUrls,
|
||||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false) && txUrl != Sources.defaultTransceiversUrl,
|
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
|
||||||
tleUrl = tleUrl,
|
legacyEnabledKey = keyUseCustomTransceivers,
|
||||||
transceiversUrl = txUrl
|
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
|
||||||
|
|
||||||
@@ -485,4 +613,55 @@ class SettingsRepo(
|
|||||||
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
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
|
||||||
}
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
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 java.io.File
|
||||||
|
|
||||||
|
class UpdateRepository(
|
||||||
|
private val remoteSource: IRemoteSource
|
||||||
|
) : IUpdateRepository {
|
||||||
|
|
||||||
|
override suspend fun getLatestRelease(): LatestRelease? = withContext(Dispatchers.IO) {
|
||||||
|
// Use the GitHub releases web page instead of the REST API endpoint:
|
||||||
|
// the API endpoint is rate-limited to 60 requests/hour per IP, which is
|
||||||
|
// quickly exhausted on shared egress IPs (e.g. VPN proxies), causing 403
|
||||||
|
// failures. The web endpoint redirects to the latest tag with no such limit.
|
||||||
|
// When GitHub is unreachable (common on mainland-China networks without a
|
||||||
|
// proxy), fall back to community GitHub accelerator mirrors that proxy the
|
||||||
|
// same page; each mirror resolves the identical tag and asset URLs.
|
||||||
|
for (baseUrl in LATEST_RELEASE_URLS) {
|
||||||
|
val result = remoteSource.getNetworkStream(baseUrl)
|
||||||
|
val stream = result.stream ?: continue
|
||||||
|
val parsed = try {
|
||||||
|
parseRelease(stream.bufferedReader().use { it.readText() }, baseUrl)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("UpdateRepository parse failure: $e")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (parsed != null) return@withContext parsed
|
||||||
|
}
|
||||||
|
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(html: String, sourceUrl: String): LatestRelease? {
|
||||||
|
// The redirect target is the latest release's tag page. Extract the tag
|
||||||
|
// from the og:url meta tag ("…/releases/tag/v4.4.6-ba7opf.8") — stable and
|
||||||
|
// unambiguous. The <title> also holds the release name, but that is free
|
||||||
|
// text and cannot be used to build the asset URL.
|
||||||
|
val ogUrl = Regex("<meta\\s+property=\"og:url\"\\s+content=\"([^\"]*)\"").find(html)
|
||||||
|
?.groupValues?.get(1) ?: return null
|
||||||
|
val tag = Regex("/releases/tag/(v[0-9][0-9A-Za-z.\\-]*)$").find(ogUrl)
|
||||||
|
?.groupValues?.get(1) ?: return null
|
||||||
|
val title = Regex("<title>(.*?)</title>", RegexOption.DOT_MATCHES_ALL)
|
||||||
|
.find(html)?.groupValues?.get(1)?.trim()
|
||||||
|
?.substringBefore("·")?.removePrefix("Release")?.trim() ?: tag
|
||||||
|
// Release notes live in the page's markdown-body section (first occurrence
|
||||||
|
// is the release description). Strip HTML tags for plain-text display.
|
||||||
|
val raw = Regex("<div[^>]*class=\"[^\"]*markdown-body[^\"]*\"[^>]*>(.*?)</div>", RegexOption.DOT_MATCHES_ALL)
|
||||||
|
.find(html)?.groupValues?.get(1) ?: ""
|
||||||
|
val body = raw.replace(Regex("<[^>]+>"), "")
|
||||||
|
.replace("<", "<").replace(">", ">").replace("&", "&")
|
||||||
|
.replace(""", "\"").replace("'", "'")
|
||||||
|
.trim()
|
||||||
|
// The release APK asset follows the fixed naming scheme used by the build:
|
||||||
|
// Look4Sat-<tag without leading v>-release.apk. When the page was fetched
|
||||||
|
// through an accelerator mirror (https://<mirror>/https://github.com/...),
|
||||||
|
// download through the same mirror — raw github.com is unreachable on the
|
||||||
|
// networks that needed the mirror in the first place.
|
||||||
|
val mirrorPrefix = sourceUrl.substringBefore("https://github.com")
|
||||||
|
val apkUrl = if (mirrorPrefix.isEmpty()) {
|
||||||
|
"$DOWNLOAD_BASE_URL/$tag/Look4Sat-${tag.removePrefix("v")}-release.apk"
|
||||||
|
} else {
|
||||||
|
"${mirrorPrefix}https://github.com/atsunatsu/Look4Sat/releases/download/$tag/Look4Sat-${tag.removePrefix("v")}-release.apk"
|
||||||
|
}
|
||||||
|
return LatestRelease(
|
||||||
|
versionTag = tag,
|
||||||
|
title = title,
|
||||||
|
body = body,
|
||||||
|
apkUrl = apkUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
// Web pages are used instead of api.github.com to avoid the 60 req/hour
|
||||||
|
// anonymous rate limit (see getLatestRelease above). The GitHub URL is
|
||||||
|
// tried first; if it is unreachable (no proxy on mainland networks) the
|
||||||
|
// accelerator mirrors are tried in order. Download URLs always point at
|
||||||
|
// the mirrors too — raw github.com release downloads are equally blocked
|
||||||
|
// without a proxy, so a mirror-resolved tag must be downloaded via the
|
||||||
|
// same mirror.
|
||||||
|
val LATEST_RELEASE_URLS = listOf(
|
||||||
|
"https://github.com/atsunatsu/Look4Sat/releases/latest",
|
||||||
|
"https://ghfast.top/https://github.com/atsunatsu/Look4Sat/releases/latest",
|
||||||
|
"https://gh.llkk.cc/https://github.com/atsunatsu/Look4Sat/releases/latest",
|
||||||
|
"https://github.moeyy.xyz/https://github.com/atsunatsu/Look4Sat/releases/latest"
|
||||||
|
)
|
||||||
|
const val DOWNLOAD_BASE_URL = "https://github.com/atsunatsu/Look4Sat/releases/download"
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
* 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.repository.IWavelogRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the list of worked gridsquares from a self-hosted Wavelog instance.
|
||||||
|
*
|
||||||
|
* Uses the Wavelog v1 API endpoint (verified against Wavelog 3.0.2):
|
||||||
|
* POST {base}/index.php/api/logbook_get_worked_grids
|
||||||
|
* Body: {"key": "<api key>", "logbook_id": "<logbook id>"}
|
||||||
|
* Response 201: JSON array of 4-char gridsquares, e.g. ["JN47","JO30"]
|
||||||
|
* (see Wavelog Api.php logbook_get_worked_grids / Api_model::get_grids_worked_in_logbook)
|
||||||
|
*
|
||||||
|
* The token field carries "<api_key>:<logbook_id>" so one credential line
|
||||||
|
* configures both values (the dialog explains this to the user).
|
||||||
|
*/
|
||||||
|
class WavelogRepository : IWavelogRepository {
|
||||||
|
|
||||||
|
override suspend fun fetchWorkedGrids(url: String, token: String): Set<String>? = withContext(Dispatchers.IO) {
|
||||||
|
val parts = token.trim().split(":")
|
||||||
|
val apiKey = parts.getOrNull(0)?.trim().orEmpty()
|
||||||
|
val logbookId = parts.getOrNull(1)?.trim().orEmpty()
|
||||||
|
if (url.isBlank() || apiKey.isBlank() || logbookId.isBlank()) return@withContext null
|
||||||
|
val base = url.trim().trimEnd('/')
|
||||||
|
val requestUrl = "$base/index.php/api/logbook_get_worked_grids"
|
||||||
|
try {
|
||||||
|
val connection = java.net.URL(requestUrl).openConnection() as java.net.HttpURLConnection
|
||||||
|
connection.connectTimeout = 10_000
|
||||||
|
connection.readTimeout = 15_000
|
||||||
|
connection.requestMethod = "POST"
|
||||||
|
connection.doOutput = true
|
||||||
|
connection.setRequestProperty("Content-Type", "application/json")
|
||||||
|
val body = JSONObject().apply {
|
||||||
|
put("key", apiKey)
|
||||||
|
put("logbook_id", logbookId)
|
||||||
|
}.toString().toByteArray()
|
||||||
|
connection.outputStream.use { it.write(body) }
|
||||||
|
val code = connection.responseCode
|
||||||
|
if (code !in 200..299) {
|
||||||
|
connection.disconnect()
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
val response = connection.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
connection.disconnect()
|
||||||
|
parseWorkedGrids(response)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("WavelogRepository fetch failure: $e")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun parseWorkedGrids(body: String): Set<String>? {
|
||||||
|
return try {
|
||||||
|
val array = when {
|
||||||
|
body.trimStart().startsWith("[") -> JSONArray(body)
|
||||||
|
else -> {
|
||||||
|
// Tolerate {"grids": [...]} wrappers too
|
||||||
|
JSONObject(body).optJSONArray("grids") ?: return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val result = mutableSetOf<String>()
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
val grid = array.optString(i, "").trim().uppercase()
|
||||||
|
// Keep only well-formed 4-character Maidenhead squares (e.g. OL62)
|
||||||
|
if (grid.length == 4 && grid[0] in 'A'..'R' && grid[1] in 'A'..'R'
|
||||||
|
&& grid[2] in '0'..'9' && grid[3] in '0'..'9'
|
||||||
|
) {
|
||||||
|
result.add(grid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
package com.rtbishop.look4sat.core.data.source
|
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatDay
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
|
||||||
import java.util.regex.Pattern
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AMSAT 卫星状态页解析器(https://amsat.org/status/)
|
|
||||||
*
|
|
||||||
* 页面结构(静态 HTML, 2026-08 实测):
|
|
||||||
* - 状态表: <table> 内 48 行, 表头 = Name + 6 天(每个 colspan=12)
|
|
||||||
* 数据行 73 格: [0]=卫星名, [1..72] = 6 天 × 12 个 2 小时槽(新→旧)
|
|
||||||
* 每格 <td width=9 bgcolor="颜色">数字</td>, 有报告时数字带
|
|
||||||
* docTips.show('id') 链接
|
|
||||||
* - 报告详情在页面内嵌 JS:
|
|
||||||
* tips.a885153 = new Array(5,5,120,'状态<br>呼号<br>网格<br>日期<br>时间段 UTC')
|
|
||||||
*
|
|
||||||
* 状态色: #648fff=活跃, #ffb000=仅遥测, #dc267f=未听到, #fe6100=矛盾
|
|
||||||
*/
|
|
||||||
object AmSatParser {
|
|
||||||
|
|
||||||
private val STATUS_COLORS = mapOf(
|
|
||||||
"#648fff" to 0xFF648FFF.toLong(),
|
|
||||||
"#ffb000" to 0xFFFFB000.toLong(),
|
|
||||||
"#dc267f" to 0xFFDC267F.toLong(),
|
|
||||||
"#fe6100" to 0xFFFE6100.toLong()
|
|
||||||
)
|
|
||||||
private val GRAY = 0xFFC0C0C0.toLong()
|
|
||||||
|
|
||||||
private val rowRe = Pattern.compile("<tr>(.*?)</tr>", Pattern.DOTALL)
|
|
||||||
private val cellRe = Pattern.compile("(<t[dh][^>]*>.*?</t[dh]>)", Pattern.DOTALL)
|
|
||||||
private val bgRe = Pattern.compile("bgcolor=\"?(#[0-9a-fA-F]{6}|C0C0C0)\"?")
|
|
||||||
private val linkRe = Pattern.compile("docTips\\.show\\('(a\\d+)'\\)")
|
|
||||||
private val tipRe = Pattern.compile(
|
|
||||||
"tips\\.(a\\d+)\\s*=\\s*new\\s+Array\\(\\s*\\d+,\\s*\\d+,\\s*\\d+,\\s*'([^']*)'"
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 解析完整页面 */
|
|
||||||
fun parse(html: String, fetchedAtUtcMs: Long): SatStatusPage {
|
|
||||||
val reports = parseReports(html)
|
|
||||||
val statuses = parseStatusTable(html, reports)
|
|
||||||
return SatStatusPage(fetchedAtUtcMs, statuses, reports)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 提取全部报告(tips.* JS 数组) */
|
|
||||||
fun parseReports(html: String): Map<String, SatReport> {
|
|
||||||
val map = mutableMapOf<String, SatReport>()
|
|
||||||
val m = tipRe.matcher(html)
|
|
||||||
while (m.find()) {
|
|
||||||
val id = m.group(1)
|
|
||||||
val parts = m.group(2).split("<br>")
|
|
||||||
map[id] = SatReport(
|
|
||||||
id = id,
|
|
||||||
statusText = parts.getOrElse(0) { "" }.trim(),
|
|
||||||
call = parts.getOrElse(1) { "" }.trim(),
|
|
||||||
grid = parts.getOrElse(2) { "" }.trim(),
|
|
||||||
dateUtc = parts.getOrElse(3) { "" }.trim(),
|
|
||||||
timeUtc = parts.getOrElse(4) { "" }.trim()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 解析状态表(48 行卫星) */
|
|
||||||
fun parseStatusTable(html: String, reports: Map<String, SatReport>): List<SatStatus> {
|
|
||||||
val result = mutableListOf<SatStatus>()
|
|
||||||
val tables = extractTables(html)
|
|
||||||
for (table in tables) {
|
|
||||||
val rows = rowRe.matcher(table)
|
|
||||||
val parsed = mutableListOf<SatStatus>()
|
|
||||||
var rowIndex = 0
|
|
||||||
var dayHeaders: List<String> = emptyList()
|
|
||||||
while (rows.find()) {
|
|
||||||
val rowHtml = rows.group(1)
|
|
||||||
val cells = cellRe.matcher(rowHtml)
|
|
||||||
val cellList = mutableListOf<String>()
|
|
||||||
while (cells.find()) cellList.add(cells.group(1))
|
|
||||||
if (rowIndex == 0) {
|
|
||||||
// 表头: Name + 6 天(每个 colspan=12)
|
|
||||||
dayHeaders = cellList.drop(1).take(6).map { stripHtml(it) }
|
|
||||||
rowIndex++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (cellList.size < 7) { rowIndex++; continue }
|
|
||||||
val name = stripHtml(cellList[0])
|
|
||||||
if (name.isBlank()) { rowIndex++; continue }
|
|
||||||
val days = mutableListOf<SatDay>()
|
|
||||||
for (d in 0 until 6) {
|
|
||||||
val start = 1 + d * 12
|
|
||||||
val end = start + 12
|
|
||||||
val slots = (start until end).mapNotNull { i ->
|
|
||||||
cellList.getOrNull(i)?.let { cell ->
|
|
||||||
val bg = bgRe.matcher(cell)
|
|
||||||
val color = if (bg.find()) {
|
|
||||||
STATUS_COLORS[bg.group(1).lowercase()] ?: GRAY
|
|
||||||
} else GRAY
|
|
||||||
val link = linkRe.matcher(cell)
|
|
||||||
val ids = mutableListOf<String>()
|
|
||||||
while (link.find()) ids.add(link.group(1))
|
|
||||||
val hasReport = ids.isNotEmpty()
|
|
||||||
val count = if (hasReport) {
|
|
||||||
stripHtml(cell).trim().toIntOrNull() ?: ids.size
|
|
||||||
} else 0
|
|
||||||
SatSlot(
|
|
||||||
statusColor = if (hasReport) color else GRAY,
|
|
||||||
count = count,
|
|
||||||
reportIds = ids
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
days.add(SatDay(dayHeaders.getOrElse(d) { "" }, slots))
|
|
||||||
}
|
|
||||||
parsed.add(SatStatus(name, days))
|
|
||||||
rowIndex++
|
|
||||||
}
|
|
||||||
if (parsed.isNotEmpty()) {
|
|
||||||
result.addAll(parsed)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun extractTables(html: String): List<String> {
|
|
||||||
val result = mutableListOf<String>()
|
|
||||||
val m = Pattern.compile("<table.*?</table>", Pattern.DOTALL).matcher(html)
|
|
||||||
while (m.find()) result.add(m.group())
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stripHtml(s: String): String =
|
|
||||||
s.replace(Regex("<[^>]+>"), "").trim()
|
|
||||||
}
|
|
||||||
@@ -20,11 +20,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,31 +45,74 @@ class RemoteSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getStatusHtml(): String? = withContext(dispatcher) {
|
override suspend fun getNetworkStream(url: String): NetworkResult = withContext(dispatcher) {
|
||||||
|
try {
|
||||||
|
val networkRequest = Request.Builder().url(url).build()
|
||||||
|
val response = httpClient.newCall(networkRequest).execute()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
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) {
|
||||||
|
println("RemoteSource network stream exception: $exception")
|
||||||
|
NetworkResult(NetworkResult.CONNECTION_ERROR, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAmSatCatalog(): String? = withContext(dispatcher) {
|
||||||
try {
|
try {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url("https://amsat.org/status/")
|
.url("https://www.amsat.org/status/api/v1/catalog.php")
|
||||||
.header("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat/4.5")
|
.header("User-Agent", "Look4Sat")
|
||||||
.build()
|
.build()
|
||||||
httpClient.newCall(request).execute().use { response ->
|
httpClient.newCall(request).execute().use { response ->
|
||||||
if (!response.isSuccessful) return@use null
|
if (!response.isSuccessful) return@use null
|
||||||
response.body?.string()
|
response.body.string()
|
||||||
}
|
}
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
println("RemoteSource amsat status exception: $exception")
|
println("RemoteSource getAmSatCatalog exception: $exception")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = withContext(dispatcher) {
|
||||||
try {
|
try {
|
||||||
val networkRequest = Request.Builder().url(url).build()
|
val request = Request.Builder()
|
||||||
httpClient.newCall(networkRequest).execute().use { response ->
|
.url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit")
|
||||||
if (!response.isSuccessful) return@withContext null
|
.header("User-Agent", "Look4Sat")
|
||||||
ByteArrayInputStream(response.body.bytes())
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@use null
|
||||||
|
response.body.string()
|
||||||
}
|
}
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
println("RemoteSource network stream 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,4 @@ class ShowToast(private val context: Context) : IShowToast {
|
|||||||
override fun invoke(message: String) {
|
override fun invoke(message: String) {
|
||||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun invoke(resId: Int) {
|
|
||||||
invoke(context.getString(resId))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+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
|
||||||
|
}
|
||||||
+57
-12
@@ -31,6 +31,8 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
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.source.NetworkResult
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -91,10 +93,8 @@ class DatabaseRepoTest {
|
|||||||
}
|
}
|
||||||
val settingsRepo = FakeSettingsRepo(
|
val settingsRepo = FakeSettingsRepo(
|
||||||
dataSources = DataSourcesSettings(
|
dataSources = DataSourcesSettings(
|
||||||
useCustomTLE = true,
|
satelliteUrls = listOf(customCsvUrl),
|
||||||
useCustomTransceivers = false,
|
transceiversUrls = emptyList()
|
||||||
tleUrl = customCsvUrl,
|
|
||||||
transceiversUrl = ""
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
@@ -102,8 +102,28 @@ class DatabaseRepoTest {
|
|||||||
repository.updateFromRemote()
|
repository.updateFromRemote()
|
||||||
|
|
||||||
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
||||||
// 新语义: 开关开 + URL 非空 -> All 源用自定义 URL, 数据归入 All 类型
|
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
||||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
|
}
|
||||||
|
|
||||||
|
@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 = """
|
private fun validCsvStream(): InputStream = """
|
||||||
@@ -116,6 +136,12 @@ class DatabaseRepoTest {
|
|||||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
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
|
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||||
""".trimIndent().byteInputStream()
|
""".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 {
|
private class FakeRemoteSource : IRemoteSource {
|
||||||
@@ -124,7 +150,14 @@ private class FakeRemoteSource : IRemoteSource {
|
|||||||
|
|
||||||
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
||||||
|
|
||||||
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.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 {
|
private class FakeLocalSource : ILocalSource {
|
||||||
@@ -177,7 +210,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||||
|
|
||||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||||
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
|
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||||
)
|
)
|
||||||
|
|
||||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||||
@@ -222,14 +255,26 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
dataSourcesSettings.value = settings
|
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 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 {
|
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||||
return DataSourcesSettings(
|
return DataSourcesSettings(
|
||||||
useCustomTLE = false,
|
satelliteUrls = emptyList(),
|
||||||
useCustomTransceivers = false,
|
transceiversUrls = emptyList()
|
||||||
tleUrl = "",
|
|
||||||
transceiversUrl = ""
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.convention.coreDomainPlugin)
|
alias(libs.plugins.convention.coreDomainPlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
|
||||||
// 编译期使用 org.json(构造/解析 WaveLog API 请求体); 运行时用 Android 系统自带的 org.json
|
|
||||||
compileOnly("org.json:json:20240303")
|
|
||||||
}
|
|
||||||
+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
|
||||||
|
)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.model
|
package com.rtbishop.look4sat.core.domain.model
|
||||||
|
|
||||||
/** 单条卫星状态报告(AMSAT 网站 tooltip 数据) */
|
/** One satellite status report (AMSAT site tooltip data) */
|
||||||
data class SatReport(
|
data class SatReport(
|
||||||
val id: String, // 报告 ID(a885153)
|
val id: String, // 报告 ID(a885153)
|
||||||
val statusText: String, // Heard / Telemetry Only / Not Heard ...
|
val statusText: String, // Heard / Telemetry Only / Not Heard ...
|
||||||
@@ -10,26 +10,26 @@ data class SatReport(
|
|||||||
val timeUtc: String // 2:46-:59 UTC
|
val timeUtc: String // 2:46-:59 UTC
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 单个 2 小时槽的状态 */
|
/** State of one 2-hour slot */
|
||||||
data class SatSlot(
|
data class SatSlot(
|
||||||
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
|
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
|
||||||
val count: Int, // 报告数量(0 = 无)
|
val count: Int, // 报告数量(0 = 无)
|
||||||
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
|
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 卫星一天的状态(12 个 2 小时槽) */
|
/** One satellite day (12 two-hour slots) */
|
||||||
data class SatDay(
|
data class SatDay(
|
||||||
val dateLabel: String, // "Aug 4"
|
val dateLabel: String, // "Aug 4"
|
||||||
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 单个卫星 6 天状态 */
|
/** One satellite, 3 days of state */
|
||||||
data class SatStatus(
|
data class SatStatus(
|
||||||
val name: String, // "AO-123_[FM]"
|
val name: String, // "AO-123_[FM]"
|
||||||
val days: List<SatDay> // 6 天(新→旧)
|
val days: List<SatDay> // 3 days (newest first)
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 页面整体解析结果 */
|
/** Overall page parse result */
|
||||||
data class SatStatusPage(
|
data class SatStatusPage(
|
||||||
val fetchedAtUtcMs: Long,
|
val fetchedAtUtcMs: Long,
|
||||||
val statuses: List<SatStatus>,
|
val statuses: List<SatStatus>,
|
||||||
|
|||||||
@@ -42,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,30 +59,37 @@ data class OtherSettings(
|
|||||||
val stateOfUtc: Boolean,
|
val stateOfUtc: Boolean,
|
||||||
val stateOfLightTheme: Boolean,
|
val stateOfLightTheme: Boolean,
|
||||||
val stateOfNightMode: Boolean = false,
|
val stateOfNightMode: Boolean = false,
|
||||||
|
val stateOfMapGrid: Boolean = false,
|
||||||
val shouldSeeWarning: Boolean,
|
val shouldSeeWarning: Boolean,
|
||||||
val shouldSeeWhatsNew: Boolean,
|
val shouldSeeWhatsNew: Boolean,
|
||||||
val sstvMode: String = "Auto",
|
val sstvMode: String = "Auto",
|
||||||
val lowElevation: Double = 15.0,
|
val lowElevation: Double = 15.0,
|
||||||
val highElevation: Double = 45.0,
|
val highElevation: Double = 45.0
|
||||||
// UI 设置: 底部导航栏隐藏的页面(Screen simpleName 列表, 默认空 = 全部显示)
|
|
||||||
val hiddenScreens: List<String> = emptyList(),
|
|
||||||
// UI 设置: 页面顺序(空 = 默认顺序: 卫星/过境/雷达/地图/设置)
|
|
||||||
val screenOrder: List<String> = emptyList(),
|
|
||||||
// UI 设置: 更多菜单顺序(空 = 默认: 匹配/漫游/CW解码)
|
|
||||||
val subMenuOrder: List<String> = emptyList(),
|
|
||||||
// WaveLog 日志系统(4.5.2): 服务器配置
|
|
||||||
val wavelogUrl: String = "",
|
|
||||||
val wavelogApiKey: String = "",
|
|
||||||
val wavelogStationId: String = "",
|
|
||||||
val wavelogAutoUpload: Boolean = false
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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 WavelogSettings(
|
||||||
|
val url: String = "",
|
||||||
|
val token: String = ""
|
||||||
|
) {
|
||||||
|
val isConfigured: Boolean get() = url.isNotBlank() && token.isNotBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class LoTWSettings(
|
||||||
|
val callsign: String = "",
|
||||||
|
val password: String = ""
|
||||||
|
) {
|
||||||
|
val isConfigured: Boolean get() = callsign.isNotBlank() && password.isNotBlank()
|
||||||
|
}
|
||||||
|
|
||||||
data class RadioControlSettings(
|
data class RadioControlSettings(
|
||||||
val enabled: Boolean,
|
val enabled: Boolean,
|
||||||
|
|||||||
+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))
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-3
@@ -1,9 +1,23 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.repository
|
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
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
|
|
||||||
/** AMSAT 卫星状态数据源 */
|
/** AMSAT satellite status data source */
|
||||||
interface IAmSatRepository {
|
interface IAmSatRepository {
|
||||||
/** 抓取并解析 AMSAT 状态页; 失败返回 null */
|
/** Cached AMSAT status page for the current foreground session, if any. */
|
||||||
suspend fun fetchStatus(): SatStatusPage?
|
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
|
||||||
}
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
* 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.repository
|
||||||
|
|
||||||
|
/** Fetches confirmed gridsquares directly from ARRL LoTW. */
|
||||||
|
interface ILoTWRepository {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all confirmed (QSL_RCVD=Y) gridsquares for the given LoTW account.
|
||||||
|
* Returns the 4-char grid set, or null on any failure (network / bad credentials).
|
||||||
|
*/
|
||||||
|
suspend fun fetchConfirmedGrids(callsign: String, password: String): Set<String>?
|
||||||
|
}
|
||||||
+4
-4
@@ -30,7 +30,10 @@ interface IMainContainer {
|
|||||||
val selectionRepo: ISelectionRepo
|
val selectionRepo: ISelectionRepo
|
||||||
val satelliteRepo: ISatelliteRepo
|
val satelliteRepo: ISatelliteRepo
|
||||||
val databaseRepo: IDatabaseRepo
|
val databaseRepo: IDatabaseRepo
|
||||||
val amSatRepo: IAmSatRepository
|
val amSatRepo: IAmSatRepository
|
||||||
|
val updateRepo: IUpdateRepository
|
||||||
|
val wavelogRepo: IWavelogRepository
|
||||||
|
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||||
val radioTrackingService: IRadioTrackingService
|
val radioTrackingService: IRadioTrackingService
|
||||||
val mutualPassData: StateFlow<MutualPassData>
|
val mutualPassData: StateFlow<MutualPassData>
|
||||||
fun setMutualPassData(data: MutualPassData)
|
fun setMutualPassData(data: MutualPassData)
|
||||||
@@ -43,9 +46,6 @@ interface IMainContainer {
|
|||||||
fun provideRxRadioController(): IRadioController
|
fun provideRxRadioController(): IRadioController
|
||||||
fun provideAudioCapture(): IAudioCapture
|
fun provideAudioCapture(): IAudioCapture
|
||||||
fun provideSaveImage(): ISaveImage
|
fun provideSaveImage(): ISaveImage
|
||||||
// WaveLog 日志(4.5.2)
|
|
||||||
val wavelogQueue: com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
|
||||||
fun provideWavelogUploader(): com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class MutualPassData(
|
data class MutualPassData(
|
||||||
|
|||||||
+1
-1
@@ -61,7 +61,7 @@ interface ISatelliteRepo {
|
|||||||
suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos>
|
suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos>
|
||||||
|
|
||||||
/** Get Doppler-shifted radio frequencies for a satellite at the given time. */
|
/** Get Doppler-shifted radio frequencies for a satellite at the given time. */
|
||||||
suspend fun getRadios(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio>
|
suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio>
|
||||||
|
|
||||||
/** Fetch radio transceivers for a satellite by its catalog number. */
|
/** Fetch radio transceivers for a satellite by its catalog number. */
|
||||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||||
|
|||||||
+29
-2
@@ -23,6 +23,7 @@ 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
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
@@ -45,8 +46,7 @@ interface ISettingsRepo {
|
|||||||
//region # Station position settings
|
//region # Station position settings
|
||||||
val stationPosition: StateFlow<GeoPos>
|
val stationPosition: StateFlow<GeoPos>
|
||||||
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
|
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
|
||||||
/** GPS 定位(挂起): 拿到位置才返回 true; 权限缺失/超时/无信号返回 false */
|
fun setStationPosition(): Boolean
|
||||||
suspend fun setStationPosition(): Boolean
|
|
||||||
fun setStationPosition(locator: String): Boolean
|
fun setStationPosition(locator: String): Boolean
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -74,8 +74,35 @@ 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
|
||||||
|
|
||||||
|
//region # Wavelog worked-grids settings
|
||||||
|
val wavelogSettings: StateFlow<WavelogSettings>
|
||||||
|
fun updateWavelogSettings(settings: WavelogSettings)
|
||||||
|
fun getWorkedGrids(): Set<String>
|
||||||
|
fun setWorkedGrids(grids: Set<String>)
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # LoTW confirmed-grids settings
|
||||||
|
val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
|
||||||
|
fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings)
|
||||||
|
//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
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
* 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.repository
|
||||||
|
|
||||||
|
/** Fetches worked gridsquares from a self-hosted Wavelog instance (API v2). */
|
||||||
|
interface IWavelogRepository {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch worked grids for the configured Wavelog URL/token.
|
||||||
|
* Returns the grid set, or null on any failure.
|
||||||
|
*/
|
||||||
|
suspend fun fetchWorkedGrids(url: String, token: String): Set<String>?
|
||||||
|
}
|
||||||
+12
-4
@@ -19,10 +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?
|
||||||
/** 抓取 AMSAT 状态页 HTML(带 UA, 返回 null = 失败) */
|
suspend fun getAmSatReports(hours: Int, limit: Int): String?
|
||||||
suspend fun getStatusHtml(): String?
|
suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>?
|
||||||
}
|
}
|
||||||
@@ -18,12 +18,11 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.source
|
package com.rtbishop.look4sat.core.domain.source
|
||||||
|
|
||||||
object Sources {
|
object Sources {
|
||||||
// 在线更新的默认 URL(用户可通过"自定义URL"对话框修改/重置)
|
|
||||||
val defaultTleUrl = "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv"
|
|
||||||
val defaultTransceiversUrl = "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
|
||||||
|
|
||||||
val satelliteDataUrls = mapOf(
|
val satelliteDataUrls = mapOf(
|
||||||
|
"LAPAN-A2" to "https://www.kaggle.com/api/v1/datasets/download/muazamnugroho/lapan-a2-satellite-two-line-element-tle-dataset/LAPAN-A2_TLE_latest.txt",
|
||||||
|
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||||
"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",
|
||||||
@@ -39,7 +38,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",
|
||||||
@@ -49,10 +48,11 @@ object Sources {
|
|||||||
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
|
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
|
||||||
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
||||||
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
|
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
|
||||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
"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"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,4 @@ package com.rtbishop.look4sat.core.domain.usecase
|
|||||||
|
|
||||||
interface IShowToast {
|
interface IShowToast {
|
||||||
operator fun invoke(message: String)
|
operator fun invoke(message: String)
|
||||||
|
|
||||||
/** 按资源 ID 显示(四语文案) */
|
|
||||||
operator fun invoke(resId: Int)
|
|
||||||
}
|
}
|
||||||
+75
-26
@@ -16,20 +16,28 @@ import java.util.Locale
|
|||||||
/**
|
/**
|
||||||
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
||||||
*
|
*
|
||||||
* For a linear (passband) transponder, uplink and downlink frequencies are
|
* The full physical path:
|
||||||
* related by a fixed passband offset. When the satellite moves, both are
|
|
||||||
* Doppler-shifted. Given one, we compute the other:
|
|
||||||
*
|
*
|
||||||
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler)
|
* TX→RX (uplink → downlink):
|
||||||
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
|
* ① 地面发射 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).
|
* Addresses GitHub issue #91 (Custom frequency Doppler correction).
|
||||||
*/
|
*/
|
||||||
object DopplerFrequencyCalculator {
|
object DopplerFrequencyCalculator {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a downlink frequency, compute the Doppler-corrected uplink frequency.
|
* Given a downlink frequency (what the user hears), compute the
|
||||||
* Returns null if the transponder is not a linear passband type.
|
* uplink frequency the user should transmit.
|
||||||
|
* Full path: ④→③→②→①
|
||||||
*/
|
*/
|
||||||
fun computeUplinkFromDownlink(
|
fun computeUplinkFromDownlink(
|
||||||
downlinkHz: Long,
|
downlinkHz: Long,
|
||||||
@@ -37,17 +45,22 @@ object DopplerFrequencyCalculator {
|
|||||||
orbitalPos: OrbitalPos
|
orbitalPos: OrbitalPos
|
||||||
): Long? {
|
): Long? {
|
||||||
if (!isLinearTransponder(transponder)) return null
|
if (!isLinearTransponder(transponder)) return null
|
||||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz, transponder) ?: return null
|
// ④→③ 逆下行多普勒:卫星转发的频率
|
||||||
return orbitalPos.getUplinkFreq(baseUplink)
|
val satTx = orbitalPos.getUplinkFreq(downlinkHz)
|
||||||
|
// ③→② 逆 passband 映射
|
||||||
|
val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null
|
||||||
|
// ②→① 逆上行多普勒:地面应发射的频率
|
||||||
|
return orbitalPos.getUplinkFreq(satRx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
|
* Given a downlink frequency (what the user hears), compute the
|
||||||
* with an offset applied to the downlink (in Hz).
|
* uplink frequency the user should transmit, with an offset applied
|
||||||
* Returns null if the transponder is not a linear passband type.
|
* to the downlink (in Hz).
|
||||||
|
* Full path: ④→③→②→①
|
||||||
*
|
*
|
||||||
* The user-entered downlink frequency already includes the offset, so subtract
|
* The user-entered downlink frequency already includes the offset, so subtract
|
||||||
* it before mapping the downlink passband position back to the uplink.
|
* it before the inverse downlink Doppler.
|
||||||
*/
|
*/
|
||||||
fun computeUplinkFromDownlinkWithOffset(
|
fun computeUplinkFromDownlinkWithOffset(
|
||||||
downlinkHz: Long,
|
downlinkHz: Long,
|
||||||
@@ -56,13 +69,20 @@ object DopplerFrequencyCalculator {
|
|||||||
offsetHz: Long
|
offsetHz: Long
|
||||||
): Long? {
|
): Long? {
|
||||||
if (!isLinearTransponder(transponder)) return null
|
if (!isLinearTransponder(transponder)) return null
|
||||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
|
// ④→③ 逆下行多普勒:卫星转发的频率(含 offset)
|
||||||
return orbitalPos.getUplinkFreq(baseUplink)
|
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, compute the Doppler-corrected downlink frequency.
|
* Given an uplink frequency (what the user transmits), compute the
|
||||||
* Returns null if the transponder is not a linear passband type.
|
* downlink frequency the user will hear.
|
||||||
|
* Full path: ①→②→③→④
|
||||||
*/
|
*/
|
||||||
fun computeDownlinkFromUplink(
|
fun computeDownlinkFromUplink(
|
||||||
uplinkHz: Long,
|
uplinkHz: Long,
|
||||||
@@ -70,14 +90,19 @@ object DopplerFrequencyCalculator {
|
|||||||
orbitalPos: OrbitalPos
|
orbitalPos: OrbitalPos
|
||||||
): Long? {
|
): Long? {
|
||||||
if (!isLinearTransponder(transponder)) return null
|
if (!isLinearTransponder(transponder)) return null
|
||||||
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
|
// ①→② 上行多普勒:卫星收到的频率
|
||||||
return orbitalPos.getDownlinkFreq(baseDownlink)
|
val satRx = orbitalPos.getDownlinkFreq(uplinkHz)
|
||||||
|
// ②→③ passband 映射
|
||||||
|
val satTx = TransponderMapper.mapUplinkToDownlink(satRx, transponder) ?: return null
|
||||||
|
// ③→④ 下行多普勒:地面听到的
|
||||||
|
return orbitalPos.getDownlinkFreq(satTx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given an uplink frequency, compute the Doppler-corrected downlink frequency
|
* Given an uplink frequency (what the user transmits), compute the
|
||||||
* with an offset applied to the downlink (in Hz).
|
* downlink frequency the user will hear, with an offset applied
|
||||||
* Returns null if the transponder is not a linear passband type.
|
* to the downlink (in Hz).
|
||||||
|
* Full path: ①→②→③→④
|
||||||
*/
|
*/
|
||||||
fun computeDownlinkFromUplinkWithOffset(
|
fun computeDownlinkFromUplinkWithOffset(
|
||||||
uplinkHz: Long,
|
uplinkHz: Long,
|
||||||
@@ -86,8 +111,13 @@ object DopplerFrequencyCalculator {
|
|||||||
offsetHz: Long
|
offsetHz: Long
|
||||||
): Long? {
|
): Long? {
|
||||||
if (!isLinearTransponder(transponder)) return null
|
if (!isLinearTransponder(transponder)) return null
|
||||||
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
|
// ①→② 上行多普勒:卫星收到的频率
|
||||||
return orbitalPos.getDownlinkFreq(baseDownlink + offsetHz)
|
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. */
|
/** True if this transponder supports linear passband mapping. */
|
||||||
@@ -114,11 +144,30 @@ object DopplerFrequencyCalculator {
|
|||||||
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
||||||
.joinToString(separator = " ")
|
.joinToString(separator = " ")
|
||||||
.lowercase(Locale.ENGLISH)
|
.lowercase(Locale.ENGLISH)
|
||||||
val hasLinearName = info.contains("linear")
|
val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
|
||||||
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
|
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
|
||||||
info.contains("xponder") || info.contains("xpdr")
|
info.contains("xponder") || info.contains("xpdr")
|
||||||
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
|
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
|
||||||
|
|
||||||
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode)
|
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) ||
|
||||||
|
(hasLinearName && hasLinearMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes duplicate transponder entries that describe the same physical
|
||||||
|
* transponder with different mode labels (e.g. SatNOGS lists AO-7's Mode A
|
||||||
|
* as both "Lin SSB" and "Lin CW", and JO-97's U/V transponder as both
|
||||||
|
* "CW Transponder" and "SSB Transponder").
|
||||||
|
*
|
||||||
|
* Entries sharing the same uplink/downlink frequency range are considered
|
||||||
|
* the same transponder. The non-CW entry is preferred because its invert
|
||||||
|
* flag is more reliable (e.g. JO-97's CW entry wrongly has invert=false).
|
||||||
|
*/
|
||||||
|
fun deduplicateTransponders(radios: List<SatRadio>): List<SatRadio> {
|
||||||
|
return radios.groupBy { radio ->
|
||||||
|
listOf(radio.uplinkLow, radio.uplinkHigh, radio.downlinkLow, radio.downlinkHigh)
|
||||||
|
}.values.map { group ->
|
||||||
|
group.firstOrNull { it.downlinkMode?.equals("CW", ignoreCase = true) != true } ?: group.first()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+38
-100
@@ -20,77 +20,53 @@ package com.rtbishop.look4sat.core.domain.utility
|
|||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a Maidenhead locator (QTH grid square) to a GeoPos.
|
* Convert a Maidenhead locator (4, 6 or 8 chars) to the position of its
|
||||||
* Supports 6-char (3 pair), 8-char (4 pair) and 10-char (5 pair) locators.
|
* square / subsquare / extended-square centre. Longer input is truncated to
|
||||||
* The returned position is the center of the finest cell encoded by the locator:
|
* 8 chars (the Maidenhead maximum); malformed or out-of-range input yields
|
||||||
* - 6 char: 5' lon x 2.5' lat cell center
|
* null.
|
||||||
* - 8 char: 30" lon x 15" lat cell center
|
|
||||||
* - 10 char: 1.25" lon x 0.625" lat cell center
|
|
||||||
*/
|
*/
|
||||||
fun qthToPosition(locator: String): GeoPos? {
|
fun qthToPosition(locator: String): GeoPos? {
|
||||||
val trimmedQth = locator.trim().uppercase()
|
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].code - 65) * 20
|
val lonFirst = (trimmedQth[0].code - 65) * 20
|
||||||
val latFirst = (trimmedQth[1].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
|
// Start from the 4-char square centre, refine for 6/8-char subsquares.
|
||||||
val latThird = (trimmedQth[5].lowercaseChar().code - 97) / 24.0
|
var longitude = lonFirst + lonSecond + 1.0
|
||||||
var longitude = lonFirst + lonSecond + lonThird - 180
|
var latitude = latFirst + latSecond + 0.5
|
||||||
var latitude = latFirst + latSecond + latThird - 90
|
if (trimmedQth.length >= 6) {
|
||||||
// 8-char extension: 4th pair, digits, 30" lon x 15" lat cells
|
val lonSub = (trimmedQth[4].code - 65) * 5.0 / 60.0
|
||||||
if (trimmedQth.length >= 8) {
|
val latSub = (trimmedQth[5].code - 65) * 2.5 / 60.0
|
||||||
longitude += trimmedQth[6].toString().toInt() / 120.0
|
if (lonSub < 0.0 || lonSub > 115.0 / 60.0 || latSub < 0.0 || latSub > 57.5 / 60.0) return null
|
||||||
latitude += trimmedQth[7].toString().toInt() / 240.0
|
longitude = lonFirst + lonSecond + lonSub + 2.5 / 60.0
|
||||||
}
|
latitude = latFirst + latSecond + latSub + 1.25 / 60.0
|
||||||
// 10-char extension: 5th pair, letters, 1.25" lon x 0.625" lat cells
|
if (trimmedQth.length >= 8) {
|
||||||
if (trimmedQth.length >= 10) {
|
val lonExt = (trimmedQth[6].code - 48) * 30.0 / 3600.0
|
||||||
longitude += (trimmedQth[8].lowercaseChar().code - 97) / 2880.0
|
val latExt = (trimmedQth[7].code - 48) * 15.0 / 3600.0
|
||||||
latitude += (trimmedQth[9].lowercaseChar().code - 97) / 5760.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
|
||||||
// Offset to the center of the finest encoded cell
|
latitude += latExt + 7.5 / 3600.0
|
||||||
when (trimmedQth.length) {
|
|
||||||
8 -> {
|
|
||||||
longitude += 1.0 / 240.0
|
|
||||||
latitude += 1.0 / 480.0
|
|
||||||
}
|
|
||||||
10 -> {
|
|
||||||
longitude += 1.0 / 5760.0
|
|
||||||
latitude += 1.0 / 11520.0
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
longitude += 1.0 / 24.0
|
|
||||||
latitude += 1.0 / 48.0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return GeoPos(latitude.round(6), longitude.round(6))
|
return GeoPos((latitude - 90.0).round(4), (longitude - 180.0).round(4))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a GeoPos to a Maidenhead locator (QTH grid square).
|
* Convert a position to its 6-char Maidenhead locator, upper-cased (field,
|
||||||
* Default precision is 8 characters (4 pairs) giving 30" lon x 15" lat resolution,
|
* square and subsquare letters). Returns null for out-of-range positions.
|
||||||
* matching common 8-char grid square tools. Pass precision = 6 for the classic
|
|
||||||
* 5' x 2.5' resolution, or precision = 10 for the finest 1.25" x 0.625" resolution.
|
|
||||||
*/
|
*/
|
||||||
fun positionToQth(latitude: Double, longitude: Double, precision: Int = 8): String? {
|
fun positionToQth(latitude: Double, longitude: Double): String? {
|
||||||
if (!isValidPosition(latitude, longitude)) return null
|
if (!isValidPosition(latitude, longitude)) return null
|
||||||
val newLongitude = longitude + 180
|
val newLongitude = if (longitude > 180.0) longitude else longitude + 180
|
||||||
val newLatitude = latitude + 90
|
val newLatitude = latitude + 90
|
||||||
val lonFirst = (65 + (newLongitude / 20).toInt().coerceIn(0, 17)).toChar()
|
val lonFirst = (65 + (newLongitude / 20)).toInt().toChar()
|
||||||
val latFirst = (65 + (newLatitude / 10).toInt().coerceIn(0, 17)).toChar()
|
val latFirst = (65 + (newLatitude / 10)).toInt().toChar()
|
||||||
val lonSecond = ((newLongitude % 20) / 2).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()
|
||||||
val qth = "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
|
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
|
||||||
if (precision < 8) return qth
|
|
||||||
val lonFourth = ((newLongitude % (1.0 / 12.0)) * 120).toInt()
|
|
||||||
val latFourth = ((newLatitude % (1.0 / 24.0)) * 240).toInt()
|
|
||||||
val qth8 = "$qth$lonFourth$latFourth"
|
|
||||||
if (precision < 10) return qth8
|
|
||||||
val lonFifth = (65 + (newLongitude % (1.0 / 120.0)) * 2880).toInt().toChar().lowercaseChar()
|
|
||||||
val latFifth = (65 + (newLatitude % (1.0 / 240.0)) * 5760).toInt().toChar().lowercaseChar()
|
|
||||||
return "$qth8$lonFifth$latFifth"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isValidPosition(lat: Double, lon: Double): Boolean {
|
private fun isValidPosition(lat: Double, lon: Double): Boolean {
|
||||||
@@ -98,49 +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]{2}\\d{2}[a-xA-X]{2}(?:\\d{2}(?:[a-xA-X]{2})?)?".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)
|
||||||
* Returns the 4-char field+square part of a locator, e.g. "OL42ih45" -> "OL42".
|
8 -> Regex("[A-X]{2}\\d{2}[A-X]{2}\\d{2}").matches(locator)
|
||||||
* A 4-char input is returned as-is when valid.
|
else -> false
|
||||||
*/
|
|
||||||
fun qthToSquare(locator: String): String {
|
|
||||||
val upper = locator.trim().uppercase()
|
|
||||||
return when {
|
|
||||||
upper.length >= 4 && isValidLocator(upper) -> upper.take(4)
|
|
||||||
upper.length == 4 && upper.matches("[a-xA-X]{2}\\d{2}".toRegex()) -> upper
|
|
||||||
else -> "----"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the 3x3 grid of 4-char squares surrounding [square] (e.g. "OL42").
|
|
||||||
* Row 0 = north (lat +1), col 0 = west (lon -1). Handles field/square carry
|
|
||||||
* at boundaries (e.g. "AA00" wraps to "RR99" at the south-west corner).
|
|
||||||
* Mirrors the neighbor logic decompiled from the QTH定位器 app.
|
|
||||||
*/
|
|
||||||
fun qthNeighbors(square: String): List<String> {
|
|
||||||
if (square.length != 4) return emptyList()
|
|
||||||
val lonField = (square[0].uppercaseChar().code - 65).coerceIn(0, 17)
|
|
||||||
val latField = (square[1].uppercaseChar().code - 65).coerceIn(0, 17)
|
|
||||||
val lonSquare = square[2].digitToCharOrNull() ?: return emptyList()
|
|
||||||
val latSquare = square[3].digitToCharOrNull() ?: return emptyList()
|
|
||||||
val result = mutableListOf<String>()
|
|
||||||
for (dLat in 1 downTo -1) { // north -> south
|
|
||||||
for (dLon in -1..1) { // west -> east
|
|
||||||
var lf = lonField
|
|
||||||
var tf = latField
|
|
||||||
var ls = lonSquare + dLon
|
|
||||||
var ts = latSquare + dLat
|
|
||||||
if (ls < 0) { lf -= 1; ls = 9 } else if (ls > 9) { lf += 1; ls = 0 }
|
|
||||||
if (ts < 0) { tf -= 1; ts = 9 } else if (ts > 9) { tf += 1; ts = 0 }
|
|
||||||
lf = (lf + 18) % 18
|
|
||||||
tf = (tf + 18) % 18
|
|
||||||
result += "${('A' + lf).toChar()}${('A' + tf).toChar()}$ls$ts"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Char.digitToCharOrNull(): Int? = digitToIntOrNull()
|
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
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.9.1").
|
||||||
|
*
|
||||||
|
* The comparison is done on the numeric version segments first; when the base versions are
|
||||||
|
* equal, the trailing build numbers of the suffix decide. All numeric segments of the suffix
|
||||||
|
* are compared as a sequence, so multi-level suffixes work correctly:
|
||||||
|
* "4.4.6-ba7opf.9.1" > "4.4.6-ba7opf.9" > "4.4.6-ba7opf.8".
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
val maxBuild = maxOf(cand.second.size, curr.second.size)
|
||||||
|
for (i in 0 until maxBuild) {
|
||||||
|
val c = cand.second.getOrElse(i) { 0 }
|
||||||
|
val k = curr.second.getOrElse(i) { 0 }
|
||||||
|
if (c != k) return c > k
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a version string into (numeric base segments, numeric suffix segments).
|
||||||
|
* "v4.4.6-ba7opf.9.1" -> ([4,4,6], [9,1]); "4.4.6-ba7opf.8" -> ([4,4,6], [8]);
|
||||||
|
* "4.4.6" -> ([4,4,6], []); "4.4.6-ba7opf" -> ([4,4,6], []).
|
||||||
|
*/
|
||||||
|
fun parse(version: String): Pair<List<Int>, List<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() }
|
||||||
|
// All numeric segments of the suffix, in order (e.g. "ba7opf.9.1" -> [9, 1]).
|
||||||
|
val build = suffixPart.split('.').mapNotNull { it.toIntOrNull() }
|
||||||
|
return base to build
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
/*
|
|
||||||
* WaveLogApi.kt — WaveLog 日志服务器 API 客户端(4.5.2 覆盖修复 2)。
|
|
||||||
*
|
|
||||||
* 同时支持 v1 与 v2(用户服务器实测只有 v1, v2 返回 404):
|
|
||||||
* v2: POST {base}/api/v2/qso (Authorization: Bearer + JSON 字段)
|
|
||||||
* v1: POST {base}/index.php/api/qso (key 在 JSON body + ADIF 字符串)
|
|
||||||
* 策略: 优先 v2, 404 自动降级 v1。
|
|
||||||
* 测试连接: v2 GET api/v2/token → 404 时 v1 POST api/get_contacts_adif。
|
|
||||||
* 站点网格: 仅 v2 有 GET api/v2/station/{id}; v1 无此端点 → 降级用用户 QTH。
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.wavelog
|
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.io.BufferedReader
|
|
||||||
import java.io.InputStreamReader
|
|
||||||
import java.io.OutputStreamWriter
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.net.URL
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
/** 站点信息(GET /api/v2/station/{id} 结果) */
|
|
||||||
data class WavelogStation(
|
|
||||||
val id: Int,
|
|
||||||
val name: String,
|
|
||||||
val callsign: String,
|
|
||||||
val gridsquare: String
|
|
||||||
)
|
|
||||||
|
|
||||||
sealed class WavelogResult {
|
|
||||||
data class Success(val message: String) : WavelogResult()
|
|
||||||
data class Failure(val message: String) : WavelogResult()
|
|
||||||
}
|
|
||||||
|
|
||||||
object WaveLogApi {
|
|
||||||
|
|
||||||
private const val TIMEOUT_MS = 15000
|
|
||||||
|
|
||||||
/** 规范化服务器地址: 去尾斜杠/去尾部 index.php; 无协议前缀时补 https:// */
|
|
||||||
fun normalizeUrl(raw: String): String {
|
|
||||||
var u = raw.trim().trimEnd('/')
|
|
||||||
if (u.isBlank()) return ""
|
|
||||||
if (!u.startsWith("http://") && !u.startsWith("https://")) u = "https://$u"
|
|
||||||
if (u.endsWith("/index.php")) u = u.removeSuffix("/index.php")
|
|
||||||
return u
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 测试连接: v2 GET api/v2/token → 404 时 v1 POST api/get_contacts_adif */
|
|
||||||
suspend fun testToken(url: String, apiKey: String, stationId: String = ""): WavelogResult = withContext(Dispatchers.IO) {
|
|
||||||
val base = normalizeUrl(url)
|
|
||||||
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
|
||||||
|
|
||||||
// v2: GET /index.php/api/v2/token
|
|
||||||
val v2 = httpRequest("$base/index.php/api/v2/token", "GET", apiKey, null)
|
|
||||||
if (v2.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v2)")
|
|
||||||
|
|
||||||
// v1: POST /index.php/api/get_contacts_adif(key 在 body)
|
|
||||||
if (stationId.isNotBlank()) {
|
|
||||||
val body = JSONObject().apply {
|
|
||||||
put("key", apiKey)
|
|
||||||
put("station_id", stationId)
|
|
||||||
put("fetchfromid", 0)
|
|
||||||
}.toString()
|
|
||||||
val v1 = httpRequest("$base/index.php/api/get_contacts_adif", "POST", apiKey, body)
|
|
||||||
if (v1.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
|
|
||||||
if (v1.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
|
|
||||||
}
|
|
||||||
// 无 index.php 的 v1 尝试
|
|
||||||
val body = JSONObject().apply {
|
|
||||||
put("key", apiKey)
|
|
||||||
put("station_id", stationId)
|
|
||||||
put("fetchfromid", 0)
|
|
||||||
}.toString()
|
|
||||||
val v1b = httpRequest("$base/api/get_contacts_adif", "POST", apiKey, body)
|
|
||||||
if (v1b.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
|
|
||||||
if (v1b.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
|
|
||||||
|
|
||||||
WavelogResult.Failure("连接失败: v2 HTTP ${v2.first}, v1 HTTP ${v1b.first} — 请确认服务器地址/密钥正确")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 站点信息: 仅 v2; v1 无此端点返回 null 标记(网格检测降级用用户 QTH) */
|
|
||||||
suspend fun getStation(url: String, apiKey: String, stationId: String): WavelogResult = withContext(Dispatchers.IO) {
|
|
||||||
val base = normalizeUrl(url)
|
|
||||||
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
|
||||||
val (code, resp) = httpRequest("$base/index.php/api/v2/station/$stationId", "GET", apiKey, null)
|
|
||||||
if (code in 200..299) {
|
|
||||||
return@withContext try {
|
|
||||||
val obj = JSONObject(resp)
|
|
||||||
val data = obj.optJSONObject("data") ?: obj
|
|
||||||
val station = WavelogStation(
|
|
||||||
id = data.optInt("id"),
|
|
||||||
name = data.optString("name"),
|
|
||||||
callsign = data.optString("callsign"),
|
|
||||||
gridsquare = data.optString("gridsquare")
|
|
||||||
)
|
|
||||||
WavelogResult.Success(JSONObject().apply {
|
|
||||||
put("id", station.id); put("name", station.name)
|
|
||||||
put("callsign", station.callsign); put("gridsquare", station.gridsquare)
|
|
||||||
}.toString())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
WavelogResult.Failure("解析失败: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// v1 无 station 端点 → 返回 Success 空(调用方降级用用户 QTH)
|
|
||||||
WavelogResult.Success("")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** LoTW 认可的卫星名: 取括号前主名 + 大写(ISS 特判) */
|
|
||||||
fun normalizeSatName(raw: String): String {
|
|
||||||
val main = raw.substringBefore('(').trim()
|
|
||||||
.ifBlank { raw.trim() }
|
|
||||||
.uppercase(Locale.ENGLISH)
|
|
||||||
// ISS 特判: 主名是 ISS/ZARYA/ARISS 变体 → ISS(LoTW 认可名)
|
|
||||||
return when {
|
|
||||||
main == "ZARYA" || main.startsWith("ISS") -> "ISS"
|
|
||||||
main == "ARISS" -> "ISS"
|
|
||||||
else -> main
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 创建 QSO: 优先 v2, 404 降级 v1(ADIF) */
|
|
||||||
suspend fun postQso(
|
|
||||||
url: String,
|
|
||||||
apiKey: String,
|
|
||||||
stationProfileId: String,
|
|
||||||
qso: WavelogQso,
|
|
||||||
gridsquare: String
|
|
||||||
): WavelogResult = withContext(Dispatchers.IO) {
|
|
||||||
val base = normalizeUrl(url)
|
|
||||||
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
|
||||||
|
|
||||||
val satName = normalizeSatName(qso.satName)
|
|
||||||
|
|
||||||
// v2: POST /index.php/api/v2/qso(JSON 字段)
|
|
||||||
val v2Body = JSONObject().apply {
|
|
||||||
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
|
|
||||||
put("call", qso.call)
|
|
||||||
put("band", "SAT")
|
|
||||||
put("mode", qso.mode)
|
|
||||||
put("qso_date", utcDate(qso.timeUtcMs))
|
|
||||||
put("time_on", utcTime(qso.timeUtcMs))
|
|
||||||
put("freq", qso.freqTxHz)
|
|
||||||
put("freq_rx", qso.freqRxHz)
|
|
||||||
put("gridsquare", gridsquare)
|
|
||||||
put("sat_name", satName)
|
|
||||||
}
|
|
||||||
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
|
|
||||||
if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)")
|
|
||||||
if (code == 409) return@withContext WavelogResult.Success("重复(已存在)")
|
|
||||||
|
|
||||||
// v1: POST /index.php/api/qso(key 在 body + ADIF)
|
|
||||||
val v1Body = JSONObject().apply {
|
|
||||||
put("key", apiKey)
|
|
||||||
put("station_profile_id", stationProfileId)
|
|
||||||
put("type", "adif")
|
|
||||||
put("string", toAdif(qso, gridsquare, satName))
|
|
||||||
}
|
|
||||||
val (code1, resp1) = httpRequest("$base/index.php/api/qso", "POST", apiKey, v1Body.toString())
|
|
||||||
if (code1 in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
|
|
||||||
|
|
||||||
// 无 index.php 的 v1
|
|
||||||
val (code1b, resp1b) = httpRequest("$base/api/qso", "POST", apiKey, v1Body.toString())
|
|
||||||
if (code1b in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
|
|
||||||
|
|
||||||
WavelogResult.Failure("上传失败: v2 HTTP $code, v1 HTTP $code1 — ${shortError(resp1.ifBlank { resp1b })}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** v1 ADIF 字符串(频率 MHz, 长度=UTF-8 字节数, sat_name 已规范化) */
|
|
||||||
private fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String {
|
|
||||||
fun field(name: String, value: String): String {
|
|
||||||
val bytes = value.toByteArray(Charsets.UTF_8).size
|
|
||||||
return "<$name:$bytes>$value"
|
|
||||||
}
|
|
||||||
return buildString {
|
|
||||||
append(field("call", qso.call))
|
|
||||||
append(field("band", "SAT"))
|
|
||||||
append(field("mode", qso.mode))
|
|
||||||
append(field("freq", String.format(Locale.ENGLISH, "%.6f", qso.freqTxHz / 1_000_000.0)))
|
|
||||||
if (qso.freqRxHz > 0) {
|
|
||||||
append(field("freq_rx", String.format(Locale.ENGLISH, "%.6f", qso.freqRxHz / 1_000_000.0)))
|
|
||||||
}
|
|
||||||
append(field("qso_date", utcDateCompact(qso.timeUtcMs)))
|
|
||||||
append(field("time_on", utcTimeCompact(qso.timeUtcMs)))
|
|
||||||
if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare.take(4)))
|
|
||||||
if (satName.isNotBlank()) {
|
|
||||||
append(field("sat_name", satName))
|
|
||||||
append(field("prop_mode", "SAT"))
|
|
||||||
}
|
|
||||||
append("<eor>")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 通用 HTTP 请求(返回 code + body) */
|
|
||||||
private fun httpRequest(url: String, method: String, apiKey: String, jsonBody: String?): Pair<Int, String> {
|
|
||||||
return try {
|
|
||||||
val conn = URL(url).openConnection() as HttpURLConnection
|
|
||||||
conn.requestMethod = method
|
|
||||||
conn.connectTimeout = TIMEOUT_MS
|
|
||||||
conn.readTimeout = TIMEOUT_MS
|
|
||||||
if (apiKey.isNotBlank()) conn.setRequestProperty("Authorization", "Bearer $apiKey")
|
|
||||||
if (jsonBody != null) {
|
|
||||||
conn.doOutput = true
|
|
||||||
conn.setRequestProperty("Content-Type", "application/json")
|
|
||||||
conn.setRequestProperty("Accept", "application/json")
|
|
||||||
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(jsonBody) }
|
|
||||||
}
|
|
||||||
val code = conn.responseCode
|
|
||||||
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
|
|
||||||
val body = if (stream != null) {
|
|
||||||
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).use { it.readText() }
|
|
||||||
} else ""
|
|
||||||
code to body
|
|
||||||
} catch (e: Exception) {
|
|
||||||
-1 to (e.message ?: e.javaClass.simpleName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun shortError(body: String): String {
|
|
||||||
if (body.startsWith("<")) return body.take(80) // HTML 错误页
|
|
||||||
return try {
|
|
||||||
val obj = JSONObject(body)
|
|
||||||
val err = obj.optJSONObject("error")
|
|
||||||
err?.optString("message")?.ifBlank { body.take(120) }
|
|
||||||
?: obj.optString("reason").ifBlank { obj.optString("message").ifBlank { body.take(120) } }
|
|
||||||
} catch (_: Exception) {
|
|
||||||
body.take(120)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun utcDate(ms: Long): String {
|
|
||||||
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
|
||||||
cal.timeInMillis = ms
|
|
||||||
return "%04d-%02d-%02d".format(
|
|
||||||
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
|
|
||||||
cal.get(java.util.Calendar.DAY_OF_MONTH)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun utcTime(ms: Long): String {
|
|
||||||
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
|
||||||
cal.timeInMillis = ms
|
|
||||||
return "%02d:%02d:%02d".format(
|
|
||||||
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
|
|
||||||
cal.get(java.util.Calendar.SECOND)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun utcDateCompact(ms: Long): String {
|
|
||||||
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
|
||||||
cal.timeInMillis = ms
|
|
||||||
return "%04d%02d%02d".format(
|
|
||||||
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
|
|
||||||
cal.get(java.util.Calendar.DAY_OF_MONTH)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun utcTimeCompact(ms: Long): String {
|
|
||||||
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
|
||||||
cal.timeInMillis = ms
|
|
||||||
return "%02d%02d%02d".format(
|
|
||||||
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
|
|
||||||
cal.get(java.util.Calendar.SECOND)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
/*
|
|
||||||
* WavelogQueue.kt — WaveLog 本地日志队列(4.5.2)。
|
|
||||||
*
|
|
||||||
* 纯 Kotlin(不依赖 Android): 存储走 IWavelogQueueStore 接口,
|
|
||||||
* 由 core/data 用 SharedPreferences 实现。
|
|
||||||
* 队列上限 500 条(超出丢最旧)。
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.wavelog
|
|
||||||
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
/** 存储抽象(SharedPreferences 实现见 core/data) */
|
|
||||||
interface IWavelogQueueStore {
|
|
||||||
fun load(): String
|
|
||||||
fun save(json: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 待上传的 QSO 条目(本地队列元素,与 POST /api/v2/qso 字段对应) */
|
|
||||||
data class WavelogQso(
|
|
||||||
val id: String, // 本地唯一 id(UUID)
|
|
||||||
val timeUtcMs: Long, // 回车时刻 UTC 毫秒(本地显示 + 组装 qso_date/time_on)
|
|
||||||
val call: String,
|
|
||||||
val mode: String,
|
|
||||||
val freqTxHz: Long, // 上行(回车那一秒多普勒修正)
|
|
||||||
val freqRxHz: Long, // 下行
|
|
||||||
val satName: String,
|
|
||||||
val sessionId: String = "", // 场次 ID: 卫星名-AOS 时间戳(过境仰角 0 秒), 空=未分组(旧数据)
|
|
||||||
val uploaded: Boolean = false // 是否已成功上传(4.5.2 修复: 成功后保留标记, 表格打勾)
|
|
||||||
)
|
|
||||||
|
|
||||||
class WavelogQueue(private val store: IWavelogQueueStore) {
|
|
||||||
|
|
||||||
private val key = "wavelog_queue"
|
|
||||||
|
|
||||||
fun all(): List<WavelogQso> {
|
|
||||||
val raw = store.load()
|
|
||||||
return try {
|
|
||||||
val arr = JSONArray(raw)
|
|
||||||
(0 until arr.length()).map { i ->
|
|
||||||
val o = arr.getJSONObject(i)
|
|
||||||
WavelogQso(
|
|
||||||
id = o.getString("id"),
|
|
||||||
timeUtcMs = o.getLong("timeUtcMs"),
|
|
||||||
call = o.optString("call"),
|
|
||||||
mode = o.optString("mode"),
|
|
||||||
freqTxHz = o.optLong("freqTxHz"),
|
|
||||||
freqRxHz = o.optLong("freqRxHz"),
|
|
||||||
satName = o.optString("satName"),
|
|
||||||
sessionId = o.optString("sessionId"),
|
|
||||||
uploaded = o.optBoolean("uploaded", false)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { emptyList() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun add(qso: WavelogQso) {
|
|
||||||
val list = all().toMutableList()
|
|
||||||
list.add(0, qso) // 最新在前
|
|
||||||
if (list.size > 500) list.removeAt(list.size - 1)
|
|
||||||
save(list)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun remove(id: String) {
|
|
||||||
save(all().filter { it.id != id })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun removeAll(ids: Set<String>) {
|
|
||||||
save(all().filter { it.id !in ids })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 标记为已上传(保留在队列, 表格打勾) */
|
|
||||||
@Synchronized
|
|
||||||
fun markUploaded(id: String) {
|
|
||||||
save(all().map { if (it.id == id) it.copy(uploaded = true) else it })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 移除所有已上传条目(可选项, 保持队列精简) */
|
|
||||||
@Synchronized
|
|
||||||
fun removeUploaded() {
|
|
||||||
save(all().filter { !it.uploaded })
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun save(list: List<WavelogQso>) {
|
|
||||||
val arr = JSONArray()
|
|
||||||
list.forEach { q ->
|
|
||||||
arr.put(JSONObject().apply {
|
|
||||||
put("id", q.id); put("timeUtcMs", q.timeUtcMs); put("call", q.call)
|
|
||||||
put("mode", q.mode); put("freqTxHz", q.freqTxHz)
|
|
||||||
put("freqRxHz", q.freqRxHz); put("satName", q.satName)
|
|
||||||
put("sessionId", q.sessionId)
|
|
||||||
put("uploaded", q.uploaded)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
store.save(arr.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-91
@@ -1,91 +0,0 @@
|
|||||||
/*
|
|
||||||
* WavelogUploader.kt — WaveLog 队列上传调度(4.5.2)。
|
|
||||||
*
|
|
||||||
* 手动/周期上传共用: 逐条网格检测(用户 QTH 前 4 位 vs 台站网格前 4 位)
|
|
||||||
* → POST /api/v2/qso → 成功移出队列。
|
|
||||||
* 网格不一致: 返回 NeedConfirm(由 UI 弹窗「忽略并上传/取消」),
|
|
||||||
* 确认后带 force=true 重试本批。
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.wavelog
|
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
sealed class UploadOutcome {
|
|
||||||
data class NeedConfirm(val stationGrid: String, val userGrid: String) : UploadOutcome()
|
|
||||||
data class Done(
|
|
||||||
val successCount: Int,
|
|
||||||
val failedCount: Int,
|
|
||||||
val message: String,
|
|
||||||
val firstError: String = ""
|
|
||||||
) : UploadOutcome()
|
|
||||||
}
|
|
||||||
|
|
||||||
class WavelogUploader(
|
|
||||||
private val settingsRepo: ISettingsRepo,
|
|
||||||
private val queue: WavelogQueue
|
|
||||||
) {
|
|
||||||
|
|
||||||
// 台站网格缓存(每次上传前刷新; 失败用旧值)
|
|
||||||
private var cachedStationGrid: String? = null
|
|
||||||
|
|
||||||
/** 上传整个队列。force=true 跳过网格确认(用户已选「忽略并上传」) */
|
|
||||||
suspend fun uploadQueue(force: Boolean = false): UploadOutcome {
|
|
||||||
val settings = settingsRepo.otherSettings.value
|
|
||||||
val url = settings.wavelogUrl
|
|
||||||
val apiKey = settings.wavelogApiKey
|
|
||||||
val stationId = settings.wavelogStationId
|
|
||||||
if (url.isBlank() || apiKey.isBlank() || stationId.isBlank()) {
|
|
||||||
return UploadOutcome.Done(0, queue.all().size, "未配置 WaveLog 服务器")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 拉站点信息(拿台站网格); v1 无此端点时降级用用户 QTH
|
|
||||||
val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid()
|
|
||||||
if (stationGrid.isNullOrBlank()) {
|
|
||||||
return UploadOutcome.Done(0, queue.all().size, "无法获取站点信息(检查站点 ID/密钥权限)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 网格检测: 用户当前 QTH 前 4 位 vs 台站网格前 4 位(v1 降级时网格相同, 跳过)
|
|
||||||
if (!force) {
|
|
||||||
val userGrid = userQthGrid()
|
|
||||||
if (userGrid != null && stationGrid.take(4).lowercase() != userGrid.take(4).lowercase()) {
|
|
||||||
return UploadOutcome.NeedConfirm(stationGrid, userGrid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 逐条上传(成功后标记 uploaded, 保留在本地供日志页打勾)
|
|
||||||
val entries = queue.all()
|
|
||||||
var ok = 0
|
|
||||||
var fail = 0
|
|
||||||
var firstError = ""
|
|
||||||
for (qso in entries) {
|
|
||||||
if (qso.uploaded) { ok++; continue }
|
|
||||||
val result = WaveLogApi.postQso(url, apiKey, stationId, qso, stationGrid)
|
|
||||||
if (result is WavelogResult.Success) {
|
|
||||||
ok++
|
|
||||||
queue.markUploaded(qso.id)
|
|
||||||
} else {
|
|
||||||
fail++
|
|
||||||
if (firstError.isBlank()) firstError = (result as? WavelogResult.Failure)?.message ?: ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val message = if (fail == 0) "成功上传 $ok 条" else "成功 $ok 条, 失败 $fail 条(保留待重试)"
|
|
||||||
return UploadOutcome.Done(ok, fail, message, firstError)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getStationGrid(url: String, apiKey: String, stationId: String): String? {
|
|
||||||
val result = WaveLogApi.getStation(url, apiKey, stationId)
|
|
||||||
if (result is WavelogResult.Success) {
|
|
||||||
return try {
|
|
||||||
JSONObject(result.message).optString("gridsquare").takeIf { it.isNotBlank() }
|
|
||||||
?: cachedStationGrid
|
|
||||||
} catch (_: Exception) { cachedStationGrid }
|
|
||||||
}
|
|
||||||
return cachedStationGrid
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 用户当前 QTH 网格(前 4 位; 无 QTH 返回 null = 跳过检测) */
|
|
||||||
private fun userQthGrid(): String? {
|
|
||||||
return settingsRepo.stationPosition.value.qthLocator?.takeIf { it.length >= 4 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+84
-1
@@ -9,6 +9,7 @@ import org.junit.Test
|
|||||||
class DopplerFrequencyCalculatorTest {
|
class DopplerFrequencyCalculatorTest {
|
||||||
|
|
||||||
private fun linearTransponder(
|
private fun linearTransponder(
|
||||||
|
uuid: String = "linear",
|
||||||
upLow: Long = 145_000_000L,
|
upLow: Long = 145_000_000L,
|
||||||
upHigh: Long = 145_500_000L,
|
upHigh: Long = 145_500_000L,
|
||||||
downLow: Long = 435_000_000L,
|
downLow: Long = 435_000_000L,
|
||||||
@@ -18,7 +19,7 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
downlinkMode: String? = "USB",
|
downlinkMode: String? = "USB",
|
||||||
uplinkMode: String? = "LSB"
|
uplinkMode: String? = "LSB"
|
||||||
) = SatRadio(
|
) = SatRadio(
|
||||||
uuid = "linear", info = info, isAlive = true,
|
uuid = uuid, info = info, isAlive = true,
|
||||||
downlinkLow = downLow, downlinkHigh = downHigh,
|
downlinkLow = downLow, downlinkHigh = downHigh,
|
||||||
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
|
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
|
||||||
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
|
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
|
||||||
@@ -68,11 +69,93 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
|
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
|
@Test
|
||||||
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
|
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
|
||||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
|
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
|
@Test
|
||||||
fun computeUplinkFromDownlink_linear_noDoppler() {
|
fun computeUplinkFromDownlink_linear_noDoppler() {
|
||||||
val xpdr = linearTransponder()
|
val xpdr = linearTransponder()
|
||||||
|
|||||||
@@ -1,63 +1,38 @@
|
|||||||
/*
|
|
||||||
* 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
|
||||||
import com.rtbishop.look4sat.core.domain.utility.qthNeighbors
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||||
import com.rtbishop.look4sat.core.domain.utility.qthToSquare
|
|
||||||
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.499913 && result.longitude == -0.22309)
|
assert(result?.latitude == 51.5188 && result.longitude == -0.1792)
|
||||||
result = qthToPosition("gf15vc")
|
result = qthToPosition("gf15vc")
|
||||||
assert(result?.latitude == -34.895833 && result.longitude == -56.208333)
|
assert(result?.latitude == -34.8958 && result.longitude == -56.2083)
|
||||||
// 8-char locators: finer 30" x 15" cell center
|
// 4-char locator -> centre of the 2deg x 1deg square
|
||||||
result = qthToPosition("io91vl47")
|
result = qthToPosition("JN58")
|
||||||
assert(result?.latitude == 51.489583 && result.longitude == -0.2125)
|
assert(result?.latitude == 48.5 && result.longitude == 11.0)
|
||||||
result = qthToPosition("jn58td25")
|
// lowercase input is accepted and normalised
|
||||||
assert(result?.latitude == 48.147917 && result.longitude == 11.604167)
|
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("io9") == null)
|
assert(qthToPosition("JN58Z") == null) // 5 chars
|
||||||
assert(qthToPosition("IO91VL7") == null)
|
assert(qthToPosition("JN58ZA") == null) // Z out of A-X range
|
||||||
assert(qthToPosition("IO91VL4X") == null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Given valid POS returns correct QTH`() {
|
fun `Given valid POS returns correct QTH`() {
|
||||||
// default precision is 8 chars
|
assert(positionToQth(51.4878, -0.2146) == "IO91VL")
|
||||||
assert(positionToQth(51.4878, -0.2146) == "IO91vl47")
|
assert(positionToQth(48.1466, 11.6083) == "JN58TD")
|
||||||
assert(positionToQth(48.1466, 11.6083) == "JN58td25")
|
assert(positionToQth(23.13, 113.26) == "OL63PD")
|
||||||
// 6-char precision still available for backwards compatibility
|
|
||||||
assert(positionToQth(51.4878, -0.2146, 6) == "IO91vl")
|
|
||||||
assert(positionToQth(48.1466, 11.6083, 6) == "JN58td")
|
|
||||||
// 10-char precision
|
|
||||||
assert(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb")
|
|
||||||
assert(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -65,61 +40,4 @@ class QthConverterTest {
|
|||||||
assert(positionToQth(91.0542, -170.1142) == null)
|
assert(positionToQth(91.0542, -170.1142) == null)
|
||||||
assert(positionToQth(89.0542, -240.1142) == null)
|
assert(positionToQth(89.0542, -240.1142) == null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `Given boundary POS stays in valid grid`() {
|
|
||||||
// antipodal / edge cases must not overflow the A-R / 0-9 / a-x alphabet
|
|
||||||
assert(positionToQth(-90.0, -180.0, 8) == "AA00aa00")
|
|
||||||
assert(positionToQth(90.0, 180.0, 8) == "RR00aa00")
|
|
||||||
assert(positionToQth(0.0, 0.0, 8) == "JJ00aa00")
|
|
||||||
// roundtrip stability: 8-char roundtrip is stable across a sample of positions
|
|
||||||
val positions = listOf(
|
|
||||||
Pair(51.4878, -0.2146), Pair(48.1466, 11.6083), Pair(-33.8688, 151.2093),
|
|
||||||
Pair(39.9042, 116.4074), Pair(35.6895, 139.6917), Pair(41.714, -72.727)
|
|
||||||
)
|
|
||||||
positions.forEach { (lat, lon) ->
|
|
||||||
val qth = positionToQth(lat, lon, 8)
|
|
||||||
val pos = qthToPosition(qth!!)
|
|
||||||
val qth2 = positionToQth(pos!!.latitude, pos.longitude, 8)
|
|
||||||
assert(qth == qth2) { "Roundtrip failed for ($lat, $lon): $qth -> $qth2" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `Given square returns correct 3x3 neighbors`() {
|
|
||||||
// Reference grid from the QTH定位器 screenshot: OL42
|
|
||||||
val neighbors = qthNeighbors("OL42")
|
|
||||||
assert(neighbors == listOf(
|
|
||||||
"OL33", "OL43", "OL53",
|
|
||||||
"OL32", "OL42", "OL52",
|
|
||||||
"OL31", "OL41", "OL51"
|
|
||||||
)) { "OL42 grid mismatch: $neighbors" }
|
|
||||||
// Center cell must be the input itself
|
|
||||||
assert(neighbors[4] == "OL42")
|
|
||||||
// 9 cells, all distinct
|
|
||||||
assert(neighbors.size == 9 && neighbors.toSet().size == 9)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `Given boundary square wraps fields correctly`() {
|
|
||||||
// South-west corner: AA00 neighbors wrap to RR99 / RA90 etc.
|
|
||||||
val sw = qthNeighbors("AA00")
|
|
||||||
assert(sw.size == 9 && sw.toSet().size == 9)
|
|
||||||
assert(sw[0] == "RA91" && sw[4] == "AA00" && sw[6] == "RR99" && sw[8] == "AR19")
|
|
||||||
// North-east corner: RR99 wraps to AA00
|
|
||||||
val ne = qthNeighbors("RR99")
|
|
||||||
assert(ne.size == 9 && ne.toSet().size == 9)
|
|
||||||
assert(ne[0] == "RA80" && ne[4] == "RR99" && ne[8] == "AR08")
|
|
||||||
// Field boundary: IO91's east neighbors cross into J field
|
|
||||||
val london = qthNeighbors("IO91")
|
|
||||||
assert(london[2] == "JO02" && london[5] == "JO01")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `Given full locator returns square part`() {
|
|
||||||
assert(qthToSquare("OL42ih45") == "OL42")
|
|
||||||
assert(qthToSquare("io91VL39FX") == "IO91")
|
|
||||||
assert(qthToSquare("JN58") == "JN58")
|
|
||||||
assert(qthToSquare("garbage!!") == "----")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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 `multi level suffixes compare correctly`() {
|
||||||
|
// The .9.1 hotfix case that exposed the old single-build-number logic.
|
||||||
|
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.9.1", "4.4.6-ba7opf.9"))
|
||||||
|
assertTrue(VersionComparator.isNewer("v4.4.6-ba7opf.9.1", "4.4.6-ba7opf.8"))
|
||||||
|
assertFalse(VersionComparator.isNewer("4.4.6-ba7opf.9", "4.4.6-ba7opf.9.1"))
|
||||||
|
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.10", "4.4.6-ba7opf.9.1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse extracts base and suffix segments`() {
|
||||||
|
assertEquals(listOf(4, 4, 6) to listOf(7), VersionComparator.parse("v4.4.6-ba7opf.7"))
|
||||||
|
assertEquals(listOf(4, 4, 6) to listOf(9, 1), VersionComparator.parse("v4.4.6-ba7opf.9.1"))
|
||||||
|
assertEquals(listOf(4, 4, 6) to emptyList<Int>(), VersionComparator.parse("4.4.6"))
|
||||||
|
assertEquals(listOf(4, 4, 6) to emptyList<Int>(), VersionComparator.parse("4.4.6-ba7opf"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
-1
@@ -280,6 +280,40 @@ 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
|
||||||
@@ -518,5 +552,6 @@ fun formatFrequency(frequencyHz: Long): String {
|
|||||||
if (frequencyHz <= 0) return "---"
|
if (frequencyHz <= 0) return "---"
|
||||||
val mhz = frequencyHz / 1_000_000
|
val mhz = frequencyHz / 1_000_000
|
||||||
val khz = (frequencyHz % 1_000_000) / 1_000
|
val khz = (frequencyHz % 1_000_000) / 1_000
|
||||||
return String.format(Locale.ENGLISH, "%d.%03d", mhz, khz)
|
val hz = frequencyHz % 1_000
|
||||||
|
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
|
||||||
}
|
}
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
package com.rtbishop.look4sat.core.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.drawscope.rotate
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a Maidenhead locator for display. Per IARU convention and the user's
|
||||||
|
* preference: field letters (1-2) and square digits (3-4) uppercase, sub-square
|
||||||
|
* letters (5-6 of a 6/8-char locator) lowercase, e.g. "OL62" / "OL62kl".
|
||||||
|
*/
|
||||||
|
fun formatGridDisplay(grid: String): String? {
|
||||||
|
val g = grid.trim().uppercase()
|
||||||
|
if (g.length !in listOf(4, 6, 8) || g.any { !it.isLetterOrDigit() }) return null
|
||||||
|
if (g.length == 4) return g
|
||||||
|
if (g.length == 6) return g.take(4) + g.drop(4).lowercase()
|
||||||
|
return g.take(4) + g.drop(4).take(2).lowercase() + g.drop(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-bar chip for the mutual-match screen: navigation needle (rotated by the
|
||||||
|
* great-circle bearing to the target grid, 0 deg = north), grid locator and
|
||||||
|
* distance. Shows a dimmed placeholder when no target grid is entered.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GridTargetChip(
|
||||||
|
grid: String?,
|
||||||
|
distanceKm: Double?,
|
||||||
|
bearingDeg: Double?,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val configured = grid != null && distanceKm != null && bearingDeg != null
|
||||||
|
val accent = MaterialTheme.colorScheme.tertiaryContainer
|
||||||
|
val placeholder = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier
|
||||||
|
.height(36.dp)
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.surface.copy(alpha = if (configured) 0.9f else 0.45f),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
) {
|
||||||
|
NavigationNeedle(
|
||||||
|
bearingDeg = bearingDeg ?: 0.0,
|
||||||
|
color = if (configured) accent else placeholder,
|
||||||
|
dimmed = !configured,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = grid?.let { formatGridDisplay(it) } ?: "----",
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = if (configured) accent else placeholder,
|
||||||
|
letterSpacing = 1.sp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = distanceKm?.let { "${"%,.0f".format(it)} km" } ?: "-- km",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = if (configured) Color.White else placeholder,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
color = if (configured) MaterialTheme.colorScheme.onSurfaceVariant else Color.Transparent,
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map-style navigation needle: slim triangular pointer whose tip points at
|
||||||
|
* [bearingDeg] (0 = north/up, clockwise positive).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NavigationNeedle(
|
||||||
|
bearingDeg: Double,
|
||||||
|
color: Color,
|
||||||
|
dimmed: Boolean,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Canvas(modifier = modifier) {
|
||||||
|
val cx = size.width / 2f
|
||||||
|
val cy = size.height / 2f
|
||||||
|
val r = size.minDimension / 2f
|
||||||
|
if (dimmed) {
|
||||||
|
drawCircle(color = color, radius = r * 0.92f, style = Stroke(width = 1.5f))
|
||||||
|
}
|
||||||
|
rotate(degrees = bearingDeg.toFloat(), pivot = Offset(cx, cy)) {
|
||||||
|
val path = Path().apply {
|
||||||
|
moveTo(cx, cy - r * 0.9f) // tip (north before rotation)
|
||||||
|
lineTo(cx - r * 0.42f, cy + r * 0.55f)
|
||||||
|
lineTo(cx, cy + r * 0.28f)
|
||||||
|
lineTo(cx + r * 0.42f, cy + r * 0.55f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(path, color = color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Great-circle distance in km between two positions. */
|
||||||
|
fun greatCircleDistanceKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
|
||||||
|
val rad = Math.PI / 180.0
|
||||||
|
val dLat = (lat2 - lat1) * rad
|
||||||
|
val dLon = (lon2 - lon1) * rad
|
||||||
|
val a = sin(dLat / 2) * sin(dLat / 2) +
|
||||||
|
cos(lat1 * rad) * cos(lat2 * rad) * sin(dLon / 2) * sin(dLon / 2)
|
||||||
|
return 6371.0 * 2 * kotlin.math.atan2(kotlin.math.sqrt(a), kotlin.math.sqrt(1 - a))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initial great-circle bearing in degrees (0 = north, clockwise). */
|
||||||
|
fun greatCircleBearingDeg(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
|
||||||
|
val rad = Math.PI / 180.0
|
||||||
|
val dLon = (lon2 - lon1) * rad
|
||||||
|
val y = sin(dLon) * cos(lat2 * rad)
|
||||||
|
val x = cos(lat1 * rad) * sin(lat2 * rad) -
|
||||||
|
sin(lat1 * rad) * cos(lat2 * rad) * cos(dLon)
|
||||||
|
return (Math.atan2(y, x) / rad + 360.0) % 360.0
|
||||||
|
}
|
||||||
+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
|
||||||
|
|||||||
+8
-26
@@ -21,45 +21,27 @@ import androidx.navigation3.runtime.NavKey
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: String) : NavKey {
|
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat, "Satellites")
|
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass, "Passes")
|
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, "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, "Map")
|
data object Mutual : Screen(R.drawable.ic_radio_tower, R.string.nav_mutual)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Mutual : Screen(R.drawable.ic_match, R.string.nav_mutual, "Mutual")
|
data object AMSAT : Screen(R.drawable.ic_satellite_alt, R.string.nav_amsat)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Roaming : Screen(R.drawable.ic_roaming, R.string.nav_roaming, "Roaming")
|
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data object CwDecode : Screen(R.drawable.ic_cw, R.string.nav_cw, "CwDecode")
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data object WavelogLog : Screen(R.drawable.ic_log, R.string.nav_log, "WavelogLog")
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data object AmSat : Screen(R.drawable.ic_satellites, R.string.nav_amsat, "AMSAT")
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UI 设置: 默认主菜单页面顺序(底部栏 5 槽)
|
|
||||||
val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings")
|
|
||||||
|
|
||||||
// UI 设置: 默认更多菜单顺序(进「更多」的页面)
|
|
||||||
val defaultSubMenuOrder = listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT")
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object RadarDestination : NavKey
|
data object RadarDestination : NavKey
|
||||||
|
|
||||||
@@ -88,4 +70,4 @@ class DeeplinkResolver(private val fallbackDestination: NavKey = Screen.Passes)
|
|||||||
matchers.forEach { it.match(deeplink)?.let { match -> return match } }
|
matchers.forEach { it.match(deeplink)?.let { match -> return match } }
|
||||||
return fallbackDestination
|
return fallbackDestination
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
|
|
||||||
<!-- Morse style icon: a dot above a dash -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFFFF"
|
|
||||||
android:pathData="M 8 4 L 16 4 L 16 8 L 8 8 Z" />
|
|
||||||
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFFFF"
|
|
||||||
android:pathData="M 3 16 L 21 16 L 21 20 L 3 20 Z" />
|
|
||||||
</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>
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M6,2h9l5,5v13c0,1.1 -0.9,2 -2,2H6c-1.1,0 -2,-0.9 -2,-2V4c0,-1.1 0.9,-2 2,-2zM14,3.5V8h4.5L14,3.5zM8,12h8v2H8v-2zM8,16h8v2H8v-2z" />
|
|
||||||
</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>
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M12,6v3l4,-4 -4,-4v3c-4.42,0 -8,3.58 -8,8 0,1.57 0.46,3.03 1.24,4.26L6.7,14.8c-0.45,-0.83 -0.7,-1.79 -0.7,-2.8 0,-3.31 2.69,-6 6,-6zM18.76,7.74L17.3,9.2c0.44,0.84 0.7,1.79 0.7,2.8 0,3.31 -2.69,6 -6,6v-3l-4,4 4,4v-3c4.42,0 8,-3.58 8,-8 0,-1.57 -0.46,-3.03 -1.24,-4.26z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="96"
|
|
||||||
android:viewportHeight="96">
|
|
||||||
|
|
||||||
<!-- Crosshair / grid-locator style icon: concentric rings + center dot -->
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/transparent"
|
|
||||||
android:strokeColor="@android:color/white"
|
|
||||||
android:strokeWidth="6"
|
|
||||||
android:strokeLineCap="round"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:pathData="M 48 12 L 48 84 M 12 48 L 84 48" />
|
|
||||||
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/transparent"
|
|
||||||
android:strokeColor="@android:color/white"
|
|
||||||
android:strokeWidth="6"
|
|
||||||
android:pathData="M 48 26 A 22 22 0 1 0 48.1 26" />
|
|
||||||
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M 44 44 L 52 44 L 52 52 L 44 52 Z" />
|
|
||||||
</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>
|
||||||
@@ -112,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>
|
||||||
|
|
||||||
@@ -143,6 +150,7 @@
|
|||||||
<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_other_switch_night_mode">Activar filtro nocturno rojo</string>
|
||||||
|
<string name="prefs_other_switch_map_grid">Mostrar cuadrícula Maidenhead en el mapa</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Resaltado de elevación</string>
|
<string name="prefs_highlight_title">Resaltado de elevación</string>
|
||||||
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
||||||
|
|||||||
@@ -1,252 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="app_name" translatable="false">Look4Sat Pro</string>
|
|
||||||
<string name="btn_accept">Terima</string>
|
|
||||||
<string name="btn_cancel">Batal</string>
|
|
||||||
<string name="empty_list_message">Tidak ada data untuk ditampilkan</string>
|
|
||||||
<string name="nav_sat">Satelit</string>
|
|
||||||
<string name="nav_pass">Lintasan</string>
|
|
||||||
<string name="nav_radar">Radar</string>
|
|
||||||
<string name="nav_mutual">Pasangan</string>
|
|
||||||
<string name="nav_roaming">Jelajah</string>
|
|
||||||
<string name="nav_cw">Dekoder CW</string>
|
|
||||||
<string name="nav_map">Peta</string>
|
|
||||||
<string name="nav_prefs">Pengaturan</string>
|
|
||||||
<string name="nav_more">Lainnya</string>
|
|
||||||
<string name="nav_log">Log</string>
|
|
||||||
<string name="nav_amsat">AMSAT</string>
|
|
||||||
<string name="amsat_title">Status Satelit AMSAT</string>
|
|
||||||
<string name="amsat_updated">Diperbarui:</string>
|
|
||||||
<string name="amsat_active">Aktif</string>
|
|
||||||
<string name="amsat_tlm">TLM/beacon</string>
|
|
||||||
<string name="amsat_not_heard">Tidak terdengar</string>
|
|
||||||
<string name="amsat_conflict">Konflik</string>
|
|
||||||
<string name="amsat_name">Name</string>
|
|
||||||
<string name="amsat_load_failed">Gagal memuat - periksa jaringan dan coba lagi</string>
|
|
||||||
<string name="amsat_no_reports">Tidak ada laporan untuk periode ini</string>
|
|
||||||
<string name="amsat_close">Tutup</string>
|
|
||||||
|
|
||||||
<string name="sat_type_hint">Jenis: %s</string>
|
|
||||||
<string name="sat_type_title">Pilih jenis satelit</string>
|
|
||||||
<string name="sat_search_hint">Id - Nama</string>
|
|
||||||
<string name="sat_search_clear">Bersihkan</string>
|
|
||||||
<string name="sat_clear_all">Bersihkan semua</string>
|
|
||||||
<string name="sat_select_all">Pilih semua</string>
|
|
||||||
<string name="sat_empty_list_message">Pastikan kata kunci pencarian Anda benar dan DB sudah diperbarui</string>
|
|
||||||
<string name="sat_warning_title">Peringatan\!</string>
|
|
||||||
<string name="sat_warning_message">
|
|
||||||
Ada lebih dari 9000 satelit yang terdaftar di aplikasi ini.
|
|
||||||
Tidak masuk akal untuk melacak semuanya sekaligus.
|
|
||||||
\n\nSelalu coba persempit daftar hanya ke satelit yang
|
|
||||||
Anda minati melalui pencarian dan pemilih jenis.</string>
|
|
||||||
<string name="pass_filter_title">Filter lintasan</string>
|
|
||||||
<string name="pass_filter_elev">Elevasi minimal</string>
|
|
||||||
<string name="pass_filter_hours">Waktu ke depan</string>
|
|
||||||
<string name="pass_filter_aos_time">Jendela AOS</string>
|
|
||||||
<string name="pass_filter_invert_time">Balik jendela AOS</string>
|
|
||||||
<string name="pass_filter_deep_space">DeepSpace (periode >225 mnt)</string>
|
|
||||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
|
||||||
<string name="pass_modes_title">Pilih jenis modulasi</string>
|
|
||||||
<string name="pass_satId" translatable="false">%05d</string>
|
|
||||||
<string name="pass_elevation">Elevasi: %.1f°</string>
|
|
||||||
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
|
||||||
<string name="pass_altitude">Ketinggian: %d km</string>
|
|
||||||
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
|
|
||||||
<string name="pass_empty_list_message">
|
|
||||||
Pastikan filter Anda benar dan Anda sudah memilih satelit</string>
|
|
||||||
<string name="pass_error_message">Tidak ada lintasan satelit mendatang untuk ditampilkan</string>
|
|
||||||
<string name="pass_welcome_title">Selamat datang di Look4Sat\!</string>
|
|
||||||
<string name="pass_welcome_message">
|
|
||||||
Pastikan Anda mengatur posisi melalui GPS, Lat/Lon atau QTH di Pengaturan.
|
|
||||||
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
|
|
||||||
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
|
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
|
||||||
* BARU: Halaman status satelit AMSAT - tabel status langsung (warna resmi), jumlah laporan, laporan detail per satelit/hari
|
|
||||||
* Log dikelompokkan per lintasan satelit (sesi = satelit + waktu AOS), garis pemisah antar sesi
|
|
||||||
* GPS kini hanya menampilkan sukses saat posisi benar-benar didapat (timeout 15 dtk, cek izin)
|
|
||||||
* Pesan kegagalan jelas saat database satelit gagal diunduh
|
|
||||||
* Frekuensi log mengikuti panel Doppler secara real-time; visual hapus geser dipulihkan
|
|
||||||
* Perbaikan bahasa Indonesia (values-id)</string>
|
|
||||||
<string name="radar_back">Kembali</string>
|
|
||||||
<string name="radar_notify">Beri tahu</string>
|
|
||||||
<string name="radar_az_text">Azimuth</string>
|
|
||||||
<string name="radar_az_value" translatable="false">%.1f°</string>
|
|
||||||
<string name="radar_el_text">Elevasi</string>
|
|
||||||
<string name="radar_el_value" translatable="false">%.1f°</string>
|
|
||||||
<string name="radar_alt_text">Ketinggian</string>
|
|
||||||
<string name="radar_alt_value" translatable="false">%.0f km</string>
|
|
||||||
<string name="radar_dist_text">Jarak</string>
|
|
||||||
<string name="radar_dist_value" translatable="false">%.0f km</string>
|
|
||||||
<string name="radar_eclipsed">Gerhana</string>
|
|
||||||
<string name="radar_downlink">Downlink</string>
|
|
||||||
<string name="radar_uplink">Uplink</string>
|
|
||||||
<string name="radar_link_low" translatable="false">%.4f</string>
|
|
||||||
<string name="radar_link_lowHigh" translatable="false">%.4f - %.4f</string>
|
|
||||||
<string name="radar_no_link" translatable="false">- - . - -</string>
|
|
||||||
<string name="radar_no_data">Satelit ini tidak memiliki transceiver</string>
|
|
||||||
<string name="radar_add">Tambah</string>
|
|
||||||
<string name="radar_data">Transceiver satelit:</string>
|
|
||||||
<string name="radar_inverted">Terbalik: %s</string>
|
|
||||||
<string name="radar_mode">Mode: %s</string>
|
|
||||||
<string name="radar_string_no">Tidak</string>
|
|
||||||
<string name="radar_string_yes">Ya</string>
|
|
||||||
<string name="radar_visible">Terlihat</string>
|
|
||||||
<string name="radar_doppler_calc">Kalkulator Frekuensi Doppler</string>
|
|
||||||
<string name="radar_doppler_tx_hint">Masukkan frekuensi TX (MHz)</string>
|
|
||||||
<string name="radar_doppler_rx_hint">Masukkan frekuensi RX (MHz)</string>
|
|
||||||
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
|
|
||||||
<string name="radar_doppler_info">Untuk transponder linier, ketik satu frekuensi untuk melihat frekuensi lainnya</string>
|
|
||||||
<string name="radar_cw_decoder">Dekoder CW</string>
|
|
||||||
<string name="radar_cw_start">Mulai</string>
|
|
||||||
<string name="radar_cw_stop">Berhenti</string>
|
|
||||||
<string name="radar_cw_reset">Bersihkan</string>
|
|
||||||
<string name="map_prev">Sebelumnya</string>
|
|
||||||
<string name="map_next">Berikutnya</string>
|
|
||||||
<string name="map_copyright" translatable="false">© Kontributor OpenStreetMap</string>
|
|
||||||
<string name="map_azimuth">Azimuth: %.1f°</string>
|
|
||||||
<string name="map_elevation">Elevasi: %.1f°</string>
|
|
||||||
<string name="map_altitude">Ketinggian: %.0f km</string>
|
|
||||||
<string name="map_distance">Jarak: %.0f km</string>
|
|
||||||
<string name="map_latitude">Lintang: %.1f°</string>
|
|
||||||
<string name="map_longitude">Bujur: %.1f°</string>
|
|
||||||
<string name="map_qth" translatable="false">QTH: %s</string>
|
|
||||||
<string name="map_phase">Fase: %.1f°</string>
|
|
||||||
<string name="map_eclipsed">Gerhana</string>
|
|
||||||
<string name="map_period">Periode: %.0f mnt</string>
|
|
||||||
<string name="map_velocity">Kecepatan: %.2f km/d</string>
|
|
||||||
<string name="map_visibility">Visibilitas: %s</string>
|
|
||||||
<string name="map_visible">Terlihat</string>
|
|
||||||
<string name="prefs_app_title" translatable="false">Look4Sat Pro v%s</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">Donasi</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_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_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
|
||||||
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
|
||||||
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
|
|
||||||
<string name="prefs_privacy_title">Kebijakan Privasi</string>
|
|
||||||
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
|
|
||||||
<string name="prefs_updated_title">Diperbarui: %s</string>
|
|
||||||
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
|
||||||
<string name="prefs_loc_title">Posisi stasiun</string>
|
|
||||||
<string name="prefs_loc_gps_title">GPS</string>
|
|
||||||
<string name="prefs_loc_gps_error">Tidak dapat mengambil lokasi - periksa izin dan sinyal GPS/jaringan</string>
|
|
||||||
<string name="prefs_loc_input_title">Input</string>
|
|
||||||
<string name="prefs_loc_input_error">Lokasi yang dimasukkan tidak valid</string>
|
|
||||||
<string name="prefs_loc_qth_title">QTH</string>
|
|
||||||
<string name="prefs_loc_qth_error">Lokator QTH tidak valid</string>
|
|
||||||
<string name="prefs_loc_success">Posisi berhasil diperbarui</string>
|
|
||||||
<string name="prefs_station_title">Pengaturan posisi stasiun</string>
|
|
||||||
<string name="prefs_station_lat_text">Atur garis lintang stasiun Anda</string>
|
|
||||||
<string name="prefs_station_lon_text">Atur garis bujur stasiun Anda</string>
|
|
||||||
<string name="prefs_locator_title">Pengaturan lokator QTH</string>
|
|
||||||
<string name="prefs_locator_text">Atur posisi stasiun menggunakan lokator</string>
|
|
||||||
<string name="prefs_data_title">Data satelit</string>
|
|
||||||
<string name="prefs_data_entries">Satelit: %s</string>
|
|
||||||
<string name="prefs_data_radios">Transceiver: %s</string>
|
|
||||||
<string name="prefs_data_update">Perbarui</string>
|
|
||||||
<string name="prefs_data_import">URL Kustom</string>
|
|
||||||
<string name="prefs_data_clear">Bersihkan</string>
|
|
||||||
<string name="prefs_data_clear_success">Data berhasil dibersihkan</string>
|
|
||||||
<string name="prefs_data_update_success">Pembaruan berhasil diselesaikan</string>
|
|
||||||
<string name="prefs_data_update_failed">Pembaruan gagal - periksa jaringan Anda</string>
|
|
||||||
<string name="prefs_data_import_satellites_error">Tidak ada satelit yang diimpor. Pilih file TLE/3LE (.txt) atau OMM (.csv) yang valid.</string>
|
|
||||||
<string name="prefs_data_import_transceivers_error">Tidak ada transceiver yang diimpor. Pilih file SatNOGS (.json) yang valid.</string>
|
|
||||||
<string name="prefs_data_sources_title">Sumber data kustom</string>
|
|
||||||
<string name="prefs_data_sources_tle_switch">URL TLE Kustom</string>
|
|
||||||
<string name="prefs_data_sources_transceivers_switch">URL transceiver kustom</string>
|
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
|
||||||
<string name="prefs_data_reset_url">Setel ulang ke URL bawaan</string>
|
|
||||||
<string name="prefs_data_output_title">Keluaran data</string>
|
|
||||||
<string name="prefs_net_output">Jaringan</string>
|
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
|
||||||
<string name="prefs_cat_output">CAT</string>
|
|
||||||
<string name="nav_radiocontrol">Kontrol Radio</string>
|
|
||||||
<string name="rc_settings_title">Kontrol Radio CAT</string>
|
|
||||||
<string name="rc_radio_model">Model Radio</string>
|
|
||||||
<string name="rc_tx_device_hint">Alamat BT Radio TX</string>
|
|
||||||
<string name="rc_rx_device_hint">Alamat BT Radio RX</string>
|
|
||||||
<string name="rc_tx_name_hint">Nama Radio TX</string>
|
|
||||||
<string name="rc_rx_name_hint">Nama Radio RX</string>
|
|
||||||
<string name="rc_enable_switch">Aktifkan Kontrol CAT</string>
|
|
||||||
<string name="prefs_net_title">Keluaran data jaringan</string>
|
|
||||||
<string name="prefs_net_rotator_switch">Aktifkan keluaran rotator</string>
|
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
|
||||||
<string name="prefs_net_rotator_format_hint">Format</string>
|
|
||||||
<string name="prefs_net_frequency_switch">Aktifkan keluaran frekuensi</string>
|
|
||||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
|
||||||
<string name="prefs_net_frequency_format_hint">Format</string>
|
|
||||||
<string name="prefs_bt_title">Keluaran data Bluetooth</string>
|
|
||||||
<string name="prefs_bt_rotator_switch">Aktifkan keluaran rotator</string>
|
|
||||||
<string name="prefs_bt_rotator_device_hint">Id perangkat</string>
|
|
||||||
<string name="prefs_bt_rotator_output_hint">Format data</string>
|
|
||||||
<string name="prefs_bt_frequency_switch">Aktifkan keluaran frekuensi</string>
|
|
||||||
<string name="prefs_bt_frequency_device_hint">Id perangkat</string>
|
|
||||||
<string name="prefs_bt_frequency_output_hint">Format data</string>
|
|
||||||
<string name="prefs_bt_perm_error">Periksa izin bluetooth Anda</string>
|
|
||||||
<string name="prefs_net_perm_error">Periksa izin jaringan Anda</string>
|
|
||||||
<string name="tracking_status">Melacak: %s</string>
|
|
||||||
<string name="loc_lat_text">Lintang: %s°</string>
|
|
||||||
<string name="loc_lon_text">Bujur: %s°</string>
|
|
||||||
<string name="loc_qth_text">QTH: %s</string>
|
|
||||||
<string name="connect_radio">Hubungkan</string>
|
|
||||||
<string name="disconnect_radio">Putuskan</string>
|
|
||||||
<string name="track_radio">Lacak</string>
|
|
||||||
<string name="stop_radio">Berhenti</string>
|
|
||||||
<string name="prefs_wavelog_title">Server log WaveLog</string>
|
|
||||||
<string name="prefs_wavelog_url_hint">Alamat server</string>
|
|
||||||
<string name="prefs_wavelog_key_hint">Kunci API</string>
|
|
||||||
<string name="prefs_wavelog_station_hint">ID stasiun</string>
|
|
||||||
<string name="prefs_wavelog_auto">Unggah otomatis</string>
|
|
||||||
<string name="prefs_wavelog_test">Uji koneksi</string>
|
|
||||||
<string name="prefs_wavelog_upload">Unggah sekarang</string>
|
|
||||||
<string name="wavelog_title">Log</string>
|
|
||||||
<string name="wavelog_select_transponder">Pilih transponder</string>
|
|
||||||
<string name="wavelog_call_hint">Tanda panggil</string>
|
|
||||||
<string name="wavelog_mode_hint">Mode</string>
|
|
||||||
<string name="wavelog_saved">Tersimpan ke log lokal</string>
|
|
||||||
<string name="wavelog_need_config">Konfigurasikan WaveLog di Pengaturan terlebih dahulu</string>
|
|
||||||
<string name="wavelog_empty">Belum ada entri log</string>
|
|
||||||
<string name="wavelog_grid_mismatch">Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah?</string>
|
|
||||||
<string name="wavelog_ignore">Abaikan dan unggah</string>
|
|
||||||
<string name="wavelog_cancel">Batal</string>
|
|
||||||
<string name="wavelog_copy">Salin</string>
|
|
||||||
<string name="wavelog_delete_undo">Urungkan</string>
|
|
||||||
<string name="wavelog_col_time">Waktu</string>
|
|
||||||
<string name="wavelog_col_freq">Frek.</string>
|
|
||||||
<string name="wavelog_col_sat">Satelit</string>
|
|
||||||
<string name="wavelog_col_call">Panggilan</string>
|
|
||||||
<string name="wavelog_col_uploaded">Terkirim</string>
|
|
||||||
<string name="prefs_other_title">Pengaturan lainnya</string>
|
|
||||||
<string name="prefs_ui_title">Pengaturan UI</string>
|
|
||||||
<string name="prefs_ui_order_title">Urutan halaman</string>
|
|
||||||
<string name="prefs_ui_main_title">Menu utama (bilah bawah)</string>
|
|
||||||
<string name="prefs_ui_sub_title">Menu lainnya</string>
|
|
||||||
<string name="prefs_ui_move_out">Pindah ke menu lainnya</string>
|
|
||||||
<string name="prefs_ui_move_back">Kembalikan ke menu utama</string>
|
|
||||||
<string name="prefs_ui_order_locked">Tetap (tidak dapat dipindahkan)</string>
|
|
||||||
<string name="prefs_ui_order_reset">Setel ulang urutan</string>
|
|
||||||
<string name="prefs_other_switch_utc">Tampilkan waktu lintasan dalam UTC</string>
|
|
||||||
<string name="prefs_other_switch_update">Aktifkan pembaruan data otomatis</string>
|
|
||||||
<string name="prefs_other_switch_sweep">Aktifkan animasi sapuan radar</string>
|
|
||||||
<string name="prefs_other_switch_sensors">Gunakan sensor untuk memutar tampilan radar</string>
|
|
||||||
<string name="prefs_other_switch_night_mode">Aktifkan filter mode malam merah</string>
|
|
||||||
<string name="prefs_highlight_title">Sorotan elevasi</string>
|
|
||||||
<string name="prefs_highlight_low">Rendah < %1$d°</string>
|
|
||||||
<string name="prefs_highlight_mid">Sedang %1$d–%2$d°</string>
|
|
||||||
<string name="prefs_highlight_high">Tinggi > %1$d°</string>
|
|
||||||
<string name="prefs_outro_title">BG7NTA & penulis asli mengucapkan terima kasih</string>
|
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
|
||||||
* Pengguna dan kontributor Look4Sat!
|
|
||||||
\n* David A. B. Johnson (predict4java)
|
|
||||||
\n* Dave Moten (predict4java)
|
|
||||||
\n* Alexandru Csete (Gpredict)
|
|
||||||
\n* Dr T.S. Kelso (Celestrak)
|
|
||||||
\n* Libre Space Foundation (SatNOGS)
|
|
||||||
\n* xdsopl dan kontributor Robot36!
|
|
||||||
\n* BA7OPF (fitur pencocokan lintasan)
|
|
||||||
\n* BG7NTA</string>
|
|
||||||
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="app_name" translatable="false">Look4Sat Pro</string>
|
|
||||||
<string name="btn_accept">Terima</string>
|
|
||||||
<string name="btn_cancel">Batal</string>
|
|
||||||
<string name="empty_list_message">Tidak ada data untuk ditampilkan</string>
|
|
||||||
<string name="nav_sat">Satelit</string>
|
|
||||||
<string name="nav_pass">Lintasan</string>
|
|
||||||
<string name="nav_radar">Radar</string>
|
|
||||||
<string name="nav_mutual">Pasangan</string>
|
|
||||||
<string name="nav_roaming">Jelajah</string>
|
|
||||||
<string name="nav_cw">Dekoder CW</string>
|
|
||||||
<string name="nav_map">Peta</string>
|
|
||||||
<string name="nav_prefs">Pengaturan</string>
|
|
||||||
<string name="nav_more">Lainnya</string>
|
|
||||||
<string name="nav_log">Log</string>
|
|
||||||
<string name="nav_amsat">AMSAT</string>
|
|
||||||
<string name="amsat_title">Status Satelit AMSAT</string>
|
|
||||||
<string name="amsat_updated">Diperbarui:</string>
|
|
||||||
<string name="amsat_active">Aktif</string>
|
|
||||||
<string name="amsat_tlm">TLM/beacon</string>
|
|
||||||
<string name="amsat_not_heard">Tidak terdengar</string>
|
|
||||||
<string name="amsat_conflict">Konflik</string>
|
|
||||||
<string name="amsat_name">Name</string>
|
|
||||||
<string name="amsat_load_failed">Gagal memuat - periksa jaringan dan coba lagi</string>
|
|
||||||
<string name="amsat_no_reports">Tidak ada laporan untuk periode ini</string>
|
|
||||||
<string name="amsat_close">Tutup</string>
|
|
||||||
<string name="sat_type_hint">Jenis: %s</string>
|
|
||||||
<string name="sat_type_title">Pilih jenis satelit</string>
|
|
||||||
<string name="sat_search_hint">Id - Nama</string>
|
|
||||||
<string name="sat_search_clear">Bersihkan</string>
|
|
||||||
<string name="sat_clear_all">Bersihkan semua</string>
|
|
||||||
<string name="sat_select_all">Pilih semua</string>
|
|
||||||
<string name="sat_empty_list_message">Pastikan kata kunci pencarian Anda benar dan DB sudah diperbarui</string>
|
|
||||||
<string name="sat_warning_title">Peringatan\!</string>
|
|
||||||
<string name="sat_warning_message">
|
|
||||||
Ada lebih dari 9000 satelit yang terdaftar di aplikasi ini.
|
|
||||||
Tidak masuk akal untuk melacak semuanya sekaligus.
|
|
||||||
\n\nSelalu coba persempit daftar hanya ke satelit yang
|
|
||||||
Anda minati melalui pencarian dan pemilih jenis.</string>
|
|
||||||
<string name="pass_filter_title">Filter lintasan</string>
|
|
||||||
<string name="pass_filter_elev">Elevasi minimal</string>
|
|
||||||
<string name="pass_filter_hours">Waktu ke depan</string>
|
|
||||||
<string name="pass_filter_aos_time">Jendela AOS</string>
|
|
||||||
<string name="pass_filter_invert_time">Balik jendela AOS</string>
|
|
||||||
<string name="pass_filter_deep_space">DeepSpace (periode >225 mnt)</string>
|
|
||||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
|
||||||
<string name="pass_modes_title">Pilih jenis modulasi</string>
|
|
||||||
<string name="pass_satId" translatable="false">%05d</string>
|
|
||||||
<string name="pass_elevation">Elevasi: %.1f°</string>
|
|
||||||
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
|
||||||
<string name="pass_altitude">Ketinggian: %d km</string>
|
|
||||||
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
|
|
||||||
<string name="pass_empty_list_message">
|
|
||||||
Pastikan filter Anda benar dan Anda sudah memilih satelit</string>
|
|
||||||
<string name="pass_error_message">Tidak ada lintasan satelit mendatang untuk ditampilkan</string>
|
|
||||||
<string name="pass_welcome_title">Selamat datang di Look4Sat\!</string>
|
|
||||||
<string name="pass_welcome_message">
|
|
||||||
Pastikan Anda mengatur posisi melalui GPS, Lat/Lon atau QTH di Pengaturan.
|
|
||||||
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
|
|
||||||
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
|
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
|
||||||
* BARU: Halaman status satelit AMSAT - tabel status langsung (warna resmi), jumlah laporan, laporan detail per satelit/hari
|
|
||||||
* Log dikelompokkan per lintasan satelit (sesi = satelit + waktu AOS), garis pemisah antar sesi
|
|
||||||
* GPS kini hanya menampilkan sukses saat posisi benar-benar didapat (timeout 15 dtk, cek izin)
|
|
||||||
* Pesan kegagalan jelas saat database satelit gagal diunduh
|
|
||||||
* Frekuensi log mengikuti panel Doppler secara real-time; visual hapus geser dipulihkan
|
|
||||||
* Perbaikan bahasa Indonesia (values-id)</string>
|
|
||||||
<string name="radar_back">Kembali</string>
|
|
||||||
<string name="radar_notify">Beri tahu</string>
|
|
||||||
<string name="radar_az_text">Azimuth</string>
|
|
||||||
<string name="radar_az_value" translatable="false">%.1f°</string>
|
|
||||||
<string name="radar_el_text">Elevasi</string>
|
|
||||||
<string name="radar_el_value" translatable="false">%.1f°</string>
|
|
||||||
<string name="radar_alt_text">Ketinggian</string>
|
|
||||||
<string name="radar_alt_value" translatable="false">%.0f km</string>
|
|
||||||
<string name="radar_dist_text">Jarak</string>
|
|
||||||
<string name="radar_dist_value" translatable="false">%.0f km</string>
|
|
||||||
<string name="radar_eclipsed">Gerhana</string>
|
|
||||||
<string name="radar_downlink">Downlink</string>
|
|
||||||
<string name="radar_uplink">Uplink</string>
|
|
||||||
<string name="radar_link_low" translatable="false">%.4f</string>
|
|
||||||
<string name="radar_link_lowHigh" translatable="false">%.4f - %.4f</string>
|
|
||||||
<string name="radar_no_link" translatable="false">- - . - -</string>
|
|
||||||
<string name="radar_no_data">Satelit ini tidak memiliki transceiver</string>
|
|
||||||
<string name="radar_add">Tambah</string>
|
|
||||||
<string name="radar_data">Transceiver satelit:</string>
|
|
||||||
<string name="radar_inverted">Terbalik: %s</string>
|
|
||||||
<string name="radar_mode">Mode: %s</string>
|
|
||||||
<string name="radar_string_no">Tidak</string>
|
|
||||||
<string name="radar_string_yes">Ya</string>
|
|
||||||
<string name="radar_visible">Terlihat</string>
|
|
||||||
<string name="radar_doppler_calc">Kalkulator Frekuensi Doppler</string>
|
|
||||||
<string name="radar_doppler_tx_hint">Masukkan frekuensi TX (MHz)</string>
|
|
||||||
<string name="radar_doppler_rx_hint">Masukkan frekuensi RX (MHz)</string>
|
|
||||||
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
|
|
||||||
<string name="radar_doppler_info">Untuk transponder linier, ketik satu frekuensi untuk melihat frekuensi lainnya</string>
|
|
||||||
<string name="radar_cw_decoder">Dekoder CW</string>
|
|
||||||
<string name="radar_cw_start">Mulai</string>
|
|
||||||
<string name="radar_cw_stop">Berhenti</string>
|
|
||||||
<string name="radar_cw_reset">Bersihkan</string>
|
|
||||||
<string name="map_prev">Sebelumnya</string>
|
|
||||||
<string name="map_next">Berikutnya</string>
|
|
||||||
<string name="map_copyright" translatable="false">© Kontributor OpenStreetMap</string>
|
|
||||||
<string name="map_azimuth">Azimuth: %.1f°</string>
|
|
||||||
<string name="map_elevation">Elevasi: %.1f°</string>
|
|
||||||
<string name="map_altitude">Ketinggian: %.0f km</string>
|
|
||||||
<string name="map_distance">Jarak: %.0f km</string>
|
|
||||||
<string name="map_latitude">Lintang: %.1f°</string>
|
|
||||||
<string name="map_longitude">Bujur: %.1f°</string>
|
|
||||||
<string name="map_qth" translatable="false">QTH: %s</string>
|
|
||||||
<string name="map_phase">Fase: %.1f°</string>
|
|
||||||
<string name="map_eclipsed">Gerhana</string>
|
|
||||||
<string name="map_period">Periode: %.0f mnt</string>
|
|
||||||
<string name="map_velocity">Kecepatan: %.2f km/d</string>
|
|
||||||
<string name="map_visibility">Visibilitas: %s</string>
|
|
||||||
<string name="map_visible">Terlihat</string>
|
|
||||||
<string name="prefs_app_title" translatable="false">Look4Sat Pro v%s</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">Donasi</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_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_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
|
||||||
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
|
||||||
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
|
|
||||||
<string name="prefs_privacy_title">Kebijakan Privasi</string>
|
|
||||||
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
|
|
||||||
<string name="prefs_updated_title">Diperbarui: %s</string>
|
|
||||||
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
|
||||||
<string name="prefs_loc_title">Posisi stasiun</string>
|
|
||||||
<string name="prefs_loc_gps_title">GPS</string>
|
|
||||||
<string name="prefs_loc_gps_error">Tidak dapat mengambil lokasi - periksa izin dan sinyal GPS/jaringan</string>
|
|
||||||
<string name="prefs_loc_input_title">Input</string>
|
|
||||||
<string name="prefs_loc_input_error">Lokasi yang dimasukkan tidak valid</string>
|
|
||||||
<string name="prefs_loc_qth_title">QTH</string>
|
|
||||||
<string name="prefs_loc_qth_error">Lokator QTH tidak valid</string>
|
|
||||||
<string name="prefs_loc_success">Posisi berhasil diperbarui</string>
|
|
||||||
<string name="prefs_station_title">Pengaturan posisi stasiun</string>
|
|
||||||
<string name="prefs_station_lat_text">Atur garis lintang stasiun Anda</string>
|
|
||||||
<string name="prefs_station_lon_text">Atur garis bujur stasiun Anda</string>
|
|
||||||
<string name="prefs_locator_title">Pengaturan lokator QTH</string>
|
|
||||||
<string name="prefs_locator_text">Atur posisi stasiun menggunakan lokator</string>
|
|
||||||
<string name="prefs_data_title">Data satelit</string>
|
|
||||||
<string name="prefs_data_entries">Satelit: %s</string>
|
|
||||||
<string name="prefs_data_radios">Transceiver: %s</string>
|
|
||||||
<string name="prefs_data_update">Perbarui</string>
|
|
||||||
<string name="prefs_data_import">URL Kustom</string>
|
|
||||||
<string name="prefs_data_clear">Bersihkan</string>
|
|
||||||
<string name="prefs_data_clear_success">Data berhasil dibersihkan</string>
|
|
||||||
<string name="prefs_data_update_success">Pembaruan berhasil diselesaikan</string>
|
|
||||||
<string name="prefs_data_update_failed">Pembaruan gagal - periksa jaringan Anda</string>
|
|
||||||
<string name="prefs_data_import_satellites_error">Tidak ada satelit yang diimpor. Pilih file TLE/3LE (.txt) atau OMM (.csv) yang valid.</string>
|
|
||||||
<string name="prefs_data_import_transceivers_error">Tidak ada transceiver yang diimpor. Pilih file SatNOGS (.json) yang valid.</string>
|
|
||||||
<string name="prefs_data_sources_title">Sumber data kustom</string>
|
|
||||||
<string name="prefs_data_sources_tle_switch">URL TLE Kustom</string>
|
|
||||||
<string name="prefs_data_sources_transceivers_switch">URL transceiver kustom</string>
|
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
|
||||||
<string name="prefs_data_reset_url">Setel ulang ke URL bawaan</string>
|
|
||||||
<string name="prefs_data_output_title">Keluaran data</string>
|
|
||||||
<string name="prefs_net_output">Jaringan</string>
|
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
|
||||||
<string name="prefs_cat_output">CAT</string>
|
|
||||||
<string name="nav_radiocontrol">Kontrol Radio</string>
|
|
||||||
<string name="rc_settings_title">Kontrol Radio CAT</string>
|
|
||||||
<string name="rc_radio_model">Model Radio</string>
|
|
||||||
<string name="rc_tx_device_hint">Alamat BT Radio TX</string>
|
|
||||||
<string name="rc_rx_device_hint">Alamat BT Radio RX</string>
|
|
||||||
<string name="rc_tx_name_hint">Nama Radio TX</string>
|
|
||||||
<string name="rc_rx_name_hint">Nama Radio RX</string>
|
|
||||||
<string name="rc_enable_switch">Aktifkan Kontrol CAT</string>
|
|
||||||
<string name="prefs_net_title">Keluaran data jaringan</string>
|
|
||||||
<string name="prefs_net_rotator_switch">Aktifkan keluaran rotator</string>
|
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
|
||||||
<string name="prefs_net_rotator_format_hint">Format</string>
|
|
||||||
<string name="prefs_net_frequency_switch">Aktifkan keluaran frekuensi</string>
|
|
||||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
|
||||||
<string name="prefs_net_frequency_format_hint">Format</string>
|
|
||||||
<string name="prefs_bt_title">Keluaran data Bluetooth</string>
|
|
||||||
<string name="prefs_bt_rotator_switch">Aktifkan keluaran rotator</string>
|
|
||||||
<string name="prefs_bt_rotator_device_hint">Id perangkat</string>
|
|
||||||
<string name="prefs_bt_rotator_output_hint">Format data</string>
|
|
||||||
<string name="prefs_bt_frequency_switch">Aktifkan keluaran frekuensi</string>
|
|
||||||
<string name="prefs_bt_frequency_device_hint">Id perangkat</string>
|
|
||||||
<string name="prefs_bt_frequency_output_hint">Format data</string>
|
|
||||||
<string name="prefs_bt_perm_error">Periksa izin bluetooth Anda</string>
|
|
||||||
<string name="prefs_net_perm_error">Periksa izin jaringan Anda</string>
|
|
||||||
<string name="tracking_status">Melacak: %s</string>
|
|
||||||
<string name="loc_lat_text">Lintang: %s°</string>
|
|
||||||
<string name="loc_lon_text">Bujur: %s°</string>
|
|
||||||
<string name="loc_qth_text">QTH: %s</string>
|
|
||||||
<string name="connect_radio">Hubungkan</string>
|
|
||||||
<string name="disconnect_radio">Putuskan</string>
|
|
||||||
<string name="track_radio">Lacak</string>
|
|
||||||
<string name="stop_radio">Berhenti</string>
|
|
||||||
<string name="prefs_wavelog_title">Server log WaveLog</string>
|
|
||||||
<string name="prefs_wavelog_url_hint">Alamat server</string>
|
|
||||||
<string name="prefs_wavelog_key_hint">Kunci API</string>
|
|
||||||
<string name="prefs_wavelog_station_hint">ID stasiun</string>
|
|
||||||
<string name="prefs_wavelog_auto">Unggah otomatis</string>
|
|
||||||
<string name="prefs_wavelog_test">Uji koneksi</string>
|
|
||||||
<string name="prefs_wavelog_upload">Unggah sekarang</string>
|
|
||||||
<string name="wavelog_title">Log</string>
|
|
||||||
<string name="wavelog_select_transponder">Pilih transponder</string>
|
|
||||||
<string name="wavelog_call_hint">Tanda panggil</string>
|
|
||||||
<string name="wavelog_mode_hint">Mode</string>
|
|
||||||
<string name="wavelog_saved">Tersimpan ke log lokal</string>
|
|
||||||
<string name="wavelog_need_config">Konfigurasikan WaveLog di Pengaturan terlebih dahulu</string>
|
|
||||||
<string name="wavelog_empty">Belum ada entri log</string>
|
|
||||||
<string name="wavelog_grid_mismatch">Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah?</string>
|
|
||||||
<string name="wavelog_ignore">Abaikan dan unggah</string>
|
|
||||||
<string name="wavelog_cancel">Batal</string>
|
|
||||||
<string name="wavelog_copy">Salin</string>
|
|
||||||
<string name="wavelog_delete_undo">Urungkan</string>
|
|
||||||
<string name="wavelog_col_time">Waktu</string>
|
|
||||||
<string name="wavelog_col_freq">Frek.</string>
|
|
||||||
<string name="wavelog_col_sat">Satelit</string>
|
|
||||||
<string name="wavelog_col_call">Panggilan</string>
|
|
||||||
<string name="wavelog_col_uploaded">Terkirim</string>
|
|
||||||
<string name="wavelog_ungrouped">Tidak dikelompokkan</string>
|
|
||||||
<string name="prefs_other_title">Pengaturan lainnya</string>
|
|
||||||
<string name="prefs_ui_title">Pengaturan UI</string>
|
|
||||||
<string name="prefs_ui_order_title">Urutan halaman</string>
|
|
||||||
<string name="prefs_ui_main_title">Menu utama (bilah bawah)</string>
|
|
||||||
<string name="prefs_ui_sub_title">Menu lainnya</string>
|
|
||||||
<string name="prefs_ui_move_out">Pindah ke menu lainnya</string>
|
|
||||||
<string name="prefs_ui_move_back">Kembalikan ke menu utama</string>
|
|
||||||
<string name="prefs_ui_order_locked">Tetap (tidak dapat dipindahkan)</string>
|
|
||||||
<string name="prefs_ui_order_reset">Setel ulang urutan</string>
|
|
||||||
<string name="prefs_other_switch_utc">Tampilkan waktu lintasan dalam UTC</string>
|
|
||||||
<string name="prefs_other_switch_update">Aktifkan pembaruan data otomatis</string>
|
|
||||||
<string name="prefs_other_switch_sweep">Aktifkan animasi sapuan radar</string>
|
|
||||||
<string name="prefs_other_switch_sensors">Gunakan sensor untuk memutar tampilan radar</string>
|
|
||||||
<string name="prefs_other_switch_night_mode">Aktifkan filter mode malam merah</string>
|
|
||||||
<string name="prefs_highlight_title">Sorotan elevasi</string>
|
|
||||||
<string name="prefs_highlight_low">Rendah < %1$d°</string>
|
|
||||||
<string name="prefs_highlight_mid">Sedang %1$d–%2$d°</string>
|
|
||||||
<string name="prefs_highlight_high">Tinggi > %1$d°</string>
|
|
||||||
<string name="prefs_outro_title">BG7NTA & penulis asli mengucapkan terima kasih</string>
|
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
|
||||||
* Pengguna dan kontributor Look4Sat!
|
|
||||||
\n* David A. B. Johnson (predict4java)
|
|
||||||
\n* Dave Moten (predict4java)
|
|
||||||
\n* Alexandru Csete (Gpredict)
|
|
||||||
\n* Dr T.S. Kelso (Celestrak)
|
|
||||||
\n* Libre Space Foundation (SatNOGS)
|
|
||||||
\n* xdsopl dan kontributor Robot36!
|
|
||||||
\n* BA7OPF (fitur pencocokan lintasan)
|
|
||||||
\n* BG7NTA</string>
|
|
||||||
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="app_name" translatable="false">Look4Sat Pro</string>
|
|
||||||
<string name="btn_accept">Terima</string>
|
|
||||||
<string name="btn_cancel">Batal</string>
|
|
||||||
<string name="empty_list_message">Tidak ada data untuk ditampilkan</string>
|
|
||||||
<string name="nav_sat">Satelit</string>
|
|
||||||
<string name="nav_pass">Lintasan</string>
|
|
||||||
<string name="nav_radar">Radar</string>
|
|
||||||
<string name="nav_mutual">Pasangan</string>
|
|
||||||
<string name="nav_roaming">Jelajah</string>
|
|
||||||
<string name="nav_cw">Dekoder CW</string>
|
|
||||||
<string name="nav_map">Peta</string>
|
|
||||||
<string name="nav_prefs">Pengaturan</string>
|
|
||||||
<string name="nav_more">Lainnya</string>
|
|
||||||
<string name="nav_log">Log</string>
|
|
||||||
<string name="nav_amsat">AMSAT</string>
|
|
||||||
<string name="amsat_title">Status Satelit AMSAT</string>
|
|
||||||
<string name="amsat_updated">Diperbarui:</string>
|
|
||||||
<string name="amsat_active">Aktif</string>
|
|
||||||
<string name="amsat_tlm">TLM/beacon</string>
|
|
||||||
<string name="amsat_not_heard">Tidak terdengar</string>
|
|
||||||
<string name="amsat_conflict">Konflik</string>
|
|
||||||
<string name="amsat_name">Name</string>
|
|
||||||
<string name="amsat_load_failed">Gagal memuat - periksa jaringan dan coba lagi</string>
|
|
||||||
<string name="amsat_no_reports">Tidak ada laporan untuk periode ini</string>
|
|
||||||
<string name="amsat_close">Tutup</string>
|
|
||||||
<string name="sat_type_hint">Jenis: %s</string>
|
|
||||||
<string name="sat_type_title">Pilih jenis satelit</string>
|
|
||||||
<string name="sat_search_hint">Id - Nama</string>
|
|
||||||
<string name="sat_search_clear">Bersihkan</string>
|
|
||||||
<string name="sat_clear_all">Bersihkan semua</string>
|
|
||||||
<string name="sat_select_all">Pilih semua</string>
|
|
||||||
<string name="sat_empty_list_message">Pastikan kata kunci pencarian Anda benar dan DB sudah diperbarui</string>
|
|
||||||
<string name="sat_warning_title">Peringatan\!</string>
|
|
||||||
<string name="sat_warning_message">
|
|
||||||
Ada lebih dari 9000 satelit yang terdaftar di aplikasi ini.
|
|
||||||
Tidak masuk akal untuk melacak semuanya sekaligus.
|
|
||||||
\n\nSelalu coba persempit daftar hanya ke satelit yang
|
|
||||||
Anda minati melalui pencarian dan pemilih jenis.</string>
|
|
||||||
<string name="pass_filter_title">Filter lintasan</string>
|
|
||||||
<string name="pass_filter_elev">Elevasi minimal</string>
|
|
||||||
<string name="pass_filter_hours">Waktu ke depan</string>
|
|
||||||
<string name="pass_filter_aos_time">Jendela AOS</string>
|
|
||||||
<string name="pass_filter_invert_time">Balik jendela AOS</string>
|
|
||||||
<string name="pass_filter_deep_space">DeepSpace (periode >225 mnt)</string>
|
|
||||||
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
|
||||||
<string name="pass_modes_title">Pilih jenis modulasi</string>
|
|
||||||
<string name="pass_satId" translatable="false">%05d</string>
|
|
||||||
<string name="pass_elevation">Elevasi: %.1f°</string>
|
|
||||||
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
|
||||||
<string name="pass_altitude">Ketinggian: %d km</string>
|
|
||||||
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
|
|
||||||
<string name="pass_empty_list_message">
|
|
||||||
Pastikan filter Anda benar dan Anda sudah memilih satelit</string>
|
|
||||||
<string name="pass_error_message">Tidak ada lintasan satelit mendatang untuk ditampilkan</string>
|
|
||||||
<string name="pass_welcome_title">Selamat datang di Look4Sat\!</string>
|
|
||||||
<string name="pass_welcome_message">
|
|
||||||
Pastikan Anda mengatur posisi melalui GPS, Lat/Lon atau QTH di Pengaturan.
|
|
||||||
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
|
|
||||||
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
|
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
|
||||||
* BARU: Halaman status satelit AMSAT - tabel status langsung (warna resmi), jumlah laporan, laporan detail per satelit/hari
|
|
||||||
* Log dikelompokkan per lintasan satelit (sesi = satelit + waktu AOS), garis pemisah antar sesi
|
|
||||||
* GPS kini hanya menampilkan sukses saat posisi benar-benar didapat (timeout 15 dtk, cek izin)
|
|
||||||
* Pesan kegagalan jelas saat database satelit gagal diunduh
|
|
||||||
* Frekuensi log mengikuti panel Doppler secara real-time; visual hapus geser dipulihkan
|
|
||||||
* Perbaikan bahasa Indonesia (values-id)</string>
|
|
||||||
<string name="radar_back">Kembali</string>
|
|
||||||
<string name="radar_notify">Beri tahu</string>
|
|
||||||
<string name="radar_az_text">Azimuth</string>
|
|
||||||
<string name="radar_az_value" translatable="false">%.1f°</string>
|
|
||||||
<string name="radar_el_text">Elevasi</string>
|
|
||||||
<string name="radar_el_value" translatable="false">%.1f°</string>
|
|
||||||
<string name="radar_alt_text">Ketinggian</string>
|
|
||||||
<string name="radar_alt_value" translatable="false">%.0f km</string>
|
|
||||||
<string name="radar_dist_text">Jarak</string>
|
|
||||||
<string name="radar_dist_value" translatable="false">%.0f km</string>
|
|
||||||
<string name="radar_eclipsed">Gerhana</string>
|
|
||||||
<string name="radar_downlink">Downlink</string>
|
|
||||||
<string name="radar_uplink">Uplink</string>
|
|
||||||
<string name="radar_link_low" translatable="false">%.4f</string>
|
|
||||||
<string name="radar_link_lowHigh" translatable="false">%.4f - %.4f</string>
|
|
||||||
<string name="radar_no_link" translatable="false">- - . - -</string>
|
|
||||||
<string name="radar_no_data">Satelit ini tidak memiliki transceiver</string>
|
|
||||||
<string name="radar_add">Tambah</string>
|
|
||||||
<string name="radar_data">Transceiver satelit:</string>
|
|
||||||
<string name="radar_inverted">Terbalik: %s</string>
|
|
||||||
<string name="radar_mode">Mode: %s</string>
|
|
||||||
<string name="radar_string_no">Tidak</string>
|
|
||||||
<string name="radar_string_yes">Ya</string>
|
|
||||||
<string name="radar_visible">Terlihat</string>
|
|
||||||
<string name="radar_doppler_calc">Kalkulator Frekuensi Doppler</string>
|
|
||||||
<string name="radar_doppler_tx_hint">Masukkan frekuensi TX (MHz)</string>
|
|
||||||
<string name="radar_doppler_rx_hint">Masukkan frekuensi RX (MHz)</string>
|
|
||||||
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
|
|
||||||
<string name="radar_doppler_info">Untuk transponder linier, ketik satu frekuensi untuk melihat frekuensi lainnya</string>
|
|
||||||
<string name="radar_cw_decoder">Dekoder CW</string>
|
|
||||||
<string name="radar_cw_start">Mulai</string>
|
|
||||||
<string name="radar_cw_stop">Berhenti</string>
|
|
||||||
<string name="radar_cw_reset">Bersihkan</string>
|
|
||||||
<string name="map_prev">Sebelumnya</string>
|
|
||||||
<string name="map_next">Berikutnya</string>
|
|
||||||
<string name="map_copyright" translatable="false">© Kontributor OpenStreetMap</string>
|
|
||||||
<string name="map_azimuth">Azimuth: %.1f°</string>
|
|
||||||
<string name="map_elevation">Elevasi: %.1f°</string>
|
|
||||||
<string name="map_altitude">Ketinggian: %.0f km</string>
|
|
||||||
<string name="map_distance">Jarak: %.0f km</string>
|
|
||||||
<string name="map_latitude">Lintang: %.1f°</string>
|
|
||||||
<string name="map_longitude">Bujur: %.1f°</string>
|
|
||||||
<string name="map_qth" translatable="false">QTH: %s</string>
|
|
||||||
<string name="map_phase">Fase: %.1f°</string>
|
|
||||||
<string name="map_eclipsed">Gerhana</string>
|
|
||||||
<string name="map_period">Periode: %.0f mnt</string>
|
|
||||||
<string name="map_velocity">Kecepatan: %.2f km/d</string>
|
|
||||||
<string name="map_visibility">Visibilitas: %s</string>
|
|
||||||
<string name="map_visible">Terlihat</string>
|
|
||||||
<string name="prefs_app_title" translatable="false">Look4Sat Pro v%s</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">Donasi</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_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_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
|
||||||
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
|
||||||
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
|
|
||||||
<string name="prefs_privacy_title">Kebijakan Privasi</string>
|
|
||||||
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
|
|
||||||
<string name="prefs_updated_title">Diperbarui: %s</string>
|
|
||||||
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
|
||||||
<string name="prefs_loc_title">Posisi stasiun</string>
|
|
||||||
<string name="prefs_loc_gps_title">GPS</string>
|
|
||||||
<string name="prefs_loc_gps_error">Tidak dapat mengambil lokasi - periksa izin dan sinyal GPS/jaringan</string>
|
|
||||||
<string name="prefs_loc_input_title">Input</string>
|
|
||||||
<string name="prefs_loc_input_error">Lokasi yang dimasukkan tidak valid</string>
|
|
||||||
<string name="prefs_loc_qth_title">QTH</string>
|
|
||||||
<string name="prefs_loc_qth_error">Lokator QTH tidak valid</string>
|
|
||||||
<string name="prefs_loc_success">Posisi berhasil diperbarui</string>
|
|
||||||
<string name="prefs_station_title">Pengaturan posisi stasiun</string>
|
|
||||||
<string name="prefs_station_lat_text">Atur garis lintang stasiun Anda</string>
|
|
||||||
<string name="prefs_station_lon_text">Atur garis bujur stasiun Anda</string>
|
|
||||||
<string name="prefs_locator_title">Pengaturan lokator QTH</string>
|
|
||||||
<string name="prefs_locator_text">Atur posisi stasiun menggunakan lokator</string>
|
|
||||||
<string name="prefs_data_title">Data satelit</string>
|
|
||||||
<string name="prefs_data_entries">Satelit: %s</string>
|
|
||||||
<string name="prefs_data_radios">Transceiver: %s</string>
|
|
||||||
<string name="prefs_data_update">Perbarui</string>
|
|
||||||
<string name="prefs_data_import">URL Kustom</string>
|
|
||||||
<string name="prefs_data_clear">Bersihkan</string>
|
|
||||||
<string name="prefs_data_clear_success">Data berhasil dibersihkan</string>
|
|
||||||
<string name="prefs_data_update_success">Pembaruan berhasil diselesaikan</string>
|
|
||||||
<string name="prefs_data_update_failed">Pembaruan gagal - periksa jaringan Anda</string>
|
|
||||||
<string name="prefs_data_import_satellites_error">Tidak ada satelit yang diimpor. Pilih file TLE/3LE (.txt) atau OMM (.csv) yang valid.</string>
|
|
||||||
<string name="prefs_data_import_transceivers_error">Tidak ada transceiver yang diimpor. Pilih file SatNOGS (.json) yang valid.</string>
|
|
||||||
<string name="prefs_data_sources_title">Sumber data kustom</string>
|
|
||||||
<string name="prefs_data_sources_tle_switch">URL TLE Kustom</string>
|
|
||||||
<string name="prefs_data_sources_transceivers_switch">URL transceiver kustom</string>
|
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
|
||||||
<string name="prefs_data_reset_url">Setel ulang ke URL bawaan</string>
|
|
||||||
<string name="prefs_data_output_title">Keluaran data</string>
|
|
||||||
<string name="prefs_net_output">Jaringan</string>
|
|
||||||
<string name="prefs_bt_output">Bluetooth</string>
|
|
||||||
<string name="prefs_cat_output">CAT</string>
|
|
||||||
<string name="nav_radiocontrol">Kontrol Radio</string>
|
|
||||||
<string name="rc_settings_title">Kontrol Radio CAT</string>
|
|
||||||
<string name="rc_radio_model">Model Radio</string>
|
|
||||||
<string name="rc_tx_device_hint">Alamat BT Radio TX</string>
|
|
||||||
<string name="rc_rx_device_hint">Alamat BT Radio RX</string>
|
|
||||||
<string name="rc_tx_name_hint">Nama Radio TX</string>
|
|
||||||
<string name="rc_rx_name_hint">Nama Radio RX</string>
|
|
||||||
<string name="rc_enable_switch">Aktifkan Kontrol CAT</string>
|
|
||||||
<string name="prefs_net_title">Keluaran data jaringan</string>
|
|
||||||
<string name="prefs_net_rotator_switch">Aktifkan keluaran rotator</string>
|
|
||||||
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
|
||||||
<string name="prefs_net_rotator_format_hint">Format</string>
|
|
||||||
<string name="prefs_net_frequency_switch">Aktifkan keluaran frekuensi</string>
|
|
||||||
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
|
||||||
<string name="prefs_net_frequency_format_hint">Format</string>
|
|
||||||
<string name="prefs_bt_title">Keluaran data Bluetooth</string>
|
|
||||||
<string name="prefs_bt_rotator_switch">Aktifkan keluaran rotator</string>
|
|
||||||
<string name="prefs_bt_rotator_device_hint">Id perangkat</string>
|
|
||||||
<string name="prefs_bt_rotator_output_hint">Format data</string>
|
|
||||||
<string name="prefs_bt_frequency_switch">Aktifkan keluaran frekuensi</string>
|
|
||||||
<string name="prefs_bt_frequency_device_hint">Id perangkat</string>
|
|
||||||
<string name="prefs_bt_frequency_output_hint">Format data</string>
|
|
||||||
<string name="prefs_bt_perm_error">Periksa izin bluetooth Anda</string>
|
|
||||||
<string name="prefs_net_perm_error">Periksa izin jaringan Anda</string>
|
|
||||||
<string name="tracking_status">Melacak: %s</string>
|
|
||||||
<string name="loc_lat_text">Lintang: %s°</string>
|
|
||||||
<string name="loc_lon_text">Bujur: %s°</string>
|
|
||||||
<string name="loc_qth_text">QTH: %s</string>
|
|
||||||
<string name="connect_radio">Hubungkan</string>
|
|
||||||
<string name="disconnect_radio">Putuskan</string>
|
|
||||||
<string name="track_radio">Lacak</string>
|
|
||||||
<string name="stop_radio">Berhenti</string>
|
|
||||||
<string name="prefs_wavelog_title">Server log WaveLog</string>
|
|
||||||
<string name="prefs_wavelog_url_hint">Alamat server</string>
|
|
||||||
<string name="prefs_wavelog_key_hint">Kunci API</string>
|
|
||||||
<string name="prefs_wavelog_station_hint">ID stasiun</string>
|
|
||||||
<string name="prefs_wavelog_auto">Unggah otomatis</string>
|
|
||||||
<string name="prefs_wavelog_test">Uji koneksi</string>
|
|
||||||
<string name="prefs_wavelog_upload">Unggah sekarang</string>
|
|
||||||
<string name="wavelog_title">Log</string>
|
|
||||||
<string name="wavelog_select_transponder">Pilih transponder</string>
|
|
||||||
<string name="wavelog_call_hint">Tanda panggil</string>
|
|
||||||
<string name="wavelog_mode_hint">Mode</string>
|
|
||||||
<string name="wavelog_saved">Tersimpan ke log lokal</string>
|
|
||||||
<string name="wavelog_need_config">Konfigurasikan WaveLog di Pengaturan terlebih dahulu</string>
|
|
||||||
<string name="wavelog_empty">Belum ada entri log</string>
|
|
||||||
<string name="wavelog_grid_mismatch">Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah?</string>
|
|
||||||
<string name="wavelog_ignore">Abaikan dan unggah</string>
|
|
||||||
<string name="wavelog_cancel">Batal</string>
|
|
||||||
<string name="wavelog_copy">Salin</string>
|
|
||||||
<string name="wavelog_delete_undo">Urungkan</string>
|
|
||||||
<string name="wavelog_col_time">Waktu</string>
|
|
||||||
<string name="wavelog_col_freq">Frek.</string>
|
|
||||||
<string name="wavelog_col_sat">Satelit</string>
|
|
||||||
<string name="wavelog_col_call">Panggilan</string>
|
|
||||||
<string name="wavelog_col_uploaded">Terkirim</string>
|
|
||||||
<string name="wavelog_ungrouped">Tidak dikelompokkan</string>
|
|
||||||
<string name="prefs_other_title">Pengaturan lainnya</string>
|
|
||||||
<string name="prefs_ui_title">Pengaturan UI</string>
|
|
||||||
<string name="prefs_ui_order_title">Urutan halaman</string>
|
|
||||||
<string name="prefs_ui_main_title">Menu utama (bilah bawah)</string>
|
|
||||||
<string name="prefs_ui_sub_title">Menu lainnya</string>
|
|
||||||
<string name="prefs_ui_move_out">Pindah ke menu lainnya</string>
|
|
||||||
<string name="prefs_ui_move_back">Kembalikan ke menu utama</string>
|
|
||||||
<string name="prefs_ui_order_locked">Tetap (tidak dapat dipindahkan)</string>
|
|
||||||
<string name="prefs_ui_order_reset">Setel ulang urutan</string>
|
|
||||||
<string name="prefs_other_switch_utc">Tampilkan waktu lintasan dalam UTC</string>
|
|
||||||
<string name="prefs_other_switch_update">Aktifkan pembaruan data otomatis</string>
|
|
||||||
<string name="prefs_other_switch_sweep">Aktifkan animasi sapuan radar</string>
|
|
||||||
<string name="prefs_other_switch_sensors">Gunakan sensor untuk memutar tampilan radar</string>
|
|
||||||
<string name="prefs_other_switch_night_mode">Aktifkan filter mode malam merah</string>
|
|
||||||
<string name="prefs_highlight_title">Sorotan elevasi</string>
|
|
||||||
<string name="prefs_highlight_low">Rendah < %1$d°</string>
|
|
||||||
<string name="prefs_highlight_mid">Sedang %1$d–%2$d°</string>
|
|
||||||
<string name="prefs_highlight_high">Tinggi > %1$d°</string>
|
|
||||||
<string name="prefs_outro_title">BG7NTA & penulis asli mengucapkan terima kasih</string>
|
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
|
||||||
* Pengguna dan kontributor Look4Sat!
|
|
||||||
\n* David A. B. Johnson (predict4java)
|
|
||||||
\n* Dave Moten (predict4java)
|
|
||||||
\n* Alexandru Csete (Gpredict)
|
|
||||||
\n* Dr T.S. Kelso (Celestrak)
|
|
||||||
\n* Libre Space Foundation (SatNOGS)
|
|
||||||
\n* xdsopl dan kontributor Robot36!
|
|
||||||
\n* BA7OPF (fitur pencocokan lintasan)
|
|
||||||
\n* BG7NTA</string>
|
|
||||||
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
|
|
||||||
</resources>
|
|
||||||
@@ -111,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>
|
||||||
|
|
||||||
@@ -142,6 +149,7 @@
|
|||||||
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
|
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
|
||||||
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
|
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
|
||||||
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
|
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
|
||||||
|
<string name="prefs_other_switch_map_grid">Показывать сетку Maidenhead на карте</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Подсветка высоты</string>
|
<string name="prefs_highlight_title">Подсветка высоты</string>
|
||||||
<string name="prefs_highlight_low">Низко < %1$d°</string>
|
<string name="prefs_highlight_low">Низко < %1$d°</string>
|
||||||
|
|||||||
@@ -112,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>
|
||||||
|
|
||||||
@@ -143,6 +150,7 @@
|
|||||||
<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_other_switch_night_mode">රතු රාත්රී පෙරහන සබල කරන්න</string>
|
||||||
|
<string name="prefs_other_switch_map_grid">සිතියමේ Maidenhead ජාලකය පෙන්වන්න</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
|
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
|
||||||
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
||||||
|
|||||||
@@ -10,22 +10,8 @@
|
|||||||
<string name="nav_sat">Uydular</string>
|
<string name="nav_sat">Uydular</string>
|
||||||
<string name="nav_pass">Geçişler</string>
|
<string name="nav_pass">Geçişler</string>
|
||||||
<string name="nav_radar">Radar</string>
|
<string name="nav_radar">Radar</string>
|
||||||
<string name="nav_cw">CW Dekoder</string>
|
|
||||||
<string name="nav_map">Harita</string>
|
<string name="nav_map">Harita</string>
|
||||||
<string name="nav_prefs">Ayarlar</string>
|
<string name="nav_prefs">Ayarlar</string>
|
||||||
<string name="nav_more">Daha Fazla</string>
|
|
||||||
<string name="nav_log">Günlük</string>
|
|
||||||
<string name="nav_amsat">AMSAT</string>
|
|
||||||
<string name="amsat_title">AMSAT Uydu Durumu</string>
|
|
||||||
<string name="amsat_updated">Güncellendi:</string>
|
|
||||||
<string name="amsat_active">Aktif</string>
|
|
||||||
<string name="amsat_tlm">TLM/işaret</string>
|
|
||||||
<string name="amsat_not_heard">Duyulmadı</string>
|
|
||||||
<string name="amsat_conflict">Çelişkili</string>
|
|
||||||
<string name="amsat_name">Name</string>
|
|
||||||
<string name="amsat_load_failed">Yükleme başarısız - ağı kontrol edip tekrar deneyin</string>
|
|
||||||
<string name="amsat_no_reports">Bu dönem için rapor yok</string>
|
|
||||||
<string name="amsat_close">Kapat</string>
|
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Uydu türü: %s</string>
|
<string name="sat_type_hint">Uydu türü: %s</string>
|
||||||
@@ -64,13 +50,13 @@
|
|||||||
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</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">
|
||||||
* YENİ: AMSAT uydu durum sayfası - canlı durum tablosu (resmi renkler), rapor sayıları, uydu/gün bazında detaylı raporlar
|
* Switched to short-form commands in NetworkReporter, by theojalba
|
||||||
* Günlükler uydu geçişine göre gruplanır (oturum = uydu + AOS zamanı), oturumlar arasında ayırıcı çizgi
|
\n* Added CAT radio control for full-duplex SAT operation, by jcmerg
|
||||||
* GPS artık yalnızca gerçek konum alındığında başarı gösterir (15 sn zaman aşımı, izin kontrolü)
|
\n* Added normalized time for passes AOS/LOS, fixed refresh state bug
|
||||||
* Uydu veritabanı indirilemediğinde net hata mesajı gösterilir
|
\n* Added multiple performance tweaks, improved passes calculation
|
||||||
* Günlük frekansları Doppler panelini gerçek zamanlı takip eder; kaydırarak silme görselleri düzeltildi
|
\n* Fixed UTC pass time problem mentioned in issue #201
|
||||||
* Endonezya dili düzeltmesi (values-id)</string>
|
</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
<string name="radar_back">Geri</string>
|
<string name="radar_back">Geri</string>
|
||||||
@@ -117,7 +103,7 @@
|
|||||||
<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 Pro 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=com.rtbishop.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>
|
||||||
@@ -135,7 +121,7 @@
|
|||||||
|
|
||||||
<string name="prefs_loc_title">İstasyon konumu</string>
|
<string name="prefs_loc_title">İstasyon konumu</string>
|
||||||
<string name="prefs_loc_gps_title">GPS</string>
|
<string name="prefs_loc_gps_title">GPS</string>
|
||||||
<string name="prefs_loc_gps_error">Konum alınamıyor - izinleri ve GPS/ağ sinyalini kontrol edin</string>
|
<string name="prefs_loc_gps_error">Konum izinlerinizi kontrol edin</string>
|
||||||
<string name="prefs_loc_input_title">Manuel giriş</string>
|
<string name="prefs_loc_input_title">Manuel giriş</string>
|
||||||
<string name="prefs_loc_input_error">Geçersiz konum girildi</string>
|
<string name="prefs_loc_input_error">Geçersiz konum girildi</string>
|
||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
@@ -151,17 +137,21 @@
|
|||||||
<string name="prefs_data_entries">Uydular: %s</string>
|
<string name="prefs_data_entries">Uydular: %s</string>
|
||||||
<string name="prefs_data_radios">Transceiver\'lar: %s</string>
|
<string name="prefs_data_radios">Transceiver\'lar: %s</string>
|
||||||
<string name="prefs_data_update">Güncelle</string>
|
<string name="prefs_data_update">Güncelle</string>
|
||||||
<string name="prefs_data_import">Özel URL</string>
|
<string name="prefs_data_import">İçe aktar</string>
|
||||||
<string name="prefs_data_clear">Temizle</string>
|
<string name="prefs_data_clear">Temizle</string>
|
||||||
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
|
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
|
||||||
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
|
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
|
||||||
<string name="prefs_data_update_failed">Güncelleme başarısız - ağınızı kontrol edin</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>
|
||||||
<string name="prefs_data_reset_url">Varsayılan URL\'ye sıfırla</string>
|
|
||||||
|
|
||||||
<string name="prefs_data_output_title">Veri aktarımı</string>
|
<string name="prefs_data_output_title">Veri aktarımı</string>
|
||||||
<string name="prefs_net_output">Ağ</string>
|
<string name="prefs_net_output">Ağ</string>
|
||||||
@@ -196,60 +186,20 @@
|
|||||||
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
|
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
|
||||||
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
|
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
|
||||||
|
|
||||||
<string name="tracking_status">İzleniyor: %s</string>
|
|
||||||
<string name="loc_lat_text">Enlem: %s°</string>
|
|
||||||
<string name="loc_lon_text">Boylam: %s°</string>
|
|
||||||
<string name="loc_qth_text">QTH: %s</string>
|
|
||||||
<string name="connect_radio">Bağlan</string>
|
|
||||||
<string name="disconnect_radio">Bağlantıyı Kes</string>
|
|
||||||
<string name="track_radio">Takip</string>
|
|
||||||
<string name="stop_radio">Dur</string>
|
|
||||||
<string name="prefs_wavelog_title">WaveLog günlük sunucusu</string>
|
|
||||||
<string name="prefs_wavelog_url_hint">Sunucu adresi</string>
|
|
||||||
<string name="prefs_wavelog_key_hint">API anahtarı</string>
|
|
||||||
<string name="prefs_wavelog_station_hint">İstasyon kimliği</string>
|
|
||||||
<string name="prefs_wavelog_auto">Otomatik yükleme</string>
|
|
||||||
<string name="prefs_wavelog_test">Bağlantıyı test et</string>
|
|
||||||
<string name="prefs_wavelog_upload">Şimdi yükle</string>
|
|
||||||
<string name="wavelog_title">Günlük</string>
|
|
||||||
<string name="wavelog_select_transponder">Transponder seç</string>
|
|
||||||
<string name="wavelog_call_hint">Çağrı işareti</string>
|
|
||||||
<string name="wavelog_mode_hint">Mod</string>
|
|
||||||
<string name="wavelog_saved">Yerel günlüğe kaydedildi</string>
|
|
||||||
<string name="wavelog_need_config">Önce Ayarlar\'da WaveLog\'u yapılandırın</string>
|
|
||||||
<string name="wavelog_empty">Henüz günlük kaydı yok</string>
|
|
||||||
<string name="wavelog_grid_mismatch">QTH ızgarası (%1$s) istasyon ızgarasıyla (%2$s) eşleşmiyor. Yine de yüklensin mi?</string>
|
|
||||||
<string name="wavelog_ignore">Yoksay ve yükle</string>
|
|
||||||
<string name="wavelog_cancel">İptal</string>
|
|
||||||
<string name="wavelog_copy">Kopyala</string>
|
|
||||||
<string name="wavelog_delete_undo">Geri al</string>
|
|
||||||
<string name="wavelog_col_time">Zaman</string>
|
|
||||||
<string name="wavelog_col_freq">Frekans</string>
|
|
||||||
<string name="wavelog_col_sat">Uydu</string>
|
|
||||||
<string name="wavelog_col_call">Çağrı</string>
|
|
||||||
<string name="wavelog_col_uploaded">Gönderildi</string>
|
|
||||||
<string name="wavelog_ungrouped">Gruplanmamış</string>
|
|
||||||
<string name="prefs_other_title">Diğer ayarlar</string>
|
<string name="prefs_other_title">Diğer ayarlar</string>
|
||||||
<string name="prefs_ui_title">UI Ayarları</string>
|
|
||||||
<string name="prefs_ui_order_title">Sayfa sırası</string>
|
|
||||||
<string name="prefs_ui_main_title">Ana menü (alt çubuk)</string>
|
|
||||||
<string name="prefs_ui_sub_title">Daha fazla menü</string>
|
|
||||||
<string name="prefs_ui_move_out">Daha fazla menüye taşı</string>
|
|
||||||
<string name="prefs_ui_move_back">Ana menüye geri taşı</string>
|
|
||||||
<string name="prefs_ui_order_locked">Sabit (taşınamaz)</string>
|
|
||||||
<string name="prefs_ui_order_reset">Sıralamayı sıfırla</string>
|
|
||||||
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
|
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
|
||||||
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
|
<string name="prefs_other_switch_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_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string>
|
||||||
|
<string name="prefs_other_switch_map_grid">Haritada Maidenhead ızgarasını göster</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
|
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
|
||||||
<string name="prefs_highlight_low">Düşük < %1$d°</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_mid">Orta %1$d-%2$d°</string>
|
||||||
<string name="prefs_highlight_high">Yüksek > %1$d°</string>
|
<string name="prefs_highlight_high">Yüksek > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">BG7NTA ve orijinal yazar teşekkür eder</string>
|
<string name="prefs_outro_title">Teşekkürler</string>
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
• Look4Sat users and contributors!
|
• Look4Sat users and contributors!
|
||||||
\n• David A. B. Johnson (predict4java)
|
\n• David A. B. Johnson (predict4java)
|
||||||
@@ -257,8 +207,7 @@
|
|||||||
\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)
|
\n• Libre Space Foundation (SatNOGS)
|
||||||
\n• BA7OPF (pass matching feature)
|
\n• BG7NTA (CW decoding feature)</string>
|
||||||
\n• BG7NTA</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>
|
||||||
@@ -112,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>
|
||||||
|
|
||||||
@@ -143,6 +150,7 @@
|
|||||||
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
|
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
|
||||||
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
|
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
|
||||||
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
|
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
|
||||||
|
<string name="prefs_other_switch_map_grid">Показувати сітку Maidenhead на мапі</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Підсвічування висоти</string>
|
<string name="prefs_highlight_title">Підсвічування висоти</string>
|
||||||
<string name="prefs_highlight_low">Низько < %1$d°</string>
|
<string name="prefs_highlight_low">Низько < %1$d°</string>
|
||||||
|
|||||||
@@ -10,23 +10,8 @@
|
|||||||
<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_mutual">匹配</string>
|
||||||
<string name="nav_roaming">漫游</string>
|
|
||||||
<string name="nav_cw">CW解码</string>
|
|
||||||
<string name="nav_map">地图</string>
|
<string name="nav_map">地图</string>
|
||||||
<string name="nav_prefs">设置</string>
|
<string name="nav_prefs">设置</string>
|
||||||
<string name="nav_more">更多</string>
|
|
||||||
<string name="nav_log">日志</string>
|
|
||||||
<string name="nav_amsat">AMSAT</string>
|
|
||||||
<string name="amsat_title">AMSAT 卫星状态</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">Name</string>
|
|
||||||
<string name="amsat_load_failed">加载失败,请检查网络后重试</string>
|
|
||||||
<string name="amsat_no_reports">该时段无报告</string>
|
|
||||||
<string name="amsat_close">关闭</string>
|
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">类型:%s</string>
|
<string name="sat_type_hint">类型:%s</string>
|
||||||
@@ -53,14 +38,41 @@
|
|||||||
<string name="pass_error_message">没有即将过境的卫星</string>
|
<string name="pass_error_message">没有即将过境的卫星</string>
|
||||||
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</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>
|
||||||
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
|
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<!-- AMSAT Status screen -->
|
||||||
* 新增: AMSAT 卫星状态页 - 实时状态表格(官网配色)、报告数量、逐卫星/逐日详细报告
|
<string name="nav_amsat">AMSAT</string>
|
||||||
* 日志按场次分组(场次 = 卫星 + AOS 时间), 组间横线分隔
|
<string name="amsat_title">AMSAT 卫星状态</string>
|
||||||
* GPS 真正定位成功才会提示成功(15 秒超时, 权限检查)
|
<string name="amsat_refresh">刷新</string>
|
||||||
* 卫星数据库下载失败时有明确失败提示
|
<string name="amsat_retry">重试</string>
|
||||||
* 日志频率实时跟随多普勒面板; 左滑删除视觉恢复
|
<string name="amsat_updated">更新于:</string>
|
||||||
* 印尼语识别修复(values-id)</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>
|
||||||
@@ -115,36 +127,56 @@
|
|||||||
<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">无法获取位置,请检查定位权限和GPS/网络信号</string>
|
<string name="prefs_loc_gps_error">检查您的位置权限</string>
|
||||||
<string name="prefs_loc_input_title">输入位置</string>
|
<string name="prefs_loc_input_title">输入位置</string>
|
||||||
<string name="prefs_loc_input_error">输入的位置无效</string>
|
<string name="prefs_loc_input_error">输入的位置无效</string>
|
||||||
<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_wavelog_title">Wavelog 已通联网格</string>
|
||||||
|
<string name="prefs_wavelog_configure">配置</string>
|
||||||
|
<string name="prefs_wavelog_configured">已从 Wavelog 同步 — 通联 %1$d 个网格</string>
|
||||||
|
<string name="prefs_wavelog_not_configured">未配置 — 连接你的 Wavelog 日志以在地图上高亮已通联网格</string>
|
||||||
|
<string name="prefs_wavelog_url">Wavelog 地址</string>
|
||||||
|
<string name="prefs_wavelog_token">API key :日志 ID</string>
|
||||||
|
<string name="prefs_wavelog_hint">已同步网格数:%1$d。格式:API key:日志ID。填好直接点同步,会自动保存并拉取。</string>
|
||||||
|
<string name="prefs_wavelog_sync">立即同步</string>
|
||||||
|
<string name="prefs_wavelog_syncing">同步中…</string>
|
||||||
|
<string name="prefs_lotw_title">LoTW 已确认网格</string>
|
||||||
|
<string name="prefs_lotw_callsign">LoTW 呼号</string>
|
||||||
|
<string name="prefs_lotw_password">LoTW 密码</string>
|
||||||
|
<string name="prefs_lotw_hint">直接从 ARRL LoTW 拉取已确认 QSO 的网格并与 Wavelog 网格合并(当前 %1$d 个)。密码仅保存在手机本地。填好直接点同步,会自动保存并拉取。</string>
|
||||||
|
<string name="prefs_lotw_sync">立即同步</string>
|
||||||
|
<string name="prefs_lotw_syncing">同步中…</string>
|
||||||
|
<string name="prefs_lotw_configured">已从 LoTW 同步 — 共 %1$d 个网格</string>
|
||||||
|
<string name="prefs_lotw_not_configured">未配置 — 直接登录 ARRL LoTW 拉取已确认 QSO 的网格,无需自建服务器</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>
|
||||||
<string name="prefs_data_radios">收发器:%s</string>
|
<string name="prefs_data_radios">收发器:%s</string>
|
||||||
<string name="prefs_data_update">在线更新</string>
|
<string name="prefs_data_update">在线更新</string>
|
||||||
<string name="prefs_data_import">自定义URL</string>
|
<string name="prefs_data_import">导入文件</string>
|
||||||
<string name="prefs_data_clear">清空</string>
|
<string name="prefs_data_clear">清空</string>
|
||||||
<string name="prefs_data_clear_success">数据清空成功</string>
|
<string name="prefs_data_clear_success">数据清空成功</string>
|
||||||
<string name="prefs_data_update_success">更新成功</string>
|
<string name="prefs_data_update_success">更新成功</string>
|
||||||
<string name="prefs_data_update_failed">更新失败,请检查网络</string>
|
|
||||||
|
|
||||||
<string name="prefs_data_import_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</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_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>
|
||||||
@@ -158,6 +190,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>
|
||||||
@@ -179,60 +213,20 @@
|
|||||||
<string name="rc_rx_name_hint">接收电台名称</string>
|
<string name="rc_rx_name_hint">接收电台名称</string>
|
||||||
<string name="rc_enable_switch">启用 CAT 控制</string>
|
<string name="rc_enable_switch">启用 CAT 控制</string>
|
||||||
|
|
||||||
<string name="tracking_status">跟踪: %s</string>
|
|
||||||
<string name="loc_lat_text">纬度: %s°</string>
|
|
||||||
<string name="loc_lon_text">经度: %s°</string>
|
|
||||||
<string name="loc_qth_text">QTH: %s</string>
|
|
||||||
<string name="connect_radio">连接</string>
|
|
||||||
<string name="disconnect_radio">断开</string>
|
|
||||||
<string name="track_radio">跟踪</string>
|
|
||||||
<string name="stop_radio">停止</string>
|
|
||||||
<string name="prefs_wavelog_title">WaveLog 日志服务器</string>
|
|
||||||
<string name="prefs_wavelog_url_hint">服务器地址</string>
|
|
||||||
<string name="prefs_wavelog_key_hint">API 密钥</string>
|
|
||||||
<string name="prefs_wavelog_station_hint">站点 ID</string>
|
|
||||||
<string name="prefs_wavelog_auto">自动上传</string>
|
|
||||||
<string name="prefs_wavelog_test">测试连接</string>
|
|
||||||
<string name="prefs_wavelog_upload">手动上传</string>
|
|
||||||
<string name="wavelog_title">日志</string>
|
|
||||||
<string name="wavelog_select_transponder">选择转发器</string>
|
|
||||||
<string name="wavelog_call_hint">对方呼号</string>
|
|
||||||
<string name="wavelog_mode_hint">模式</string>
|
|
||||||
<string name="wavelog_saved">已存入本地日志</string>
|
|
||||||
<string name="wavelog_need_config">请先在设置中配置 WaveLog</string>
|
|
||||||
<string name="wavelog_empty">暂无日志记录</string>
|
|
||||||
<string name="wavelog_grid_mismatch">当前 QTH 网格(%1$s)与站点网格(%2$s)不一致,仍要上传?</string>
|
|
||||||
<string name="wavelog_ignore">忽略并上传</string>
|
|
||||||
<string name="wavelog_cancel">取消</string>
|
|
||||||
<string name="wavelog_copy">复制</string>
|
|
||||||
<string name="wavelog_delete_undo">撤销</string>
|
|
||||||
<string name="wavelog_col_time">时间</string>
|
|
||||||
<string name="wavelog_col_freq">频率</string>
|
|
||||||
<string name="wavelog_col_sat">卫星</string>
|
|
||||||
<string name="wavelog_col_call">呼号</string>
|
|
||||||
<string name="wavelog_col_uploaded">已传</string>
|
|
||||||
<string name="wavelog_ungrouped">未分组</string>
|
|
||||||
<string name="prefs_other_title">其他设置</string>
|
<string name="prefs_other_title">其他设置</string>
|
||||||
<string name="prefs_ui_title">UI设置</string>
|
|
||||||
<string name="prefs_ui_order_title">页面顺序</string>
|
|
||||||
<string name="prefs_ui_main_title">主菜单(底部栏)</string>
|
|
||||||
<string name="prefs_ui_sub_title">更多菜单</string>
|
|
||||||
<string name="prefs_ui_move_out">移入更多菜单</string>
|
|
||||||
<string name="prefs_ui_move_back">移回主菜单</string>
|
|
||||||
<string name="prefs_ui_order_locked">固定(不可移动)</string>
|
|
||||||
<string name="prefs_ui_order_reset">重置顺序</string>
|
|
||||||
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
||||||
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
||||||
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
|
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
|
||||||
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
|
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
|
||||||
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
|
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
|
||||||
|
<string name="prefs_other_switch_map_grid">地图显示 Maidenhead 网格</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">仰角高亮</string>
|
<string name="prefs_highlight_title">仰角高亮</string>
|
||||||
<string name="prefs_highlight_low">低 < %1$d°</string>
|
<string name="prefs_highlight_low">低 < %1$d°</string>
|
||||||
<string name="prefs_highlight_mid">中 %1$d-%2$d°</string>
|
<string name="prefs_highlight_mid">中 %1$d-%2$d°</string>
|
||||||
<string name="prefs_highlight_high">高 > %1$d°</string>
|
<string name="prefs_highlight_high">高 > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">BG7NTA 与原作者感谢:</string>
|
<string name="prefs_outro_title">我要感谢:</string>
|
||||||
<string name="prefs_outro_thanks" translatable="false">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
• Look4Sat 所有用户及贡献者!
|
• Look4Sat 所有用户及贡献者!
|
||||||
\n• David A. B. Johnson (predict4java)
|
\n• David A. B. Johnson (predict4java)
|
||||||
@@ -241,8 +235,51 @@
|
|||||||
\n• Dr T.S. Kelso (Celestrak)
|
\n• Dr T.S. Kelso (Celestrak)
|
||||||
\n• Libre Space Foundation (SatNOGS)
|
\n• Libre Space Foundation (SatNOGS)
|
||||||
\n• xdsopl 和 Robot36 贡献者!
|
\n• xdsopl 和 Robot36 贡献者!
|
||||||
\n• BA7OPF(过境匹配功能)
|
\n• BG7NTA(CW解码功能)</string>
|
||||||
\n• BG7NTA</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 Pro</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>
|
||||||
@@ -11,23 +11,8 @@
|
|||||||
<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_mutual">Match</string>
|
||||||
<string name="nav_roaming">Roaming</string>
|
|
||||||
<string name="nav_cw">CW Decoder</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>
|
||||||
<string name="nav_more">More</string>
|
|
||||||
<string name="nav_log">Log</string>
|
|
||||||
<string name="nav_amsat">AMSAT</string>
|
|
||||||
<string name="amsat_title">AMSAT Satellite Status</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>
|
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Type: %s</string>
|
<string name="sat_type_hint">Type: %s</string>
|
||||||
@@ -66,13 +51,45 @@
|
|||||||
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</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">
|
||||||
* NEW: AMSAT satellite status page - live status grid (official colors), report counts, detailed reports per satellite/day
|
* Added required tweaks to support Android 17 (API 37)
|
||||||
* Log entries grouped by satellite pass (session ID = satellite + AOS time), divider lines between sessions
|
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
||||||
* GPS now only reports success when a real fix is acquired (15s timeout, permission check)
|
</string>
|
||||||
* Data update shows a clear failure message when the satellite database cannot be downloaded
|
|
||||||
* Log frequencies follow the Doppler panel in real time; swipe-to-delete visuals restored
|
<!-- AMSAT Status screen -->
|
||||||
* Indonesian locale fix (values-id)</string>
|
<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>
|
||||||
@@ -129,7 +146,7 @@
|
|||||||
<string name="map_visible">Visible</string>
|
<string name="map_visible">Visible</string>
|
||||||
|
|
||||||
<!-- Settings screen -->
|
<!-- Settings screen -->
|
||||||
<string name="prefs_app_title" translatable="false">Look4Sat Pro 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=com.rtbishop.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>
|
||||||
@@ -147,7 +164,7 @@
|
|||||||
|
|
||||||
<string name="prefs_loc_title">Station position</string>
|
<string name="prefs_loc_title">Station position</string>
|
||||||
<string name="prefs_loc_gps_title">GPS</string>
|
<string name="prefs_loc_gps_title">GPS</string>
|
||||||
<string name="prefs_loc_gps_error">Unable to get location - check permission and GPS/network signal</string>
|
<string name="prefs_loc_gps_error">Check your location permissions</string>
|
||||||
<string name="prefs_loc_input_title">Input</string>
|
<string name="prefs_loc_input_title">Input</string>
|
||||||
<string name="prefs_loc_input_error">Invalid location entered</string>
|
<string name="prefs_loc_input_error">Invalid location entered</string>
|
||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
@@ -157,25 +174,43 @@
|
|||||||
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
|
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
|
||||||
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
|
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
|
||||||
<string name="prefs_locator_title">QTH locator settings</string>
|
<string name="prefs_locator_title">QTH locator settings</string>
|
||||||
|
<string name="prefs_wavelog_title">Wavelog worked grids</string>
|
||||||
|
<string name="prefs_wavelog_configure">Configure</string>
|
||||||
|
<string name="prefs_wavelog_configured">Synced from Wavelog — %1$d grids worked</string>
|
||||||
|
<string name="prefs_wavelog_not_configured">Not configured — connect your Wavelog logbook to highlight worked grids on the map</string>
|
||||||
|
<string name="prefs_wavelog_url">Wavelog URL</string>
|
||||||
|
<string name="prefs_wavelog_token">API key : logbook ID</string>
|
||||||
|
<string name="prefs_wavelog_hint">Worked grids synced: %1$d. Format: API key, colon, logbook ID. Just tap Sync — credentials are saved automatically.</string>
|
||||||
|
<string name="prefs_wavelog_sync">Sync now</string>
|
||||||
|
<string name="prefs_wavelog_syncing">Syncing…</string>
|
||||||
|
<string name="prefs_lotw_title">LoTW confirmed grids</string>
|
||||||
|
<string name="prefs_lotw_callsign">LoTW callsign</string>
|
||||||
|
<string name="prefs_lotw_password">LoTW password</string>
|
||||||
|
<string name="prefs_lotw_hint">Fetch confirmed-QSO grids directly from ARRL LoTW and merge them with Wavelog grids (currently %1$d). The password is stored on this device only. Just tap Sync — credentials are saved automatically.</string>
|
||||||
|
<string name="prefs_lotw_sync">Sync now</string>
|
||||||
|
<string name="prefs_lotw_syncing">Syncing…</string>
|
||||||
|
<string name="prefs_lotw_configured">Synced from LoTW — %1$d grids total</string>
|
||||||
|
<string name="prefs_lotw_not_configured">Not configured — pull confirmed grids directly from ARRL LoTW, no self-hosted server needed</string>
|
||||||
<string name="prefs_locator_text">Set station\'s position using locator</string>
|
<string name="prefs_locator_text">Set station\'s position using locator</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Satellite data</string>
|
<string name="prefs_data_title">Satellite data</string>
|
||||||
<string name="prefs_data_entries">Satellites: %s</string>
|
<string name="prefs_data_entries">Satellites: %s</string>
|
||||||
<string name="prefs_data_radios">Transceivers: %s</string>
|
<string name="prefs_data_radios">Transceivers: %s</string>
|
||||||
<string name="prefs_data_update">Update</string>
|
<string name="prefs_data_update">Update</string>
|
||||||
<string name="prefs_data_import">Custom URL</string>
|
<string name="prefs_data_import">Import</string>
|
||||||
<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_update_failed">Update failed - check your network</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_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_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_reset_url">Reset to default URL</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>
|
||||||
@@ -199,6 +234,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>
|
||||||
@@ -210,60 +247,20 @@
|
|||||||
<string name="prefs_bt_perm_error">Check your bluetooth permission</string>
|
<string name="prefs_bt_perm_error">Check your bluetooth permission</string>
|
||||||
<string name="prefs_net_perm_error">Check your network permission</string>
|
<string name="prefs_net_perm_error">Check your network permission</string>
|
||||||
|
|
||||||
<string name="tracking_status">Tracking: %s</string>
|
|
||||||
<string name="loc_lat_text">Lat: %s°</string>
|
|
||||||
<string name="loc_lon_text">Lon: %s°</string>
|
|
||||||
<string name="loc_qth_text">Qth: %s</string>
|
|
||||||
<string name="connect_radio">Connect</string>
|
|
||||||
<string name="disconnect_radio">Disconnect</string>
|
|
||||||
<string name="track_radio">Track</string>
|
|
||||||
<string name="stop_radio">Stop</string>
|
|
||||||
<string name="prefs_wavelog_title">WaveLog log server</string>
|
|
||||||
<string name="prefs_wavelog_url_hint">Server address</string>
|
|
||||||
<string name="prefs_wavelog_key_hint">API key</string>
|
|
||||||
<string name="prefs_wavelog_station_hint">Station ID</string>
|
|
||||||
<string name="prefs_wavelog_auto">Auto upload</string>
|
|
||||||
<string name="prefs_wavelog_test">Test connection</string>
|
|
||||||
<string name="prefs_wavelog_upload">Upload now</string>
|
|
||||||
<string name="wavelog_title">Log</string>
|
|
||||||
<string name="wavelog_select_transponder">Select transponder</string>
|
|
||||||
<string name="wavelog_call_hint">Callsign</string>
|
|
||||||
<string name="wavelog_mode_hint">Mode</string>
|
|
||||||
<string name="wavelog_saved">Saved to local log</string>
|
|
||||||
<string name="wavelog_need_config">Configure WaveLog in Settings first</string>
|
|
||||||
<string name="wavelog_empty">No log entries yet</string>
|
|
||||||
<string name="wavelog_grid_mismatch">QTH grid (%1$s) does not match station grid (%2$s). Upload anyway?</string>
|
|
||||||
<string name="wavelog_ignore">Ignore and upload</string>
|
|
||||||
<string name="wavelog_cancel">Cancel</string>
|
|
||||||
<string name="wavelog_copy">Copy</string>
|
|
||||||
<string name="wavelog_delete_undo">Undo</string>
|
|
||||||
<string name="wavelog_col_time">Time</string>
|
|
||||||
<string name="wavelog_col_freq">Freq</string>
|
|
||||||
<string name="wavelog_col_sat">Satellite</string>
|
|
||||||
<string name="wavelog_col_call">Call</string>
|
|
||||||
<string name="wavelog_col_uploaded">Sent</string>
|
|
||||||
<string name="wavelog_ungrouped">Ungrouped</string>
|
|
||||||
<string name="prefs_other_title">Other settings</string>
|
<string name="prefs_other_title">Other settings</string>
|
||||||
<string name="prefs_ui_title">UI Settings</string>
|
|
||||||
<string name="prefs_ui_order_title">Page order</string>
|
|
||||||
<string name="prefs_ui_main_title">Main menu (bottom bar)</string>
|
|
||||||
<string name="prefs_ui_sub_title">More menu</string>
|
|
||||||
<string name="prefs_ui_move_out">Move to more menu</string>
|
|
||||||
<string name="prefs_ui_move_back">Move back to main menu</string>
|
|
||||||
<string name="prefs_ui_order_locked">Fixed (cannot be moved)</string>
|
|
||||||
<string name="prefs_ui_order_reset">Reset order</string>
|
|
||||||
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
|
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
|
||||||
<string name="prefs_other_switch_update">Enable automatic data update</string>
|
<string name="prefs_other_switch_update">Enable automatic data update</string>
|
||||||
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
|
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
|
||||||
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
|
<string name="prefs_other_switch_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_other_switch_map_grid">Show Maidenhead grid on map</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Elevation highlight</string>
|
<string name="prefs_highlight_title">Elevation highlight</string>
|
||||||
<string name="prefs_highlight_low">Low < %1$d°</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_mid">Mid %1$d–%2$d°</string>
|
||||||
<string name="prefs_highlight_high">High > %1$d°</string>
|
<string name="prefs_highlight_high">High > %1$d°</string>
|
||||||
|
|
||||||
<string name="prefs_outro_title">BG7NTA & the original author would like to thank</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!
|
||||||
\n* David A. B. Johnson (predict4java)
|
\n* David A. B. Johnson (predict4java)
|
||||||
@@ -272,9 +269,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* BA7OPF (pass matching feature)
|
\n* BG7NTA (CW decoding feature)
|
||||||
\n* BG7NTA
|
|
||||||
</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>
|
||||||
@@ -181,7 +181,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
|
|||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.cw_mic_permission),
|
text = "Microphone permission is required for CW decoding",
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.padding(horizontal = 24.dp)
|
modifier = Modifier.padding(horizontal = 24.dp)
|
||||||
@@ -190,7 +190,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
|
|||||||
Button(onClick = {
|
Button(onClick = {
|
||||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
}) {
|
}) {
|
||||||
Text(stringResource(R.string.cw_grant_permission))
|
Text("Grant permission")
|
||||||
}
|
}
|
||||||
if (permanentlyDenied) {
|
if (permanentlyDenied) {
|
||||||
// 勾选了"不再询问": 引导去系统设置开启
|
// 勾选了"不再询问": 引导去系统设置开启
|
||||||
@@ -202,7 +202,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}) {
|
}) {
|
||||||
Text(stringResource(R.string.cw_open_settings))
|
Text("Open app settings")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ public class MainActivity {
|
|||||||
public void onResume() {
|
public void onResume() {
|
||||||
android.content.SharedPreferences prefs = mActivity.getSharedPreferences(
|
android.content.SharedPreferences prefs = mActivity.getSharedPreferences(
|
||||||
mActivity.getPackageName() + "_preferences", Context.MODE_PRIVATE);
|
mActivity.getPackageName() + "_preferences", Context.MODE_PRIVATE);
|
||||||
String messageType = prefs.getString("message_type", "general_text");
|
String messageType = prefs.getString("message_type", "ham_radio_qso");
|
||||||
b bVar = this.f11037F;
|
b bVar = this.f11037F;
|
||||||
if (bVar != null) {
|
if (bVar != null) {
|
||||||
bVar.f623s.set(messageType.equals("ham_radio_qso"));
|
bVar.f623s.set(messageType.equals("ham_radio_qso"));
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ package g3;
|
|||||||
public abstract class d {
|
public abstract class d {
|
||||||
|
|
||||||
/* renamed from: a, reason: collision with root package name */
|
/* renamed from: a, reason: collision with root package name */
|
||||||
public static final double[][] f11591a = {new double[]{0.0d, 0.0d}, new double[]{9.760860120877624E-4d, -3.903230345984362E-11d}, new double[]{0.0019512202125042677d, -8.124251825289188E-11d}, new double[]{0.0029254043474793434d, -1.8374207360194882E-11d}, new double[]{0.0038986406289041042d, -2.1324678121885073E-10d}, new double[]{0.004870930686593056d, -4.5199654318611534E-10d}, new double[]{0.00584227591753006d, -2.933016992001806E-10d}, new double[]{0.006812678650021553d, -2.325147219074669E-10d}, new double[]{0.007782140746712685d, -3.046577356838847E-10d}, new double[]{0.008750664070248604d, -5.500631513861575E-10d}, new double[]{0.00971824862062931d, 8.48292035519895E-10d}, new double[]{0.010684899985790253d, 1.1422610134013436E-10d}, new double[]{0.01165061630308628d, 9.168889933128375E-10d}, new double[]{0.012615403160452843d, -5.303786078838E-10d}, new double[]{0.013579258695244789d, -5.688639355498786E-10d}, new double[]{0.01454218477010727d, 7.296670293275653E-10d}, new double[]{0.015504186972975731d, -4.370104767451421E-10d}, new double[]{0.016465261578559875d, 1.43695591408832E-9d}, new double[]{0.01742541790008545d, -1.1862263158849434E-9d}, new double[]{0.018384650349617004d, -9.482976524690715E-10d}, new double[]{0.01934296265244484d, 1.9068609515836638E-10d}, new double[]{0.020300358533859253d, 2.655990315697216E-10d}, new double[]{0.021256837993860245d, 1.0315548713040775E-9d}, new double[]{0.022212404757738113d, 5.13345647019085E-10d}, new double[]{0.02316705882549286d, 4.5604151934208014E-10d}, new double[]{0.02412080392241478d, -1.1255706987475148E-9d}, new double[]{0.025073636323213577d, 1.2289023836765196E-9d}, new double[]{0.02602556347846985d, 1.7990281828096504E-9d}, new double[]{0.026976589113473892d, -1.4152718164638451E-9d}, new double[]{0.02792670577764511d, 7.568772963781632E-10d}, new double[]{0.0288759246468544d, -1.1449998592111558E-9d}, new double[]{0.029824241995811462d, -1.6850976862319495E-9d}, new double[]{0.030771657824516296d, 8.422373919843096E-10d}, new double[]{0.0317181795835495d, 6.872350402175489E-10d}, new double[]{0.03266380727291107d, -4.541194749189272E-10d}, new double[]{0.03360854089260101d, -8.9064764856495E-10d}, new double[]{0.034552380442619324d, 1.0640404096769032E-9d}, new double[]{0.0354953333735466d, -3.5901655945224663E-10d}, new double[]{0.03643739968538284d, -3.4829517943661266E-9d}, new double[]{0.037378571927547455d, 8.149473794244232E-10d}, new double[]{0.03831886500120163d, -6.990650304449166E-10d}, new double[]{0.03925827145576477d, 1.0883076226453258E-9d}, new double[]{0.040196798741817474d, 3.845192807999274E-10d}, new double[]{0.04113444685935974d, -1.1570594692045927E-9d}, new double[]{0.04207121580839157d, -1.8877045166697178E-9d}, new double[]{0.043007105588912964d, -1.6332083257987747E-10d}, new double[]{0.04394212365150452d, -1.7950057534514933E-9d}, new double[]{0.04487626254558563d, 2.302710041648838E-9d}, new double[]{0.045809537172317505d, -1.1410233017161343E-9d}, new double[]{0.04674194008111954d, -3.0498741599744685E-9d}, new double[]{0.04767347127199173d, -1.8026348269183678E-9d}, new double[]{0.04860413819551468d, -3.233204600453039E-9d}, new double[]{0.04953393340110779d, 1.7211688427961583E-9d}, new double[]{0.05046287178993225d, -2.329967807055457E-10d}, new double[]{0.05139094591140747d, -4.191810118556531E-11d}, new double[]{0.052318163216114044d, -3.5574324788328143E-9d}, new double[]{0.053244516253471375d, -1.7346590916458485E-9d}, new double[]{0.05417001247406006d, -4.343048751383674E-10d}, new double[]{0.055094651877880096d, 1.92909364037955E-9d}, new double[]{0.056018441915512085d, -5.139745677199588E-10d}, new double[]{0.05694137513637543d, 1.2637629975129189E-9d}, new double[]{0.05786345899105072d, 1.3840561112481119E-9d}, new double[]{0.058784693479537964d, 1.414889689612056E-9d}, new double[]{0.05970507860183716d, 2.9199191907666474E-9d}, new double[]{0.0606246218085289d, 7.90594243412116E-12d}, new double[]{0.06154331564903259d, 1.6844747839686189E-9d}, new double[]{0.06246116757392883d, 2.0498074572151747E-9d}, new double[]{0.06337818503379822d, -4.800180493433863E-9d}, new double[]{0.06429435312747955d, -2.4220822960064277E-9d}, new double[]{0.06520968675613403d, -4.179048566709334E-9d}, new double[]{0.06612417101860046d, 6.363872957010456E-9d}, new double[]{0.06703783571720123d, 9.339468680056365E-10d}, new double[]{0.06795066595077515d, -4.04226739708981E-9d}, new double[]{0.0688626617193222d, -7.043545052852817E-9d}, new double[]{0.06977382302284241d, -6.552819560439773E-9d}, new double[]{0.07068414986133575d, -1.0571674860370546E-9d}, new double[]{0.07159365713596344d, -3.948954622015801E-9d}, new double[]{0.07250232994556427d, 1.1776625988228244E-9d}, new double[]{0.07341018319129944d, 9.221072639606492E-10d}, new double[]{0.07431721687316895d, -3.219119568928366E-9d}, new double[]{0.0752234160900116d, 5.147575929018918E-9d}, new double[]{0.07612881064414978d, -2.291749683541979E-9d}, new doubleLine truncated
|
public static final double[][] f11591a = {new double[]{0.0d, 0.0d}, new double[]{9.760860120877624E-4d, -3.903230345984362E-11d}, new double[]{0.0019512202125042677d, -8.124251825289188E-11d}, new double[]{0.0029254043474793434d, -1.8374207360194882E-11d}, new double[]{0.0038986406289041042d, -2.1324678121885073E-10d}, new double[]{0.004870930686593056d, -4.5199654318611534E-10d}, new double[]{0.00584227591753006d, -2.933016992001806E-10d}, new double[]{0.006812678650021553d, -2.325147219074669E-10d}, new double[]{0.007782140746712685d, -3.046577356838847E-10d}, new double[]{0.008750664070248604d, -5.500631513861575E-10d}, new double[]{0.00971824862062931d, 8.48292035519895E-10d}, new double[]{0.010684899985790253d, 1.1422610134013436E-10d}, new double[]{0.01165061630308628d, 9.168889933128375E-10d}, new double[]{0.012615403160452843d, -5.303786078838E-10d}, new double[]{0.013579258695244789d, -5.688639355498786E-10d}, new double[]{0.01454218477010727d, 7.296670293275653E-10d}, new double[]{0.015504186972975731d, -4.370104767451421E-10d}, new double[]{0.016465261578559875d, 1.43695591408832E-9d}, new double[]{0.01742541790008545d, -1.1862263158849434E-9d}, new double[]{0.018384650349617004d, -9.482976524690715E-10d}, new double[]{0.01934296265244484d, 1.9068609515836638E-10d}, new double[]{0.020300358533859253d, 2.655990315697216E-10d}, new double[]{0.021256837993860245d, 1.0315548713040775E-9d}, new double[]{0.022212404757738113d, 5.13345647019085E-10d}, new double[]{0.02316705882549286d, 4.5604151934208014E-10d}, new double[]{0.02412080392241478d, -1.1255706987475148E-9d}, new double[]{0.025073636323213577d, 1.2289023836765196E-9d}, new double[]{0.02602556347846985d, 1.7990281828096504E-9d}, new double[]{0.026976589113473892d, -1.4152718164638451E-9d}, new double[]{0.02792670577764511d, 7.568772963781632E-10d}, new double[]{0.0288759246468544d, -1.1449998592111558E-9d}, new double[]{0.029824241995811462d, -1.6850976862319495E-9d}, new double[]{0.030771657824516296d, 8.422373919843096E-10d}, new double[]{0.0317181795835495d, 6.872350402175489E-10d}, new double[]{0.03266380727291107d, -4.541194749189272E-10d}, new double[]{0.03360854089260101d, -8.9064764856495E-10d}, new double[]{0.034552380442619324d, 1.0640404096769032E-9d}, new double[]{0.0354953333735466d, -3.5901655945224663E-10d}, new double[]{0.03643739968538284d, -3.4829517943661266E-9d}, new double[]{0.037378571927547455d, 8.149473794244232E-10d}, new double[]{0.03831886500120163d, -6.990650304449166E-10d}, new double[]{0.03925827145576477d, 1.0883076226453258E-9d}, new double[]{0.040196798741817474d, 3.845192807999274E-10d}, new double[]{0.04113444685935974d, -1.1570594692045927E-9d}, new double[]{0.04207121580839157d, -1.8877045166697178E-9d}, new double[]{0.043007105588912964d, -1.6332083257987747E-10d}, new double[]{0.04394212365150452d, -1.7950057534514933E-9d}, new double[]{0.04487626254558563d, 2.302710041648838E-9d}, new double[]{0.045809537172317505d, -1.1410233017161343E-9d}, new double[]{0.04674194008111954d, -3.0498741599744685E-9d}, new double[]{0.04767347127199173d, -1.8026348269183678E-9d}, new double[]{0.04860413819551468d, -3.233204600453039E-9d}, new double[]{0.04953393340110779d, 1.7211688427961583E-9d}, new double[]{0.05046287178993225d, -2.329967807055457E-10d}, new double[]{0.05139094591140747d, -4.191810118556531E-11d}, new double[]{0.052318163216114044d, -3.5574324788328143E-9d}, new double[]{0.053244516253471375d, -1.7346590916458485E-9d}, new double[]{0.05417001247406006d, -4.343048751383674E-10d}, new double[]{0.055094651877880096d, 1.92909364037955E-9d}, new double[]{0.056018441915512085d, -5.139745677199588E-10d}, new double[]{0.05694137513637543d, 1.2637629975129189E-9d}, new double[]{0.05786345899105072d, 1.3840561112481119E-9d}, new double[]{0.058784693479537964d, 1.414889689612056E-9d}, new double[]{0.05970507860183716d, 2.9199191907666474E-9d}, new double[]{0.0606246218085289d, 7.90594243412116E-12d}, new double[]{0.06154331564903259d, 1.6844747839686189E-9d}, new double[]{0.06246116757392883d, 2.0498074572151747E-9d}, new double[]{0.06337818503379822d, -4.800180493433863E-9d}, new double[]{0.06429435312747955d, -2.4220822960064277E-9d}, new double[]{0.06520968675613403d, -4.179048566709334E-9d}, new double[]{0.06612417101860046d, 6.363872957010456E-9d}, new double[]{0.06703783571720123d, 9.339468680056365E-10d}, new double[]{0.06795066595077515d, -4.04226739708981E-9d}, new double[]{0.0688626617193222d, -7.043545052852817E-9d}, new double[]{0.06977382302284241d, -6.552819560439773E-9d}, new double[]{0.07068414986133575d, -1.0571674860370546E-9d}, new double[]{0.07159365713596344d, -3.948954622015801E-9d}, new double[]{0.07250232994556427d, 1.1776625988228244E-9d}, new double[]{0.07341018319129944d, 9.221072639606492E-10d}, new double[]{0.07431721687316895d, -3.219119568928366E-9d}, new double[]{0.0752234160900116d, 5.147575929018918E-9d}, new double[]{0.07612881064414978d, -2.291749683541979E-9d}, new doubleLine truncated
|
||||||
new double[]{0.6813591718673706d, 5.294053246347931E-8d}, new double[]{0.681853175163269d, -1.2220826223585654E-9d}, new double[]{0.6823468208312988d, 5.8377876767612725E-8d}, new double[]{0.6828403472900391d, -6.437492120743254E-9d}, new double[]{0.6833335161209106d, 4.2990710043633113E-8d}, new double[]{0.6838265657424927d, -3.1516131027023284E-8d}, new double[]{0.684319257736206d, 8.70017386744679E-9d}, new double[]{0.6848117113113403d, 4.466959125843237E-8d}, new double[]{0.6853040456771851d, -4.25782656420497E-8d}, new double[]{0.6857960224151611d, -1.4386267593671393E-8d}, new double[]{0.6862877607345581d, 1.0274494061148778E-8d}, new double[]{0.686779260635376d, 3.164186629229597E-8d}, new double[]{0.6872705221176147d, 4.995334552140326E-8d}, new double[]{0.687761664390564d, -5.3763211240398744E-8d}, new double[]{0.6882524490356445d, -4.0852427502515625E-8d}, new double[]{0.688742995262146d, -3.0287143914420064E-8d}, new double[]{0.6892333030700684d, -2.183125937905008E-8d}, new double[]{0.6897233724594116d, -1.524901992178814E-8d}, new double[]{0.6902132034301758d, -1.0305018010328949E-8d}, new double[]{0.6907027959823608d, -6.764191876212205E-9d}, new double[]{0.6911921501159668d, -4.391824838015402E-9d}, new double[]{0.6916812658309937d, -2.9535446262017846E-9d}, new double[]{0.6921701431274414d, -2.2153227096187463E-9d}, new double[]{0.6926587820053101d, -1.943473623641502E-9d}};
|
new double[]{0.6813591718673706d, 5.294053246347931E-8d}, new double[]{0.681853175163269d, -1.2220826223585654E-9d}, new double[]{0.6823468208312988d, 5.8377876767612725E-8d}, new double[]{0.6828403472900391d, -6.437492120743254E-9d}, new double[]{0.6833335161209106d, 4.2990710043633113E-8d}, new double[]{0.6838265657424927d, -3.1516131027023284E-8d}, new double[]{0.684319257736206d, 8.70017386744679E-9d}, new double[]{0.6848117113113403d, 4.466959125843237E-8d}, new double[]{0.6853040456771851d, -4.25782656420497E-8d}, new double[]{0.6857960224151611d, -1.4386267593671393E-8d}, new double[]{0.6862877607345581d, 1.0274494061148778E-8d}, new double[]{0.686779260635376d, 3.164186629229597E-8d}, new double[]{0.6872705221176147d, 4.995334552140326E-8d}, new double[]{0.687761664390564d, -5.3763211240398744E-8d}, new double[]{0.6882524490356445d, -4.0852427502515625E-8d}, new double[]{0.688742995262146d, -3.0287143914420064E-8d}, new double[]{0.6892333030700684d, -2.183125937905008E-8d}, new double[]{0.6897233724594116d, -1.524901992178814E-8d}, new double[]{0.6902132034301758d, -1.0305018010328949E-8d}, new double[]{0.6907027959823608d, -6.764191876212205E-9d}, new double[]{0.6911921501159668d, -4.391824838015402E-9d}, new double[]{0.6916812658309937d, -2.9535446262017846E-9d}, new double[]{0.6921701431274414d, -2.2153227096187463E-9d}, new double[]{0.6926587820053101d, -1.943473623641502E-9d}};
|
||||||
}
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="purple_500">#ff6200ee</color>
|
|
||||||
<integer name="default_font_size">18</integer>
|
|
||||||
<string name="idle">Diam</string>
|
|
||||||
<string name="waterfall_display">Tampilan waterfall</string>
|
|
||||||
<string name="frequency_scale">Skala frekuensi</string>
|
|
||||||
<string name="decoded_text">Teks terdekode</string>
|
|
||||||
<string name="clear">Bersihkan</string>
|
|
||||||
<string name="pause">Jeda Dekode</string>
|
|
||||||
<string name="save_text">Simpan Teks</string>
|
|
||||||
<string name="record_signals">Rekam Sinyal</string>
|
|
||||||
<string name="settings">Pengaturan</string>
|
|
||||||
<string name="help">Bantuan Daring</string>
|
|
||||||
<string name="rate">Nilai Aplikasi Ini</string>
|
|
||||||
<string name="premium">Dapatkan Versi Premium</string>
|
|
||||||
<string name="tap_back_again_to_close">Ketuk Kembali lagi untuk menutup aplikasi</string>
|
|
||||||
<string name="message_type">Tipe Pesan</string>
|
|
||||||
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk dekode CW</string>
|
|
||||||
<string name="cw_grant_permission">Beri izin</string>
|
|
||||||
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="purple_500">#ff6200ee</color>
|
|
||||||
<integer name="default_font_size">18</integer>
|
|
||||||
<string name="idle">Diam</string>
|
|
||||||
<string name="waterfall_display">Tampilan waterfall</string>
|
|
||||||
<string name="frequency_scale">Skala frekuensi</string>
|
|
||||||
<string name="decoded_text">Teks terdekode</string>
|
|
||||||
<string name="clear">Bersihkan</string>
|
|
||||||
<string name="pause">Jeda Dekode</string>
|
|
||||||
<string name="save_text">Simpan Teks</string>
|
|
||||||
<string name="record_signals">Rekam Sinyal</string>
|
|
||||||
<string name="settings">Pengaturan</string>
|
|
||||||
<string name="help">Bantuan Daring</string>
|
|
||||||
<string name="rate">Nilai Aplikasi Ini</string>
|
|
||||||
<string name="premium">Dapatkan Versi Premium</string>
|
|
||||||
<string name="tap_back_again_to_close">Ketuk Kembali lagi untuk menutup aplikasi</string>
|
|
||||||
<string name="message_type">Tipe Pesan</string>
|
|
||||||
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk dekode CW</string>
|
|
||||||
<string name="cw_grant_permission">Beri izin</string>
|
|
||||||
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="purple_500">#ff6200ee</color>
|
|
||||||
<integer name="default_font_size">18</integer>
|
|
||||||
<string name="idle">Diam</string>
|
|
||||||
<string name="waterfall_display">Tampilan waterfall</string>
|
|
||||||
<string name="frequency_scale">Skala frekuensi</string>
|
|
||||||
<string name="decoded_text">Teks terdekode</string>
|
|
||||||
<string name="clear">Bersihkan</string>
|
|
||||||
<string name="pause">Jeda Dekode</string>
|
|
||||||
<string name="save_text">Simpan Teks</string>
|
|
||||||
<string name="record_signals">Rekam Sinyal</string>
|
|
||||||
<string name="settings">Pengaturan</string>
|
|
||||||
<string name="help">Bantuan Daring</string>
|
|
||||||
<string name="rate">Nilai Aplikasi Ini</string>
|
|
||||||
<string name="premium">Dapatkan Versi Premium</string>
|
|
||||||
<string name="tap_back_again_to_close">Ketuk Kembali lagi untuk menutup aplikasi</string>
|
|
||||||
<string name="message_type">Tipe Pesan</string>
|
|
||||||
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk dekode CW</string>
|
|
||||||
<string name="cw_grant_permission">Beri izin</string>
|
|
||||||
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="purple_500">#ff6200ee</color>
|
|
||||||
<integer name="default_font_size">18</integer>
|
|
||||||
<string name="idle">Beklemede</string>
|
|
||||||
<string name="waterfall_display">Şelale görüntüsü</string>
|
|
||||||
<string name="frequency_scale">Frekans ölçeği</string>
|
|
||||||
<string name="decoded_text">Çözülen metin</string>
|
|
||||||
<string name="clear">Temizle</string>
|
|
||||||
<string name="pause">Çözmeyi Duraklat</string>
|
|
||||||
<string name="save_text">Metni Kaydet</string>
|
|
||||||
<string name="record_signals">Sinyalleri Kaydet</string>
|
|
||||||
<string name="settings">Ayarlar</string>
|
|
||||||
<string name="help">Çevrimiçi Yardım</string>
|
|
||||||
<string name="rate">Bu Uygulamayı Değerlendir</string>
|
|
||||||
<string name="premium">Premium Sürümü Al</string>
|
|
||||||
<string name="tap_back_again_to_close">Kapatmak için Geriye tekrar dokunun</string>
|
|
||||||
<string name="message_type">Mesaj Türü</string>
|
|
||||||
<string name="cw_mic_permission">CW çözme için mikrofon izni gereklidir</string>
|
|
||||||
<string name="cw_grant_permission">İzin ver</string>
|
|
||||||
<string name="cw_open_settings">Uygulama ayarlarını aç</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="purple_500">#ff6200ee</color>
|
|
||||||
<integer name="default_font_size">18</integer>
|
|
||||||
<string name="idle">空闲</string>
|
|
||||||
<string name="waterfall_display">瀑布图</string>
|
|
||||||
<string name="frequency_scale">频率刻度</string>
|
|
||||||
<string name="decoded_text">解码文本</string>
|
|
||||||
<string name="clear">清除</string>
|
|
||||||
<string name="pause">暂停解码</string>
|
|
||||||
<string name="save_text">保存文本</string>
|
|
||||||
<string name="record_signals">录音信号</string>
|
|
||||||
<string name="settings">设置</string>
|
|
||||||
<string name="help">在线帮助</string>
|
|
||||||
<string name="rate">评价此应用</string>
|
|
||||||
<string name="premium">获取高级版</string>
|
|
||||||
<string name="tap_back_again_to_close">再次按返回键退出应用</string>
|
|
||||||
<string name="message_type">消息类型</string>
|
|
||||||
<string name="cw_mic_permission">CW 解码需要麦克风权限</string>
|
|
||||||
<string name="cw_grant_permission">授予权限</string>
|
|
||||||
<string name="cw_open_settings">打开应用设置</string>
|
|
||||||
</resources>
|
|
||||||
@@ -16,7 +16,4 @@
|
|||||||
<string name="premium">Get Premium Version</string>
|
<string name="premium">Get Premium Version</string>
|
||||||
<string name="tap_back_again_to_close">Tap Back again to close the app</string>
|
<string name="tap_back_again_to_close">Tap Back again to close the app</string>
|
||||||
<string name="message_type">Message Type</string>
|
<string name="message_type">Message Type</string>
|
||||||
<string name="cw_mic_permission">Microphone permission is required for CW decoding</string>
|
|
||||||
<string name="cw_grant_permission">Grant permission</string>
|
|
||||||
<string name="cw_open_settings">Open app settings</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
/*
|
||||||
|
* 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.feature.map
|
||||||
|
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import org.osmdroid.views.MapView
|
||||||
|
import org.osmdroid.views.Projection
|
||||||
|
import org.osmdroid.views.overlay.Overlay
|
||||||
|
import kotlin.math.ceil
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maidenhead locator grid overlay, drawn directly on the map canvas.
|
||||||
|
*
|
||||||
|
* Zoom levels (map maxZoom = 7):
|
||||||
|
* - zoom < GRID_ZOOM_SUB: 10° x 20° fields with 2-character labels (e.g. "PM")
|
||||||
|
* - zoom >= GRID_ZOOM_SUB: 1° x 2° squares with 4-character labels (e.g. "PM95")
|
||||||
|
*
|
||||||
|
* All geometry is computed per-frame from the projection, so the overlay
|
||||||
|
* stays correct while panning/zooming. Grid lines that cross the antimeridian
|
||||||
|
* are drawn in segments clamped to the visible bounding box.
|
||||||
|
*/
|
||||||
|
class MaidenheadGridOverlay : Overlay() {
|
||||||
|
|
||||||
|
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
strokeWidth = 1.5f
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
color = Color.argb(160, 255, 224, 130)
|
||||||
|
}
|
||||||
|
private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
textSize = 26f
|
||||||
|
style = android.graphics.Paint.Style.FILL
|
||||||
|
color = Color.argb(220, 255, 224, 130)
|
||||||
|
setShadowLayer(3f, 2f, 2f, Color.BLACK)
|
||||||
|
}
|
||||||
|
private val workedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
style = android.graphics.Paint.Style.FILL
|
||||||
|
color = Color.argb(90, 76, 217, 100)
|
||||||
|
}
|
||||||
|
private val ownLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
strokeWidth = 4.5f
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
color = Color.argb(255, 90, 200, 255)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Worked gridsquares (4-char, uppercase) to highlight, e.g. {"OL62", "PM95"}. */
|
||||||
|
var workedGrids: Set<String> = emptySet()
|
||||||
|
|
||||||
|
/** The station's own 4-char gridsquare, drawn with a distinct outline. */
|
||||||
|
var ownGrid: String? = null
|
||||||
|
|
||||||
|
/** Viewport width, refreshed each draw; used by projectionToX bounds. */
|
||||||
|
private var canvasWidthPx = 1080f
|
||||||
|
|
||||||
|
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
|
||||||
|
if (shadow || !isEnabled) return
|
||||||
|
val projection = mapView.projection
|
||||||
|
val zoom = mapView.zoomLevelDouble
|
||||||
|
canvasWidthPx = canvas.width.toFloat()
|
||||||
|
val cellLat = if (zoom >= GRID_ZOOM_SUB) SUB_SQUARE_LAT else FIELD_LAT
|
||||||
|
val cellLon = if (zoom >= GRID_ZOOM_SUB) SUB_SQUARE_LON else FIELD_LON
|
||||||
|
|
||||||
|
// Visible bounding box in geographic coordinates
|
||||||
|
val north = projection.fromPixels(0, 0)
|
||||||
|
val south = projection.fromPixels(canvas.width, canvas.height)
|
||||||
|
val topLat = max(north.latitude, south.latitude).coerceIn(-90.0, 90.0)
|
||||||
|
val bottomLat = min(north.latitude, south.latitude).coerceIn(-90.0, 90.0)
|
||||||
|
val leftLon = min(north.longitude, south.longitude)
|
||||||
|
val rightLon = max(north.longitude, south.longitude)
|
||||||
|
|
||||||
|
val firstRow = floor(bottomLat / cellLat).toInt()
|
||||||
|
val lastRow = ceil(topLat / cellLat).toInt()
|
||||||
|
// Longitude cell indices may exceed the [-180, 180) range when the view
|
||||||
|
// crosses the antimeridian; index arithmetic still works (PMxx at 360° == same grid)
|
||||||
|
val firstCol = floor(leftLon / cellLon).toInt()
|
||||||
|
val lastCol = ceil(rightLon / cellLon).toInt()
|
||||||
|
val centerLon = (leftLon + rightLon) / 2.0
|
||||||
|
|
||||||
|
// The station's own grid square (4-char, only meaningful at sub-square zoom)
|
||||||
|
val ownCell = ownGrid?.takeIf { zoom >= GRID_ZOOM_SUB }
|
||||||
|
|
||||||
|
// Worked-grid highlight fills (only meaningful at sub-square zoom)
|
||||||
|
if (workedGrids.isNotEmpty() && zoom >= GRID_ZOOM_SUB) {
|
||||||
|
for (row in firstRow..lastRow) {
|
||||||
|
val lat = row * cellLat
|
||||||
|
if (lat < -90.0 || lat >= 90.0) continue
|
||||||
|
val topLatCell = lat + cellLat
|
||||||
|
if (topLatCell > 90.0) continue
|
||||||
|
val yTop = projectionToY(projection, topLatCell)
|
||||||
|
val yBottom = projectionToY(projection, lat)
|
||||||
|
if (yTop == null || yBottom == null) continue
|
||||||
|
for (col in firstCol..lastCol) {
|
||||||
|
val lon = col * cellLon
|
||||||
|
val xLeft = projectionToX(projection, lon, centerLon) ?: continue
|
||||||
|
val xRight = projectionToX(projection, lon + cellLon, centerLon) ?: continue
|
||||||
|
if (xRight < 0f || xLeft > canvas.width) continue
|
||||||
|
if (cellLabel(lat, lon, zoom) in workedGrids) {
|
||||||
|
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical lines (meridians)
|
||||||
|
for (col in firstCol..lastCol) {
|
||||||
|
val lon = col * cellLon
|
||||||
|
val x = projectionToX(projection, lon, centerLon)
|
||||||
|
if (x == null) continue
|
||||||
|
canvas.drawLine(x, 0f, x, canvas.height.toFloat(), linePaint)
|
||||||
|
}
|
||||||
|
// Horizontal lines (parallels)
|
||||||
|
for (row in firstRow..lastRow) {
|
||||||
|
val lat = row * cellLat
|
||||||
|
if (lat <= -90.0 || lat >= 90.0) continue
|
||||||
|
val y = projectionToY(projection, lat)
|
||||||
|
if (y == null) continue
|
||||||
|
canvas.drawLine(0f, y, canvas.width.toFloat(), y, linePaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The station's own grid square: redraw its four borders thicker on top.
|
||||||
|
if (ownCell != null) {
|
||||||
|
val ownColIdx = firstCol..lastCol
|
||||||
|
for (row in firstRow..lastRow) {
|
||||||
|
val lat = row * cellLat
|
||||||
|
if (lat < -90.0 || lat >= 90.0) continue
|
||||||
|
for (col in ownColIdx) {
|
||||||
|
val lon = col * cellLon
|
||||||
|
if (cellLabel(lat, lon, zoom) != ownCell) continue
|
||||||
|
val yTop = projectionToY(projection, lat + cellLat) ?: continue
|
||||||
|
val yBottom = projectionToY(projection, lat) ?: continue
|
||||||
|
val xLeft = projectionToX(projection, lon, centerLon) ?: continue
|
||||||
|
val xRight = projectionToX(projection, lon + cellLon, centerLon) ?: continue
|
||||||
|
canvas.drawLine(xLeft, yTop, xRight, yTop, ownLinePaint)
|
||||||
|
canvas.drawLine(xLeft, yBottom, xRight, yBottom, ownLinePaint)
|
||||||
|
canvas.drawLine(xLeft, yTop, xLeft, yBottom, ownLinePaint)
|
||||||
|
canvas.drawLine(xRight, yTop, xRight, yBottom, ownLinePaint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Labels: centered in each cell, only when the cell is large enough on
|
||||||
|
// screen to hold a label (avoid clutter at low zoom)
|
||||||
|
val showLabels = when {
|
||||||
|
zoom >= GRID_ZOOM_SUB -> true
|
||||||
|
zoom >= FIELD_ZOOM_LABELS -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
if (!showLabels) return
|
||||||
|
// Estimate on-screen cell height to avoid clutter at low zoom:
|
||||||
|
// project two points 1° apart in latitude and measure the pixel distance.
|
||||||
|
val y1 = projectionToY(projection, 0.0)
|
||||||
|
val y2 = projectionToY(projection, 1.0)
|
||||||
|
if (y1 == null || y2 == null) return
|
||||||
|
val pixelsPerDegree = Math.abs(y2 - y1)
|
||||||
|
if (pixelsPerDegree * cellLat < MIN_LABEL_CELL_PX) return
|
||||||
|
|
||||||
|
labelPaint.textAlign = Paint.Align.CENTER
|
||||||
|
val fontMetrics = labelPaint.fontMetrics
|
||||||
|
val textHalfHeight = (fontMetrics.descent + fontMetrics.ascent) / 2f
|
||||||
|
for (row in firstRow..lastRow) {
|
||||||
|
val lat = row * cellLat
|
||||||
|
if (lat < -90.0 || lat >= 90.0) continue
|
||||||
|
val topLatCell = lat + cellLat
|
||||||
|
if (topLatCell > 90.0) continue
|
||||||
|
val yTop = projectionToY(projection, topLatCell) ?: continue
|
||||||
|
val yBottom = projectionToY(projection, lat) ?: continue
|
||||||
|
val yCenter = (yTop + yBottom) / 2f - textHalfHeight
|
||||||
|
if (yBottom < 0f || yTop > canvas.height) continue
|
||||||
|
for (col in firstCol..lastCol) {
|
||||||
|
val lon = col * cellLon
|
||||||
|
val xLeft = projectionToX(projection, lon, centerLon) ?: continue
|
||||||
|
val xRight = projectionToX(projection, lon + cellLon, centerLon) ?: continue
|
||||||
|
if (xRight < 0f || xLeft > canvas.width) continue
|
||||||
|
val label = cellLabel(lat, lon, zoom)
|
||||||
|
canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** X pixel for a longitude (meridians are vertical in Web Mercator). */
|
||||||
|
private fun projectionToX(projection: Projection, lon: Double, centerLon: Double): Float? {
|
||||||
|
// osmdroid clips Mercator X to [0, mapSize], which destroys the projection of
|
||||||
|
// longitudes outside the [0, 360) window of the current view (visible at low
|
||||||
|
// zoom where the whole world is narrower than the viewport). Normalizing the
|
||||||
|
// longitude to the equivalent value closest to the view center keeps every
|
||||||
|
// meridian's X near the center, safely inside the clip window.
|
||||||
|
var normalized = lon
|
||||||
|
while (normalized < centerLon - 180.0) normalized += 360.0
|
||||||
|
while (normalized > centerLon + 180.0) normalized -= 360.0
|
||||||
|
val geo = org.osmdroid.util.GeoPoint(0.0, normalized)
|
||||||
|
val p = projection.toPixels(geo, null)
|
||||||
|
// Accept generous horizontal overshoot: at low zoom the world repeats and
|
||||||
|
// a meridian just off-screen may still be projected; rejecting too early
|
||||||
|
// makes lines vanish while panning. 2x viewport width is plenty.
|
||||||
|
val limit = canvasWidthPx * 2f + MAX_OVERSHOOT_PX
|
||||||
|
return if (p.x >= -limit && p.x <= limit) p.x.toFloat() else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Y pixel for a latitude, or null when outside the viewport. */
|
||||||
|
private fun projectionToY(projection: Projection, lat: Double): Float? {
|
||||||
|
val geo = org.osmdroid.util.GeoPoint(lat, 0.0)
|
||||||
|
val p = projection.toPixels(geo, null)
|
||||||
|
return p.y.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cellLabel(lat: Double, lon: Double, zoom: Double): String {
|
||||||
|
// Maidenhead field: longitude 20° fields A-R starting at -180,
|
||||||
|
// latitude 10° fields A-R starting at -90.
|
||||||
|
val lonNorm = normalizeLon(lon)
|
||||||
|
val latNorm = lat.coerceIn(-90.0, 89.999)
|
||||||
|
val fieldLon = ((lonNorm + 180.0) / FIELD_LON).toInt()
|
||||||
|
val fieldLat = ((latNorm + 90.0) / FIELD_LAT).toInt()
|
||||||
|
val field = "${'A' + fieldLon}${'A' + fieldLat}"
|
||||||
|
if (zoom < GRID_ZOOM_SUB) return field
|
||||||
|
// Square: 2° x 1° digits
|
||||||
|
val squareLon = (((lonNorm + 180.0) % FIELD_LON) / SUB_SQUARE_LON).toInt()
|
||||||
|
val squareLat = (((latNorm + 90.0) % FIELD_LAT) / SUB_SQUARE_LAT).toInt()
|
||||||
|
return "$field$squareLon$squareLat"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeLon(lon: Double): Double {
|
||||||
|
var l = lon % 360.0
|
||||||
|
if (l >= 180.0) l -= 360.0
|
||||||
|
if (l < -180.0) l += 360.0
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val FIELD_LAT = 10.0
|
||||||
|
const val FIELD_LON = 20.0
|
||||||
|
const val SUB_SQUARE_LAT = 1.0
|
||||||
|
const val SUB_SQUARE_LON = 2.0
|
||||||
|
const val GRID_ZOOM_SUB = 5.0
|
||||||
|
const val FIELD_ZOOM_LABELS = 3.0
|
||||||
|
const val MIN_LABEL_CELL_PX = 48f
|
||||||
|
const val MAX_OVERSHOOT_PX = 64
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,9 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
@@ -80,14 +82,15 @@ import org.osmdroid.views.overlay.Marker
|
|||||||
import org.osmdroid.views.overlay.Polyline
|
import org.osmdroid.views.overlay.Polyline
|
||||||
|
|
||||||
// Overlay indices
|
// Overlay indices
|
||||||
private const val OVERLAY_STATION = 0
|
private const val OVERLAY_GRID = 0
|
||||||
private const val OVERLAY_TRACK = 1
|
private const val OVERLAY_STATION = 1
|
||||||
private const val OVERLAY_FOOTPRINT = 2
|
private const val OVERLAY_TRACK = 2
|
||||||
private const val OVERLAY_POSITIONS = 3
|
private const val OVERLAY_FOOTPRINT = 3
|
||||||
private const val OVERLAY_TERMINATOR = 4
|
private const val OVERLAY_POSITIONS = 4
|
||||||
private const val OVERLAY_SUN = 5
|
private const val OVERLAY_TERMINATOR = 5
|
||||||
private const val OVERLAY_MOON = 6
|
private const val OVERLAY_SUN = 6
|
||||||
private const val OVERLAY_COUNT = 7
|
private const val OVERLAY_MOON = 7
|
||||||
|
private const val OVERLAY_COUNT = 8
|
||||||
|
|
||||||
private val minLat = MapView.getTileSystem().minLatitude
|
private val minLat = MapView.getTileSystem().minLatitude
|
||||||
private val maxLat = MapView.getTileSystem().maxLatitude
|
private val maxLat = MapView.getTileSystem().maxLatitude
|
||||||
@@ -158,18 +161,37 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
|||||||
}
|
}
|
||||||
ElevatedCard(modifier = Modifier.weight(1f)) {
|
ElevatedCard(modifier = Modifier.weight(1f)) {
|
||||||
Box(contentAlignment = Alignment.BottomCenter) {
|
Box(contentAlignment = Alignment.BottomCenter) {
|
||||||
|
// Track grid-mode transitions; initial value false so that entering
|
||||||
|
// the map page with grid mode already ON also centers the map.
|
||||||
|
var prevGridMode by remember { mutableStateOf(false) }
|
||||||
AndroidView({ mapView }) { view ->
|
AndroidView({ mapView }) { view ->
|
||||||
uiState.stationPosition?.let { setStationPosition(it, view) }
|
// Center on the station grid whenever entering grid mode.
|
||||||
uiState.track?.let { setSatelliteTrack(it, view) }
|
// Only mark the transition as consumed once a real position
|
||||||
uiState.footprint?.let { setFootprint(it, view) }
|
// was available; otherwise a first frame with a null
|
||||||
uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } }
|
// stationPosition would swallow the centering forever.
|
||||||
setTerminator(uiState.sunLatDeg, uiState.sunLonDeg, view)
|
val shouldCenter = uiState.isGridMode && !prevGridMode
|
||||||
setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view)
|
setGridMode(
|
||||||
setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view)
|
uiState.isGridMode, uiState.workedGrids, view,
|
||||||
|
if (shouldCenter) uiState.stationPosition else null
|
||||||
|
)
|
||||||
|
if (!shouldCenter || uiState.stationPosition != null) {
|
||||||
|
prevGridMode = uiState.isGridMode
|
||||||
|
}
|
||||||
|
if (!uiState.isGridMode) {
|
||||||
|
uiState.stationPosition?.let { setStationPosition(it, view) }
|
||||||
|
uiState.track?.let { setSatelliteTrack(it, view) }
|
||||||
|
uiState.footprint?.let { setFootprint(it, view) }
|
||||||
|
uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } }
|
||||||
|
setTerminator(uiState.sunLatDeg, uiState.sunLonDeg, view)
|
||||||
|
setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view)
|
||||||
|
setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view)
|
||||||
|
}
|
||||||
view.invalidate()
|
view.invalidate()
|
||||||
}
|
}
|
||||||
uiState.mapData?.let { mapData ->
|
uiState.mapData?.let { mapData ->
|
||||||
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
|
if (!uiState.isGridMode) {
|
||||||
|
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,6 +283,61 @@ private fun MapDataCards(data: MapData) {
|
|||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Map overlay helpers
|
// region Map overlay helpers
|
||||||
|
|
||||||
|
/** Toggle the grid-mode layer visibility on/off without recreating any overlay. */
|
||||||
|
private fun setGridMode(
|
||||||
|
gridMode: Boolean,
|
||||||
|
workedGrids: Set<String>,
|
||||||
|
mapView: MapView,
|
||||||
|
stationPosition: GeoPos?
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
val gridOverlay = mapView.overlays[OVERLAY_GRID]
|
||||||
|
if (gridOverlay is MaidenheadGridOverlay) {
|
||||||
|
gridOverlay.isEnabled = gridMode
|
||||||
|
gridOverlay.workedGrids = workedGrids
|
||||||
|
gridOverlay.ownGrid = ownGridOf(stationPosition)
|
||||||
|
} else {
|
||||||
|
mapView.overlays[OVERLAY_GRID] = MaidenheadGridOverlay().apply {
|
||||||
|
isEnabled = gridMode
|
||||||
|
this.workedGrids = workedGrids
|
||||||
|
this.ownGrid = ownGridOf(stationPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Satellite-related layers are hidden in grid mode; the grid overlay
|
||||||
|
// itself is controlled by its own enabled flag.
|
||||||
|
for (index in OVERLAY_STATION..OVERLAY_MOON) {
|
||||||
|
mapView.overlays.getOrNull(index)?.isEnabled = !gridMode
|
||||||
|
}
|
||||||
|
// Entering grid mode: center on the station's current grid square,
|
||||||
|
// keeping the current zoom level unchanged.
|
||||||
|
if (gridMode) {
|
||||||
|
val pos = stationPosition ?: return
|
||||||
|
val lat = pos.latitude
|
||||||
|
val lon = pos.longitude
|
||||||
|
val fieldLat = ((lat + 90.0) / 10.0).toInt().coerceIn(0, 17)
|
||||||
|
val fieldLon = ((lon + 180.0) / 20.0).toInt().coerceIn(0, 17)
|
||||||
|
val centerLat = fieldLat * 10.0 + 5.0
|
||||||
|
val centerLon = fieldLon * 20.0 + 10.0
|
||||||
|
mapView.controller.setCenter(GeoPoint(centerLat, centerLon))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The station's 4-char Maidenhead gridsquare, e.g. "OL62", or null without a position. */
|
||||||
|
private fun ownGridOf(stationPosition: GeoPos?): String? {
|
||||||
|
val pos = stationPosition ?: return null
|
||||||
|
val lat = pos.latitude
|
||||||
|
val lon = pos.longitude
|
||||||
|
val fieldLat = ((lat + 90.0) / 10.0).toInt().coerceIn(0, 17)
|
||||||
|
val fieldLon = ((lon + 180.0) / 20.0).toInt().coerceIn(0, 17)
|
||||||
|
val subLat = ((lat + 90.0) % 10.0).toInt()
|
||||||
|
val subLon = ((lon + 180.0) % 20.0 / 2.0).toInt()
|
||||||
|
return "${'A' + fieldLon}${'A' + fieldLat}$subLon$subLat"
|
||||||
|
}
|
||||||
|
|
||||||
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
||||||
try {
|
try {
|
||||||
val overlay = mapView.overlays[OVERLAY_STATION]
|
val overlay = mapView.overlays[OVERLAY_STATION]
|
||||||
@@ -500,6 +577,7 @@ private fun rememberMapViewWithLifecycle(): MapView {
|
|||||||
overlayManager.tilesOverlay.setColorFilter(createColorFilter())
|
overlayManager.tilesOverlay.setColorFilter(createColorFilter())
|
||||||
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
|
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
|
||||||
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
|
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
|
||||||
|
overlays[OVERLAY_GRID] = MaidenheadGridOverlay()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
|
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ data class MapState(
|
|||||||
val mapData: MapData? = null,
|
val mapData: MapData? = null,
|
||||||
val isLightUi: Boolean = false,
|
val isLightUi: Boolean = false,
|
||||||
val isUtc: Boolean = false,
|
val isUtc: Boolean = false,
|
||||||
|
val isGridMode: Boolean = false,
|
||||||
|
val workedGrids: Set<String> = emptySet(),
|
||||||
val stationPosition: GeoPos? = null,
|
val stationPosition: GeoPos? = null,
|
||||||
val orbitalPass: OrbitalPass,
|
val orbitalPass: OrbitalPass,
|
||||||
val track: List<List<GeoPos>>? = null,
|
val track: List<List<GeoPos>>? = null,
|
||||||
|
|||||||
@@ -74,11 +74,17 @@ class MapViewModel(
|
|||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepo.otherSettings.collectLatest { settings ->
|
settingsRepo.otherSettings.collectLatest { settings ->
|
||||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
|
_uiState.update { it.copy(isUtc = settings.stateOfUtc, isGridMode = settings.stateOfMapGrid) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsRepo.wavelogSettings.collectLatest { _ ->
|
||||||
|
_uiState.update { it.copy(workedGrids = settingsRepo.getWorkedGrids()) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
|
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
|
||||||
selectDefaultSatellite(if (selectedCatNum != 0) selectedCatNum else -1)
|
selectDefaultSatellite(if (selectedCatNum != 0) selectedCatNum else -1)
|
||||||
|
getStationPosition()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onAction(action: MapAction) {
|
fun onAction(action: MapAction) {
|
||||||
|
|||||||
+4
-2
@@ -24,6 +24,7 @@ import androidx.compose.foundation.gestures.detectTapGestures
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -40,6 +41,7 @@ import androidx.compose.ui.graphics.nativeCanvas
|
|||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
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 com.rtbishop.look4sat.core.presentation.R
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -226,8 +228,8 @@ fun ElevationCurveChart(
|
|||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
) {
|
) {
|
||||||
Text("● 站点A", color = colorA, fontSize = 12.sp)
|
Text(stringResource(R.string.mutual_station_a), color = colorA, fontSize = 12.sp)
|
||||||
Text("● 站点B", color = colorB, fontSize = 12.sp)
|
Text(stringResource(R.string.mutual_station_b), color = colorB, fontSize = 12.sp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -57,8 +57,8 @@ private const val STROKE_WIDTH = 2.5f
|
|||||||
fun MutualRadarView(
|
fun MutualRadarView(
|
||||||
trackSamples: List<TrackSample>,
|
trackSamples: List<TrackSample>,
|
||||||
progress: Float = 0.5f,
|
progress: Float = 0.5f,
|
||||||
labelA: String = "站点A",
|
labelA: String = "Station A",
|
||||||
labelB: String = "站点B",
|
labelB: String = "Station B",
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
if (trackSamples.isEmpty()) return
|
if (trackSamples.isEmpty()) return
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||||
import com.rtbishop.look4sat.core.domain.repository.TrackSampleData
|
import com.rtbishop.look4sat.core.domain.repository.TrackSampleData
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.GridTargetChip
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
@@ -75,7 +75,6 @@ import java.util.Locale
|
|||||||
@Composable
|
@Composable
|
||||||
fun MutualScreen(
|
fun MutualScreen(
|
||||||
viewModel: MutualViewModel,
|
viewModel: MutualViewModel,
|
||||||
navigateUp: () -> Unit = {},
|
|
||||||
navigateToRadar: (Int, Long, MutualPassData?) -> Unit = { _, _, _ -> },
|
navigateToRadar: (Int, Long, MutualPassData?) -> Unit = { _, _, _ -> },
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
@@ -85,12 +84,10 @@ fun MutualScreen(
|
|||||||
topBar = { isVertical ->
|
topBar = { isVertical ->
|
||||||
TopBar(
|
TopBar(
|
||||||
isVerticalLayout = isVertical,
|
isVerticalLayout = isVertical,
|
||||||
startAction = {
|
startAction = {},
|
||||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
|
||||||
},
|
|
||||||
topInfo = {
|
topInfo = {
|
||||||
Text(
|
Text(
|
||||||
text = "过境匹配",
|
text = stringResource(R.string.mutual_title),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
@@ -118,7 +115,29 @@ fun MutualScreen(
|
|||||||
strokeWidth = 2.dp
|
strokeWidth = 2.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MutualStatusChip(state)
|
// Target-grid chip: station B grid (station A defaults to
|
||||||
|
// the user's own position), bearing/distance from A to B.
|
||||||
|
val gridB = state.stationBGrid.trim().uppercase()
|
||||||
|
val posA = com.rtbishop.look4sat.core.domain.utility.qthToPosition(state.stationAGrid)
|
||||||
|
?: state.stationALat.toDoubleOrNull()?.let { lat ->
|
||||||
|
state.stationALon.toDoubleOrNull()?.let { lon ->
|
||||||
|
com.rtbishop.look4sat.core.domain.predict.GeoPos(lat, lon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val posB = com.rtbishop.look4sat.core.domain.utility.qthToPosition(gridB)
|
||||||
|
if (posA != null && posB != null) {
|
||||||
|
GridTargetChip(
|
||||||
|
grid = gridB,
|
||||||
|
distanceKm = com.rtbishop.look4sat.core.presentation.greatCircleDistanceKm(
|
||||||
|
posA.latitude, posA.longitude, posB.latitude, posB.longitude
|
||||||
|
),
|
||||||
|
bearingDeg = com.rtbishop.look4sat.core.presentation.greatCircleBearingDeg(
|
||||||
|
posA.latitude, posA.longitude, posB.latitude, posB.longitude
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
GridTargetChip(grid = null, distanceKm = null, bearingDeg = null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -198,7 +217,7 @@ private fun MutualContent(
|
|||||||
) {
|
) {
|
||||||
if (isVertical) {
|
if (isVertical) {
|
||||||
StationInputCard(
|
StationInputCard(
|
||||||
title = "你的位置",
|
title = stringResource(R.string.mutual_station_you),
|
||||||
titleColor = MaterialTheme.colorScheme.primary,
|
titleColor = MaterialTheme.colorScheme.primary,
|
||||||
lat = state.stationALat,
|
lat = state.stationALat,
|
||||||
lon = state.stationALon,
|
lon = state.stationALon,
|
||||||
@@ -214,7 +233,7 @@ private fun MutualContent(
|
|||||||
currentPositionAction = onUseCurrentPosition
|
currentPositionAction = onUseCurrentPosition
|
||||||
)
|
)
|
||||||
StationInputCard(
|
StationInputCard(
|
||||||
title = "友台位置",
|
title = stringResource(R.string.mutual_station_opposite),
|
||||||
titleColor = MaterialTheme.colorScheme.tertiary,
|
titleColor = MaterialTheme.colorScheme.tertiary,
|
||||||
lat = state.stationBLat,
|
lat = state.stationBLat,
|
||||||
lon = state.stationBLon,
|
lon = state.stationBLon,
|
||||||
@@ -234,7 +253,7 @@ private fun MutualContent(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
) {
|
) {
|
||||||
StationInputCard(
|
StationInputCard(
|
||||||
title = "你的位置",
|
title = stringResource(R.string.mutual_station_you),
|
||||||
titleColor = MaterialTheme.colorScheme.primary,
|
titleColor = MaterialTheme.colorScheme.primary,
|
||||||
lat = state.stationALat,
|
lat = state.stationALat,
|
||||||
lon = state.stationALon,
|
lon = state.stationALon,
|
||||||
@@ -251,7 +270,7 @@ private fun MutualContent(
|
|||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
StationInputCard(
|
StationInputCard(
|
||||||
title = "友台位置",
|
title = stringResource(R.string.mutual_station_opposite),
|
||||||
titleColor = MaterialTheme.colorScheme.tertiary,
|
titleColor = MaterialTheme.colorScheme.tertiary,
|
||||||
lat = state.stationBLat,
|
lat = state.stationBLat,
|
||||||
lon = state.stationBLon,
|
lon = state.stationBLon,
|
||||||
@@ -294,8 +313,8 @@ private fun MutualContent(
|
|||||||
startTime = pass.startTime,
|
startTime = pass.startTime,
|
||||||
endTime = pass.endTime,
|
endTime = pass.endTime,
|
||||||
maxElev = maxOf(pass.maxElevationA, pass.maxElevationB, 10.0),
|
maxElev = maxOf(pass.maxElevationA, pass.maxElevationB, 10.0),
|
||||||
labelA = "你",
|
labelA = stringResource(R.string.mutual_label_you),
|
||||||
labelB = "友台"
|
labelB = stringResource(R.string.mutual_label_opposite)
|
||||||
)
|
)
|
||||||
MutualPassCard(
|
MutualPassCard(
|
||||||
pass = pass,
|
pass = pass,
|
||||||
@@ -341,20 +360,22 @@ private fun MutualStatusChip(state: MutualUiState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
private fun mutualStatusText(state: MutualUiState): String = when {
|
private fun mutualStatusText(state: MutualUiState): String = when {
|
||||||
state.isCalculating -> "正在计算双方共同可见窗口…"
|
state.isCalculating -> stringResource(R.string.mutual_status_calculating)
|
||||||
state.errorMessage != null -> "请修正提示后重新查询"
|
state.errorMessage != null -> stringResource(R.string.mutual_status_error)
|
||||||
state.mutualPasses.isNotEmpty() -> "找到 ${state.mutualPasses.size} 个匹配 · 点击卡片展开曲线"
|
state.mutualPasses.isNotEmpty() -> stringResource(R.string.mutual_status_matches, state.mutualPasses.size)
|
||||||
state.hasSearched -> "未找到共同可见窗口,调整时间或仰角后重试"
|
state.hasSearched -> stringResource(R.string.mutual_status_none)
|
||||||
else -> "输入双方位置后点击查询"
|
else -> stringResource(R.string.mutual_status_idle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
private fun mutualStatusChipText(state: MutualUiState): String = when {
|
private fun mutualStatusChipText(state: MutualUiState): String = when {
|
||||||
state.isCalculating -> "计算中"
|
state.isCalculating -> stringResource(R.string.mutual_chip_calculating)
|
||||||
state.errorMessage != null -> "有错误"
|
state.errorMessage != null -> stringResource(R.string.mutual_chip_error)
|
||||||
state.mutualPasses.isNotEmpty() -> "${state.mutualPasses.size}个匹配"
|
state.mutualPasses.isNotEmpty() -> stringResource(R.string.mutual_chip_matches, state.mutualPasses.size)
|
||||||
state.hasSearched -> "无匹配"
|
state.hasSearched -> stringResource(R.string.mutual_chip_none)
|
||||||
else -> "待查询"
|
else -> stringResource(R.string.mutual_chip_idle)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -393,7 +414,7 @@ private fun StationInputCard(
|
|||||||
)
|
)
|
||||||
if (currentPositionAction != null) {
|
if (currentPositionAction != null) {
|
||||||
OutlinedButton(onClick = currentPositionAction) {
|
OutlinedButton(onClick = currentPositionAction) {
|
||||||
Text("填入当前位置", style = MaterialTheme.typography.bodySmall)
|
Text(stringResource(R.string.mutual_use_current), style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -405,7 +426,7 @@ private fun StationInputCard(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = lat,
|
value = lat,
|
||||||
onValueChange = onLatChange,
|
onValueChange = onLatChange,
|
||||||
label = { Text("纬度") },
|
label = { Text(stringResource(R.string.mutual_lat)) },
|
||||||
placeholder = { Text(latPlaceholder) },
|
placeholder = { Text(latPlaceholder) },
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
@@ -414,7 +435,7 @@ private fun StationInputCard(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = lon,
|
value = lon,
|
||||||
onValueChange = onLonChange,
|
onValueChange = onLonChange,
|
||||||
label = { Text("经度") },
|
label = { Text(stringResource(R.string.mutual_lon)) },
|
||||||
placeholder = { Text(lonPlaceholder) },
|
placeholder = { Text(lonPlaceholder) },
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
@@ -425,15 +446,15 @@ private fun StationInputCard(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = grid,
|
value = grid,
|
||||||
onValueChange = onGridChange,
|
onValueChange = onGridChange,
|
||||||
label = { Text("网格") },
|
label = { Text(stringResource(R.string.mutual_grid)) },
|
||||||
placeholder = { Text(gridPlaceholder) },
|
placeholder = { Text(gridPlaceholder) },
|
||||||
supportingText = { Text("支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度") },
|
supportingText = { Text(stringResource(R.string.mutual_grid_hint)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "最小仰角:${minElev.toInt()}°",
|
text = stringResource(R.string.mutual_min_elevation, minElev.toInt()),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -461,7 +482,7 @@ private fun MatchSearchCard(
|
|||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "时间范围",
|
text = stringResource(R.string.mutual_time_range),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.Medium
|
||||||
)
|
)
|
||||||
@@ -492,7 +513,7 @@ private fun MatchSearchCard(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
}
|
}
|
||||||
Text(if (isCalculating) "计算中..." else "查询匹配")
|
Text(if (isCalculating) stringResource(R.string.mutual_calculating) else stringResource(R.string.mutual_query))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -558,12 +579,12 @@ private fun MutualPassCard(
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "你: ${pass.maxElevationA}°",
|
text = stringResource(R.string.mutual_elevation_you, pass.maxElevationA.toInt()),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "友台: ${pass.maxElevationB}°",
|
text = stringResource(R.string.mutual_elevation_opposite, pass.maxElevationB.toInt()),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.tertiary
|
color = MaterialTheme.colorScheme.tertiary
|
||||||
)
|
)
|
||||||
@@ -579,7 +600,7 @@ private fun MutualPassCard(
|
|||||||
// Dual-station radar track (polar plot)
|
// Dual-station radar track (polar plot)
|
||||||
if (visibleTracks.isNotEmpty()) {
|
if (visibleTracks.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = "双方轨迹",
|
text = stringResource(R.string.mutual_both_tracks),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
@@ -587,8 +608,8 @@ private fun MutualPassCard(
|
|||||||
MutualRadarView(
|
MutualRadarView(
|
||||||
trackSamples = visibleTracks,
|
trackSamples = visibleTracks,
|
||||||
progress = dragProgress,
|
progress = dragProgress,
|
||||||
labelA = "你",
|
labelA = stringResource(R.string.mutual_label_you),
|
||||||
labelB = "友台"
|
labelB = stringResource(R.string.mutual_label_opposite)
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
@@ -613,7 +634,7 @@ private fun MutualPassCard(
|
|||||||
modifier = Modifier.height(16.dp).width(16.dp)
|
modifier = Modifier.height(16.dp).width(16.dp)
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
Text("查看雷达")
|
Text(stringResource(R.string.mutual_open_radar))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-91
@@ -26,6 +26,9 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -34,7 +37,6 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
import kotlin.math.floor
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
data class MutualUiState(
|
data class MutualUiState(
|
||||||
@@ -56,7 +58,10 @@ data class MutualUiState(
|
|||||||
|
|
||||||
class MutualViewModel(
|
class MutualViewModel(
|
||||||
private val satelliteRepo: ISatelliteRepo,
|
private val satelliteRepo: ISatelliteRepo,
|
||||||
private val settingsRepo: ISettingsRepo
|
private val settingsRepo: ISettingsRepo,
|
||||||
|
// Injected so unit tests can run the pass computation on a test dispatcher;
|
||||||
|
// production callers keep the CPU-bound pool default.
|
||||||
|
private val computeDispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(MutualUiState())
|
private val _uiState = MutableStateFlow(MutualUiState())
|
||||||
@@ -66,7 +71,7 @@ class MutualViewModel(
|
|||||||
// Pre-fill station A with the user's current station position (as grid),
|
// Pre-fill station A with the user's current station position (as grid),
|
||||||
// and default min elevation to the same value used by the main radar passes
|
// and default min elevation to the same value used by the main radar passes
|
||||||
val pos = settingsRepo.stationPosition.value
|
val pos = settingsRepo.stationPosition.value
|
||||||
val grid = latLonToGrid(pos.latitude, pos.longitude)
|
val grid = positionToQth(pos.latitude, pos.longitude) ?: ""
|
||||||
_uiState.update { it.copy(
|
_uiState.update { it.copy(
|
||||||
stationAGrid = grid,
|
stationAGrid = grid,
|
||||||
stationALat = "%.4f".format(pos.latitude),
|
stationALat = "%.4f".format(pos.latitude),
|
||||||
@@ -81,7 +86,9 @@ class MutualViewModel(
|
|||||||
val lat = value.toDoubleOrNull()
|
val lat = value.toDoubleOrNull()
|
||||||
val lon = _uiState.value.stationALon.toDoubleOrNull()
|
val lon = _uiState.value.stationALon.toDoubleOrNull()
|
||||||
if (lat != null && lon != null) {
|
if (lat != null && lon != null) {
|
||||||
_uiState.update { it.copy(stationAGrid = latLonToGrid(lat, lon)) }
|
positionToQth(lat, lon)?.let { grid ->
|
||||||
|
_uiState.update { it.copy(stationAGrid = grid) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,15 +97,18 @@ class MutualViewModel(
|
|||||||
val lat = _uiState.value.stationALat.toDoubleOrNull()
|
val lat = _uiState.value.stationALat.toDoubleOrNull()
|
||||||
val lon = value.toDoubleOrNull()
|
val lon = value.toDoubleOrNull()
|
||||||
if (lat != null && lon != null) {
|
if (lat != null && lon != null) {
|
||||||
_uiState.update { it.copy(stationAGrid = latLonToGrid(lat, lon)) }
|
positionToQth(lat, lon)?.let { grid ->
|
||||||
|
_uiState.update { it.copy(stationAGrid = grid) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onStationAGrid(value: String) {
|
fun onStationAGrid(value: String) {
|
||||||
val old = _uiState.value.stationAGrid
|
val old = _uiState.value.stationAGrid
|
||||||
_uiState.update { it.copy(stationAGrid = value) }
|
val newGrid = value.trim().uppercase()
|
||||||
if (value.trim().uppercase() == old.trim().uppercase()) return
|
_uiState.update { it.copy(stationAGrid = newGrid) }
|
||||||
val pos = gridToLatLon(value.trim().uppercase())
|
if (newGrid == old.trim().uppercase()) return
|
||||||
|
val pos = qthToPosition(newGrid)
|
||||||
if (pos != null) {
|
if (pos != null) {
|
||||||
_uiState.update { it.copy(
|
_uiState.update { it.copy(
|
||||||
stationALat = "%.4f".format(pos.latitude),
|
stationALat = "%.4f".format(pos.latitude),
|
||||||
@@ -112,7 +122,9 @@ class MutualViewModel(
|
|||||||
val lat = value.toDoubleOrNull()
|
val lat = value.toDoubleOrNull()
|
||||||
val lon = _uiState.value.stationBLon.toDoubleOrNull()
|
val lon = _uiState.value.stationBLon.toDoubleOrNull()
|
||||||
if (lat != null && lon != null) {
|
if (lat != null && lon != null) {
|
||||||
_uiState.update { it.copy(stationBGrid = latLonToGrid(lat, lon)) }
|
positionToQth(lat, lon)?.let { grid ->
|
||||||
|
_uiState.update { it.copy(stationBGrid = grid) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,15 +133,18 @@ class MutualViewModel(
|
|||||||
val lat = _uiState.value.stationBLat.toDoubleOrNull()
|
val lat = _uiState.value.stationBLat.toDoubleOrNull()
|
||||||
val lon = value.toDoubleOrNull()
|
val lon = value.toDoubleOrNull()
|
||||||
if (lat != null && lon != null) {
|
if (lat != null && lon != null) {
|
||||||
_uiState.update { it.copy(stationBGrid = latLonToGrid(lat, lon)) }
|
positionToQth(lat, lon)?.let { grid ->
|
||||||
|
_uiState.update { it.copy(stationBGrid = grid) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onStationBGrid(value: String) {
|
fun onStationBGrid(value: String) {
|
||||||
val old = _uiState.value.stationBGrid
|
val old = _uiState.value.stationBGrid
|
||||||
_uiState.update { it.copy(stationBGrid = value) }
|
val newGrid = value.trim().uppercase()
|
||||||
if (value.trim().uppercase() == old.trim().uppercase()) return
|
_uiState.update { it.copy(stationBGrid = newGrid) }
|
||||||
val pos = gridToLatLon(value.trim().uppercase())
|
if (newGrid == old.trim().uppercase()) return
|
||||||
|
val pos = qthToPosition(newGrid)
|
||||||
if (pos != null) {
|
if (pos != null) {
|
||||||
_uiState.update { it.copy(
|
_uiState.update { it.copy(
|
||||||
stationBLat = "%.4f".format(pos.latitude),
|
stationBLat = "%.4f".format(pos.latitude),
|
||||||
@@ -144,7 +159,7 @@ class MutualViewModel(
|
|||||||
_uiState.update { it.copy(
|
_uiState.update { it.copy(
|
||||||
stationALat = "%.4f".format(pos.latitude),
|
stationALat = "%.4f".format(pos.latitude),
|
||||||
stationALon = "%.4f".format(pos.longitude),
|
stationALon = "%.4f".format(pos.longitude),
|
||||||
stationAGrid = latLonToGrid(pos.latitude, pos.longitude)
|
stationAGrid = positionToQth(pos.latitude, pos.longitude) ?: ""
|
||||||
) }
|
) }
|
||||||
}
|
}
|
||||||
fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) }
|
fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) }
|
||||||
@@ -158,13 +173,13 @@ class MutualViewModel(
|
|||||||
var posB = resolvePosition(state.stationBLat, state.stationBLon, state.stationBGrid)
|
var posB = resolvePosition(state.stationBLat, state.stationBLon, state.stationBGrid)
|
||||||
|
|
||||||
if (posA == null || posB == null) {
|
if (posA == null || posB == null) {
|
||||||
_uiState.update { it.copy(errorMessage = "请输入有效的位置坐标或网格(4/6/8位)") }
|
_uiState.update { it.copy(errorMessage = "Please enter valid coordinates or a Maidenhead grid (4/6/8 chars)") }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val satellites = satelliteRepo.satellites.value
|
val satellites = satelliteRepo.satellites.value
|
||||||
if (satellites.isEmpty()) {
|
if (satellites.isEmpty()) {
|
||||||
_uiState.update { it.copy(errorMessage = "没有卫星数据,请先在卫星列表中选择卫星") }
|
_uiState.update { it.copy(errorMessage = "No satellite data. Select satellites from the list first.") }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +199,7 @@ class MutualViewModel(
|
|||||||
val minElevB = state.stationBMinElev
|
val minElevB = state.stationBMinElev
|
||||||
val hours = state.hoursAhead
|
val hours = state.hoursAhead
|
||||||
|
|
||||||
val results = withContext(Dispatchers.Default) {
|
val results = withContext(computeDispatcher) {
|
||||||
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours)
|
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,9 +208,9 @@ class MutualViewModel(
|
|||||||
val passCount = satelliteRepo.passes.value.size
|
val passCount = satelliteRepo.passes.value.size
|
||||||
val satCount = satellites.size
|
val satCount = satellites.size
|
||||||
when {
|
when {
|
||||||
passCount == 0 -> "过境列表为空,请先在卫星列表中选择卫星"
|
passCount == 0 -> "Pass list is empty. Select satellites from the list first."
|
||||||
satCount == 0 -> "没有已选中的卫星"
|
satCount == 0 -> "No satellites selected."
|
||||||
else -> "未找到共同过境(${passCount}个过境,${satCount}颗卫星)"
|
else -> "No mutual passes found (${passCount} passes, ${satCount} satellites)."
|
||||||
}
|
}
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
@@ -213,83 +228,13 @@ class MutualViewModel(
|
|||||||
private fun resolvePosition(latText: String, lonText: String, gridText: String): GeoPos? {
|
private fun resolvePosition(latText: String, lonText: String, gridText: String): GeoPos? {
|
||||||
val trimmed = gridText.trim().uppercase()
|
val trimmed = gridText.trim().uppercase()
|
||||||
if (trimmed.length in listOf(4, 6, 8) && trimmed.all { it.isLetterOrDigit() }) {
|
if (trimmed.length in listOf(4, 6, 8) && trimmed.all { it.isLetterOrDigit() }) {
|
||||||
return gridToLatLon(trimmed)
|
return qthToPosition(trimmed)
|
||||||
}
|
}
|
||||||
val lat = latText.toDoubleOrNull()
|
val lat = latText.toDoubleOrNull()
|
||||||
val lon = lonText.toDoubleOrNull()
|
val lon = lonText.toDoubleOrNull()
|
||||||
return if (lat != null && lon != null) GeoPos(lat, lon) else null
|
return if (lat != null && lon != null) GeoPos(lat, lon) else null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convert lat/lon to Maidenhead grid (6-char). */
|
|
||||||
private fun latLonToGrid(lat: Double, lon: Double): String {
|
|
||||||
var adjLon = (lon + 180.0) % 360.0
|
|
||||||
var adjLat = (lat + 90.0) % 180.0
|
|
||||||
|
|
||||||
val fieldLon = (adjLon / 20.0).toInt()
|
|
||||||
val fieldLat = (adjLat / 10.0).toInt()
|
|
||||||
adjLon -= fieldLon * 20.0
|
|
||||||
adjLat -= fieldLat * 10.0
|
|
||||||
|
|
||||||
val squareLon = (adjLon / 2.0).toInt()
|
|
||||||
val squareLat = (adjLat / 1.0).toInt()
|
|
||||||
adjLon -= squareLon * 2.0
|
|
||||||
adjLat -= squareLat * 1.0
|
|
||||||
|
|
||||||
val subLon = (adjLon * 60.0 / 5.0).toInt()
|
|
||||||
val subLat = (adjLat * 60.0 / 2.5).toInt()
|
|
||||||
|
|
||||||
return buildString {
|
|
||||||
append('A' + fieldLon)
|
|
||||||
append('A' + fieldLat)
|
|
||||||
append('0' + squareLon)
|
|
||||||
append('0' + squareLat)
|
|
||||||
append('A' + subLon)
|
|
||||||
append('A' + subLat)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert Maidenhead grid (4, 6, or 8 chars) to lat/lon center of the square. */
|
|
||||||
private fun gridToLatLon(grid: String): GeoPos? {
|
|
||||||
val g = grid.uppercase()
|
|
||||||
if (g.length < 4) return null
|
|
||||||
val lonField = (g[0] - 'A').toDouble() * 20.0
|
|
||||||
val latField = (g[1] - 'A').toDouble() * 10.0
|
|
||||||
if (lonField < 0 || lonField > 340 || latField < 0 || latField > 170) return null
|
|
||||||
|
|
||||||
val lonSquare = (g[2] - '0').toDouble() * 2.0
|
|
||||||
val latSquare = (g[3] - '0').toDouble() * 1.0
|
|
||||||
if (lonSquare < 0 || lonSquare > 18 || latSquare < 0 || latSquare > 9) return null
|
|
||||||
|
|
||||||
var lon = lonField + lonSquare
|
|
||||||
var lat = latField + latSquare
|
|
||||||
|
|
||||||
if (g.length >= 6) {
|
|
||||||
val lonSub = (g[4] - 'A').toDouble() * 5.0 / 60.0
|
|
||||||
val latSub = (g[5] - 'A').toDouble() * 2.5 / 60.0
|
|
||||||
if (lonSub < 0 || lonSub > 115.0 / 60.0 || latSub < 0 || latSub > 57.5 / 60.0) return null
|
|
||||||
lon += lonSub
|
|
||||||
lat += latSub
|
|
||||||
|
|
||||||
if (g.length >= 8) {
|
|
||||||
val lonExt = (g[6] - '0').toDouble() * 30.0 / 3600.0
|
|
||||||
val latExt = (g[7] - '0').toDouble() * 15.0 / 3600.0
|
|
||||||
if (lonExt < 0 || lonExt > 270.0 / 3600.0 || latExt < 0 || latExt > 135.0 / 3600.0) return null
|
|
||||||
lon += lonExt + 15.0 / 3600.0
|
|
||||||
lat += latExt + 7.5 / 3600.0
|
|
||||||
} else {
|
|
||||||
lon += 2.5 / 60.0
|
|
||||||
lat += 1.25 / 60.0
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
lon += 1.0
|
|
||||||
lat += 0.5
|
|
||||||
}
|
|
||||||
|
|
||||||
lon = lon - 180.0
|
|
||||||
lat = lat - 90.0
|
|
||||||
return GeoPos(lat, lon)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findMutualPasses(
|
private fun findMutualPasses(
|
||||||
satellites: List<OrbitalObject>,
|
satellites: List<OrbitalObject>,
|
||||||
posA: GeoPos, posB: GeoPos,
|
posA: GeoPos, posB: GeoPos,
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.rtbishop.look4sat.feature.mutual
|
||||||
|
|
||||||
|
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.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test fake for ISatelliteRepo. Only `satellites` and `passes` are backed by
|
||||||
|
* mutable state; everything else the ViewModel never touches fails loudly.
|
||||||
|
*/
|
||||||
|
class FakeSatelliteRepo(
|
||||||
|
satellites: List<OrbitalObject> = emptyList(),
|
||||||
|
passes: List<OrbitalPass> = emptyList()
|
||||||
|
) : ISatelliteRepo {
|
||||||
|
|
||||||
|
override val satellites = MutableStateFlow(satellites)
|
||||||
|
override val passes = MutableStateFlow(passes)
|
||||||
|
override val isCalculating = MutableStateFlow(false)
|
||||||
|
override val selectedPass = MutableStateFlow(Pair(0, 0L))
|
||||||
|
|
||||||
|
override fun selectPass(catNum: Int, aosTime: Long) = TODO()
|
||||||
|
override suspend fun initRepository() = TODO()
|
||||||
|
override suspend fun calculatePasses(
|
||||||
|
time: Long, hoursAhead: Int, minElevation: Double,
|
||||||
|
aosStartMinute: Int, aosEndMinute: Int,
|
||||||
|
invertAosTimeWindow: Boolean, modes: List<String>
|
||||||
|
) = TODO()
|
||||||
|
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos = TODO()
|
||||||
|
override suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos> = TODO()
|
||||||
|
override suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio> = TODO()
|
||||||
|
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = TODO()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test fake for ISettingsRepo. Only [stationPosition] is backed by mutable
|
||||||
|
* state; everything else the ViewModel never touches fails loudly.
|
||||||
|
*/
|
||||||
|
class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISettingsRepo {
|
||||||
|
|
||||||
|
override val stationPosition = MutableStateFlow(initialPosition)
|
||||||
|
|
||||||
|
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 databaseState: StateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||||
|
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||||
|
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||||
|
)
|
||||||
|
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||||
|
OtherSettings(false, false, false, false, false, false, false, false)
|
||||||
|
)
|
||||||
|
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
|
||||||
|
DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList())
|
||||||
|
)
|
||||||
|
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||||
|
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun setSelectedIds(ids: List<Int>) = TODO()
|
||||||
|
override fun setSelectedTypes(types: List<String>) = TODO()
|
||||||
|
override fun setPassesSettings(settings: PassesSettings) = TODO()
|
||||||
|
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
|
||||||
|
override fun setStationPosition(): Boolean = TODO()
|
||||||
|
override fun setStationPosition(locator: String): Boolean = TODO()
|
||||||
|
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
|
||||||
|
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
|
||||||
|
override fun updateDatabaseState(state: DatabaseState) = TODO()
|
||||||
|
override fun updateRCSettings(settings: RCSettings) = TODO()
|
||||||
|
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
|
||||||
|
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
|
||||||
|
|
||||||
|
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||||
|
|
||||||
|
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
|
||||||
|
|
||||||
|
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
|
||||||
|
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||||
|
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||||
|
override fun getAmSatCallsign(): String = ""
|
||||||
|
override fun setAmSatCallsign(callsign: String) = TODO()
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package com.rtbishop.look4sat.feature.mutual
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.TestDispatcher
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.rules.TestWatcher
|
||||||
|
import org.junit.runner.Description
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swaps the Main dispatcher for a [StandardTestDispatcher] so that
|
||||||
|
* `viewModelScope.launch` work can be driven deterministically with
|
||||||
|
* `runTest` / `advanceUntilIdle()` instead of racing real threads.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class MainDispatcherRule(
|
||||||
|
val dispatcher: TestDispatcher = StandardTestDispatcher()
|
||||||
|
) : TestWatcher() {
|
||||||
|
|
||||||
|
override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
|
||||||
|
|
||||||
|
override fun finished(description: Description) = Dispatchers.resetMain()
|
||||||
|
}
|
||||||
+288
@@ -0,0 +1,288 @@
|
|||||||
|
package com.rtbishop.look4sat.feature.mutual
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlin.random.Random
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class MutualViewModelTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val mainDispatcherRule = MainDispatcherRule()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ViewModel wired to fakes and a test dispatcher that shares the runTest
|
||||||
|
* scheduler, so [advanceUntilIdle] deterministically drains both the
|
||||||
|
* viewModelScope (Main) queue and the compute dispatcher.
|
||||||
|
*/
|
||||||
|
private fun TestScope.createVm(
|
||||||
|
satellites: List<OrbitalObject> = emptyList(),
|
||||||
|
passes: List<OrbitalPass> = emptyList(),
|
||||||
|
position: GeoPos = TestOrbits.GUANGZHOU
|
||||||
|
): MutualViewModel = MutualViewModel(
|
||||||
|
satelliteRepo = FakeSatelliteRepo(satellites, passes),
|
||||||
|
settingsRepo = FakeSettingsRepo(position),
|
||||||
|
computeDispatcher = StandardTestDispatcher(mainDispatcherRule.dispatcher.scheduler)
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun TestScope.queryAndSettle(vm: MutualViewModel) {
|
||||||
|
vm.queryMutualPasses()
|
||||||
|
advanceUntilIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- init
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `init prefills station A with the current position`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||||
|
val vm = createVm(position = TestOrbits.GUANGZHOU)
|
||||||
|
val state = vm.uiState.value
|
||||||
|
|
||||||
|
assertEquals("23.1300", state.stationALat)
|
||||||
|
assertEquals("113.2600", state.stationALon)
|
||||||
|
assertEquals(6, state.stationAGrid.length)
|
||||||
|
assertTrue(state.stationAGrid.startsWith("OL63"))
|
||||||
|
assertTrue(state.stationAGrid.uppercase() == state.stationAGrid)
|
||||||
|
assertEquals(0.0, state.stationAMinElev, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- station input
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `entering latitude recomputes the maidenhead grid`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||||
|
val vm = createVm(position = TestOrbits.SHENZHEN) // station A pre-filled with SZ
|
||||||
|
vm.onStationALat("23.5")
|
||||||
|
|
||||||
|
// 23.5N 114.06E -> field OL, square 73 (Shenzhen), subsquare AM
|
||||||
|
assertEquals("OL73AM", vm.uiState.value.stationAGrid)
|
||||||
|
// raw input string is stored verbatim, not reformatted
|
||||||
|
assertEquals("23.5", vm.uiState.value.stationALat)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `entering longitude recomputes the maidenhead grid`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||||
|
val vm = createVm(position = TestOrbits.GUANGZHOU)
|
||||||
|
vm.onStationALon("113.5")
|
||||||
|
|
||||||
|
// 23.13N 113.5E -> Guangzhou field OL63, subsquare SD
|
||||||
|
assertEquals("OL63SD", vm.uiState.value.stationAGrid)
|
||||||
|
// raw input string is stored verbatim, not reformatted
|
||||||
|
assertEquals("113.5", vm.uiState.value.stationALon)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `entering a lowercase grid normalises to uppercase and updates lat lon`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||||
|
val vm = createVm(position = TestOrbits.NEW_YORK)
|
||||||
|
vm.onStationAGrid("ol63")
|
||||||
|
|
||||||
|
val state = vm.uiState.value
|
||||||
|
// The grid is normalised to upper case on input (Bug fix: raw input
|
||||||
|
// used to be stored verbatim, e.g. "ol63" stayed lowercase).
|
||||||
|
assertEquals("OL63", state.stationAGrid)
|
||||||
|
// 4-char grid resolves to the square centre
|
||||||
|
assertEquals(23.5, state.stationALat.toDouble(), 1e-9)
|
||||||
|
assertEquals(113.0, state.stationALon.toDouble(), 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `invalid grid leaves lat and lon untouched`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||||
|
val vm = createVm(position = TestOrbits.GUANGZHOU)
|
||||||
|
val before = vm.uiState.value.stationALat
|
||||||
|
vm.onStationAGrid("ZZ99") // valid shape, out-of-range letters
|
||||||
|
|
||||||
|
assertEquals("ZZ99", vm.uiState.value.stationAGrid)
|
||||||
|
assertEquals(before, vm.uiState.value.stationALat)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `use current position restores station A from settings`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||||
|
val vm = createVm(position = TestOrbits.GUANGZHOU)
|
||||||
|
vm.onStationALat("10.0")
|
||||||
|
vm.onStationALon("20.0")
|
||||||
|
assertNotEquals("23.1300", vm.uiState.value.stationALat)
|
||||||
|
|
||||||
|
vm.onUseCurrentPosition()
|
||||||
|
|
||||||
|
val state = vm.uiState.value
|
||||||
|
assertEquals("23.1300", state.stationALat)
|
||||||
|
assertEquals("113.2600", state.stationALon)
|
||||||
|
assertTrue(state.stationAGrid.startsWith("OL63"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `station B independent from station A`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||||
|
val vm = createVm()
|
||||||
|
vm.onStationBLat("22.54")
|
||||||
|
vm.onStationBLon("114.06")
|
||||||
|
|
||||||
|
// 22.54N 114.06E is Shenzhen: OL72 field (lon square 7, lat square 2)
|
||||||
|
assertEquals("OL72", vm.uiState.value.stationBGrid.take(4))
|
||||||
|
assertTrue(vm.uiState.value.stationBGrid.length == 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------ query: errors
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `query with invalid coordinates sets an error and does not search`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||||
|
val vm = createVm(satellites = listOf(TestOrbits.ISS), passes = TestOrbits.findPassWindows())
|
||||||
|
vm.onStationAGrid("ZZ99")
|
||||||
|
vm.onStationBGrid("ZZ99")
|
||||||
|
|
||||||
|
queryAndSettle(vm)
|
||||||
|
|
||||||
|
val state = vm.uiState.value
|
||||||
|
assertTrue(state.errorMessage != null)
|
||||||
|
assertFalse(state.hasSearched)
|
||||||
|
assertFalse(state.isCalculating)
|
||||||
|
assertTrue(state.mutualPasses.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `query with no satellites sets an error`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||||
|
val vm = createVm(satellites = emptyList())
|
||||||
|
vm.onStationBGrid("OL62")
|
||||||
|
|
||||||
|
queryAndSettle(vm)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"No satellite data. Select satellites from the list first.",
|
||||||
|
vm.uiState.value.errorMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `query with everything empty reports empty pass list`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||||
|
val vm = createVm(satellites = listOf(TestOrbits.ISS), passes = emptyList())
|
||||||
|
vm.onStationBGrid("OL62")
|
||||||
|
vm.onStationAMinElev(89.0)
|
||||||
|
vm.onStationBMinElev(89.0)
|
||||||
|
vm.onHoursAhead(2)
|
||||||
|
|
||||||
|
queryAndSettle(vm)
|
||||||
|
|
||||||
|
val state = vm.uiState.value
|
||||||
|
assertFalse(state.isCalculating)
|
||||||
|
assertEquals("Pass list is empty. Select satellites from the list first.", state.errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------- query: happy path
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `query finds mutual passes reusing the main pass list`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||||
|
val windows = TestOrbits.findPassWindows(hoursAhead = 12)
|
||||||
|
assertTrue("test fixture must produce ISS passes over Guangzhou", windows.isNotEmpty())
|
||||||
|
|
||||||
|
val vm = createVm(
|
||||||
|
satellites = listOf(TestOrbits.ISS),
|
||||||
|
passes = windows
|
||||||
|
)
|
||||||
|
vm.onStationBGrid("OL62")
|
||||||
|
vm.onHoursAhead(12)
|
||||||
|
|
||||||
|
queryAndSettle(vm)
|
||||||
|
|
||||||
|
val state = vm.uiState.value
|
||||||
|
assertNull(state.errorMessage)
|
||||||
|
assertTrue(state.hasSearched)
|
||||||
|
assertFalse(state.isCalculating)
|
||||||
|
assertTrue("expected at least one mutual pass", state.mutualPasses.isNotEmpty())
|
||||||
|
assertTrue(state.mutualPasses.all { it.catNum == 25544 })
|
||||||
|
assertTrue(state.mutualPasses.all { it.name == "ISS (ZARYA)" })
|
||||||
|
// AOS/LOS within the searched horizon
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
assertTrue(state.mutualPasses.all { it.startTime >= now - 120_000L })
|
||||||
|
assertTrue(state.mutualPasses.all { it.endTime <= now + 12 * 3600_000L + 120_000L })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `query results are sorted by start time even when input is shuffled`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||||
|
// A 24h horizon reliably yields several windows per satellite (ISS +
|
||||||
|
// staggered variant), guaranteeing >= 2 distinct passes.
|
||||||
|
val windows = TestOrbits.findPassWindows(hoursAhead = 24) +
|
||||||
|
TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24)
|
||||||
|
assertTrue("fixture must produce at least two windows", windows.size >= 2)
|
||||||
|
|
||||||
|
val shuffled = windows.shuffled(Random(42))
|
||||||
|
val vm = createVm(
|
||||||
|
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
|
||||||
|
passes = shuffled
|
||||||
|
)
|
||||||
|
vm.onStationBGrid("OL62")
|
||||||
|
vm.onHoursAhead(24)
|
||||||
|
|
||||||
|
queryAndSettle(vm)
|
||||||
|
|
||||||
|
val starts = vm.uiState.value.mutualPasses.map { it.startTime }
|
||||||
|
assertEquals(starts.sorted(), starts)
|
||||||
|
// Refined AOS must not drift far from the coarse window
|
||||||
|
val byAos = windows.associateBy { it.aosTime }
|
||||||
|
vm.uiState.value.mutualPasses.forEach { mp ->
|
||||||
|
assertTrue("refined AOS ${mp.startTime} near a coarse AOS", byAos.keys.any { kotlin.math.abs(it - mp.startTime) < 5 * 60_000L })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------- query: fallback
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `query falls back to independent search when pass list is empty`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||||
|
// Two satellites (ISS + staggered variant) raise the odds of a window
|
||||||
|
// within the fallback horizon while keeping the search realistic.
|
||||||
|
val vm = createVm(
|
||||||
|
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
|
||||||
|
passes = emptyList()
|
||||||
|
)
|
||||||
|
vm.onStationBGrid("OL62")
|
||||||
|
vm.onHoursAhead(12)
|
||||||
|
|
||||||
|
queryAndSettle(vm)
|
||||||
|
|
||||||
|
val state = vm.uiState.value
|
||||||
|
assertFalse(state.isCalculating)
|
||||||
|
assertTrue(state.hasSearched)
|
||||||
|
// GZ + SZ are ~100km apart: a 12h ISS window must yield at least one mutual pass
|
||||||
|
assertNull(state.errorMessage)
|
||||||
|
assertTrue("fallback should find mutual passes", state.mutualPasses.isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `query over antipodal stations finds no mutual passes`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||||
|
val vm = createVm(satellites = listOf(TestOrbits.ISS), passes = emptyList())
|
||||||
|
vm.onStationBLat("40.71")
|
||||||
|
vm.onStationBLon("-74.01")
|
||||||
|
vm.onHoursAhead(2)
|
||||||
|
|
||||||
|
queryAndSettle(vm)
|
||||||
|
|
||||||
|
val state = vm.uiState.value
|
||||||
|
assertFalse(state.isCalculating)
|
||||||
|
assertTrue(state.mutualPasses.isEmpty())
|
||||||
|
assertTrue(state.errorMessage != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- misc
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clearError resets the error message`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||||
|
val vm = createVm()
|
||||||
|
vm.onStationAGrid("ZZ99")
|
||||||
|
vm.onStationBGrid("ZZ99")
|
||||||
|
queryAndSettle(vm)
|
||||||
|
assertTrue(vm.uiState.value.errorMessage != null)
|
||||||
|
|
||||||
|
vm.clearError()
|
||||||
|
|
||||||
|
assertNull(vm.uiState.value.errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.rtbishop.look4sat.feature.mutual
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
|
import kotlin.math.PI
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic orbital test fixtures: real ISS TLE elements (2024-03-09,
|
||||||
|
* same source as DataParserTest) so SGP4 propagation produces genuine
|
||||||
|
* visibility windows instead of fabricated numbers.
|
||||||
|
*/
|
||||||
|
object TestOrbits {
|
||||||
|
|
||||||
|
/** ISS (ZARYA) from the 2024-03-09 TLE used in DataParserTest. */
|
||||||
|
val ISS: OrbitalObject = NearEarthObject(
|
||||||
|
OrbitalData(
|
||||||
|
name = "ISS (ZARYA)",
|
||||||
|
epoch = 24069.23963816,
|
||||||
|
meanmo = 15.49756209,
|
||||||
|
eccn = 0.0005741,
|
||||||
|
incl = 51.6418,
|
||||||
|
raan = 90.7424,
|
||||||
|
argper = 343.9724,
|
||||||
|
meanan = 92.8274,
|
||||||
|
catnum = 25544,
|
||||||
|
bstar = 0.00025016
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Guangzhou (default BA7OPF grid OL62/OL63 area). */
|
||||||
|
val GUANGZHOU = GeoPos(23.13, 113.26)
|
||||||
|
|
||||||
|
/** Shenzhen, ~100 km from Guangzhou — nearly every LEO pass is mutual. */
|
||||||
|
val SHENZHEN = GeoPos(22.54, 114.06)
|
||||||
|
|
||||||
|
/** New York — on the opposite side of the globe, never mutual with GZ. */
|
||||||
|
val NEW_YORK = GeoPos(40.71, -74.01)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Second LEO with the same ISS elements but a different argument of
|
||||||
|
* perigee, so its visibility windows land at different times. Gives tests
|
||||||
|
* two distinct passes to exercise ordering/aggregation logic.
|
||||||
|
*/
|
||||||
|
val ISS_VARIANT: OrbitalObject = NearEarthObject(
|
||||||
|
OrbitalData(
|
||||||
|
name = "ISS-V2",
|
||||||
|
epoch = 24069.23963816,
|
||||||
|
meanmo = 15.49756209,
|
||||||
|
eccn = 0.0005741,
|
||||||
|
incl = 51.6418,
|
||||||
|
raan = 90.7424,
|
||||||
|
argper = 100.0,
|
||||||
|
meanan = 92.8274,
|
||||||
|
catnum = 25545,
|
||||||
|
bstar = 0.00025016
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coarse pass-window search (30s steps) producing OrbitalPass entries with
|
||||||
|
* honest AOS/LOS times. Mirrors the coarse phase of SatelliteRepo.getLeoPass;
|
||||||
|
* the ViewModel under test re-refines the edges to ~1s itself.
|
||||||
|
*/
|
||||||
|
fun findPassWindows(
|
||||||
|
sat: OrbitalObject = ISS,
|
||||||
|
pos: GeoPos = GUANGZHOU,
|
||||||
|
time: Long = System.currentTimeMillis(),
|
||||||
|
hoursAhead: Int = 12
|
||||||
|
): List<OrbitalPass> {
|
||||||
|
if (!sat.willBeSeen(pos)) return emptyList()
|
||||||
|
val passes = mutableListOf<OrbitalPass>()
|
||||||
|
val step = 30_000L
|
||||||
|
val end = time + hoursAhead * 3600_000L
|
||||||
|
var t = time
|
||||||
|
var inWindow = false
|
||||||
|
var aos = 0L
|
||||||
|
var maxElev = 0.0
|
||||||
|
while (t < end) {
|
||||||
|
val elev = sat.getElevation(pos, t) * 180.0 / PI
|
||||||
|
if (elev > 0.0 && !inWindow) {
|
||||||
|
inWindow = true
|
||||||
|
aos = t
|
||||||
|
maxElev = 0.0
|
||||||
|
}
|
||||||
|
if (inWindow && elev > maxElev) maxElev = elev
|
||||||
|
if (elev <= 0.0 && inWindow) {
|
||||||
|
inWindow = false
|
||||||
|
passes.add(
|
||||||
|
OrbitalPass(
|
||||||
|
aosTime = aos,
|
||||||
|
losTime = t,
|
||||||
|
maxElevation = maxElev,
|
||||||
|
orbitalObject = sat
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
t += step
|
||||||
|
}
|
||||||
|
return passes
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,19 +84,23 @@ import java.util.Locale
|
|||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) {
|
fun PassesDestination(
|
||||||
|
navigateToRadar: (Int, Long) -> Unit,
|
||||||
|
navigateToMap: () -> Unit
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
|
val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
|
||||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
PassesScreen(uiState, viewModel::onAction, navigateToRadar)
|
PassesScreen(uiState, viewModel::onAction, navigateToRadar, navigateToMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PassesScreen(
|
private fun PassesScreen(
|
||||||
uiState: PassesState,
|
uiState: PassesState,
|
||||||
onAction: (PassesAction) -> Unit,
|
onAction: (PassesAction) -> Unit,
|
||||||
navigateToRadar: (Int, Long) -> Unit
|
navigateToRadar: (Int, Long) -> Unit,
|
||||||
|
navigateToMap: () -> Unit
|
||||||
) {
|
) {
|
||||||
if (uiState.isPassesDialogShown) {
|
if (uiState.isPassesDialogShown) {
|
||||||
PassesFilterDialog(
|
PassesFilterDialog(
|
||||||
@@ -163,7 +167,7 @@ private fun PassesScreen(
|
|||||||
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
||||||
},
|
},
|
||||||
endAction = {
|
endAction = {
|
||||||
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
|
IconCard(action = navigateToMap, resId = R.drawable.ic_map)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-2
@@ -23,6 +23,7 @@ import androidx.lifecycle.viewmodel.initializer
|
|||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
|
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
@@ -167,6 +168,7 @@ class PassesViewModel(
|
|||||||
// CelestialComputer.findSunRiseSet() returns the next rise/set after the supplied time, so pass
|
// CelestialComputer.findSunRiseSet() returns the next rise/set after the supplied time, so pass
|
||||||
// the calendar day's 00:00 in the same timezone used by the visible date group. Passing a pass's
|
// the calendar day's 00:00 in the same timezone used by the visible date group. Passing a pass's
|
||||||
// AOS time can jump to the following day's sunrise/sunset when that AOS is after the local event.
|
// AOS time can jump to the following day's sunrise/sunset when that AOS is after the local event.
|
||||||
|
// For white nights / polar day edge cases, we fall back to searching from the previous day.
|
||||||
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
||||||
val stationPos = settingsRepo.stationPosition.value
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
@@ -175,7 +177,7 @@ class PassesViewModel(
|
|||||||
val result = LinkedHashMap<String, Pair<String, String>>()
|
val result = LinkedHashMap<String, Pair<String, String>>()
|
||||||
// DeepSpace group always shows today's sun times
|
// DeepSpace group always shows today's sun times
|
||||||
if (passes.any { it.isDeepSpace }) {
|
if (passes.any { it.isDeepSpace }) {
|
||||||
val riseSet = CelestialComputer.findSunRiseSet(stationPos, startOfDayMillis(System.currentTimeMillis(), tz))
|
val riseSet = findTodaySunRiseSet(stationPos, startOfDayMillis(System.currentTimeMillis(), tz))
|
||||||
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
||||||
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
||||||
result["DeepSpace (period >225min)"] = rise to set
|
result["DeepSpace (period >225min)"] = rise to set
|
||||||
@@ -184,7 +186,7 @@ class PassesViewModel(
|
|||||||
if (pass.isDeepSpace) continue
|
if (pass.isDeepSpace) continue
|
||||||
val label = sdfDate.format(Date(pass.aosTime))
|
val label = sdfDate.format(Date(pass.aosTime))
|
||||||
if (label in result) continue
|
if (label in result) continue
|
||||||
val riseSet = CelestialComputer.findSunRiseSet(stationPos, startOfDayMillis(pass.aosTime, tz))
|
val riseSet = findTodaySunRiseSet(stationPos, startOfDayMillis(pass.aosTime, tz))
|
||||||
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
||||||
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
||||||
result[label] = rise to set
|
result[label] = rise to set
|
||||||
@@ -192,6 +194,33 @@ class PassesViewModel(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find today's sunrise and sunset by starting from local midnight.
|
||||||
|
* Handles the white nights / polar day edge case where the sun is still above
|
||||||
|
* the -0.8333° threshold at midnight: in that case we search from the previous
|
||||||
|
* day's midnight to find a valid sunrise/sunset pair within the target day.
|
||||||
|
*/
|
||||||
|
private fun findTodaySunRiseSet(observer: GeoPos, localMidnightMillis: Long): CelestialComputer.RiseSetTimes {
|
||||||
|
val threshold = 0.8333
|
||||||
|
val sunPos = CelestialComputer.getSunPosition(observer, localMidnightMillis)
|
||||||
|
if (sunPos.elevation > -threshold) {
|
||||||
|
// White nights / polar day: the sun is too high at midnight.
|
||||||
|
// Search from earlier days so the algorithm goes through a full
|
||||||
|
// sunset → sunrise → sunset cycle, landing in the target day.
|
||||||
|
val dayEnd = localMidnightMillis + 86400000L
|
||||||
|
for (offset in 1..3) {
|
||||||
|
val result = CelestialComputer.findSunRiseSet(observer, localMidnightMillis - offset * 86400000L)
|
||||||
|
if (result.riseTimeMillis in localMidnightMillis..dayEnd &&
|
||||||
|
result.setTimeMillis in localMidnightMillis..dayEnd) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No valid pair found (midnight sun / polar day)
|
||||||
|
return CelestialComputer.RiseSetTimes(0L, 0L)
|
||||||
|
}
|
||||||
|
return CelestialComputer.findSunRiseSet(observer, localMidnightMillis)
|
||||||
|
}
|
||||||
|
|
||||||
private fun startOfDayMillis(timeMillis: Long, tz: TimeZone): Long {
|
private fun startOfDayMillis(timeMillis: Long, tz: TimeZone): Long {
|
||||||
return Calendar.getInstance(tz, Locale.getDefault()).apply {
|
return Calendar.getInstance(tz, Locale.getDefault()).apply {
|
||||||
timeInMillis = timeMillis
|
timeInMillis = timeMillis
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":feature:mutual"))
|
implementation(project(":feature:mutual"))
|
||||||
// CW 解码面板: 复用 feature:cw 的 Morse Expert 引擎(布局 cw_panel_main + MainActivity 控制器)
|
|
||||||
implementation(project(":feature:cw"))
|
implementation(project(":feature:cw"))
|
||||||
// 与 feature:cw 一致(控制器按 ID 递归查找 ConstraintLayout 视图)
|
|
||||||
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
|
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
|
||||||
}
|
}
|
||||||
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user