Compare commits

...
Author SHA1 Message Date
atsunatsu 2c9a5b376f feat: release-notes body in update checker; grid mode centers on station grid on every entry; own grid square drawn with thick outline; low-zoom meridian vanishing while panning fixed 2026-09-07 15:06:33 +08:00
atsunatsu 422e480b2f release: v4.4.6-ba7opf.9.3 2026-09-07 14:07:57 +08:00
atsunatsu efd6931abb fix: LoTW sync — force full report with qso_qslsince (LoTW default only returns new confirmations since last query) and accept lowercase <eoh> header 2026-09-07 13:51:41 +08:00
atsunatsu c7ca77a943 fix: sync dialogs use typed values directly (save+sync in one step); LoTW card wording; center map on station grid when entering grid mode; center grid labels in cells; update-check falls back to GitHub accelerator mirrors 2026-09-07 07:22:41 +08:00
atsunatsu be6473e692 feat: LoTW confirmed-grids sync card — fetch confirmed QSO grids directly from ARRL LoTW (lotwreport.adi) and merge with Wavelog grids; credentials stored locally 2026-09-07 06:55:42 +08:00
atsunatsu 75d162301b fix: meridians vanish at low zoom (osmdroid clips Mercator X; normalize longitude to view center); hide satellite layer and bottom info card in grid mode 2026-09-07 06:28:58 +08:00
atsunatsu 07c09ec3e9 fix: VersionComparator parses multi-level suffixes (4.4.6-ba7opf.9.1) by comparing all numeric segments, not just the last one 2026-09-07 06:11:31 +08:00
atsunatsu 513d50e56a chore: bump version to 4.4.6-ba7opf.9.1 2026-09-07 06:01:45 +08:00
atsunatsu 366edd8bbb fix: correct longitude bounds in Maidenhead grid overlay (left/right were swapped, so meridians and worked-grid fills never drew) 2026-09-07 06:01:10 +08:00
atsunatsu 13df0dfcec fix: Wavelog sync via v1 API logbook_get_worked_grids (3.0.2 has no API v2) 2026-09-07 02:11:40 +08:00
atsunatsu eb1a5d51a2 chore: bump version to 4.4.6-ba7opf.9 2026-09-07 02:02:36 +08:00
atsunatsu 4e3547649f feat: Maidenhead grid mode with worked-grid highlights from Wavelog (API v2 grid=all) 2026-09-07 01:42:42 +08:00
atsunatsu 7abbeb6eb3 fix: update check via GitHub web endpoint, avoiding API 60 req/hour rate limit 2026-09-06 21:33:41 +08:00
atsunatsu 5c73d5ed7a feat: default TLE source order with LAPAN-A2 (Kaggle) first, R4UAB second 2026-09-06 20:18:04 +08:00
atsunatsu a663ef7f1b build: read release signing credentials from local.properties/env instead of hardcoding
Passwords for the release keystore were committed in plaintext to a
public repo. Move them to gitignored local.properties with an
environment-variable fallback. Keystore file itself stays in $HOME.
2026-09-05 07:57:33 +08:00
atsunatsu 1f1e1fb144 fix: keep update install action visible 2026-09-01 11:03:47 +08:00
atsunatsu 5166daa297 fix: localize AMSAT date labels in Chinese 2026-09-01 10:18:56 +08:00
atsunatsu 604c2ec59d feat: check for updates page in settings
Add a full-screen update checker accessible from a button at the
bottom of the settings page. It queries the fork's GitHub latest
release and can download + install the APK in place.

- UpdateRepository (core/data): fetch releases/latest via GitHub API,
  parse tag/description/APK asset, stream APK download to cacheDir
- VersionComparator (core/domain): compare 4.4.6-ba7opf.N style tags
  (base version first, then build number; v-prefix tolerant) + tests
- IMainContainer/MainContainer: inject updateRepo
- UpdateCheckerScreen (feature/settings): shows current version,
  checks for updates, displays latest version + release notes, and a
  Download & install button; FileProvider hands the APK to the
  system installer
- SettingsScreen: bottom entry button, full-screen page toggle
- AndroidManifest: REQUEST_INSTALL_PACKAGES permission + FileProvider
- i18n strings (en/zh)
2026-08-29 16:25:51 +08:00
atsunatsu 1904a6b3e6 feat: drag reorder handle in data sources dialog
Port the upstream drag-reorder pattern (dragHandle/draggedVisual/
autoScroll/DragRowState) so custom data source rows can be reordered
by long-pressing the handle icon, replacing the old move-up button.

- Add 40dp drag-handle box at row start with long-press gesture
- Live reorder while dragging (neighbour squeeze + spring settle)
- Edge auto-scroll during drag
- Keep per-source enabled checkbox + HTTP status label

This file also carries the #242 data-source dialog UI (enabled
checkbox, status code label, restore-default button) which shares
the same sourceSection function and cannot be cleanly split.
2026-08-29 16:25:09 +08:00
atsunatsu 7f390117b8 feat: data source toggles + HTTP status codes (upstream #242)
Port upstream PR #242 data layer: per-source enabled toggles and HTTP
status codes for custom data sources.

- IRemoteSource: NetworkResult(code, stream) + getNetworkStream
- RemoteSource: return HTTP status code (200 ok / error code / ERR)
- ISettingsRepo: dataSourcesStatus StateFlow<Map<String,Int>>
- SettingsRepo: persist enabled flags (single JSON per key) + status
- DatabaseRepo: filter by enabled toggles, write back status codes
- Settings.kt: DataSourcesSettings + satelliteEnabled/transceiversEnabled
- Sync test fakes (AmSatRepositoryTest, DatabaseRepoTest, Fakes.kt)

Fork-specific features preserved: builtinTypesByUrl reverse lookup,
satnogs migration, map-type data source structure.
2026-08-29 16:21:59 +08:00
atsunatsu 346803fe6e fix: normalize Maidenhead grid input to uppercase in mutual pass
- onStationAGrid/onStationBGrid store the trimmed upper-cased grid instead
  of the raw input (ol63 used to stay lowercase in the UI)
- drop the local latLonToGrid/gridToLatLon copies and reuse the domain
  QthConverter, removing the drift risk between two implementations
- inject computeDispatcher (default Dispatchers.Default, no behaviour
  change) so the pass computation is unit-testable on a test scheduler
- add MutualViewModelTest (15 cases): init prefill, grid/lat-lon linkage,
  error paths, main-list reuse, fallback search, sorting, clearError
2026-08-29 10:55:15 +08:00
atsunatsu 47b87a1604 refactor: consolidate Maidenhead conversion into domain QthConverter (4/6/8-char)
- QthConverter.qthToPosition now accepts 4/6/8-char locators (previously
  6-char only: 4-char returned null, 8-char was silently truncated)
- positionToQth now emits upper-case sub-square letters (IO91VL) so all
  callers render grids consistently
- Extended QthConverterTest: 4/6/8-char parsing, odd-length and out-of-range
  rejection, lower-case input normalisation
2026-08-29 10:54:55 +08:00
atsunatsu 935fe6d99f chore: bump version to 4.4.6-ba7opf.6 2026-08-28 15:39:17 +08:00
atsunatsu 69af1c38bb fix: use real SatNOGS TLE source so newly launched satellites appear
- Point the built-in SatNOGS source at db.satnogs.org/api/tle/?format=3le
  (the CelesTrak satnogs group is a separate, slower subset); rename the
  old CelesTrak source to 'CelesTrak SatNOGS'.
- Add a one-time migration in SettingsRepo: installed apps that already
  persisted a satelliteUrls list containing the old CelesTrak SatNOGS URL
  get the real SatNOGS TLE URL inserted right after it.
- Dedupe imported satellites by catnum and radios by uuid in
  DatabaseRepo.updateFromRemote so multiple sources don't insert the
  same primary key twice.
- Test: importing JAMX-0825b (NORAD 98248) from the real SatNOGS source.
2026-08-28 15:23:23 +08:00
atsunatsu 5c35f2418d chore: bump version to 4.4.6-ba7opf.5 2026-08-28 01:07:26 +08:00
atsunatsu 2aa9858660 feat: open map from radar header
Replace the radar page calendar action with the map icon and route the action through the Passes map flow so the Passes navigation item remains selected.
2026-08-28 00:42:00 +08:00
atsunatsu bc6e065804 feat: prefetch AMSAT status on foreground
Start AMSAT status prefetch when the app enters foreground and share the in-flight request with the status page fetch path to avoid duplicate network requests.
2026-08-28 00:41:32 +08:00
atsunatsu 7924a6f69e feat: cache AMSAT status while app is foreground 2026-08-23 19:27:16 +08:00
atsunatsu 9a2a715d1f chore: bump version to 4.4.6-ba7opf.3 2026-08-22 21:15:41 +08:00
atsunatsu ee32c7a1eb feat: confirm AMSAT report uploads 2026-08-22 20:50:39 +08:00
atsunatsu 1d54ac4718 style: color code AMSAT upload status chips 2026-08-22 19:43:46 +08:00
atsunatsu 433ce85561 feat: upload AMSAT status reports 2026-08-22 19:14:24 +08:00
atsunatsu cebe3b7ec4 refactor: move map access under passes 2026-08-22 18:35:53 +08:00
atsunatsu d8f60cf270 docs: add Mutual Pass and AMSAT status features to README 2026-08-20 14:28:09 +08:00
atsunatsu 4a3332fdb5 chore: remove dist/ from tracking, add to .gitignore 2026-08-19 22:19:29 +08:00
atsunatsu 9e7fdbfe71 fix: passband RX update now uses full Doppler round-trip instead of pure passband mapping
When adjusting RX in passband mode (via +/- buttons or slider), the old
code used TransponderMapper.mapDownlinkToUplink (pure passband mapping
without Doppler compensation), producing ~10kHz error at LEO velocities.

Fix: use DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset
for the full TX-from-RX round-trip, matching the RX→PASSBAND button
onClick logic. Also fixed frame mismatch: position now uses txLow/txRange
(Doppler-shifted frame) consistently.
2026-08-19 22:19:14 +08:00
atsunatsu 463df58eb9 fix: keep calculator page content composed while scrolling 2026-08-12 17:50:10 +08:00
atsunatsu 58b8801c0c fix: preserve calculator frequencies while scrolling 2026-08-11 23:42:06 +08:00
atsunatsu 2581ba92c0 feat: remember per-satellite doppler offset in calculator
Store the offset (kHz) per NORAD catalog id in SharedPreferences so
each satellite's last entered offset is restored when reopening the
frequency calculator.
2026-08-10 15:11:02 +08:00
atsunatsu e5131ebb72 fix: amsat icon position, swap mutual icon to radio-tower
- Adjust ic_satellite_alt translateY from -226.13 to -230.5 so icon
  is centered vertically (not clipped at bottom)
- Replace ic_match with ic_radio_tower (MDI) for Mutual page
2026-08-09 01:53:16 +08:00
atsunatsu f9accdd517 feat: add AMSAT status page to bottom navigation bar
- Add ic_satellite_alt.xml icon (from upstream author rt-bishop)
- Add Screen.AMSAT to Navigation.kt
- Add AMSAT entry in MainScreen.kt nav bar + NavDisplay entry
- Wire up feature:status module dependency in ApplicationPlugin
- Chinese/English nav_amsat strings already exist from earlier port
2026-08-09 01:03:30 +08:00
atsunatsu ee3c1e7058 feat: add AMSAT satellite status tracking page (#234)
Port upstream PR #234 to fork main:
- New feature:status module (SatStatusScreen, SatStatusViewModel)
- New AmSatRepository + IAmSatRepository + SatStatus models
- RemoteSource: AMSAT API methods (getAmSatCatalog, getAmSatReports)
- MainContainer/IMainContainer: DI injection
- MainTheme: AMSAT status colors (tertiary slots)
- Chinese translations added for all AMSAT strings
- 26 unit tests passing

Note: navigation entry not included — upstream PR #234 intentionally
left it out pending UI restructuring. Add AMSAT to your nav bar or
menu as desired.
2026-08-08 22:13:56 +08:00
atsunatsu a08a28df39 fix: recalculate passes when station position changes
Use combine(selectedIds, stationPosition) in SatelliteRepo.initRepository
so that passes are recalculated on either satellite selection change or
station position change. StateFlow already emits distinct values.
2026-08-08 20:30:08 +08:00
atsunatsu 759a6de2c6 chore: replace workflow badge with release version badge 2026-08-08 15:58:11 +08:00
atsunatsu ba5a984c16 fix: broaden linear transponder matching and deduplicate same-range entries
- isNamedLinearTransponder: match ' Lin' prefix (AO-7 style) and
  add fallback branch (hasLinearName && hasLinearMode) for entries
  with 'Linear'/'Lin' but no 'transponder' word (AO-7, AO-73)
- add deduplicateTransponders: merge entries sharing the same
  uplink/downlink frequency range, preferring non-CW entry
  (AO-7 Mode A had separate SSB and CW entries; JO-97 had
  separate CW and SSB Transponder entries)
- apply dedup in CalculatorPage filter chain
- 6 new tests, 23 total, all passing
2026-08-08 15:42:33 +08:00
atsunatsu 083b8a2847 Merge pull request #5 from MCKero6423/feat/thanks-bg7nta
Add BG7NTA (CW decoding feature) to the thanks list
2026-08-08 12:44:05 +08:00
mckero e8ebfb1ef3 feat(credits): add BG7NTA to the thanks list
Add BG7NTA (CW decoding feature) to the in-app credits/thanks section
across all three locale files (en, zh, tr).
2026-08-08 03:52:24 +00:00
atsunatsu 12eed1d9a9 i18n: use string resources for mutual match page (zh/en) 2026-08-07 21:27:01 +08:00
atsunatsu b3859fbece i18n: translate '友台' to 'opposite' for clarity 2026-08-07 20:06:43 +08:00
atsunatsu f40a67e1b7 revert: remove fldigi CW decoder migration, keep Morse Expert 2026-08-07 20:01:53 +08:00
atsunatsu 16dce5df2a chore: remove .cxx build artifacts from git tracking 2026-08-07 19:59:03 +08:00
atsunatsu 80a58bed61 i18n: localize mutual match page to English 2026-08-07 19:53:36 +08:00
atsunatsu a8afc63bba fix: default CW decoder to ham radio QSO mode 2026-08-07 18:43:01 +08:00
atsunatsu dbf9a2a65f fix: native decoder crash on 64-bit devices; restore calculator layout 2026-08-07 11:25:42 +08:00
atsunatsu ddecaa6017 docs: update README for BA7OPF fork features 2026-08-06 13:19:32 +08:00
atsunatsu 418a05e343 fix(i18n): update Chinese station position wording 2026-08-04 21:03:35 +08:00
85 changed files with 5837 additions and 736 deletions

No files matched your search

+2
View File
@@ -18,6 +18,7 @@ out/
# Gradle files
.gradle/
build/
.cxx/
# Local configuration file (sdk path, etc)
local.properties
@@ -67,3 +68,4 @@ fastlane/readme.md
/app/release/output-metadata.json
/app/release/
/.kotlin/sessions/
dist/
+23 -30
View File
@@ -1,45 +1,38 @@
# Look4Sat: Satellite tracker
# Look4Sat-BA7OPF
[![Look4Sat CI](https://github.com/rt-bishop/Look4Sat/actions/workflows/release.yml/badge.svg)](https://github.com/rt-bishop/Look4Sat/actions/workflows/release.yml)
[![Release](https://img.shields.io/github/v/release/atsunatsu/Look4Sat)](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)
[<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/)
**BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的业余无线电卫星追踪器,增加了线性卫星频率计算器等功能。
### Radio satellite tracker and pass predictor for Android, inspired by Gpredict
## 本仓库特色功能
<p float="left">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="192"/>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="192"/>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="192"/>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="192">
</p>
- **双站过境匹配(Mutual Pass)** — 输入友台经纬度/网格,筛选双方同时可见的卫星过境,在地图上同时显示双方仰角曲线和地面轨迹,支持一键跳转雷达页查看详情
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本
- **Passband 模式** — 支持通过位置滑块(Passband)自动计算 TX/RX 频率,避免切换时跳变
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放
- **中文界面优化** — 翻译修正、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.\
You can search the entire database by NORAD Catalog Number or the satellite's name.
本仓库是 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的分支,上游仓库的原始功能包括:
Orbital positions and passes are calculated relative to your location.\
To get reliable data make sure to set the station position via the app Settings.
- 基于 Celestrak / SatNOGS 数据的 9000+ 活跃卫星追踪
- 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:
* 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.
GNU General Public License v3.0。详见 [LICENSE](LICENSE)。
## 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>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&theme=dark&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://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&legend=top-left" />
</picture>
</a>
+28 -4
View File
@@ -1,12 +1,36 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
alias(libs.plugins.convention.applicationPlugin)
}
// Release signing credentials live in local.properties (gitignored) or
// environment variables — never hardcode passwords in VCS.
val releaseProps = Properties().apply {
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 {
// PR #1 CW decoder uses the Morse Expert native decoder, which ships only armeabi-v7a.
namespace = libs.versions.packageName.get()
defaultConfig {
ndk {
abiFilters += listOf("armeabi-v7a")
applicationId = "cn.ba7opf.look4sat"
ndk { abiFilters.add("armeabi-v7a") }
}
signingConfigs {
create("release") {
storeFile = file(System.getProperty("user.home") + "/my-release-key.jks")
storePassword = releaseCred("RELEASE_STORE_PASSWORD")
keyAlias = releaseCred("RELEASE_KEY_ALIAS").ifEmpty { "look4sat" }
keyPassword = releaseCred("RELEASE_KEY_PASSWORD")
}
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
}
}
}
+11
View File
@@ -13,6 +13,7 @@
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".MainApplication"
@@ -45,5 +46,15 @@
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
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>
</manifest>
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
observeNightFilterState()
setContent {
MainTheme(isDarkTheme = true) { MainScreen() }
MainTheme(isDarkTheme = true) { NavRoot() }
}
}
@@ -17,7 +17,10 @@
*/
package com.rtbishop.look4sat
import android.app.Activity
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.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
@@ -35,12 +38,37 @@ class MainApplication : Application(), IContainerProvider {
override fun onCreate() {
super.onCreate()
container = MainContainer(this)
clearAmSatCacheOnAppBackground()
// trigger automatic update every 48 hours
container.appScope.launch { checkAutoUpdate() }
// load satellite data on every app start
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()) {
if (container.settingsRepo.otherSettings.value.stateOfAutoUpdate) {
val timeDelta = timeNow - container.settingsRepo.databaseState.value.updateTimestamp
@@ -25,8 +25,6 @@ import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
@@ -44,7 +42,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
@@ -53,6 +51,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -87,37 +88,53 @@ import com.rtbishop.look4sat.feature.passes.PassesDestination
import com.rtbishop.look4sat.feature.radar.RadarDestination
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
import com.rtbishop.look4sat.feature.settings.SettingsDestination
import com.rtbishop.look4sat.feature.status.SatStatusDestination
@Composable
fun NavRoot(deeplink: String? = null) {
val rootBackStack = rememberNavBackStack(Screen.Passes)
val deeplinkResolver = DeeplinkResolver()
var openMapRequest by remember { mutableIntStateOf(0) }
LaunchedEffect(deeplink) {
deeplink?.let {
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
rootBackStack.add(destination)
}
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
}
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
val navigateToMap: () -> Unit = {
while (rootBackStack.size > 1) rootBackStack.removeAt(rootBackStack.size - 1)
openMapRequest += 1
}
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
slideOutHorizontally(tween(300)) { -it / 3 }
// Reverse: outgoing slides out to the right, incoming drifts in from the left
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
slideOutHorizontally(tween(300)) { it }
NavDisplay(
modifier = Modifier.fillMaxSize(),
backStack = rootBackStack,
onBack = navigateBack,
transitionSpec = { slideInTransition },
popTransitionSpec = { slideOutTransition },
predictivePopTransitionSpec = { slideOutTransition },
transitionSpec = { pushTransition },
popTransitionSpec = { popTransition },
predictivePopTransitionSpec = { popTransition },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
entry<Screen.Passes> {
MainScreen(
navigateToRadar = navigateToRadar,
openMapRequest = openMapRequest,
onOpenMapRequestHandled = { openMapRequest = 0 }
)
}
entry<RadarDestination> {
Scaffold { innerPadding ->
RadarDestination(navigateUp = navigateBack)
innerPadding.calculateTopPadding()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
RadarDestination(navigateUp = navigateBack, navigateToMap = navigateToMap)
}
}
}
@@ -125,12 +142,26 @@ fun NavRoot(deeplink: String? = null) {
}
@Composable
fun MainScreen(navigateToRadar: () -> Unit = {}) {
fun MainScreen(
navigateToRadar: () -> Unit = {},
openMapRequest: Int = 0,
onOpenMapRequestHandled: () -> Unit = {}
) {
val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
val navigateToMap: () -> Unit = {
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
backStack.add(Screen.Map)
}
LaunchedEffect(openMapRequest) {
if (openMapRequest > 0) {
navigateToMap()
onOpenMapRequestHandled()
}
}
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Map, Screen.Settings)
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.AMSAT, Screen.Mutual, Screen.Settings)
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
@@ -154,9 +185,9 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
val isSelected = when (currentKey) {
is Screen.Satellites -> screen is Screen.Satellites
is Screen.Passes -> screen is Screen.Passes
is Screen.Radar -> screen is Screen.Radar
is Screen.Map -> screen is Screen.Passes
is Screen.AMSAT -> screen is Screen.AMSAT
is Screen.Mutual -> screen is Screen.Mutual
is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings
else -> false
}
@@ -165,7 +196,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
label = { Text(stringResource(screen.titleResId)) },
selected = isSelected,
onClick = {
if (isSelected) return@item
if (isSelected && !(currentKey is Screen.Map && screen is Screen.Passes)) return@item
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
if (screen !is Screen.Passes) backStack.add(screen)
}
@@ -200,15 +231,14 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
SatellitesDestination(navigateUp = navigateBack)
}
entry<Screen.Passes> {
PassesDestination { catNum, aosTime ->
container.setMutualPassData(MutualPassData())
container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar)
// navigateToRadar()
}
}
entry<Screen.Radar> {
RadarDestination(navigateUp = navigateBack)
PassesDestination(
navigateToRadar = { catNum, aosTime ->
container.setMutualPassData(MutualPassData())
container.satelliteRepo.selectPass(catNum, aosTime)
navigateToRadar()
},
navigateToMap = navigateToMap
)
}
entry<Screen.Map> {
MapDestination()
@@ -216,14 +246,14 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
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)
navigateToRadar()
}
)
}
entry<Screen.AMSAT> { SatStatusDestination() }
entry<Screen.Settings> {
SettingsDestination()
}
@@ -249,7 +279,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
if (pass != null) {
container.setMutualPassData(MutualPassData())
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
backStack.add(Screen.Radar)
navigateToRadar()
}
}
.padding(horizontal = 12.dp, vertical = 6.dp)
+4
View File
@@ -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>
@@ -37,6 +37,7 @@ internal class ApplicationPlugin : Plugin<Project> {
implementation(project(":feature:radar"))
implementation(project(":feature:satellites"))
implementation(project(":feature:settings"))
implementation(project(":feature:status"))
implementation(libs.androidx.core.splashscreen)
implementation(libs.compose.material3.adaptive)
implementation(libs.compose.navigation3)
@@ -17,8 +17,10 @@
*/
package com.rtbishop.look4sat.core.data.framework
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.repository.IReporter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -31,18 +33,30 @@ class NetworkReporter(
private val rotatorServer: String,
private val rotatorPort: Int,
private val frequencyServer: String,
private val frequencyPort: Int
private val frequencyPort: Int,
private val frequencyOffsetHz: Long = 0L
) : IReporter {
private val writeMutex = Mutex()
private val connectionMutex = Mutex()
private val frequencyCommands = Channel<String>(Channel.CONFLATED)
private var rotatorSocket: SocketChannel? = null
private var rotatorConnected = false
private var rotatorConnecting = false
private var frequencySocket: SocketChannel? = null
private var frequencyConnected = false
private var frequencyConnecting = false
init {
// Keep only the latest frequency command to avoid stale backlog and effective lag.
reporterScope.launch {
for (command in frequencyCommands) {
ensureFrequencyConnected()
if (!frequencyConnected) continue
write(frequencySocket, command) { resetFrequencyConnection() }
}
}
}
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
reporterScope.launch {
@@ -53,51 +67,48 @@ class NetworkReporter(
.replace($$"$AZ", azimuth.toString())
.replace($$"$EL", el.toString())
.unescapeControlChars()
write(rotatorSocket, command) { rotatorConnected = false }
write(rotatorSocket, command) { resetRotatorConnection() }
}
}
override fun reportFrequency(format: String, frequency: Long) {
reporterScope.launch {
ensureFrequencyConnected()
if (!frequencyConnected) return@launch
val command = format
.replace($$"$FREQ", frequency.toString())
.unescapeControlChars()
write(frequencySocket, command) { frequencyConnected = false }
}
val clampedOffset = frequencyOffsetHz.coerceIn(
Constants.FREQ_OFFSET_MIN_HZ,
Constants.FREQ_OFFSET_MAX_HZ
)
val correctedFreq = frequency.coerceAtLeast(0L).safeAdd(clampedOffset).coerceAtLeast(0L)
val command = format
.replace($$"$FREQ", correctedFreq.toString())
.unescapeControlChars()
frequencyCommands.trySend(command)
}
private fun ensureRotatorConnected() {
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
reporterScope.launch {
private suspend fun ensureRotatorConnected() {
connectionMutex.withLock {
if (rotatorConnected || rotatorServer.isBlank()) return
try {
rotatorConnecting = true
resetRotatorConnection()
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
rotatorConnected = true
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
} catch (e: Exception) {
println("NetworkReporter rotator connect error: ${e.message}")
rotatorConnected = false
} finally {
rotatorConnecting = false
resetRotatorConnection()
}
}
}
private fun ensureFrequencyConnected() {
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
reporterScope.launch {
private suspend fun ensureFrequencyConnected() {
connectionMutex.withLock {
if (frequencyConnected || frequencyServer.isBlank()) return
try {
frequencyConnecting = true
resetFrequencyConnection()
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
frequencyConnected = true
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
} catch (e: Exception) {
println("NetworkReporter frequency connect error: ${e.message}")
frequencyConnected = false
} finally {
frequencyConnecting = false
resetFrequencyConnection()
}
}
}
@@ -106,7 +117,9 @@ class NetworkReporter(
try {
writeMutex.withLock {
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
socket?.write(buffer)
while (buffer.hasRemaining()) {
socket?.write(buffer)
}
}
} catch (e: Exception) {
println("NetworkReporter write error: ${e.message}")
@@ -114,6 +127,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 =
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
replace("""\r""", 13.toChar().toString())
.replace("""\n""", 10.toChar().toString())
.replace("""\t""", 9.toChar().toString())
}
@@ -29,11 +29,15 @@ import com.rtbishop.look4sat.core.data.framework.Ft817Controller
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
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.RemoteSource
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
@@ -46,10 +50,12 @@ import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.IRadioController
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
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.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
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.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
@@ -70,12 +76,17 @@ import okhttp3.OkHttpClient
class MainContainer(private val context: Context) : IMainContainer {
private val localSource = provideLocalSource()
private val remoteSource = provideRemoteSource()
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
override val settingsRepo = provideSettingsRepo()
override val selectionRepo = provideSelectionRepo()
override val satelliteRepo = provideSatelliteRepo()
override val databaseRepo = provideDatabaseRepo()
override val amSatRepo by lazy { AmSatRepository(remoteSource, 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 {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
@@ -114,7 +125,8 @@ class MainContainer(private val context: Context) : IMainContainer {
rc.rotatorAddress,
rc.rotatorPort.toIntOrNull() ?: 0,
rc.frequencyAddress,
rc.frequencyPort.toIntOrNull() ?: 0
rc.frequencyPort.toIntOrNull() ?: 0,
rc.frequencyOffsetHz
)
}
@@ -0,0 +1,273 @@
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
import com.rtbishop.look4sat.core.domain.model.SatDay
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/** One report from the AMSAT API (data layer model). */
private data class ApiReport(
val id: String,
val name: String,
val callsign: String,
val report: String,
val gridSquare: String,
val reportedTimeUtcSec: Long
)
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
class AmSatRepository(
private val remoteSource: IRemoteSource,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
) : IAmSatRepository {
private val statusCacheMutex = Mutex()
@Volatile
private var statusCache: SatStatusPage? = null
@Volatile
private var statusFetchInFlight: Deferred<SatStatusPage?>? = null
@Volatile
private var cacheGeneration = 0
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
override fun getCachedStatus(): SatStatusPage? = statusCache
override suspend fun fetchStatus(forceRefresh: Boolean): SatStatusPage? {
if (!forceRefresh) statusCache?.let { return it }
var cachedPage: SatStatusPage? = null
val inFlightFetch = statusCacheMutex.withLock {
if (!forceRefresh) {
val cached = statusCache
if (cached != null) {
cachedPage = cached
return@withLock null
}
}
statusFetchInFlight ?: scope.async(Dispatchers.IO) {
fetchStatusFromRemote(cacheGeneration)
}.also { statusFetchInFlight = it }
}
cachedPage?.let { return it }
val fetch = inFlightFetch ?: return null
return try {
fetch.await()
} finally {
statusCacheMutex.withLock {
if (statusFetchInFlight === fetch && fetch.isCompleted) statusFetchInFlight = null
}
}
}
private suspend fun fetchStatusFromRemote(generation: Int): SatStatusPage? {
val nowSec = System.currentTimeMillis() / 1000
val catalogJson = remoteSource.getAmSatCatalog() ?: return null
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return null
val names = parseCatalog(catalogJson)
val reports = parseReports(reportsJson)
if (names.isEmpty() && reports.isEmpty()) return null
val statuses = buildStatuses(names, reports, nowSec)
val reportMap = reports.associate { it.id to toSatReport(it) }
return SatStatusPage(System.currentTimeMillis(), statuses, reportMap).also { page ->
if (generation == cacheGeneration) statusCache = page
}
}
override suspend fun prefetchStatus() {
fetchStatus(forceRefresh = false)
}
override fun clearStatusCache() {
cacheGeneration += 1
statusCache = null
statusFetchInFlight = null
}
override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
val payload = JSONObject().apply {
put("name", submission.name)
put("report", submission.report)
put("callsign", submission.callsign)
put("reported_at", isoUtcFormat.format(Date(submission.reportedAtUtcMillis)))
if (submission.gridSquare.isNotBlank()) put("grid_square", submission.gridSquare)
}
val response = remoteSource.submitAmSatReport(payload.toString())
?: return@withContext AmSatReportSubmitResult(success = false, message = "Network request failed")
val (code, body) = response
val errorMessage = parseSubmitError(body)
return@withContext if (code in 200..299 && errorMessage.isBlank()) {
AmSatReportSubmitResult(success = true, reportId = parseSubmitReportId(body))
} else {
AmSatReportSubmitResult(
success = false,
message = errorMessage.ifBlank { "HTTP $code" }
)
}
}
private fun parseSubmitError(json: String): String {
return try {
JSONObject(json).optJSONObject("error")?.optString("message").orEmpty()
} catch (_: Exception) {
""
}
}
private fun parseSubmitReportId(json: String): String? {
return try {
val obj = JSONObject(json)
obj.optJSONObject("data")?.optString("id")?.takeIf { it.isNotBlank() }
?: obj.optString("id").takeIf { it.isNotBlank() }
} catch (_: Exception) {
null
}
}
/** Parse catalog JSON to list of satellite names */
private fun parseCatalog(json: String): List<String> {
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
} catch (_: Exception) {
emptyList()
}
}
/** Parse reports JSON to list of ApiReport domain objects */
private fun parseReports(json: String): List<ApiReport> {
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).mapNotNull { i ->
val o = arr.getJSONObject(i)
val iso = o.optString("reported_time", "")
if (iso.isEmpty()) null else ApiReport(
id = o.optString("id", ""),
name = o.optString("name", ""),
callsign = o.optString("callsign", ""),
report = o.optString("report", ""),
gridSquare = o.optString("grid_square", ""),
reportedTimeUtcSec = parseIsoUtcSec(iso)
)
}
} catch (_: Exception) {
emptyList()
}
}
/** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */
private fun parseIsoUtcSec(iso: String): Long {
return try {
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
} catch (_: Exception) {
0L
}
}
/** Build one SatStatus (3 days x 12 slots) per catalog satellite, slotting reports by age. */
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
val byName = reports.groupBy { it.name }
val 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
}
}
@@ -66,28 +66,45 @@ class DatabaseRepo(
}
override suspend fun updateFromRemote() = withContext(dispatcher) {
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
val tleUrls = buildMap {
putAll(Sources.satelliteDataUrls)
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
}.filterValues { it.isNotBlank() }
val radioUrls = buildMap {
putAll(Sources.transceiversDataUrls)
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
}.filterValues { it.isNotBlank() }
// launch all network requests concurrently
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
// parse fetched data concurrently and associate with types
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
}
}
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
}
val settings = settingsRepo.dataSourcesSettings.value
// Keep the raw URL as the status key (matches what the settings UI
// displays) while requesting with the normalized URL.
val tleUrls = settings.satelliteUrls
.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) }
.map { it to normalizeUrl(it) }
.distinctBy { it.second }
val radioUrls = settings.transceiversUrls
.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) }
.map { it to normalizeUrl(it) }
.distinctBy { it.second }
val builtinTypesByUrl = Sources.satelliteDataUrls
.filterValues { it.isNotBlank() }
.mapValues { normalizeUrl(it.value) }
.entries
.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 radioResults = radioJobs.awaitAll()
// report the HTTP status code of every source (200, 404, ...)
settingsRepo.updateDataSourcesStatus(
(tleResults + radioResults).associate { (url, result) -> url to result.code }
)
// parse fetched data concurrently and associate known built-in URLs with existing type filters.
val importedEntries = tleResults.flatMap { (rawUrl, result) ->
val normUrl = normalizeUrl(rawUrl)
val entries = result.stream?.let { parseSatelliteStream(normUrl, unwrapIfZipped(normUrl, it)) }.orEmpty()
val type = builtinTypesByUrl[normUrl] ?: customSourceType
importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum })
entries
}.distinctBy { it.catnum }
importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
val importedRadios = radioResults.flatMap { (rawUrl, result) ->
val normUrl = normalizeUrl(rawUrl)
result.stream?.let { dataParser.parseJSONStream(unwrapIfZipped(normUrl, it)) }.orEmpty()
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
// insert parsed data into the database
localSource.insertEntries(importedEntries)
localSource.insertRadios(importedRadios)
@@ -100,6 +117,9 @@ class DatabaseRepo(
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> {
val bufferedStream = stream.buffered()
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"
}
}
@@ -34,6 +34,7 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import java.util.TimeZone
@@ -63,19 +64,23 @@ class SatelliteRepo(
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
override suspend fun initRepository() = withContext(dispatcher) {
settingsRepo.selectedIds.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val settings = settingsRepo.passesSettings.value
calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = settings.hoursAhead,
minElevation = settings.minElevation,
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settings.selectedModes
)
}
combine(
settingsRepo.selectedIds,
settingsRepo.stationPosition
) { selectedIds, _ -> selectedIds }
.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val settings = settingsRepo.passesSettings.value
calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = settings.hoursAhead,
minElevation = settings.minElevation,
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settings.selectedModes
)
}
}
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
@@ -29,6 +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.RCSettings
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.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.positionToQth
@@ -37,6 +38,10 @@ import com.rtbishop.look4sat.core.domain.utility.round
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.json.JSONObject
import java.util.Locale
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.source.Sources
class SettingsRepo(
private val locationManager: LocationManager,
@@ -67,6 +72,7 @@ class SettingsRepo(
private val keyFrequencyAddress = "frequencyAddress"
private val keyFrequencyPort = "frequencyPort"
private val keyFrequencyFormat = "frequencyFormat"
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
private val keySelectedIds = "selectedIds"
private val keySelectedTypes = "selectedTypes"
private val keySelectedModes = "selectedModes"
@@ -76,6 +82,7 @@ class SettingsRepo(
private val keyStateOfUtc = "stateOfUtc"
private val keyStateOfLightTheme = "stateOfLightTheme"
private val keyStateOfNightMode = "stateOfNightMode"
private val keyStateOfMapGrid = "stateOfMapGrid"
private val keyStationAltitude = "stationAltitude"
private val keyStationLatitude = "stationLatitude"
private val keyStationLongitude = "stationLongitude"
@@ -91,7 +98,15 @@ class SettingsRepo(
private val keyUseCustomTransceivers = "useCustomTransceivers"
private val keyTleUrl = "tleUrl"
private val keyTransceiversUrl = "transceiversUrl"
private val keySatelliteUrls = "satelliteUrls"
private val keyTransceiversUrls = "transceiversUrls"
private val keySatelliteEnabled = "satelliteEnabled"
private val keyTransceiversEnabled = "transceiversEnabled"
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
private val separatorComma = ","
private val separatorUrl = "\n"
private val legacyCelestrakSatnogsUrl =
"https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv"
//region # Satellites selection settings
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
@@ -124,7 +139,73 @@ class SettingsRepo(
}
//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())
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
@@ -298,6 +379,10 @@ class SettingsRepo(
override val rcSettings: StateFlow<RCSettings> = _rcSettings
override fun updateRCSettings(settings: RCSettings) {
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
Constants.FREQ_OFFSET_MIN_HZ,
Constants.FREQ_OFFSET_MAX_HZ
)
preferences.edit {
putBoolean(keyRotatorState, settings.rotatorState)
putString(keyRotatorAddress, settings.rotatorAddress)
@@ -307,6 +392,7 @@ class SettingsRepo(
putString(keyFrequencyAddress, settings.frequencyAddress)
putString(keyFrequencyPort, settings.frequencyPort)
putString(keyFrequencyFormat, settings.frequencyFormat)
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
@@ -315,7 +401,7 @@ class SettingsRepo(
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
}
_rcSettings.value = settings
_rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
}
private fun getRCSettings(): RCSettings = RCSettings(
@@ -327,6 +413,8 @@ class SettingsRepo(
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
frequencyOffsetHz = preferences.getLong(keyFrequencyOffsetHz, 0L)
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ),
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
@@ -351,6 +439,7 @@ class SettingsRepo(
putBoolean(keyStateOfUtc, new.stateOfUtc)
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
putBoolean(keyStateOfMapGrid, new.stateOfMapGrid)
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
putString(keySstvMode, new.sstvMode)
@@ -368,6 +457,7 @@ class SettingsRepo(
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
stateOfMapGrid = preferences.getBoolean(keyStateOfMapGrid, false),
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
@@ -381,21 +471,108 @@ class SettingsRepo(
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _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 {
putBoolean(keyUseCustomTle, settings.useCustomTLE)
putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers)
putString(keyTleUrl, settings.tleUrl)
putString(keyTransceiversUrl, settings.transceiversUrl)
putString(keySatelliteUrls, normalized.satelliteUrls.joinToString(separatorUrl))
putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
putBoolean(keySatnogsTleSourceMigration, true)
}
_dataSourcesSettings.value = settings
_dataSourcesSettings.value = normalized
}
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
satelliteUrls = getDataSourceUrls(
key = keySatelliteUrls,
defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
legacyEnabledKey = keyUseCustomTle,
legacyUrlKey = keyTleUrl
).migrateSatnogsTleSource(),
transceiversUrls = getDataSourceUrls(
key = keyTransceiversUrls,
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
legacyEnabledKey = keyUseCustomTransceivers,
legacyUrlKey = keyTransceiversUrl
),
satelliteEnabled = readEnabledFlags(keySatelliteEnabled),
transceiversEnabled = readEnabledFlags(keyTransceiversEnabled)
)
/** Read the persisted per-source enabled flags (empty when never stored). */
private fun readEnabledFlags(key: String): List<Boolean> {
return preferences.getString(key, null)
?.split(separatorComma)
?.mapNotNull { it.trim() }
?.filter { it == "true" || it == "false" }
?.map { it == "true" }
?: emptyList()
}
/** Keep the flags positionally aligned with the URL list, defaulting to enabled. */
private fun alignFlags(urls: List<String>, flags: List<Boolean>): List<Boolean> {
if (flags.size >= urls.size) return flags.take(urls.size)
return flags + List(urls.size - flags.size) { true }
}
private fun getDataSourceUrls(
key: String,
defaultUrls: List<String>,
legacyEnabledKey: String,
legacyUrlKey: String
): List<String> {
val stored = preferences.getString(key, null)
?.split(separatorUrl)
?.map { it.trim() }
?.filter { it.isNotBlank() }
if (stored != null) return stored
val legacyCustomUrl = preferences.getString(legacyUrlKey, null)?.trim().orEmpty()
return if (preferences.getBoolean(legacyEnabledKey, false) && legacyCustomUrl.isNotBlank()) {
(defaultUrls + legacyCustomUrl).distinct()
} else {
defaultUrls
}
}
private fun List<String>.migrateSatnogsTleSource(): List<String> {
if (preferences.getBoolean(keySatnogsTleSourceMigration, false)) return this
val satnogsTleUrl = Sources.satelliteDataUrls["SatNOGS"].orEmpty()
if (satnogsTleUrl.isBlank() || containsSourceUrl(satnogsTleUrl)) return this
val celestrakSatnogsIndex = indexOfFirst { isSameSourceUrl(it, legacyCelestrakSatnogsUrl) }
if (celestrakSatnogsIndex < 0) return this
val migrated = toMutableList().apply { add(celestrakSatnogsIndex + 1, satnogsTleUrl) }
preferences.edit {
putString(keySatelliteUrls, migrated.joinToString(separatorUrl))
putBoolean(keySatnogsTleSourceMigration, true)
}
return migrated
}
private fun List<String>.containsSourceUrl(url: String): Boolean = any { isSameSourceUrl(it, url) }
private fun isSameSourceUrl(first: String, second: String): Boolean =
normalizeSourceUrl(first).equals(normalizeSourceUrl(second), ignoreCase = true)
private fun normalizeSourceUrl(url: String): String {
val trimmed = url.trim()
return if (trimmed.startsWith("http", ignoreCase = true)) trimmed else "https://$trimmed"
}
//endregion
//region # Data sources status
private val _dataSourcesStatus = MutableStateFlow<Map<String, Int>>(emptyMap())
override val dataSourcesStatus: StateFlow<Map<String, Int>> = _dataSourcesStatus
override fun updateDataSourcesStatus(status: Map<String, Int>) {
_dataSourcesStatus.value = status
}
//endregion
//region # Radio control settings
@@ -436,4 +613,55 @@ class SettingsRepo(
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
)
//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
}
@@ -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("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
.replace("&quot;", "\"").replace("&#39;", "'")
.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"
}
}
@@ -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
}
}
}
@@ -20,11 +20,13 @@ package com.rtbishop.look4sat.core.data.source
import android.content.ContentResolver
import androidx.core.net.toUri
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.ByteArrayInputStream
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.InputStream
class RemoteSource(
@@ -43,15 +45,74 @@ class RemoteSource(
}
}
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
override suspend fun getNetworkStream(url: String): NetworkResult = withContext(dispatcher) {
try {
val networkRequest = Request.Builder().url(url).build()
httpClient.newCall(networkRequest).execute().use { response ->
if (!response.isSuccessful) return@withContext null
ByteArrayInputStream(response.body.bytes())
val response = httpClient.newCall(networkRequest).execute()
if (!response.isSuccessful) {
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 {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/catalog.php")
.header("User-Agent", "Look4Sat")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource getAmSatCatalog exception: $exception")
null
}
}
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit")
.header("User-Agent", "Look4Sat")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource getAmSatReports exception: $exception")
null
}
}
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = withContext(dispatcher) {
try {
val body = payloadJson.toRequestBody("application/json; charset=utf-8".toMediaType())
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/reports.php")
.header("User-Agent", "Look4Sat")
.post(body)
.build()
httpClient.newCall(request).execute().use { response ->
response.code to response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource submitAmSatReport exception: $exception")
null
}
}
@@ -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
}
@@ -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.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.utility.DataParser
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -91,10 +93,8 @@ class DatabaseRepoTest {
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
useCustomTLE = true,
useCustomTransceivers = false,
tleUrl = customCsvUrl,
transceiversUrl = ""
satelliteUrls = listOf(customCsvUrl),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
@@ -105,6 +105,27 @@ class DatabaseRepoTest {
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
}
@Test
fun `remote update imports satellites from real SatNOGS TLE source`() = runTest(dispatcher) {
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[satnogsUrl] = { jamxTleStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(satnogsUrl),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
assertTrue(localSource.insertedEntries.any { it.name == "JAMX-0825b" && it.catnum == 98248 })
assertEquals(listOf(98248), settingsRepo.satelliteTypeIdsByType["SatNOGS"])
}
private fun validCsvStream(): InputStream = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
@@ -115,6 +136,12 @@ class DatabaseRepoTest {
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream()
private fun jamxTleStream(): InputStream = """
JAMX-0825b
1 98248U 26237.16675926 .00015724 00000-0 97477-3 0 00013
2 98248 097.5373 310.9694 0011309 278.1232 340.7230 15.09766181000012
""".trimIndent().byteInputStream()
}
private class FakeRemoteSource : IRemoteSource {
@@ -123,7 +150,14 @@ private class FakeRemoteSource : IRemoteSource {
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
override suspend fun 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 {
@@ -176,7 +210,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
)
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
@@ -221,14 +255,26 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
dataSourcesSettings.value = settings
}
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
override fun updateDataSourcesStatus(status: Map<String, Int>) {
(dataSourcesStatus as? MutableStateFlow)?.value = status
}
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = Unit
}
private fun defaultDataSourcesSettings(): DataSourcesSettings {
return DataSourcesSettings(
useCustomTLE = false,
useCustomTransceivers = false,
tleUrl = "",
transceiversUrl = ""
satelliteUrls = emptyList(),
transceiversUrls = emptyList()
)
}
@@ -0,0 +1,17 @@
package com.rtbishop.look4sat.core.domain.model
/** One public AMSAT satellite status report submission. */
data class AmSatReportSubmission(
val name: String,
val report: String,
val callsign: String,
val gridSquare: String,
val reportedAtUtcMillis: Long
)
/** Result of submitting an AMSAT satellite status report. */
data class AmSatReportSubmitResult(
val success: Boolean,
val message: String = "",
val reportId: String? = null
)
@@ -0,0 +1,23 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.model
object Constants {
const val FREQ_OFFSET_MIN_HZ = -50_000L
const val FREQ_OFFSET_MAX_HZ = 50_000L
}
@@ -0,0 +1,9 @@
package com.rtbishop.look4sat.core.domain.model
/** Latest release info fetched from the fork's GitHub releases API. */
data class LatestRelease(
val versionTag: String, // e.g. "v4.4.6-ba7opf.6"
val title: String, // release name, may be empty
val body: String, // release notes / update description
val apkUrl: String? // first .apk asset download URL, null if absent
)
@@ -0,0 +1,37 @@
package com.rtbishop.look4sat.core.domain.model
/** One satellite status report (AMSAT site tooltip data) */
data class SatReport(
val id: String, // 报告 ID(a885153)
val statusText: String, // Heard / Telemetry Only / Not Heard ...
val call: String, // 呼号
val grid: String, // 网格坐标(可为空)
val dateUtc: String, // 2026-08-04
val timeUtc: String // 2:46-:59 UTC
)
/** State of one 2-hour slot */
data class SatSlot(
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
val count: Int, // 报告数量(0 = 无)
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
)
/** One satellite day (12 two-hour slots) */
data class SatDay(
val dateLabel: String, // "Aug 4"
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
)
/** One satellite, 3 days of state */
data class SatStatus(
val name: String, // "AO-123_[FM]"
val days: List<SatDay> // 3 days (newest first)
)
/** Overall page parse result */
data class SatStatusPage(
val fetchedAtUtcMs: Long,
val statuses: List<SatStatus>,
val reports: Map<String, SatReport> // id → 报告
)
@@ -42,6 +42,7 @@ data class RCSettings(
val frequencyAddress: String,
val frequencyPort: String,
val frequencyFormat: String,
val frequencyOffsetHz: Long = 0L,
val bluetoothRotatorState: Boolean,
val bluetoothRotatorFormat: String,
val bluetoothRotatorName: String,
@@ -58,6 +59,7 @@ data class OtherSettings(
val stateOfUtc: Boolean,
val stateOfLightTheme: Boolean,
val stateOfNightMode: Boolean = false,
val stateOfMapGrid: Boolean = false,
val shouldSeeWarning: Boolean,
val shouldSeeWhatsNew: Boolean,
val sstvMode: String = "Auto",
@@ -66,11 +68,28 @@ data class OtherSettings(
)
data class DataSourcesSettings(
val useCustomTLE: Boolean,
val useCustomTransceivers: Boolean,
val tleUrl: String,
val transceiversUrl: String
)
val satelliteUrls: List<String>,
val transceiversUrls: List<String>,
val satelliteEnabled: List<Boolean> = emptyList(),
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(
val enabled: Boolean,
@@ -424,11 +424,15 @@ object CelestialComputer {
}
if (sunrise == 0.0) sunrise = daynum
// Phase 4: fast-forward through the day until sun drops back below threshold
daynum = sunrise
// Phase 4: fast-forward through the day until sun drops back below threshold.
// Start from just after sunrise (small offset) so the sun is clearly above
// the threshold. This prevents a bug where Phase 3 converges to a point
// slightly below -threshold, causing Phase 4 to skip and Phase 5 to converge
// to the same time as sunrise, producing identical sunrise/sunset times.
daynum = sunrise + 0.001
sunPos = getSunPosition(observer, daynumToMillis(daynum))
guard = 0
while (sunPos.elevation > -threshold && guard++ < 500) {
while (sunPos.elevation >= -threshold && guard++ < 500) {
daynum += 0.008
sunPos = getSunPosition(observer, daynumToMillis(daynum))
}
@@ -0,0 +1,23 @@
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
/** AMSAT satellite status data source */
interface IAmSatRepository {
/** Cached AMSAT status page for the current foreground session, if any. */
fun getCachedStatus(): SatStatusPage?
/** Fetch and parse the AMSAT status page; null on failure. */
suspend fun fetchStatus(forceRefresh: Boolean = false): SatStatusPage?
/** Warm the foreground-session cache without forcing a network reload. */
suspend fun prefetchStatus()
/** Clear the foreground-session status cache. */
fun clearStatusCache()
/** Submit a public AMSAT satellite status report. */
suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult
}
@@ -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>?
}
@@ -30,6 +30,10 @@ interface IMainContainer {
val selectionRepo: ISelectionRepo
val satelliteRepo: ISatelliteRepo
val databaseRepo: IDatabaseRepo
val amSatRepo: IAmSatRepository
val updateRepo: IUpdateRepository
val wavelogRepo: IWavelogRepository
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
val radioTrackingService: IRadioTrackingService
val mutualPassData: StateFlow<MutualPassData>
fun setMutualPassData(data: MutualPassData)
@@ -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.RCSettings
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 kotlinx.coroutines.flow.StateFlow
@@ -73,8 +74,35 @@ interface ISettingsRepo {
fun updateDataSourcesSettings(settings: DataSourcesSettings)
//endregion
//region # Data sources status
val dataSourcesStatus: StateFlow<Map<String, Int>>
fun updateDataSourcesStatus(status: Map<String, Int>)
//endregion
//region # Radio control settings
val radioControlSettings: StateFlow<RadioControlSettings>
fun updateRadioControlSettings(settings: RadioControlSettings)
//endregion
//region # Per-satellite calculator offset settings
fun getSatelliteOffset(catnum: Int): String
fun setSatelliteOffset(catnum: Int, offset: String)
//endregion
//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
}
@@ -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
}
@@ -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>?
}
@@ -19,7 +19,18 @@ package com.rtbishop.look4sat.core.domain.source
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 {
suspend fun getFileStream(uri: String): InputStream?
suspend fun getNetworkStream(url: String): InputStream?
suspend fun getNetworkStream(url: String): NetworkResult
suspend fun getAmSatCatalog(): String?
suspend fun getAmSatReports(hours: Int, limit: Int): String?
suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>?
}
@@ -19,7 +19,10 @@ package com.rtbishop.look4sat.core.domain.source
object Sources {
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",
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
"Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv",
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
@@ -35,7 +38,7 @@ object Sources {
"OneWeb" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=oneweb&FORMAT=csv",
"Orbcomm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=orbcomm&FORMAT=csv",
"Resource" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=resource&FORMAT=csv",
"SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv",
"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",
"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",
@@ -45,10 +48,11 @@ object Sources {
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
"R4UAB" to "https://r4uab.ru/satonline.txt",
"ARISS" to "https://live.ariss.org/iss.txt",
"Other" to "" // key for sats filter
)
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"
)
}
@@ -16,20 +16,28 @@ import java.util.Locale
/**
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
*
* For a linear (passband) transponder, uplink and downlink frequencies are
* related by a fixed passband offset. When the satellite moves, both are
* Doppler-shifted. Given one, we compute the other:
* The full physical path:
*
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler)
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
* TX→RX (uplink → downlink):
* ① 地面发射 f_tx
* ② 卫星收到 f_tx × (c - v) / c (上行多普勒)
* ③ 卫星转发 = passband映射(②) (在卫星上做映射)
* ④ 地面听到 ③ × (c - v) / c (下行多普勒)
*
* RX→TX (downlink → uplink):
* ④ 地面听到 f_rx
* ③ 卫星转发 = f_rx × (c + v) / c (逆下行多普勒)
* ② 卫星收到 = 逆passband映射(③)
* ① 地面应发射 = ② × (c + v) / c (逆上行多普勒)
*
* Addresses GitHub issue #91 (Custom frequency Doppler correction).
*/
object DopplerFrequencyCalculator {
/**
* Given a downlink frequency, compute the Doppler-corrected uplink frequency.
* Returns null if the transponder is not a linear passband type.
* Given a downlink frequency (what the user hears), compute the
* uplink frequency the user should transmit.
* Full path: ④→③→②→①
*/
fun computeUplinkFromDownlink(
downlinkHz: Long,
@@ -37,17 +45,22 @@ object DopplerFrequencyCalculator {
orbitalPos: OrbitalPos
): Long? {
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
* with an offset applied to the downlink (in Hz).
* Returns null if the transponder is not a linear passband type.
* Given a downlink frequency (what the user hears), compute the
* uplink frequency the user should transmit, with an offset applied
* to the downlink (in Hz).
* Full path: ④→③→②→①
*
* The user-entered downlink frequency already includes the offset, so subtract
* it before mapping the downlink passband position back to the uplink.
* it before the inverse downlink Doppler.
*/
fun computeUplinkFromDownlinkWithOffset(
downlinkHz: Long,
@@ -56,13 +69,20 @@ object DopplerFrequencyCalculator {
offsetHz: Long
): Long? {
if (!isLinearTransponder(transponder)) return null
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
return orbitalPos.getUplinkFreq(baseUplink)
// ④→③ 逆下行多普勒:卫星转发的频率(含 offset)
val satTxWithOffset = orbitalPos.getUplinkFreq(downlinkHz)
// ③ 去掉 offset(offset 在卫星本地频率域)
val satTx = satTxWithOffset - offsetHz
// ③→② 逆 passband 映射
val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null
// ②→① 逆上行多普勒:地面应发射的频率
return orbitalPos.getUplinkFreq(satRx)
}
/**
* Given an uplink frequency, compute the Doppler-corrected downlink frequency.
* Returns null if the transponder is not a linear passband type.
* Given an uplink frequency (what the user transmits), compute the
* downlink frequency the user will hear.
* Full path: ①→②→③→④
*/
fun computeDownlinkFromUplink(
uplinkHz: Long,
@@ -70,14 +90,19 @@ object DopplerFrequencyCalculator {
orbitalPos: OrbitalPos
): Long? {
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
* with an offset applied to the downlink (in Hz).
* Returns null if the transponder is not a linear passband type.
* Given an uplink frequency (what the user transmits), compute the
* downlink frequency the user will hear, with an offset applied
* to the downlink (in Hz).
* Full path: ①→②→③→④
*/
fun computeDownlinkFromUplinkWithOffset(
uplinkHz: Long,
@@ -86,8 +111,13 @@ object DopplerFrequencyCalculator {
offsetHz: Long
): Long? {
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. */
@@ -114,11 +144,30 @@ object DopplerFrequencyCalculator {
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
.joinToString(separator = " ")
.lowercase(Locale.ENGLISH)
val hasLinearName = info.contains("linear")
val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
info.contains("xponder") || info.contains("xpdr")
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode)
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) ||
(hasLinearName && hasLinearMode)
}
/**
* Removes duplicate transponder entries that describe the same physical
* transponder with different mode labels (e.g. SatNOGS lists AO-7's Mode A
* as both "Lin SSB" and "Lin CW", and JO-97's U/V transponder as both
* "CW Transponder" and "SSB Transponder").
*
* Entries sharing the same uplink/downlink frequency range are considered
* the same transponder. The non-CW entry is preferred because its invert
* flag is more reliable (e.g. JO-97's CW entry wrongly has invert=false).
*/
fun deduplicateTransponders(radios: List<SatRadio>): List<SatRadio> {
return radios.groupBy { radio ->
listOf(radio.uplinkLow, radio.uplinkHigh, radio.downlinkLow, radio.downlinkHigh)
}.values.map { group ->
group.firstOrNull { it.downlinkMode?.equals("CW", ignoreCase = true) != true } ?: group.first()
}
}
}
@@ -19,20 +19,43 @@ package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.predict.GeoPos
/**
* Convert a Maidenhead locator (4, 6 or 8 chars) to the position of its
* square / subsquare / extended-square centre. Longer input is truncated to
* 8 chars (the Maidenhead maximum); malformed or out-of-range input yields
* null.
*/
fun qthToPosition(locator: String): GeoPos? {
val trimmedQth = locator.take(6)
if (!isValidLocator(trimmedQth)) return null
val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20
val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10
val trimmedQth = locator.trim().uppercase().take(8)
if (trimmedQth.length !in listOf(4, 6, 8) || !isValidLocator(trimmedQth)) return null
val lonFirst = (trimmedQth[0].code - 65) * 20
val latFirst = (trimmedQth[1].code - 65) * 10
val lonSecond = trimmedQth[2].toString().toInt() * 2
val latSecond = trimmedQth[3].toString().toInt()
val lonThird = (((trimmedQth[4].lowercaseChar().code - 97) / 12.0) + (1.0 / 24.0)) - 180
val latThird = (((trimmedQth[5].lowercaseChar().code - 97) / 24.0) + (1.0 / 48.0)) - 90
val longitude = (lonFirst + lonSecond + lonThird).round(4)
val latitude = (latFirst + latSecond + latThird).round(4)
return GeoPos(latitude, longitude)
// Start from the 4-char square centre, refine for 6/8-char subsquares.
var longitude = lonFirst + lonSecond + 1.0
var latitude = latFirst + latSecond + 0.5
if (trimmedQth.length >= 6) {
val lonSub = (trimmedQth[4].code - 65) * 5.0 / 60.0
val latSub = (trimmedQth[5].code - 65) * 2.5 / 60.0
if (lonSub < 0.0 || lonSub > 115.0 / 60.0 || latSub < 0.0 || latSub > 57.5 / 60.0) return null
longitude = lonFirst + lonSecond + lonSub + 2.5 / 60.0
latitude = latFirst + latSecond + latSub + 1.25 / 60.0
if (trimmedQth.length >= 8) {
val lonExt = (trimmedQth[6].code - 48) * 30.0 / 3600.0
val latExt = (trimmedQth[7].code - 48) * 15.0 / 3600.0
if (lonExt < 0.0 || lonExt > 270.0 / 3600.0 || latExt < 0.0 || latExt > 135.0 / 3600.0) return null
longitude += lonExt + 15.0 / 3600.0
latitude += latExt + 7.5 / 3600.0
}
}
return GeoPos((latitude - 90.0).round(4), (longitude - 180.0).round(4))
}
/**
* Convert a position to its 6-char Maidenhead locator, upper-cased (field,
* square and subsquare letters). Returns null for out-of-range positions.
*/
fun positionToQth(latitude: Double, longitude: Double): String? {
if (!isValidPosition(latitude, longitude)) return null
val newLongitude = if (longitude > 180.0) longitude else longitude + 180
@@ -41,8 +64,8 @@ fun positionToQth(latitude: Double, longitude: Double): String? {
val latFirst = (65 + (newLatitude / 10)).toInt().toChar()
val lonSecond = ((newLongitude / 2) % 10).toInt()
val latSecond = (newLatitude % 10).toInt()
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar()
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar()
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().uppercaseChar()
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().uppercaseChar()
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
}
@@ -51,5 +74,11 @@ private fun isValidPosition(lat: Double, lon: Double): Boolean {
}
private fun isValidLocator(locator: String): Boolean {
return locator.matches("[a-xA-X][a-xA-X]\\d\\d[a-xA-X][a-xA-X]".toRegex())
// 4 chars: [A-X][A-X]\d\d ; 6 chars: + [A-X][A-X] ; 8 chars: + \d\d
return when (locator.length) {
4 -> Regex("[A-X]{2}\\d{2}").matches(locator)
6 -> Regex("[A-X]{2}\\d{2}[A-X]{2}").matches(locator)
8 -> Regex("[A-X]{2}\\d{2}[A-X]{2}\\d{2}").matches(locator)
else -> false
}
}
@@ -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
}
}
@@ -9,6 +9,7 @@ import org.junit.Test
class DopplerFrequencyCalculatorTest {
private fun linearTransponder(
uuid: String = "linear",
upLow: Long = 145_000_000L,
upHigh: Long = 145_500_000L,
downLow: Long = 435_000_000L,
@@ -18,7 +19,7 @@ class DopplerFrequencyCalculatorTest {
downlinkMode: String? = "USB",
uplinkMode: String? = "LSB"
) = SatRadio(
uuid = "linear", info = info, isAlive = true,
uuid = uuid, info = info, isAlive = true,
downlinkLow = downLow, downlinkHigh = downHigh,
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
@@ -68,11 +69,93 @@ class DopplerFrequencyCalculatorTest {
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
}
@Test
fun isNamedLinearTransponder_returnsTrueForAbbreviatedLinName() {
// AO-7 style: "Mode V/A (A) Lin SSB" — "Lin" abbreviation, no "transponder" word
val ao7Entry = linearTransponder(info = "Mode V/A (A) Lin SSB", downlinkMode = "USB", uplinkMode = "USB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7Entry))
val ao7CwEntry = linearTransponder(info = "Mode V/A (A) Lin CW", downlinkMode = "CW", uplinkMode = "CW")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7CwEntry))
val ao7ModeBEntry = linearTransponder(info = "Mode U/V (B) Lin", downlinkMode = "USB", uplinkMode = "LSB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7ModeBEntry))
}
@Test
fun isNamedLinearTransponder_returnsTrueForLinearWithoutTransponderWord() {
// AO-73 style: "Mode U/V Linear" — has "Linear" but no "transponder"
val ao73Entry = linearTransponder(info = "Mode U/V Linear", downlinkMode = "USB", uplinkMode = "LSB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao73Entry))
}
@Test
fun isNamedLinearTransponder_returnsFalseForDownlinkContainingLinInsideWord() {
// "Downlink" contains "lin" but is not a linear-transponder name
val downlinkEntry = linearTransponder(info = "Mode U Downlink", downlinkMode = "FM", uplinkMode = "FM")
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(downlinkEntry))
}
@Test
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
}
@Test
fun deduplicateTransponders_mergesSameFrequencyRange() {
// AO-7's Mode A: same range, SSB and CW entries
val ssb = linearTransponder(
uuid = "ssb-uuid", info = "Mode V/A (A) Lin SSB",
downlinkMode = "USB", uplinkMode = "USB"
)
val cw = linearTransponder(
uuid = "cw-uuid", info = "Mode V/A (A) Lin CW",
downlinkMode = "CW", uplinkMode = "CW"
)
val modeB = linearTransponder(
uuid = "modeb-uuid", info = "Mode U/V (B) Lin",
upLow = 432_125_000L, upHigh = 432_175_000L,
downLow = 145_925_000L, downHigh = 145_975_000L,
downlinkMode = "USB", uplinkMode = "LSB"
)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(ssb, cw, modeB))
assertEquals(2, result.size)
// SSB entry should be preferred over CW (same range)
assertEquals("ssb-uuid", result[0].uuid)
assertEquals("modeb-uuid", result[1].uuid)
}
@Test
fun deduplicateTransponders_prefersNonCwEntry() {
// JO-97: CW entry has invert=false (wrong), SSB has invert=true (correct)
val cw = linearTransponder(
uuid = "cw-uuid", info = "U/V CW Transponder",
downlinkMode = "CW", uplinkMode = "CW",
upLow = 435_100_000L, upHigh = 435_120_000L,
downLow = 145_855_000L, downHigh = 145_875_000L
)
val ssb = linearTransponder(
uuid = "ssb-uuid", info = "U/V SSB Transponder",
downlinkMode = "USB", uplinkMode = "LSB",
upLow = 435_100_000L, upHigh = 435_120_000L,
downLow = 145_855_000L, downHigh = 145_875_000L,
inverted = true
)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(cw, ssb))
assertEquals(1, result.size)
assertEquals("ssb-uuid", result[0].uuid)
// Verify the correct invert flag is preserved
assertTrue(result[0].isInverted)
}
@Test
fun deduplicateTransponders_preservesUniqueEntries() {
val t1 = linearTransponder(uuid = "t1", upLow = 145_000_000L, upHigh = 145_500_000L,
downLow = 435_000_000L, downHigh = 435_500_000L)
val t2 = linearTransponder(uuid = "t2", upLow = 435_000_000L, upHigh = 435_500_000L,
downLow = 145_000_000L, downHigh = 145_500_000L)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(t1, t2))
assertEquals(2, result.size)
}
@Test
fun computeUplinkFromDownlink_linear_noDoppler() {
val xpdr = linearTransponder()
@@ -1,20 +1,3 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.utility.positionToQth
@@ -22,25 +5,34 @@ import com.rtbishop.look4sat.core.domain.utility.qthToPosition
import org.junit.Test
class QthConverterTest {
@Test
fun `Given valid QTH returns correct POS`() {
// 8-char locator -> centre of the extended square (higher precision)
var result = qthToPosition("io91VL39FX")
assert(result?.latitude == 51.4792 && result.longitude == -0.2083)
assert(result?.latitude == 51.5188 && result.longitude == -0.1792)
result = qthToPosition("gf15vc")
assert(result?.latitude == -34.8958 && result.longitude == -56.2083)
// 4-char locator -> centre of the 2deg x 1deg square
result = qthToPosition("JN58")
assert(result?.latitude == 48.5 && result.longitude == 11.0)
// lowercase input is accepted and normalised
result = qthToPosition("ol63pd")
assert(result?.latitude == 23.1458 && result.longitude == 113.2917)
}
@Test
fun `Given invalid QTH returns null`() {
assert(qthToPosition("ZZ00zz") == null)
assert(qthToPosition("JN58") == null)
assert(qthToPosition("JN5") == null) // odd length
assert(qthToPosition("JN58Z") == null) // 5 chars
assert(qthToPosition("JN58ZA") == null) // Z out of A-X range
}
@Test
fun `Given valid POS returns correct QTH`() {
assert(positionToQth(51.4878, -0.2146) == "IO91vl")
assert(positionToQth(48.1466, 11.6083) == "JN58td")
assert(positionToQth(51.4878, -0.2146) == "IO91VL")
assert(positionToQth(48.1466, 11.6083) == "JN58TD")
assert(positionToQth(23.13, 113.26) == "OL63PD")
}
@Test
@@ -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"))
}
}
@@ -280,6 +280,40 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass(
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
fun SharedDialog(
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
@@ -79,6 +79,10 @@ private val lightScheme = lightColorScheme(
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFF1E1BB),
onSecondaryContainer = Color(0xFF221B04),
tertiary = Color(0xFF3C6FE0), // AMSAT Active (darker for light theme)
onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFE09800), // AMSAT Telemetry (darker)
onTertiaryContainer = Color(0xFF000000),
background = Color(0xFFFFF8F0),
onBackground = Color(0xFF1E1B13),
surface = Color(0xFFFFF8F0),
@@ -101,10 +105,10 @@ private val darkScheme = darkColorScheme(
onSecondary = Color(0xFF000000),
secondaryContainer = Color(0xFF404040), // navBar indicator,
onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon
// tertiary = Color(0xFF121212),
// onTertiary = Color(0xFF121212),
// tertiaryContainer = Color(0xFF121212),
// onTertiaryContainer = Color(0xFF121212),
tertiary = Color(0xFF648FFF), // AMSAT Active (from amsat.org/status)
onTertiary = Color(0xFF000000),
tertiaryContainer = Color(0xFFFFB000), // AMSAT Telemetry
onTertiaryContainer = Color(0xFF000000),
background = Color(0xFF121212),
onBackground = Color(0xFFE0E0E0),
surface = Color(0xFF202020), // card background
@@ -29,14 +29,14 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
@Serializable
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
@Serializable
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
@Serializable
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
@Serializable
data object Mutual : Screen(R.drawable.ic_match, R.string.nav_mutual)
data object Mutual : Screen(R.drawable.ic_radio_tower, R.string.nav_mutual)
@Serializable
data object AMSAT : Screen(R.drawable.ic_satellite_alt, R.string.nav_amsat)
@Serializable
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/white" android:pathData="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M240,320q-33,0 -56.5,-23.5T160,240q0,-33 23.5,-56.5T240,160q33,0 56.5,23.5T320,240q0,33 -23.5,56.5T240,320zM240,560q-33,0 -56.5,-23.5T160,480q0,-33 23.5,-56.5T240,400q33,0 56.5,23.5T320,480q0,33 -23.5,56.5T240,560zM240,800q-33,0 -56.5,-23.5T160,720q0,-33 23.5,-56.5T240,640q33,0 56.5,23.5T320,720q0,33 -23.5,56.5T240,800zM720,320q-33,0 -56.5,-23.5T640,240q0,-33 23.5,-56.5T720,160q33,0 56.5,23.5T800,240q0,33 -23.5,56.5T720,320zM720,560q-33,0 -56.5,-23.5T640,480q0,-33 23.5,-56.5T720,400q33,0 56.5,23.5T800,480q0,33 -23.5,56.5T720,560zM720,800q-33,0 -56.5,-23.5T640,720q0,-33 23.5,-56.5T720,640q33,0 56.5,23.5T800,720q0,33 -23.5,56.5T720,800z" />
</vector>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M12,10A2,2 0 0,1 14,12C14,12.5 13.82,12.94 13.53,13.29L16.7,22H14.57L12,14.93L9.43,22H7.3L10.47,13.29C10.18,12.94 10,12.5 10,12A2,2 0 0,1 12,10M12,8A4,4 0 0,0 8,12C8,12.5 8.1,13 8.28,13.46L7.4,15.86C6.53,14.81 6,13.47 6,12A6,6 0 0,1 12,6A6,6 0 0,1 18,12C18,13.47 17.47,14.81 16.6,15.86L15.72,13.46C15.9,13 16,12.5 16,12A4,4 0 0,0 12,8M12,4A8,8 0 0,0 4,12C4,14.36 5,16.5 6.64,17.94L5.92,19.94C3.54,18.11 2,15.23 2,12A10,10 0 0,1 12,2A10,10 0 0,1 22,12C22,15.23 20.46,18.11 18.08,19.94L17.36,17.94C19,16.5 20,14.36 20,12A8,8 0 0,0 12,4Z" />
</vector>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Standard Material refresh icon (24dp viewport), centered geometry -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
>
<path
android:fillColor="#FF000000"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
</vector>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- AMSAT tab icon provided by rt-bishop (upstream author), original SVG asset -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group
android:scaleX="0.242"
android:scaleY="0.242"
android:translateX="0.3"
android:translateY="-230.5">
<path android:fillColor="#FF000000" android:pathData="m 21.275553,967.35933 a 2.0000048,2.0000048 0 0 0 -1.1875,0.59375 l -13.5000003,13.5 a 2.0000048,2.0000048 0 0 0 0,2.8125 l 18.5625003,18.59382 a 2.0000048,2.0000048 0 0 0 1.2813,0.5625 l 5.5312,0.4375 -4.0312,4.0312 a 2.0000048,2.0000048 0 0 0 0,2.8126 l 13.6874,13.7187 a 2.0000048,2.0000048 0 0 0 2.8438,0 l 4.0312,-4.0312 0.4376,5.5312 a 2.0000048,2.0000048 0 0 0 0.5624,1.2813 l 18.5938,18.5625 a 2.0000048,2.0000048 0 0 0 2.8438,0 l 13.468705,-13.4688 a 2.0000048,2.0000048 0 0 0 0,-2.8437 l -18.562505,-18.5938 a 2.0000048,2.0000048 0 0 0 -1.2812,-0.5625 l -5.5313,-0.4375 4.0313,-4.0313 a 2.0000048,2.0000048 0 0 0 0,-2.8437 l -13.6876,-13.68757 a 2.0000048,2.0000048 0 0 0 -2.8437,0 l -4.0313,4.03125 -0.4374,-5.53125 a 2.0000048,2.0000048 0 0 0 -0.5626,-1.28125 l -18.5937,-18.5625 a 2.0000048,2.0000048 0 0 0 -1.4375,-0.59375 2.0000048,2.0000048 0 0 0 -0.1875,0 z m 0.2187,4.8125 16.6563,16.65625 0.5625,7.46875 -3.7812,3.78127 -7.4688,-0.59377 -16.6562,-16.625 z m 34.5626,41.46887 7.4687,0.5937 16.656305,16.625 -10.687605,10.6875 -16.625,-16.6562 -0.5937,-7.4688 z" />
<path android:fillColor="#FF000000" android:pathData="m 50.940553,966.18283 a 2.000005,2.000005 0 1 0 0,3.99969 c 7.9999,0 15.9734,3.03851 22.0884,9.15344 6.103605,6.10359 9.143805,14.08909 9.143805,22.07844 a 2.000005,2.000005 0 1 0 3.9994,0 c 0,-9.00692 -3.4462,-18.03775 -10.315,-24.90657 -6.877605,-6.8775 -15.900305,-10.325 -24.916605,-10.325 z" />
<path android:fillColor="#FF000000" android:pathData="m 50.940553,958.36533 a 2.000005,2.000005 0 1 0 0,3.99969 c 10.0021,0 19.9781,3.79987 27.613705,11.43562 7.6352,7.63517 11.4357,17.62207 11.4357,27.61376 a 2.000005,2.000005 0 1 0 3.9997,0 c 0,-11.00842 -4.207,-22.04171 -12.6072,-30.44188 -8.399505,-8.3996 -19.424005,-12.60719 -30.441905,-12.60719 z" />
<path android:fillColor="#FF000000" android:pathData="m 50.940553,973.94033 a 2.000005,2.000005 0 1 0 0,3.99938 c 6.0191,0 12.0105,2.279 16.6031,6.87156 4.5926,4.59255 6.881605,10.59683 6.881605,16.60313 a 2.000005,2.000005 0 1 0 3.9997,0 c 0,-7.02618 -2.6961,-14.07429 -8.053105,-19.43126 -5.3571,-5.35696 -12.398,-8.04281 -19.4313,-8.04281 z" />
<path android:fillColor="#FF000000" android:pathData="m 50.940553,981.77783 a 2.000005,2.000005 0 1 0 0,3.99938 c 4.0058,0 7.9968,1.51836 11.0578,4.57937 3.061,3.06099 4.5794,7.05193 4.5794,11.05782 a 2.000005,2.000005 0 1 0 3.9994,0 c 0,-5.02276 -1.9251,-10.06035 -5.7507,-13.88594 -3.8256,-3.82559 -8.8631,-5.75063 -13.8859,-5.75063 z" />
</group>
</vector>
@@ -112,6 +112,13 @@
<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_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_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_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_map_grid">Mostrar cuadrícula Maidenhead en el mapa</string>
<string name="prefs_highlight_title">Resaltado de elevación</string>
<string name="prefs_highlight_low">Baja &lt; %1$d°</string>
@@ -111,6 +111,13 @@
<string name="prefs_data_update_success">Обновление прошло успешно</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_transceivers_switch">Custom Transceivers URL</string>
@@ -142,6 +149,7 @@
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
<string name="prefs_other_switch_map_grid">Показывать сетку Maidenhead на карте</string>
<string name="prefs_highlight_title">Подсветка высоты</string>
<string name="prefs_highlight_low">Низко &lt; %1$d°</string>
@@ -112,6 +112,13 @@
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</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_transceivers_switch">Custom transceivers URL</string>
@@ -143,6 +150,7 @@
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
<string name="prefs_other_switch_night_mode">රතු රාත්‍රී පෙරහන සබල කරන්න</string>
<string name="prefs_other_switch_map_grid">සිතියමේ Maidenhead ජාලකය පෙන්වන්න</string>
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
<string name="prefs_highlight_low">අඩු &lt; %1$d°</string>
@@ -143,6 +143,12 @@
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
<string name="prefs_data_sources_title">Özel veri kaynakları</string>
<string name="prefs_data_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_transceivers_switch">Özel transceiver URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -186,6 +192,7 @@
<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_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_low">Düşük &lt; %1$d°</string>
@@ -199,7 +206,8 @@
\n• Dave Moten (predict4java)
\n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)</string>
\n• Libre Space Foundation (SatNOGS)
\n• BG7NTA (CW decoding feature)</string>
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
</resources>
@@ -112,6 +112,13 @@
<string name="prefs_data_update_success">Оновлення успішне</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_transceivers_switch">Custom transceivers URL</string>
@@ -143,6 +150,7 @@
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
<string name="prefs_other_switch_map_grid">Показувати сітку Maidenhead на мапі</string>
<string name="prefs_highlight_title">Підсвічування висоти</string>
<string name="prefs_highlight_low">Низько &lt; %1$d°</string>
@@ -39,6 +39,41 @@
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</string>
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
<!-- AMSAT Status screen -->
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT 卫星状态</string>
<string name="amsat_refresh">刷新</string>
<string name="amsat_retry">重试</string>
<string name="amsat_updated">更新于:</string>
<string name="amsat_active">活跃</string>
<string name="amsat_tlm">遥测/信标</string>
<string name="amsat_not_heard">未听到</string>
<string name="amsat_conflict">冲突</string>
<string name="amsat_name">名称</string>
<string name="amsat_load_failed">加载失败 - 检查网络后重试</string>
<string name="amsat_no_reports">此时间段无报告</string>
<string name="amsat_close">关闭</string>
<string name="amsat_upload">上传</string>
<string name="amsat_upload_back">返回</string>
<string name="amsat_upload_report">上传报告到 AMSAT</string>
<string name="amsat_upload_status">状态</string>
<string name="amsat_upload_callsign">呼号</string>
<string name="amsat_upload_grid">网格</string>
<string name="amsat_upload_time_now">时间:当前 UTC %s</string>
<string name="amsat_upload_submit">提交到 AMSAT</string>
<string name="amsat_upload_confirm">确认上传</string>
<string name="amsat_upload_confirm_title">确认上传到 AMSAT</string>
<string name="amsat_upload_confirm_message">这条公开报告将上传到 AMSAT。\n卫星:%1$s\n状态:%2$s\n呼号:%3$s\n网格:%4$s</string>
<string name="amsat_upload_grid_none">未填写</string>
<string name="amsat_upload_success">报告已上传</string>
<string name="amsat_upload_failed">上传失败:%s</string>
<string name="amsat_upload_callsign_required">请填写呼号</string>
<string name="amsat_upload_grid_invalid">网格应为 4 或 6 位 Maidenhead 定位符</string>
<string name="amsat_upload_active">活跃</string>
<string name="amsat_upload_tlm">遥测/信标</string>
<string name="amsat_upload_not_heard">未听到</string>
<string name="amsat_upload_crew_active">ISS 语音</string>
<!-- Radar screen -->
<string name="radar_back">后退</string>
<string name="radar_notify">通知</string>
@@ -92,7 +127,7 @@
<string name="prefs_updated_title">更新:%s</string>
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
<string name="prefs_loc_title">站位</string>
<string name="prefs_loc_title">站点位置</string>
<string name="prefs_loc_gps_title">GPS 定位</string>
<string name="prefs_loc_gps_error">检查您的位置权限</string>
<string name="prefs_loc_input_title">输入位置</string>
@@ -100,11 +135,28 @@
<string name="prefs_loc_qth_title">QTH 网格</string>
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
<string name="prefs_loc_success">位置更新成功</string>
<string name="prefs_station_title">站位设置</string>
<string name="prefs_station_lat_text">输入站位的纬度</string>
<string name="prefs_station_lon_text">输入站位的经度</string>
<string name="prefs_station_title">站点位置设置</string>
<string name="prefs_station_lat_text">输入站点的纬度</string>
<string name="prefs_station_lon_text">输入站点的经度</string>
<string name="prefs_locator_title">QTH 网格</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站位</string>
<string name="prefs_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_entries">卫星:%s</string>
@@ -119,8 +171,12 @@
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</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_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_net_output">网络</string>
@@ -134,6 +190,8 @@
<string name="prefs_net_frequency_switch">启用多普勒频率输出</string>
<string name="prefs_net_frequency_address_hint">IP:端口</string>
<string name="prefs_net_frequency_format_hint">数据格式</string>
<string name="prefs_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_rotator_switch">启用方位俯仰角输出</string>
@@ -161,6 +219,7 @@
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
<string name="prefs_other_switch_map_grid">地图显示 Maidenhead 网格</string>
<string name="prefs_highlight_title">仰角高亮</string>
<string name="prefs_highlight_low">低 &lt; %1$d°</string>
@@ -175,7 +234,52 @@
\n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)
\n• xdsopl 和 Robot36 贡献者!</string>
\n• xdsopl 和 Robot36 贡献者!
\n• BG7NTA(CW解码功能)</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>
@@ -56,6 +56,41 @@
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
</string>
<!-- AMSAT Status screen -->
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT Satellite Status</string>
<string name="amsat_refresh">Refresh</string>
<string name="amsat_retry">Retry</string>
<string name="amsat_updated">Updated:</string>
<string name="amsat_active">Active</string>
<string name="amsat_tlm">TLM/Beacon</string>
<string name="amsat_not_heard">Not Heard</string>
<string name="amsat_conflict">Conflicting</string>
<string name="amsat_name">Name</string>
<string name="amsat_load_failed">Load failed - check network and retry</string>
<string name="amsat_no_reports">No reports for this period</string>
<string name="amsat_close">Close</string>
<string name="amsat_upload">Upload</string>
<string name="amsat_upload_back">Back</string>
<string name="amsat_upload_report">Upload report to AMSAT</string>
<string name="amsat_upload_status">Status</string>
<string name="amsat_upload_callsign">Callsign</string>
<string name="amsat_upload_grid">Grid</string>
<string name="amsat_upload_time_now">Time: current UTC %s</string>
<string name="amsat_upload_submit">Submit to AMSAT</string>
<string name="amsat_upload_confirm">Upload</string>
<string name="amsat_upload_confirm_title">Confirm AMSAT upload</string>
<string name="amsat_upload_confirm_message">This public report will be uploaded to AMSAT.\nSatellite: %1$s\nStatus: %2$s\nCallsign: %3$s\nGrid: %4$s</string>
<string name="amsat_upload_grid_none">Not provided</string>
<string name="amsat_upload_success">Report uploaded</string>
<string name="amsat_upload_failed">Upload failed: %s</string>
<string name="amsat_upload_callsign_required">Callsign is required</string>
<string name="amsat_upload_grid_invalid">Grid must be a 4 or 6 character Maidenhead locator</string>
<string name="amsat_upload_active">Active</string>
<string name="amsat_upload_tlm">TLM/Beacon</string>
<string name="amsat_upload_not_heard">Not Heard</string>
<string name="amsat_upload_crew_active">Crew Active</string>
<!-- Radar screen -->
<string name="radar_back">Back</string>
<string name="radar_notify">Notify</string>
@@ -139,6 +174,23 @@
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
<string name="prefs_locator_title">QTH locator settings</string>
<string name="prefs_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_data_title">Satellite data</string>
@@ -153,9 +205,12 @@
<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_restore">Restore default sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_sources_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_net_output">Network</string>
@@ -179,6 +234,8 @@
<string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">Format</string>
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
<string name="prefs_bt_title">Bluetooth data output</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
@@ -196,6 +253,7 @@
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
<string name="prefs_other_switch_map_grid">Show Maidenhead grid on map</string>
<string name="prefs_highlight_title">Elevation highlight</string>
<string name="prefs_highlight_low">Low &lt; %1$d°</string>
@@ -211,7 +269,52 @@
\n* Dr T.S. Kelso (Celestrak)
\n* Libre Space Foundation (SatNOGS)
\n* xdsopl and Robot36 contributors!
\n* BG7NTA (CW decoding feature)
</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 &amp; 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>
@@ -139,7 +139,7 @@ public class MainActivity {
public void onResume() {
android.content.SharedPreferences prefs = mActivity.getSharedPreferences(
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;
if (bVar != null) {
bVar.f623s.set(messageType.equals("ham_radio_qso"));
@@ -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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
@@ -80,14 +82,15 @@ import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polyline
// Overlay indices
private const val OVERLAY_STATION = 0
private const val OVERLAY_TRACK = 1
private const val OVERLAY_FOOTPRINT = 2
private const val OVERLAY_POSITIONS = 3
private const val OVERLAY_TERMINATOR = 4
private const val OVERLAY_SUN = 5
private const val OVERLAY_MOON = 6
private const val OVERLAY_COUNT = 7
private const val OVERLAY_GRID = 0
private const val OVERLAY_STATION = 1
private const val OVERLAY_TRACK = 2
private const val OVERLAY_FOOTPRINT = 3
private const val OVERLAY_POSITIONS = 4
private const val OVERLAY_TERMINATOR = 5
private const val OVERLAY_SUN = 6
private const val OVERLAY_MOON = 7
private const val OVERLAY_COUNT = 8
private val minLat = MapView.getTileSystem().minLatitude
private val maxLat = MapView.getTileSystem().maxLatitude
@@ -158,18 +161,30 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
}
ElevatedCard(modifier = Modifier.weight(1f)) {
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 ->
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)
setGridMode(
uiState.isGridMode, uiState.workedGrids, view,
if (uiState.isGridMode && !prevGridMode) uiState.stationPosition else 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()
}
uiState.mapData?.let { mapData ->
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
if (!uiState.isGridMode) {
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
}
}
}
}
@@ -261,6 +276,61 @@ private fun MapDataCards(data: MapData) {
// endregion
// 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) {
try {
val overlay = mapView.overlays[OVERLAY_STATION]
@@ -500,6 +570,7 @@ private fun rememberMapViewWithLifecycle(): MapView {
overlayManager.tilesOverlay.setColorFilter(createColorFilter())
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
overlays[OVERLAY_GRID] = MaidenheadGridOverlay()
}
}
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
@@ -26,6 +26,8 @@ data class MapState(
val mapData: MapData? = null,
val isLightUi: Boolean = false,
val isUtc: Boolean = false,
val isGridMode: Boolean = false,
val workedGrids: Set<String> = emptySet(),
val stationPosition: GeoPos? = null,
val orbitalPass: OrbitalPass,
val track: List<List<GeoPos>>? = null,
@@ -74,11 +74,17 @@ class MapViewModel(
init {
viewModelScope.launch {
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
selectDefaultSatellite(if (selectedCatNum != 0) selectedCatNum else -1)
getStationPosition()
}
fun onAction(action: MapAction) {
@@ -24,6 +24,7 @@ import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.presentation.R
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -226,8 +228,8 @@ fun ElevationCurveChart(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Text("● 站点A", color = colorA, fontSize = 12.sp)
Text("● 站点B", color = colorB, fontSize = 12.sp)
Text(stringResource(R.string.mutual_station_a), color = colorA, fontSize = 12.sp)
Text(stringResource(R.string.mutual_station_b), color = colorB, fontSize = 12.sp)
}
}
}
@@ -57,8 +57,8 @@ private const val STROKE_WIDTH = 2.5f
fun MutualRadarView(
trackSamples: List<TrackSample>,
progress: Float = 0.5f,
labelA: String = "站点A",
labelB: String = "站点B",
labelA: String = "Station A",
labelB: String = "Station B",
modifier: Modifier = Modifier
) {
if (trackSamples.isEmpty()) return
@@ -63,7 +63,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.repository.TrackSampleData
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.TopBar
@@ -75,7 +74,6 @@ import java.util.Locale
@Composable
fun MutualScreen(
viewModel: MutualViewModel,
navigateUp: () -> Unit = {},
navigateToRadar: (Int, Long, MutualPassData?) -> Unit = { _, _, _ -> },
modifier: Modifier = Modifier
) {
@@ -85,12 +83,10 @@ fun MutualScreen(
topBar = { isVertical ->
TopBar(
isVerticalLayout = isVertical,
startAction = {
IconCard(action = navigateUp, resId = R.drawable.ic_back)
},
startAction = {},
topInfo = {
Text(
text = "过境匹配",
text = stringResource(R.string.mutual_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
@@ -198,7 +194,7 @@ private fun MutualContent(
) {
if (isVertical) {
StationInputCard(
title = "你的位置",
title = stringResource(R.string.mutual_station_you),
titleColor = MaterialTheme.colorScheme.primary,
lat = state.stationALat,
lon = state.stationALon,
@@ -214,7 +210,7 @@ private fun MutualContent(
currentPositionAction = onUseCurrentPosition
)
StationInputCard(
title = "友台位置",
title = stringResource(R.string.mutual_station_opposite),
titleColor = MaterialTheme.colorScheme.tertiary,
lat = state.stationBLat,
lon = state.stationBLon,
@@ -234,7 +230,7 @@ private fun MutualContent(
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
StationInputCard(
title = "你的位置",
title = stringResource(R.string.mutual_station_you),
titleColor = MaterialTheme.colorScheme.primary,
lat = state.stationALat,
lon = state.stationALon,
@@ -251,7 +247,7 @@ private fun MutualContent(
modifier = Modifier.weight(1f)
)
StationInputCard(
title = "友台位置",
title = stringResource(R.string.mutual_station_opposite),
titleColor = MaterialTheme.colorScheme.tertiary,
lat = state.stationBLat,
lon = state.stationBLon,
@@ -294,8 +290,8 @@ private fun MutualContent(
startTime = pass.startTime,
endTime = pass.endTime,
maxElev = maxOf(pass.maxElevationA, pass.maxElevationB, 10.0),
labelA = "你",
labelB = "友台"
labelA = stringResource(R.string.mutual_label_you),
labelB = stringResource(R.string.mutual_label_opposite)
)
MutualPassCard(
pass = pass,
@@ -341,20 +337,22 @@ private fun MutualStatusChip(state: MutualUiState) {
}
}
@Composable
private fun mutualStatusText(state: MutualUiState): String = when {
state.isCalculating -> "正在计算双方共同可见窗口…"
state.errorMessage != null -> "请修正提示后重新查询"
state.mutualPasses.isNotEmpty() -> "找到 ${state.mutualPasses.size} 个匹配 · 点击卡片展开曲线"
state.hasSearched -> "未找到共同可见窗口,调整时间或仰角后重试"
else -> "输入双方位置后点击查询"
state.isCalculating -> stringResource(R.string.mutual_status_calculating)
state.errorMessage != null -> stringResource(R.string.mutual_status_error)
state.mutualPasses.isNotEmpty() -> stringResource(R.string.mutual_status_matches, state.mutualPasses.size)
state.hasSearched -> stringResource(R.string.mutual_status_none)
else -> stringResource(R.string.mutual_status_idle)
}
@Composable
private fun mutualStatusChipText(state: MutualUiState): String = when {
state.isCalculating -> "计算中"
state.errorMessage != null -> "有错误"
state.mutualPasses.isNotEmpty() -> "${state.mutualPasses.size}个匹配"
state.hasSearched -> "无匹配"
else -> "待查询"
state.isCalculating -> stringResource(R.string.mutual_chip_calculating)
state.errorMessage != null -> stringResource(R.string.mutual_chip_error)
state.mutualPasses.isNotEmpty() -> stringResource(R.string.mutual_chip_matches, state.mutualPasses.size)
state.hasSearched -> stringResource(R.string.mutual_chip_none)
else -> stringResource(R.string.mutual_chip_idle)
}
@Composable
@@ -393,7 +391,7 @@ private fun StationInputCard(
)
if (currentPositionAction != null) {
OutlinedButton(onClick = currentPositionAction) {
Text("填入当前位置", style = MaterialTheme.typography.bodySmall)
Text(stringResource(R.string.mutual_use_current), style = MaterialTheme.typography.bodySmall)
}
}
}
@@ -405,7 +403,7 @@ private fun StationInputCard(
OutlinedTextField(
value = lat,
onValueChange = onLatChange,
label = { Text("纬度") },
label = { Text(stringResource(R.string.mutual_lat)) },
placeholder = { Text(latPlaceholder) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
@@ -414,7 +412,7 @@ private fun StationInputCard(
OutlinedTextField(
value = lon,
onValueChange = onLonChange,
label = { Text("经度") },
label = { Text(stringResource(R.string.mutual_lon)) },
placeholder = { Text(lonPlaceholder) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
@@ -425,15 +423,15 @@ private fun StationInputCard(
OutlinedTextField(
value = grid,
onValueChange = onGridChange,
label = { Text("网格") },
label = { Text(stringResource(R.string.mutual_grid)) },
placeholder = { Text(gridPlaceholder) },
supportingText = { Text("支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度") },
supportingText = { Text(stringResource(R.string.mutual_grid_hint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "最小仰角:${minElev.toInt()}°",
text = stringResource(R.string.mutual_min_elevation, minElev.toInt()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -461,7 +459,7 @@ private fun MatchSearchCard(
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "时间范围",
text = stringResource(R.string.mutual_time_range),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
@@ -492,7 +490,7 @@ private fun MatchSearchCard(
)
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 +556,12 @@ private fun MutualPassCard(
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "你: ${pass.maxElevationA}°",
text = stringResource(R.string.mutual_elevation_you, pass.maxElevationA.toInt()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "友台: ${pass.maxElevationB}°",
text = stringResource(R.string.mutual_elevation_opposite, pass.maxElevationB.toInt()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary
)
@@ -579,7 +577,7 @@ private fun MutualPassCard(
// Dual-station radar track (polar plot)
if (visibleTracks.isNotEmpty()) {
Text(
text = "双方轨迹",
text = stringResource(R.string.mutual_both_tracks),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -587,8 +585,8 @@ private fun MutualPassCard(
MutualRadarView(
trackSamples = visibleTracks,
progress = dragProgress,
labelA = "你",
labelB = "友台"
labelA = stringResource(R.string.mutual_label_you),
labelB = stringResource(R.string.mutual_label_opposite)
)
Spacer(Modifier.height(8.dp))
}
@@ -613,7 +611,7 @@ private fun MutualPassCard(
modifier = Modifier.height(16.dp).width(16.dp)
)
Spacer(Modifier.width(6.dp))
Text("查看雷达")
Text(stringResource(R.string.mutual_open_radar))
}
}
}
@@ -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.ISatelliteRepo
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.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -34,7 +37,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.PI
import kotlin.math.floor
import kotlin.math.roundToInt
data class MutualUiState(
@@ -56,7 +58,10 @@ data class MutualUiState(
class MutualViewModel(
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() {
private val _uiState = MutableStateFlow(MutualUiState())
@@ -66,7 +71,7 @@ class MutualViewModel(
// 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
val pos = settingsRepo.stationPosition.value
val grid = latLonToGrid(pos.latitude, pos.longitude)
val grid = positionToQth(pos.latitude, pos.longitude) ?: ""
_uiState.update { it.copy(
stationAGrid = grid,
stationALat = "%.4f".format(pos.latitude),
@@ -81,7 +86,9 @@ class MutualViewModel(
val lat = value.toDoubleOrNull()
val lon = _uiState.value.stationALon.toDoubleOrNull()
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 lon = value.toDoubleOrNull()
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) {
val old = _uiState.value.stationAGrid
_uiState.update { it.copy(stationAGrid = value) }
if (value.trim().uppercase() == old.trim().uppercase()) return
val pos = gridToLatLon(value.trim().uppercase())
val newGrid = value.trim().uppercase()
_uiState.update { it.copy(stationAGrid = newGrid) }
if (newGrid == old.trim().uppercase()) return
val pos = qthToPosition(newGrid)
if (pos != null) {
_uiState.update { it.copy(
stationALat = "%.4f".format(pos.latitude),
@@ -112,7 +122,9 @@ class MutualViewModel(
val lat = value.toDoubleOrNull()
val lon = _uiState.value.stationBLon.toDoubleOrNull()
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 lon = value.toDoubleOrNull()
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) {
val old = _uiState.value.stationBGrid
_uiState.update { it.copy(stationBGrid = value) }
if (value.trim().uppercase() == old.trim().uppercase()) return
val pos = gridToLatLon(value.trim().uppercase())
val newGrid = value.trim().uppercase()
_uiState.update { it.copy(stationBGrid = newGrid) }
if (newGrid == old.trim().uppercase()) return
val pos = qthToPosition(newGrid)
if (pos != null) {
_uiState.update { it.copy(
stationBLat = "%.4f".format(pos.latitude),
@@ -144,7 +159,7 @@ class MutualViewModel(
_uiState.update { it.copy(
stationALat = "%.4f".format(pos.latitude),
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) }
@@ -158,13 +173,13 @@ class MutualViewModel(
var posB = resolvePosition(state.stationBLat, state.stationBLon, state.stationBGrid)
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
}
val satellites = satelliteRepo.satellites.value
if (satellites.isEmpty()) {
_uiState.update { it.copy(errorMessage = "没有卫星数据,请先在卫星列表中选择卫星") }
_uiState.update { it.copy(errorMessage = "No satellite data. Select satellites from the list first.") }
return
}
@@ -184,7 +199,7 @@ class MutualViewModel(
val minElevB = state.stationBMinElev
val hours = state.hoursAhead
val results = withContext(Dispatchers.Default) {
val results = withContext(computeDispatcher) {
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours)
}
@@ -193,9 +208,9 @@ class MutualViewModel(
val passCount = satelliteRepo.passes.value.size
val satCount = satellites.size
when {
passCount == 0 -> "过境列表为空,请先在卫星列表中选择卫星"
satCount == 0 -> "没有已选中的卫星"
else -> "未找到共同过境(${passCount}个过境,${satCount}颗卫星)"
passCount == 0 -> "Pass list is empty. Select satellites from the list first."
satCount == 0 -> "No satellites selected."
else -> "No mutual passes found (${passCount} passes, ${satCount} satellites)."
}
} else null
@@ -213,83 +228,13 @@ class MutualViewModel(
private fun resolvePosition(latText: String, lonText: String, gridText: String): GeoPos? {
val trimmed = gridText.trim().uppercase()
if (trimmed.length in listOf(4, 6, 8) && trimmed.all { it.isLetterOrDigit() }) {
return gridToLatLon(trimmed)
return qthToPosition(trimmed)
}
val lat = latText.toDoubleOrNull()
val lon = lonText.toDoubleOrNull()
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(
satellites: List<OrbitalObject>,
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()
}
@@ -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()
}
@@ -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
@Composable
fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) {
fun PassesDestination(
navigateToRadar: (Int, Long) -> Unit,
navigateToMap: () -> Unit
) {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
PassesScreen(uiState, viewModel::onAction, navigateToRadar)
PassesScreen(uiState, viewModel::onAction, navigateToRadar, navigateToMap)
}
@Composable
private fun PassesScreen(
uiState: PassesState,
onAction: (PassesAction) -> Unit,
navigateToRadar: (Int, Long) -> Unit
navigateToRadar: (Int, Long) -> Unit,
navigateToMap: () -> Unit
) {
if (uiState.isPassesDialogShown) {
PassesFilterDialog(
@@ -163,7 +167,7 @@ private fun PassesScreen(
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
},
endAction = {
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
IconCard(action = navigateToMap, resId = R.drawable.ic_map)
}
)
}
@@ -23,6 +23,7 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.PassesSettings
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.repository.IMainContainer
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
// 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.
// 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>> {
val stationPos = settingsRepo.stationPosition.value
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
@@ -175,7 +177,7 @@ class PassesViewModel(
val result = LinkedHashMap<String, Pair<String, String>>()
// DeepSpace group always shows today's sun times
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 set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
result["DeepSpace (period >225min)"] = rise to set
@@ -184,7 +186,7 @@ class PassesViewModel(
if (pass.isDeepSpace) continue
val label = sdfDate.format(Date(pass.aosTime))
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 set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
result[label] = rise to set
@@ -192,6 +194,33 @@ class PassesViewModel(
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 {
return Calendar.getInstance(tz, Locale.getDefault()).apply {
timeInMillis = timeMillis
@@ -84,7 +84,7 @@ private enum class RadarPage(val title: String) {
}
@Composable
fun RadarDestination(navigateUp: () -> Unit) {
fun RadarDestination(navigateUp: () -> Unit, navigateToMap: () -> Unit) {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
@@ -96,6 +96,12 @@ fun RadarDestination(navigateUp: () -> Unit) {
}
navigateUp()
}
val navigateToMapAndClearMutual = {
if (container.mutualPassData.value.endTime > 0L) {
container.setMutualPassData(MutualPassData())
}
navigateToMap()
}
LaunchedEffect(mutualData.endTime) {
if (mutualData.endTime <= 0L) return@LaunchedEffect
while (true) {
@@ -121,7 +127,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
viewModel.onAction(RadarAction.CwPermissionResult(granted))
}
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = {
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, navigateToMapAndClearMutual, mutualData, requestMicPermission = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
})
}
@@ -131,13 +137,11 @@ private fun RadarScreen(
uiState: RadarState,
onAction: (RadarAction) -> Unit,
navigateUp: () -> Unit,
navigateToMap: () -> Unit,
mutualData: MutualPassData,
requestMicPermission: () -> Unit
) {
val upcomingPass = uiState.currentPass ?: getDefaultPass()
val addToCalendar: () -> Unit = {
uiState.currentPass?.let { onAction(RadarAction.AddToCalendar(it.name, it.aosTime, it.losTime)) }
}
// Station-B overlay: full track line (only where B's elevation > 0) + live position dot
// at the current moment, same display mode as the local station.
val trackB = remember(mutualData.trackSamples) {
@@ -173,7 +177,7 @@ private fun RadarScreen(
TopBar {
IconCard(action = navigateUp, resId = R.drawable.ic_back)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
IconCard(action = navigateToMap, resId = R.drawable.ic_map)
}
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
} else {
@@ -181,7 +185,7 @@ private fun RadarScreen(
IconCard(action = navigateUp, resId = R.drawable.ic_back)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
IconCard(action = navigateToMap, resId = R.drawable.ic_map)
}
}
if (isVertical) {
@@ -249,6 +253,7 @@ private fun PagerCard(
selectedUuid = uiState.transceivers.selectedUuid,
orbitalPos = uiState.orbitalPos,
cw = uiState.cw,
calculatorOffsetKHz = uiState.calculatorOffsetKHz,
onAction = onAction,
requestMicPermission = requestMicPermission
)
@@ -69,7 +69,8 @@ data class RadarState(
val mutualEndTime: Long = 0L,
val mutualMaxElev: Double = 10.0,
val mutualLabelA: String = "你",
val mutualLabelB: String = "友台"
val mutualLabelB: String = "友台",
val calculatorOffsetKHz: String = ""
)
enum class SstvStatus { Idle, Recording }
@@ -124,4 +125,7 @@ sealed interface RadarAction {
data class CwSetToneFreq(val freq: Float) : RadarAction
data class CwToggleExpanded(val expanded: Boolean) : RadarAction
data class CwPermissionResult(val granted: Boolean) : RadarAction
// Calculator actions
data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction
}
@@ -151,7 +151,13 @@ class RadarViewModel(
transponders = allRadios.filter { it.downlinkLow != null }
if (allRadios.isNotEmpty()) {
val firstUuid = allRadios.first().uuid
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = firstUuid)) }
val offsetKHz = allRadios.first().catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
_uiState.update {
it.copy(
transceivers = it.transceivers.copy(selectedUuid = firstUuid),
calculatorOffsetKHz = offsetKHz
)
}
transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) }
}
if (!pass.isDeepSpace) {
@@ -228,7 +234,16 @@ class RadarViewModel(
// Compute toggle state before the update so we don't read post-update value
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
val newUuid = if (isTogglingOff) null else action.uuid
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = newUuid)) }
val offsetKHz = if (!isTogglingOff) {
transponders.find { it.uuid == action.uuid }
?.catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
} else ""
_uiState.update {
it.copy(
transceivers = it.transceivers.copy(selectedUuid = newUuid),
calculatorOffsetKHz = offsetKHz
)
}
// Only update the tracking service when selecting a different transponder to
// avoid resetting a user-adjusted TX base on re-expand
if (!isTogglingOff) {
@@ -296,6 +311,15 @@ class RadarViewModel(
is RadarAction.CwToggleExpanded -> {
_uiState.update { it.copy(cw = it.cw.copy(isExpanded = action.expanded)) }
}
is RadarAction.ChangeCalculatorOffset -> {
val catnum = _uiState.value.transceivers.selectedUuid?.let { uuid ->
transponders.find { it.uuid == uuid }?.catnum
}
if (catnum != null) {
settingsRepo.setSatelliteOffset(catnum, action.offsetKHz)
}
_uiState.update { it.copy(calculatorOffsetKHz = action.offsetKHz) }
}
}
}
@@ -23,14 +23,20 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -41,6 +47,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
@@ -49,22 +56,31 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.CardDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
@@ -128,12 +144,14 @@ fun CalculatorPage(
selectedUuid: String?,
orbitalPos: OrbitalPos?,
cw: CwSubState,
calculatorOffsetKHz: String = "",
onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit = {},
modifier: Modifier = Modifier
) {
val calculatorTransceivers = remember(transceivers) {
transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder)
.let(DopplerFrequencyCalculator::deduplicateTransponders)
}
val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) {
calculatorTransceivers.firstOrNull { it.uuid == selectedUuid }
@@ -145,61 +163,55 @@ fun CalculatorPage(
return
}
LazyColumn(
Column(
modifier = modifier
.fillMaxSize()
.padding(8.dp),
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
if (calculatorTransceivers.size > 1) {
item {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth()
) {
calculatorTransceivers.forEach { radio ->
FilterChip(
selected = radio.uuid == selectedTransceiver.uuid,
onClick = {
if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid))
},
label = {
Text(
text = transceiverTitle(radio),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth()
) {
calculatorTransceivers.forEach { radio ->
FilterChip(
selected = radio.uuid == selectedTransceiver.uuid,
onClick = {
if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid))
},
label = {
Text(
text = transceiverTitle(radio),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
}
item {
DopplerFrequencyCalculator(
transponder = selectedTransceiver,
orbitalPos = orbitalPos,
modifier = Modifier.fillMaxWidth()
)
}
DopplerFrequencyCalculator(
transponder = selectedTransceiver,
orbitalPos = orbitalPos,
offsetKHz = calculatorOffsetKHz,
onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) },
modifier = Modifier.fillMaxWidth()
)
item {
HorizontalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
}
HorizontalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
item {
CwDecoderPanel(
cw = cw,
onAction = onAction,
requestMicPermission = requestMicPermission,
modifier = Modifier.fillMaxWidth()
)
}
CwDecoderPanel(
cw = cw,
onAction = onAction,
requestMicPermission = requestMicPermission,
modifier = Modifier.fillMaxWidth()
)
}
}
@@ -532,152 +544,365 @@ private fun ExpandedRadioControl(
}
}
private enum class EditedField { TX, PASSBAND, RX }
@Composable
private fun DopplerFrequencyCalculator(
transponder: SatRadio,
orbitalPos: OrbitalPos?,
offsetKHz: String = "",
onOffsetChange: (String) -> Unit = {},
modifier: Modifier = Modifier
) {
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
var txInputMHz by remember { mutableStateOf("") }
var rxInputMHz by remember { mutableStateOf("") }
var offsetKHz by remember { mutableStateOf("") }
var lastEditedBy by remember { mutableStateOf(EditedField.TX) }
var lastEditedField by rememberSaveable(transponder.uuid) { mutableStateOf(EditedField.TX) }
var txFrequencyHz by rememberSaveable(transponder.uuid) { mutableStateOf(0L) }
var rxFrequencyHz by rememberSaveable(transponder.uuid) { mutableStateOf(0L) }
var passbandPosition by rememberSaveable(transponder.uuid) { mutableStateOf(0.5f) }
var stepSizeKHz by remember { mutableIntStateOf(1) }
val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
// Real-time refresh: when orbitalPos changes (1Hz), recompute the opposite field
val txLow = transponder.uplinkLow ?: return
val txHigh = transponder.uplinkHigh ?: return
val rxLow = transponder.downlinkLow ?: return
val rxHigh = transponder.downlinkHigh ?: return
val txRange = txHigh - txLow
val rxRange = rxHigh - rxLow
if (txRange <= 0L || rxRange <= 0L) return
// 原始转发器值(逆转多普勒),用于 passband 映射计算
val rawTxLow = orbitalPos.getDownlinkFreq(txLow)
val rawTxHigh = orbitalPos.getDownlinkFreq(txHigh)
val rawRxLow = orbitalPos.getUplinkFreq(rxLow)
val rawRxHigh = orbitalPos.getUplinkFreq(rxHigh)
val rawTxRange = rawTxHigh - rawTxLow
val rawRxRange = rawRxHigh - rawRxLow
val rawTransponder = transponder.copy(
uplinkLow = rawTxLow, uplinkHigh = rawTxHigh,
downlinkLow = rawRxLow, downlinkHigh = rawRxHigh
)
// 初始化
LaunchedEffect(Unit) {
if (txFrequencyHz == 0L) {
txFrequencyHz = txLow + txRange / 2
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: (rxLow + rxRange / 2)
}
}
// 实时重算:卫星移动时,锚点侧不变,重算另一侧
LaunchedEffect(orbitalPos) {
if (lastEditedBy == EditedField.TX) {
val txMHz = txInputMHz.toDoubleOrNull()
if (txMHz != null && txMHz > 0) {
val txHz = (txMHz * 1_000_000).toLong()
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txHz, transponder, orbitalPos, offsetHz
)
if (rxHz != null) {
rxInputMHz = String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0)
}
if (lastEditedField == EditedField.TX) {
// TX 是锚点,不变;重算 RX
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
} else if (lastEditedField == EditedField.RX) {
// RX 是锚点,不变;重算 TX
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
} else if (lastEditedField == EditedField.PASSBAND) {
// 相对位置不变(passbandPosition),TX 用地面频率,RX 经 Transceiver 完整路径计算
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
}
val txSliderValue = if (lastEditedField == EditedField.PASSBAND) {
passbandPosition
} else {
((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
}
val rxSliderValue = if (lastEditedField == EditedField.PASSBAND) {
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
} else {
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
}
fun updateTx(newTxHz: Long) {
when (lastEditedField) {
EditedField.PASSBAND -> {
// Passband 模式:滑块位置 → passbandPosition → 地面频率,RX 经 Transceiver 计算
val pos = ((newTxHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
passbandPosition = pos
txFrequencyHz = txLow + (pos * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
} else {
val rxMHz = rxInputMHz.toDoubleOrNull()
if (rxMHz != null && rxMHz > 0) {
val rxHz = (rxMHz * 1_000_000).toLong()
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxHz, transponder, orbitalPos, offsetHz
else -> {
txFrequencyHz = newTxHz.coerceIn(txLow, txHigh)
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
}
}
fun updateRx(newRxHz: Long) {
when (lastEditedField) {
EditedField.PASSBAND -> {
// Passband 模式:从 RX 经完整往返路径(含多普勒补偿)反推 TX 位置
val txFromRx = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
newRxHz, rawTransponder, orbitalPos, offsetHz
)
if (txHz != null) {
txInputMHz = String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0)
passbandPosition = if (txFromRx != null) {
((txFromRx - txLow).toFloat() / txRange).coerceIn(0f, 1f)
} else {
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
}
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
else -> {
rxFrequencyHz = newRxHz.coerceIn(rxLow, rxHigh)
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
}
}
}
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp)
modifier = modifier.fillMaxWidth().padding(top = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = stringResource(R.string.radar_doppler_calc),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.radar_doppler_info),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Offset input
OutlinedTextField(
value = offsetKHz,
onValueChange = { newVal ->
offsetKHz = newVal
// Trigger recompute based on last edited field
if (lastEditedBy == EditedField.TX) {
val txMHz = txInputMHz.toDoubleOrNull()
if (txMHz != null && txMHz > 0) {
val txHz = (txMHz * 1_000_000).toLong()
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txHz, transponder, orbitalPos, offsetHz
)
rxInputMHz = if (rxHz != null) String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) else ""
}
} else {
val rxMHz = rxInputMHz.toDoubleOrNull()
if (rxMHz != null && rxMHz > 0) {
val rxHz = (rxMHz * 1_000_000).toLong()
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxHz, transponder, orbitalPos, offsetHz
)
txInputMHz = if (txHz != null) String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) else ""
}
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
// 左半边: TX + RX 按钮
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
FilterChip(
selected = lastEditedField == EditedField.TX,
onClick = {
when (lastEditedField) {
EditedField.TX -> {
passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
lastEditedField = EditedField.PASSBAND
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
EditedField.PASSBAND -> { lastEditedField = EditedField.TX }
else -> { lastEditedField = EditedField.TX }
}
},
label = {
Text("TX", fontSize = 13.sp, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth())
},
modifier = Modifier.weight(1f).fillMaxHeight()
)
FilterChip(
selected = lastEditedField == EditedField.RX,
onClick = {
when (lastEditedField) {
EditedField.RX -> {
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
lastEditedField = EditedField.PASSBAND
}
EditedField.PASSBAND -> {
lastEditedField = EditedField.RX
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
}
else -> { lastEditedField = EditedField.RX }
}
},
label = {
Text("RX", fontSize = 13.sp, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth())
},
modifier = Modifier.weight(1f).fillMaxHeight()
)
}
},
label = { Text(stringResource(R.string.radar_doppler_offset_hint)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
modifier = Modifier.fillMaxWidth()
)
// 右半边: Offset 输入框(带细线边框)
Box(
modifier = Modifier
.weight(1f)
.height(48.dp)
.border(1.dp, MaterialTheme.colorScheme.outline, MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
BasicTextField(
value = offsetKHz,
onValueChange = onOffsetChange,
singleLine = true,
textStyle = TextStyle(
fontSize = 16.sp,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
),
decorationBox = { innerTextField ->
if (offsetKHz.isEmpty()) {
Text(
text = "Offset (kHz)",
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
innerTextField()
}
)
}
}
// TX and RX inputs on the same row
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
// TX input → compute RX
OutlinedTextField(
value = txInputMHz,
onValueChange = { newVal ->
txInputMHz = newVal
lastEditedBy = EditedField.TX
val mhz = newVal.toDoubleOrNull()
if (mhz != null && mhz > 0) {
val txHz = (mhz * 1_000_000).toLong()
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txHz, transponder, orbitalPos, offsetHz
// TX 行
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(
onClick = { updateTx(txFrequencyHz - stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("−", fontSize = 14.sp) }
Slider(
value = txSliderValue,
onValueChange = { norm ->
val rawFreq = txLow + (txRange * norm).toLong()
val snapped = ((rawFreq + 500) / 1000L) * 1000L
updateTx(snapped.coerceIn(txLow, txHigh))
},
valueRange = 0f..1f,
modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp)
)
OutlinedButton(
onClick = { updateTx(txFrequencyHz + stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("+", fontSize = 14.sp) }
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = String.format(Locale.ENGLISH, "%.4f", txLow / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = String.format(Locale.ENGLISH, "%.6f", txFrequencyHz / 1_000_000.0),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = " MHz",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
rxInputMHz = if (rxHz != null) String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) else ""
} else if (newVal.isEmpty()) {
rxInputMHz = ""
}
},
label = { Text(stringResource(R.string.radar_doppler_tx_hint)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.weight(1f)
Text(
text = String.format(Locale.ENGLISH, "%.4f", txHigh / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End
)
}
}
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
// RX input → compute TX
OutlinedTextField(
value = rxInputMHz,
onValueChange = { newVal ->
rxInputMHz = newVal
lastEditedBy = EditedField.RX
val mhz = newVal.toDoubleOrNull()
if (mhz != null && mhz > 0) {
val rxHz = (mhz * 1_000_000).toLong()
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxHz, transponder, orbitalPos, offsetHz
// RX 行
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(
onClick = { updateRx(rxFrequencyHz - stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("−", fontSize = 14.sp) }
Slider(
value = rxSliderValue,
onValueChange = { norm ->
val rawFreq = rxLow + (rxRange * norm).toLong()
val snapped = ((rawFreq + 500) / 1000L) * 1000L
updateRx(snapped.coerceIn(rxLow, rxHigh))
},
valueRange = 0f..1f,
modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp)
)
OutlinedButton(
onClick = { updateRx(rxFrequencyHz + stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("+", fontSize = 14.sp) }
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = String.format(Locale.ENGLISH, "%.4f", rxLow / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = String.format(Locale.ENGLISH, "%.6f", rxFrequencyHz / 1_000_000.0),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = " MHz",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
txInputMHz = if (txHz != null) String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) else ""
} else if (newVal.isEmpty()) {
txInputMHz = ""
}
},
label = { Text(stringResource(R.string.radar_doppler_rx_hint)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.weight(1f)
)
Text(
text = String.format(Locale.ENGLISH, "%.4f", rxHigh / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End
)
}
}
}
}
}
private enum class EditedField { TX, RX }
@Composable
private fun CwDecoderPanel(
cw: CwSubState,
@@ -717,30 +942,36 @@ private fun CwDecoderPanel(
// PR #1 Morse Expert engine: mini layout with waterfall + decoded text.
// Keep the old Kotlin decoder state/actions as fallback code, but this panel no longer feeds it.
val context = LocalContext.current
val activity = remember { context as? Activity }
val activity = remember(context) {
context as? Activity ?: error("CwDecoderPanel must be hosted in an Activity")
}
val controller = remember { MainActivity() }
val rootView = remember {
LayoutInflater.from(context).inflate(CwR.layout.cw_panel_main, null) as ConstraintLayout
val rootView = remember(context) {
LayoutInflater.from(context).inflate(CwR.layout.activity_main, null) as ConstraintLayout
}
var initialized by remember { mutableStateOf(false) }
var listening by remember { mutableStateOf(false) }
DisposableEffect(Unit) {
if (activity != null) {
controller.onCreate(activity, rootView, false)
}
onDispose {
controller.onPause()
controller.onDestroy()
}
}
LaunchedEffect(cw.hasPermission) {
if (cw.hasPermission && !initialized) {
// 重要: 不在展开时初始化控制器(避免"一展开就崩溃")。
// controller.onCreate/onResume/onPermissionGranted 全部推迟到用户点 Start 才执行。
fun startDecoding() {
if (!initialized) {
controller.onCreate(activity, rootView)
controller.onPermissionGranted()
controller.onResume()
initialized = true
listening = true
} else {
controller.onResume()
}
listening = true
}
DisposableEffect(Unit) {
onDispose {
if (initialized) {
controller.onPause()
controller.onDestroy()
}
}
}
@@ -754,14 +985,8 @@ private fun CwDecoderPanel(
onClick = {
if (!cw.hasPermission) {
requestMicPermission()
} else if (!initialized) {
controller.onPermissionGranted()
controller.onResume()
initialized = true
listening = true
} else {
controller.onResume()
listening = true
startDecoding()
}
},
modifier = Modifier.weight(1f)
@@ -780,19 +1005,23 @@ private fun CwDecoderPanel(
}
}
OutlinedButton(
onClick = { controller.clearDecoded() },
onClick = { if (initialized) controller.clearDecoded() },
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.radar_cw_reset))
}
}
AndroidView(
factory = { rootView },
Box(
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
)
) {
AndroidView(
factory = { rootView },
modifier = Modifier.fillMaxSize()
)
}
}
}
}
@@ -19,39 +19,84 @@ package com.rtbishop.look4sat.feature.settings
import android.bluetooth.BluetoothManager
import android.content.Context
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Checkbox
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableFloatState
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Preview(showBackground = true)
@Composable
@@ -111,16 +156,159 @@ fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Uni
}
}
@Composable
fun WavelogDialog(
initialSettings: com.rtbishop.look4sat.core.domain.model.WavelogSettings,
workedGridsCount: Int,
isSyncing: Boolean,
message: String?,
dismiss: () -> Unit,
onSave: (com.rtbishop.look4sat.core.domain.model.WavelogSettings) -> Unit,
onSync: (com.rtbishop.look4sat.core.domain.model.WavelogSettings) -> Unit
) {
val url = rememberSaveable { mutableStateOf(initialSettings.url) }
val token = rememberSaveable { mutableStateOf(initialSettings.token) }
SharedDialog(
title = stringResource(R.string.prefs_wavelog_title),
onCancel = dismiss,
onAccept = {
onSave(com.rtbishop.look4sat.core.domain.model.WavelogSettings(url.value, token.value))
dismiss()
}
) {
OutlinedTextField(
value = url.value,
onValueChange = { url.value = it },
label = { Text(text = stringResource(id = R.string.prefs_wavelog_url)) },
placeholder = { Text(text = "http://192.168.1.10") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
OutlinedTextField(
value = token.value,
onValueChange = { token.value = it },
label = { Text(text = stringResource(id = R.string.prefs_wavelog_token)) },
placeholder = { Text(text = "abcdef123456:1") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
Text(
text = stringResource(R.string.prefs_wavelog_hint, workedGridsCount),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
if (message != null) {
Text(
text = message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
// Sync uses the values typed in the fields directly — saving and syncing
// happen in one step, no need to close and reopen the dialog.
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
) {
TextButton(
onClick = { onSync(com.rtbishop.look4sat.core.domain.model.WavelogSettings(url.value, token.value)) },
enabled = !isSyncing
) {
Text(text = if (isSyncing) stringResource(R.string.prefs_wavelog_syncing)
else stringResource(R.string.prefs_wavelog_sync))
}
}
Spacer(modifier = Modifier.height(0.dp))
}
}
@Composable
fun LoTWDialog(
initialSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
workedGridsCount: Int,
isSyncing: Boolean,
message: String?,
dismiss: () -> Unit,
onSave: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit,
onSync: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit
) {
val call = rememberSaveable { mutableStateOf(initialSettings.callsign) }
val pass = rememberSaveable { mutableStateOf(initialSettings.password) }
SharedDialog(
title = stringResource(R.string.prefs_lotw_title),
onCancel = dismiss,
onAccept = {
onSave(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value))
dismiss()
}
) {
OutlinedTextField(
value = call.value,
onValueChange = { call.value = it.uppercase() },
label = { Text(text = stringResource(id = R.string.prefs_lotw_callsign)) },
placeholder = { Text(text = "BA7OPF") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
OutlinedTextField(
value = pass.value,
onValueChange = { pass.value = it },
label = { Text(text = stringResource(id = R.string.prefs_lotw_password)) },
placeholder = { Text(text = "********") },
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
Text(
text = stringResource(R.string.prefs_lotw_hint, workedGridsCount),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
if (message != null) {
Text(
text = message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
// Sync uses the values typed in the fields directly — saving and syncing
// happen in one step, no need to close and reopen the dialog.
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
) {
TextButton(
onClick = { onSync(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value)) },
enabled = !isSyncing
) {
Text(text = if (isSyncing) stringResource(R.string.prefs_lotw_syncing)
else stringResource(R.string.prefs_lotw_sync))
}
}
Spacer(modifier = Modifier.height(0.dp))
}
}
@Preview(showBackground = true)
@Composable
private fun TransceiversDialogPreview() {
MainTheme {
DataSourcesDialog(
useCustomTle = true,
useCustomTransceivers = true,
tleUrl = "https://example.com/tle.txt",
transceiversUrl = "https://example.com/tx.json",
requestCustomSourcesPermission = { onGranted, _ -> onGranted() },
satelliteUrls = listOf(
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"amsat.org/tle/current/nasabare.txt"
),
transceiversUrls = listOf(
"db.satnogs.org/api/transmitters/?format=json&status=active"
),
satelliteEnabled = listOf(true, false),
transceiversEnabled = listOf(true),
statusCodes = mapOf(
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv" to 200,
"amsat.org/tle/current/nasabare.txt" to 404
),
onImportTle = {},
onImportTransceivers = {},
onDismiss = {},
@@ -131,111 +319,404 @@ private fun TransceiversDialogPreview() {
@Composable
fun DataSourcesDialog(
useCustomTle: Boolean,
useCustomTransceivers: Boolean,
tleUrl: String,
transceiversUrl: String,
requestCustomSourcesPermission: (onGranted: () -> Unit, onDenied: () -> Unit) -> Unit,
satelliteUrls: List<String>,
transceiversUrls: List<String>,
satelliteEnabled: List<Boolean>,
transceiversEnabled: List<Boolean>,
statusCodes: Map<String, Int>,
onImportTle: () -> Unit,
onImportTransceivers: () -> Unit,
onDismiss: () -> Unit,
onSave: (Boolean, Boolean, String, String) -> Unit
onSave: (List<String>, List<String>, List<Boolean>, List<Boolean>) -> Unit
) {
val padding = LocalSpacing.current.large
val isEnabledCustomTle = rememberSaveable { mutableStateOf(useCustomTle) }
val isEnabledCustomTransceivers = rememberSaveable { mutableStateOf(useCustomTransceivers) }
val urlTle = rememberSaveable { mutableStateOf(tleUrl) }
val urlTransceivers = rememberSaveable { mutableStateOf(transceiversUrl) }
val onAccept = {
onSave(isEnabledCustomTle.value, isEnabledCustomTransceivers.value, urlTle.value, urlTransceivers.value)
onDismiss()
// Stable Long IDs avoid key collisions when several entries are empty or duplicated.
val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) }
val satUrls = remember {
satelliteUrls.mapIndexed { i, url -> i.toLong() to url }.toMutableStateList()
}
val onCancel = { onDismiss() }
SharedDialog(
title = stringResource(id = R.string.prefs_data_sources_title),
onCancel = onCancel,
onAccept = onAccept,
) {
Column(modifier = Modifier.padding(horizontal = padding)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
CardButton(
onClick = {
onImportTle()
onDismiss()
},
text = "TLE/3LE (.txt)\nOMM (.csv)",
modifier = Modifier.weight(1f)
)
CardButton(
onClick = {
onImportTransceivers()
onDismiss()
},
text = "Transceivers\nSatNOGS (.json)",
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(6.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.prefs_data_sources_tle_switch))
Switch(
checked = isEnabledCustomTle.value,
onCheckedChange = { enabled ->
if (!enabled) {
isEnabledCustomTle.value = false
} else {
requestCustomSourcesPermission(
{ isEnabledCustomTle.value = true },
{ isEnabledCustomTle.value = false }
)
}
}
)
}
OutlinedTextField(
value = urlTle.value,
onValueChange = { urlTle.value = it },
label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
enabled = isEnabledCustomTle.value,
)
Spacer(modifier = Modifier.height(6.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.prefs_data_sources_transceivers_switch))
Switch(
checked = isEnabledCustomTransceivers.value,
onCheckedChange = { enabled ->
if (!enabled) {
isEnabledCustomTransceivers.value = false
} else {
requestCustomSourcesPermission(
{ isEnabledCustomTransceivers.value = true },
{ isEnabledCustomTransceivers.value = false }
)
}
}
)
}
OutlinedTextField(
value = urlTransceivers.value,
onValueChange = { urlTransceivers.value = it },
label = { Text(text = stringResource(id = R.string.prefs_data_sources_url_title)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
enabled = isEnabledCustomTransceivers.value,
)
Spacer(modifier = Modifier.height(12.dp))
val txUrls = remember {
transceiversUrls.mapIndexed { i, url -> (satelliteUrls.size + i).toLong() to url }.toMutableStateList()
}
val satEnabled = remember {
mutableStateMapOf<Long, Boolean>().apply {
satUrls.forEachIndexed { i, (id, _) -> this[id] = satelliteEnabled.getOrElse(i) { true } }
}
}
val txEnabled = remember {
mutableStateMapOf<Long, Boolean>().apply {
txUrls.forEachIndexed { i, (id, _) -> this[id] = transceiversEnabled.getOrElse(i) { true } }
}
}
val onRestoreDefaults = {
val satDefaults = Sources.satelliteDataUrls.values.filter { it.isNotBlank() }
val txDefaults = Sources.transceiversDataUrls.values.filter { it.isNotBlank() }
nextId.longValue = (satDefaults.size + txDefaults.size).toLong()
satUrls.clear()
satUrls.addAll(satDefaults.mapIndexed { i, url -> i.toLong() to url })
txUrls.clear()
txUrls.addAll(txDefaults.mapIndexed { i, url -> (satDefaults.size + i).toLong() to url })
satEnabled.clear()
txEnabled.clear()
Unit
}
val listState = rememberLazyListState()
val satDraggedId = remember { mutableStateOf(-1L) }
val txDraggedId = remember { mutableStateOf(-1L) }
val onAccept = {
val satFiltered = satUrls.filter { it.second.trim().isNotBlank() }
val txFiltered = txUrls.filter { it.second.trim().isNotBlank() }
onSave(
satFiltered.map { it.second.trim() },
txFiltered.map { it.second.trim() },
satFiltered.map { satEnabled[it.first] ?: true },
txFiltered.map { txEnabled[it.first] ?: true }
)
onDismiss()
}
SharedDialog(
title = stringResource(id = R.string.prefs_data_sources_title),
onCancel = onDismiss,
onAccept = onAccept,
) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxHeight(0.84f)
.padding(horizontal = padding),
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = PaddingValues(vertical = 6.dp)
) {
item {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
CardButton(
onClick = { onImportTle(); onDismiss() },
text = "TLE/3LE (.txt)\nOMM (.csv)",
modifier = Modifier.weight(1f)
)
CardButton(
onClick = { onImportTransceivers(); onDismiss() },
text = "Transceivers\nSatNOGS (.json)",
modifier = Modifier.weight(1f)
)
}
}
item {
CardButton(
onClick = onRestoreDefaults,
text = stringResource(R.string.prefs_data_sources_restore),
modifier = Modifier.fillMaxWidth()
)
}
sourceSection(
sectionKey = "sat",
labelResId = R.string.prefs_data_sources_satellites_label,
urls = satUrls,
listState = listState,
draggedId = satDraggedId,
statusCodes = statusCodes,
enabledMap = satEnabled,
onToggle = { id -> satEnabled[id] = !(satEnabled[id] ?: true) },
onAdd = { satUrls.add(nextId.longValue++ to "") },
onMove = { from, to -> satUrls.add(to, satUrls.removeAt(from)) },
onRemove = { i -> satUrls.removeAt(i) },
onUrlChange = { i, v -> satUrls[i] = satUrls[i].first to v }
)
sourceSection(
sectionKey = "tx",
labelResId = R.string.prefs_data_sources_transceivers_label,
urls = txUrls,
listState = listState,
draggedId = txDraggedId,
statusCodes = statusCodes,
enabledMap = txEnabled,
onToggle = { id -> txEnabled[id] = !(txEnabled[id] ?: true) },
onAdd = { txUrls.add(nextId.longValue++ to "") },
onMove = { from, to -> txUrls.add(to, txUrls.removeAt(from)) },
onRemove = { i -> txUrls.removeAt(i) },
onUrlChange = { i, v -> txUrls[i] = txUrls[i].first to v }
)
}
}
}
private fun LazyListScope.sourceSection(
sectionKey: String,
labelResId: Int,
urls: List<Pair<Long, String>>,
listState: LazyListState,
draggedId: MutableState<Long>,
statusCodes: Map<String, Int>,
enabledMap: Map<Long, Boolean>,
onToggle: (Long) -> Unit,
onAdd: () -> Unit,
onMove: (Int, Int) -> Unit,
onRemove: (Int) -> Unit,
onUrlChange: (Int, String) -> Unit
) {
item {
Spacer(modifier = Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(labelResId),
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
IconCard(action = onAdd, resId = R.drawable.ic_add)
}
}
itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (id, url) ->
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant
val enabled = enabledMap[id] ?: true
val rowState = remember { DragRowState() }
val scope = rememberCoroutineScope()
val isDragging = draggedId.value == id
val isLifted = isDragging || rowState.isSettling.value
LaunchedEffect(isDragging) {
if (!isDragging) return@LaunchedEffect
autoScroll(listState, rowState.startCenterY, rowState.fingerOffset, rowState.scrollComp)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.draggedVisual(
isLifted = isLifted,
translationY = if (rowState.isSettling.value) {
rowState.settleAnim.value
} else {
rowState.offsetY.floatValue + rowState.scrollComp.floatValue
}
)
.animateItem(
fadeInSpec = spring(),
// The dragged row repositions instantly, while its neighbours spring
// out of the way (the "squeeze" effect).
placementSpec = if (isDragging) {
tween<IntOffset>(durationMillis = 0)
} else {
spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow
)
},
fadeOutSpec = spring()
)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(40.dp)
.dragHandle(listState, sectionKey, id, urls, draggedId, rowState, scope, onMove)
) {
Icon(
painter = painterResource(R.drawable.ic_drag_handle),
contentDescription = null,
tint = enabledTint
)
}
OutlinedTextField(
value = url,
onValueChange = { onUrlChange(index, it) },
label = { Text(stringResource(R.string.prefs_data_sources_url_title)) },
supportingText = statusCodes[url]?.let { code ->
{ Text(statusLabel(code), color = statusColor(code), fontSize = 12.sp) }
},
trailingIcon = {
IconButton(onClick = { onRemove(index) }) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = null
)
}
},
singleLine = true,
enabled = enabled,
modifier = Modifier.weight(1f)
)
Checkbox(
checked = enabled,
onCheckedChange = { onToggle(id) }
)
}
}
}
/**
* Per-row drag state, kept in one object to keep the drag-handle modifier signature small.
*
* [offsetY] is the compensated visual displacement during a drag (finger travel minus the
* heights of already-swapped neighbours), so the row stays glued to the finger. [fingerOffset]
* tracks the raw finger travel for edge auto-scroll and swap detection. [settleAnim] smoothly
* flies the lifted row back into its slot once the finger is released.
*/
private class DragRowState {
val offsetY = mutableFloatStateOf(0f)
val fingerOffset = mutableFloatStateOf(0f)
val scrollComp = mutableFloatStateOf(0f)
val startCenterY = mutableFloatStateOf(0f)
val settleAnim = Animatable(0f)
val isSettling = mutableStateOf(false)
}
/** Spring shared by neighbour "squeeze" and the settle-back animation: soft and slightly bouncy. */
private val reorderSpring = spring<Float>(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow
)
/**
* Drag handle gesture that performs live reordering while dragging.
*/
@Composable
private fun Modifier.dragHandle(
listState: LazyListState,
sectionKey: String,
entryId: Long,
urls: List<Pair<Long, String>>,
draggedId: MutableState<Long>,
rowState: DragRowState,
scope: CoroutineScope,
onMove: (from: Int, to: Int) -> Unit
): Modifier = pointerInput(entryId, sectionKey) {
fun reorderLive() {
val myIndex = urls.indexOfFirst { it.first == entryId }
if (myIndex !in urls.indices) return
val myCenter = rowState.startCenterY.floatValue + rowState.fingerOffset.floatValue
val visible = listState.layoutInfo.visibleItemsInfo
// Dragging down: swap when the dragged centre passes the next row's midpoint.
if (myIndex < urls.lastIndex) {
val next = visible.firstOrNull { it.key == "$sectionKey-${urls[myIndex + 1].first}" }
if (next != null && myCenter > next.offset + next.size / 2f) {
onMove(myIndex, myIndex + 1)
rowState.offsetY.floatValue -= next.size.toFloat()
return
}
}
// Dragging up: swap when the dragged centre passes the previous row's midpoint.
if (myIndex > 0) {
val prev = visible.firstOrNull { it.key == "$sectionKey-${urls[myIndex - 1].first}" }
if (prev != null && myCenter < prev.offset + prev.size / 2f) {
onMove(myIndex, myIndex - 1)
rowState.offsetY.floatValue += prev.size.toFloat()
}
}
}
// Reset the drag bookkeeping and fly the lifted row back into its slot.
fun finishDrag() {
val lastOffset = rowState.offsetY.floatValue + rowState.scrollComp.floatValue
if (kotlin.math.abs(lastOffset) < 1f) {
rowState.fingerOffset.floatValue = 0f
rowState.offsetY.floatValue = 0f
rowState.scrollComp.floatValue = 0f
draggedId.value = -1L
return
}
scope.launch {
rowState.settleAnim.snapTo(lastOffset)
rowState.isSettling.value = true
rowState.fingerOffset.floatValue = 0f
rowState.offsetY.floatValue = 0f
rowState.scrollComp.floatValue = 0f
draggedId.value = -1L
rowState.settleAnim.animateTo(0f, reorderSpring)
rowState.isSettling.value = false
}
}
detectDragGesturesAfterLongPress(
onDragStart = {
rowState.isSettling.value = false
val layout = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == "$sectionKey-$entryId" }
rowState.startCenterY.floatValue = (layout?.offset ?: 0) + (layout?.size ?: 0) / 2f
rowState.fingerOffset.floatValue = 0f
rowState.offsetY.floatValue = 0f
rowState.scrollComp.floatValue = 0f
draggedId.value = entryId
},
onDragEnd = ::finishDrag,
onDragCancel = ::finishDrag
) { change, dragAmount ->
change.consume()
if (draggedId.value != entryId) return@detectDragGesturesAfterLongPress
rowState.fingerOffset.floatValue += dragAmount.y
rowState.offsetY.floatValue += dragAmount.y
reorderLive()
}
}
@Composable
private fun Modifier.draggedVisual(isLifted: Boolean, translationY: Float): Modifier {
val shape = MaterialTheme.shapes.small
// Smoothly scale the row up/down as the lifted card appears and disappears.
val scale by animateFloatAsState(
targetValue = if (isLifted) 1.02f else 1f,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
label = "dragScale"
)
return this
.graphicsLayer {
if (isLifted) {
this.translationY = translationY
scaleX = scale
scaleY = scale
}
}
.then(
if (isLifted) {
// Solid card on top so the lifted row fully covers the row beneath it
// instead of showing a translucent overlap of both rows.
Modifier
.zIndex(1f)
.shadow(8.dp, shape, clip = false)
.background(MaterialTheme.colorScheme.surface, shape)
} else {
Modifier
}
)
}
/**
* Scrolls the list while dragging so the entry follows the finger past the viewport edges.
* The visual centre is tracked independently of the entry's layout slot (which can scroll out
* of [LazyListState.layoutInfo.visibleItemsInfo] during a long drag); [startCenterY] is the
* entry's viewport centre captured at drag start and [fingerOffset] is the raw finger delta.
*/
private suspend fun autoScroll(
listState: LazyListState,
startCenterY: MutableFloatState,
fingerOffset: MutableFloatState,
scrollComp: MutableFloatState
) {
val threshold = 48f
val maxSpeed = 24f
while (true) {
val info = listState.layoutInfo
val center = startCenterY.floatValue + fingerOffset.floatValue
val top = info.viewportStartOffset + threshold
val bottom = info.viewportEndOffset - threshold
val delta = when {
center < top -> -(top - center).coerceAtMost(maxSpeed)
center > bottom -> (center - bottom).coerceAtMost(maxSpeed)
else -> 0f
}
if (delta != 0f) scrollComp.floatValue += listState.scrollBy(delta)
delay(16L)
}
}
private fun statusLabel(code: Int): String = if (code == NetworkResult.CONNECTION_ERROR) "ERR" else code.toString()
@Composable
private fun statusColor(code: Int): Color = when {
code == NetworkResult.CONNECTION_ERROR -> MaterialTheme.colorScheme.error
code in 200..299 -> Color(0xFF66BB6A)
else -> MaterialTheme.colorScheme.error
}
@Preview(showBackground = true)
@@ -252,6 +733,7 @@ fun PreviewNetworkOutputDialog() {
frequencyAddress = "127.0.0.1",
frequencyPort = "4532",
frequencyFormat = $$"F $FREQ",
frequencyOffsetHz = 0L,
bluetoothRotatorState = false,
bluetoothRotatorFormat = $$"P $AZ $EL",
bluetoothRotatorName = "Default",
@@ -261,7 +743,7 @@ fun PreviewNetworkOutputDialog() {
bluetoothFrequencyFormat = $$"F $FREQ"
),
onDismiss = {},
onSave = { _, _, _, _, _, _, _, _ -> }
onSave = { _, _, _, _, _, _, _, _, _ -> }
)
}
}
@@ -272,7 +754,7 @@ fun NetworkOutputDialog(
onDismiss: () -> Unit,
onSave: (
Boolean, String, String, String,
Boolean, String, String, String
Boolean, String, String, String, Long
) -> Unit
) {
val padding = LocalSpacing.current.large
@@ -286,12 +768,15 @@ fun NetworkOutputDialog(
mutableStateOf("${initialSettings.frequencyAddress}:${initialSettings.frequencyPort}")
}
val frequencyFormat = rememberSaveable { mutableStateOf(initialSettings.frequencyFormat) }
val frequencyOffsetHz = rememberSaveable { mutableStateOf(initialSettings.frequencyOffsetHz.toString()) }
val onAccept = {
val (rotIp, rotPort) = splitAddress(rotatorAddress.value)
val (freqIp, freqPort) = splitAddress(frequencyAddress.value)
val offsetHz = (frequencyOffsetHz.value.trim().toLongOrNull() ?: 0L)
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ)
onSave(
rotatorState.value, rotIp, rotPort, rotatorFormat.value,
frequencyState.value, freqIp, freqPort, frequencyFormat.value
frequencyState.value, freqIp, freqPort, frequencyFormat.value, offsetHz
)
onDismiss()
}
@@ -324,6 +809,24 @@ fun NetworkOutputDialog(
onFormatChange = { frequencyFormat.value = it },
formatLabel = stringResource(R.string.prefs_net_frequency_format_hint)
)
Spacer(modifier = Modifier.height(6.dp))
OutlinedTextField(
value = frequencyOffsetHz.value,
onValueChange = { frequencyOffsetHz.value = it },
singleLine = true,
label = { Text(stringResource(R.string.prefs_net_frequency_offset_hint)) },
supportingText = { Text(stringResource(R.string.prefs_net_frequency_offset_help)) },
trailingIcon = {
IconButton(
onClick = { frequencyOffsetHz.value = "0" },
enabled = frequencyState.value && frequencyOffsetHz.value != "0"
) {
Icon(painter = painterResource(R.drawable.ic_close), contentDescription = null)
}
},
modifier = Modifier.fillMaxWidth(),
enabled = frequencyState.value
)
Spacer(modifier = Modifier.height(12.dp))
}
}
@@ -352,6 +855,7 @@ fun PreviewBluetoothOutputDialog() {
frequencyAddress = "127.0.0.1",
frequencyPort = "4532",
frequencyFormat = $$"F $FREQ",
frequencyOffsetHz = 0L,
bluetoothRotatorState = false,
bluetoothRotatorFormat = $$"P $AZ $EL",
bluetoothRotatorName = "Default",
@@ -61,7 +61,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.CardButton
@@ -81,13 +84,25 @@ import java.util.Locale
fun SettingsDestination() {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container, context))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SettingsScreen(uiState, viewModel::onAction)
}
@Composable
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
var showUpdateChecker by rememberSaveable { mutableStateOf(false) }
if (showUpdateChecker) {
UpdateCheckerScreen(
currentVersion = uiState.appVersionName,
state = uiState.updateChecker,
onBack = { showUpdateChecker = false },
onCheck = { onAction(SettingsAction.CheckForUpdate) },
onDownload = { onAction(SettingsAction.DownloadUpdate) },
onConsumeApk = { onAction(SettingsAction.ConsumeDownloadedApk) }
)
return
}
val dialogs = rememberDialogVisibility()
val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
val pendingCustomSourcesDeny = remember { mutableStateOf<(() -> Unit)?>(null) }
@@ -125,30 +140,23 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
}
if (dialogs.dataSources) {
DataSourcesDialog(
useCustomTle = uiState.dataSourcesSettings.useCustomTLE,
useCustomTransceivers = uiState.dataSourcesSettings.useCustomTransceivers,
tleUrl = uiState.dataSourcesSettings.tleUrl,
transceiversUrl = uiState.dataSourcesSettings.transceiversUrl,
requestCustomSourcesPermission = { onGranted, onDenied ->
pendingCustomSourcesGrant.value = onGranted
pendingCustomSourcesDeny.value = onDenied
permissions.launchCustomSourcesPermission()
},
satelliteUrls = uiState.dataSourcesSettings.satelliteUrls,
transceiversUrls = uiState.dataSourcesSettings.transceiversUrls,
satelliteEnabled = uiState.dataSourcesSettings.satelliteEnabled,
transceiversEnabled = uiState.dataSourcesSettings.transceiversEnabled,
statusCodes = uiState.dataSourcesStatus,
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
onDismiss = { dialogs.dataSources = false },
onSave = { useCustomTle, useCustomTransceivers, tleUrl, transceiversUrl ->
val current = uiState.dataSourcesSettings
val newSettings = current.copy(
useCustomTLE = if (!useCustomTle || tleUrl.isNotBlank()) useCustomTle else current.useCustomTLE,
tleUrl = if (!useCustomTle || tleUrl.isNotBlank()) tleUrl else current.tleUrl,
useCustomTransceivers = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) useCustomTransceivers else current.useCustomTransceivers,
transceiversUrl = if (!useCustomTransceivers || transceiversUrl.isNotBlank()) transceiversUrl else current.transceiversUrl
onSave = { satUrls, txUrls, satEnabled, txEnabled ->
val newSettings = DataSourcesSettings(
satelliteUrls = satUrls,
transceiversUrls = txUrls,
satelliteEnabled = satEnabled,
transceiversEnabled = txEnabled
)
if (newSettings != current) onAction(SettingsAction.UpdateDataSources(newSettings))
if (newSettings.useCustomTLE || newSettings.useCustomTransceivers) {
onAction(SettingsAction.UpdateFromWeb)
}
if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
onAction(SettingsAction.UpdateFromWeb)
}
)
}
@@ -156,14 +164,15 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
NetworkOutputDialog(
initialSettings = uiState.rcSettings,
onDismiss = { dialogs.network = false },
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt ->
onSave = { rotState, rotAddr, rotPort, rotFmt, freqState, freqAddr, freqPort, freqFmt, freqOffsetHz ->
onAction(
SettingsAction.UpdateRC(
uiState.rcSettings.copy(
rotatorState = rotState, rotatorAddress = rotAddr,
rotatorPort = rotPort, rotatorFormat = rotFmt,
frequencyState = freqState, frequencyAddress = freqAddr,
frequencyPort = freqPort, frequencyFormat = freqFmt
frequencyPort = freqPort, frequencyFormat = freqFmt,
frequencyOffsetHz = freqOffsetHz
)
)
)
@@ -194,6 +203,29 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
)
}
if (dialogs.wavelog) {
WavelogDialog(
initialSettings = uiState.wavelogSettings,
workedGridsCount = uiState.workedGridsCount,
isSyncing = uiState.wavelogSyncing,
message = uiState.wavelogMessage,
dismiss = { dialogs.wavelog = false },
onSave = { onAction(SettingsAction.UpdateWavelog(it)) },
onSync = { onAction(SettingsAction.SyncWorkedGrids(it)) }
)
}
if (dialogs.lotw) {
LoTWDialog(
initialSettings = uiState.lotwSettings,
workedGridsCount = uiState.workedGridsCount,
isSyncing = uiState.lotwSyncing,
message = uiState.lotwMessage,
dismiss = { dialogs.lotw = false },
onSave = { onAction(SettingsAction.UpdateLoTW(it)) },
onSync = { onAction(SettingsAction.SyncLoTWGrids(it)) }
)
}
// URLs for top bar
val uriHandler = LocalUriHandler.current
@@ -298,8 +330,29 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
onRadioControlClick = { dialogs.radioControl = true }
)
}
item {
WavelogCard(
settings = uiState.wavelogSettings,
workedGridsCount = uiState.workedGridsCount,
showWavelogDialog = { dialogs.wavelog = true }
)
}
item {
LoTWCard(
settings = uiState.lotwSettings,
workedGridsCount = uiState.workedGridsCount,
showLoTWDialog = { dialogs.lotw = true }
)
}
item { OtherCard(uiState.otherSettings, onAction) }
item { CardCredits() }
item(span = { GridItemSpan(maxLineSpan) }) {
CardButton(
onClick = { showUpdateChecker = true },
text = stringResource(R.string.update_check_button),
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
@@ -475,6 +528,7 @@ private fun OtherCardPreview() = MainTheme {
stateOfUtc = false,
stateOfLightTheme = false,
stateOfNightMode = false,
stateOfMapGrid = false,
shouldSeeWarning = false,
shouldSeeWhatsNew = false
)
@@ -486,7 +540,7 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.height(268.dp)
.height(312.dp)
) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
@@ -508,6 +562,9 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
onAction(SettingsAction.ToggleNightMode(it))
}
SwitchRow(R.string.prefs_other_switch_map_grid, settings.stateOfMapGrid) {
onAction(SettingsAction.ToggleMapGrid(it))
}
}
}
}
@@ -524,6 +581,76 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole
}
}
@Preview(showBackground = true)
@Composable
private fun WavelogCardPreview() = MainTheme {
WavelogCard(settings = WavelogSettings("http://192.168.1.10", "wl2_demo"), workedGridsCount = 42) {}
}
@Composable
private fun WavelogCard(
settings: WavelogSettings,
workedGridsCount: Int,
showWavelogDialog: () -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
text = stringResource(id = R.string.prefs_wavelog_title),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = if (settings.isConfigured) {
stringResource(R.string.prefs_wavelog_configured, workedGridsCount)
} else {
stringResource(R.string.prefs_wavelog_not_configured)
},
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)
Spacer(modifier = Modifier.height(4.dp))
CardButton(
onClick = showWavelogDialog,
text = stringResource(id = R.string.prefs_wavelog_configure),
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun LoTWCard(
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
workedGridsCount: Int,
showLoTWDialog: () -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
text = stringResource(id = R.string.prefs_lotw_title),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = if (settings.isConfigured) {
stringResource(R.string.prefs_lotw_configured, workedGridsCount)
} else {
stringResource(R.string.prefs_lotw_not_configured)
},
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)
Spacer(modifier = Modifier.height(4.dp))
CardButton(
onClick = showLoTWDialog,
text = stringResource(id = R.string.prefs_wavelog_configure),
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun formatUpdateTime(updateTime: Long): String {
val timePattern = stringResource(id = R.string.prefs_updated_time)
@@ -643,6 +770,8 @@ private class DialogVisibility {
var network by mutableStateOf(false)
var bluetooth by mutableStateOf(false)
var radioControl by mutableStateOf(false)
var wavelog by mutableStateOf(false)
var lotw by mutableStateOf(false)
}
@Composable
@@ -650,12 +779,13 @@ private fun rememberDialogVisibility(): DialogVisibility {
return rememberSaveable(saver = run {
androidx.compose.runtime.saveable.Saver(
save = {
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl)
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw)
},
restore = {
DialogVisibility().apply {
position = it[0]; locator = it[1]; dataSources = it[2]
network = it[3]; bluetooth = it[4]; radioControl = it[5]
network = it[3]; bluetooth = it[4]; radioControl = it[5]; wavelog = it[6]
lotw = it.getOrElse(7) { false }
}
}
)
@@ -18,10 +18,13 @@
package com.rtbishop.look4sat.feature.settings
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.LatestRelease
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
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 java.io.File
data class PositionSettings(
val isUpdating: Boolean, val stationPos: GeoPos, val messageResId: Int
@@ -34,6 +37,15 @@ data class DataSettings(
val timestamp: Long
)
data class UpdateCheckerState(
val isChecking: Boolean = false,
val release: LatestRelease? = null,
val hasUpdate: Boolean = false,
val isDownloading: Boolean = false,
val apkFile: File? = null,
val errorResId: Int? = null
)
data class SettingsState(
val appVersionName: String,
val positionSettings: PositionSettings,
@@ -41,7 +53,16 @@ data class SettingsState(
val otherSettings: OtherSettings,
val rcSettings: RCSettings,
val radioControlSettings: RadioControlSettings,
val dataSourcesSettings: DataSourcesSettings
val dataSourcesSettings: DataSourcesSettings,
val dataSourcesStatus: Map<String, Int> = emptyMap(),
val wavelogSettings: WavelogSettings = WavelogSettings(),
val workedGridsCount: Int = 0,
val wavelogSyncing: Boolean = false,
val wavelogMessage: String? = null,
val lotwSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings = com.rtbishop.look4sat.core.domain.model.LoTWSettings(),
val lotwSyncing: Boolean = false,
val lotwMessage: String? = null,
val updateChecker: UpdateCheckerState = UpdateCheckerState()
)
sealed interface SettingsAction {
@@ -64,6 +85,7 @@ sealed interface SettingsAction {
data class ToggleSensor(val value: Boolean) : SettingsAction
data class ToggleLightTheme(val value: Boolean) : SettingsAction
data class ToggleNightMode(val value: Boolean) : SettingsAction
data class ToggleMapGrid(val value: Boolean) : SettingsAction
// Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction
@@ -72,6 +94,19 @@ sealed interface SettingsAction {
// Data sources
data class UpdateDataSources(val settings: DataSourcesSettings) : SettingsAction
// Wavelog worked grids
data class UpdateWavelog(val settings: WavelogSettings) : SettingsAction
data class SyncWorkedGrids(val settings: WavelogSettings) : SettingsAction
// LoTW confirmed grids
data class UpdateLoTW(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction
data class SyncLoTWGrids(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction
// Update checker
data object CheckForUpdate : SettingsAction
data object DownloadUpdate : SettingsAction
data object ConsumeDownloadedApk : SettingsAction
// System
data class ShowToast(val message: String) : SettingsAction
}
@@ -17,23 +17,33 @@
*/
package com.rtbishop.look4sat.feature.settings
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
import com.rtbishop.look4sat.core.presentation.R
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
class SettingsViewModel(
private val databaseRepo: IDatabaseRepo,
private val settingsRepo: ISettingsRepo,
private val updateRepo: IUpdateRepository,
private val wavelogRepo: IWavelogRepository,
private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository,
private val apkFile: File,
private val showToast: IShowToast
) : ViewModel() {
@@ -47,7 +57,10 @@ class SettingsViewModel(
otherSettings = settingsRepo.otherSettings.value,
rcSettings = settingsRepo.rcSettings.value,
radioControlSettings = settingsRepo.radioControlSettings.value,
dataSourcesSettings = settingsRepo.dataSourcesSettings.value
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
wavelogSettings = settingsRepo.wavelogSettings.value,
workedGridsCount = settingsRepo.getWorkedGrids().size
)
)
@@ -90,11 +103,26 @@ class SettingsViewModel(
_uiState.update { it.copy(dataSourcesSettings = settings) }
}
}
viewModelScope.launch {
settingsRepo.dataSourcesStatus.collect { status ->
_uiState.update { it.copy(dataSourcesStatus = status) }
}
}
viewModelScope.launch {
settingsRepo.radioControlSettings.collect { settings ->
_uiState.update { it.copy(radioControlSettings = settings) }
}
}
viewModelScope.launch {
settingsRepo.wavelogSettings.collect { settings ->
_uiState.update { it.copy(wavelogSettings = settings) }
}
}
viewModelScope.launch {
settingsRepo.lotwSettings.collect { settings ->
_uiState.update { it.copy(lotwSettings = settings) }
}
}
}
@@ -121,15 +149,118 @@ class SettingsViewModel(
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
is SettingsAction.ToggleMapGrid -> settingsRepo.updateOtherSettings { it.copy(stateOfMapGrid = action.value) }
// Remote control & data sources
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
// Wavelog worked grids
is SettingsAction.UpdateWavelog -> settingsRepo.updateWavelogSettings(action.settings)
is SettingsAction.SyncWorkedGrids -> syncWorkedGrids(action.settings)
// LoTW confirmed grids
is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings)
// Update checker
SettingsAction.CheckForUpdate -> checkForUpdate()
SettingsAction.DownloadUpdate -> downloadUpdate()
SettingsAction.ConsumeDownloadedApk -> consumeDownloadedApk()
// System
is SettingsAction.ShowToast -> showToast(action.message)
}
}
// region Wavelog worked grids
private fun syncWorkedGrids(settings: WavelogSettings) {
if (!settings.isConfigured) {
_uiState.update { it.copy(wavelogMessage = "Wavelog URL/token not configured") }
return
}
// Persist the credentials first, then sync with the freshly-typed values.
settingsRepo.updateWavelogSettings(settings)
_uiState.update { it.copy(wavelogSyncing = true, wavelogMessage = null) }
viewModelScope.launch {
val grids = wavelogRepo.fetchWorkedGrids(settings.url, settings.token)
_uiState.update {
if (grids != null) {
settingsRepo.setWorkedGrids(grids)
it.copy(wavelogSyncing = false, workedGridsCount = grids.size, wavelogMessage = null)
} else {
it.copy(wavelogSyncing = false, wavelogMessage = "Sync failed — check URL/token/network")
}
}
}
}
private fun syncLoTWGrids(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) {
if (!settings.isConfigured) {
_uiState.update { it.copy(lotwMessage = "LoTW callsign/password not configured") }
return
}
// Persist the credentials first, then sync with the freshly-typed values.
settingsRepo.updateLoTWSettings(settings)
_uiState.update { it.copy(lotwSyncing = true, lotwMessage = null) }
viewModelScope.launch {
val lotwGrids = lotwRepo.fetchConfirmedGrids(settings.callsign, settings.password)
_uiState.update { state ->
if (lotwGrids == null) {
state.copy(lotwSyncing = false, lotwMessage = "LoTW sync failed — check callsign/password/network")
} else {
// Merge with Wavelog grids: union of both sources, all confirmed/worked
val merged = settingsRepo.getWorkedGrids() + lotwGrids
settingsRepo.setWorkedGrids(merged)
state.copy(lotwSyncing = false, workedGridsCount = merged.size, lotwMessage = null)
}
}
}
}
// endregion
// region Update checker
private fun checkForUpdate() {
_uiState.update { it.copy(updateChecker = it.updateChecker.copy(isChecking = true, errorResId = null)) }
viewModelScope.launch {
val release = updateRepo.getLatestRelease()
val hasUpdate = release != null &&
VersionComparator.isNewer(release.versionTag, settingsRepo.appVersionName)
_uiState.update {
it.copy(
updateChecker = it.updateChecker.copy(
isChecking = false,
release = release,
hasUpdate = hasUpdate,
errorResId = if (release == null) R.string.update_check_error else null
)
)
}
}
}
private fun downloadUpdate() {
val url = _uiState.value.updateChecker.release?.apkUrl ?: return
_uiState.update { it.copy(updateChecker = it.updateChecker.copy(isDownloading = true, errorResId = null)) }
viewModelScope.launch {
val success = updateRepo.downloadApk(url, apkFile)
_uiState.update {
it.copy(
updateChecker = it.updateChecker.copy(
isDownloading = false,
apkFile = if (success) apkFile else null,
errorResId = if (success) null else R.string.update_check_download_error
)
)
}
}
}
private fun consumeDownloadedApk() {
_uiState.update { it.copy(updateChecker = it.updateChecker.copy(apkFile = null)) }
}
// endregion
// region Position helpers — consolidated from 3 near-identical functions
private fun setGpsPosition() {
@@ -201,11 +332,15 @@ class SettingsViewModel(
companion object {
fun factory(container: IMainContainer) = viewModelFactory {
fun factory(container: IMainContainer, context: Context) = viewModelFactory {
initializer {
SettingsViewModel(
databaseRepo = container.databaseRepo,
settingsRepo = container.settingsRepo,
updateRepo = container.updateRepo,
wavelogRepo = container.wavelogRepo,
lotwRepo = container.lotwRepo,
apkFile = File(context.cacheDir, "look4sat-update.apk"),
showToast = container.provideShowToast()
)
}
@@ -0,0 +1,236 @@
/*
* 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.settings
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.TopBar
@Composable
fun UpdateCheckerScreen(
currentVersion: String,
state: UpdateCheckerState,
onBack: () -> Unit,
onCheck: () -> Unit,
onDownload: () -> Unit,
onConsumeApk: () -> Unit
) {
val context = LocalContext.current
// When the APK finishes downloading, hand it to the system installer.
LaunchedEffect(state.apkFile) {
val file = state.apkFile ?: return@LaunchedEffect
val uri: Uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
onConsumeApk()
}
ScreenColumn(
topBar = { isVerticalLayout ->
TopBar {
IconCard(
action = onBack,
resId = R.drawable.ic_arrow,
modifier = Modifier.rotate(180f)
)
Text(
text = stringResource(R.string.update_check_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
)
}
}
) {
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxSize()
.padding(10.dp)
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp)
) {
Text(
text = stringResource(R.string.update_check_current, currentVersion),
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))
CardButton(
onClick = onCheck,
text = stringResource(R.string.update_check_button),
modifier = Modifier.fillMaxWidth()
)
}
}
when {
state.isChecking -> StatusRow(showSpinner = true)
state.errorResId != null -> StatusRow(showSpinner = false, message = stringResource(state.errorResId))
state.release == null -> Unit // not checked yet
!state.hasUpdate -> StatusRow(showSpinner = false, message = stringResource(R.string.update_check_up_to_date))
else -> ReleaseCard(
state = state,
onDownload = onDownload,
modifier = Modifier.weight(1f)
)
}
}
}
}
@Composable
private fun StatusRow(showSpinner: Boolean, message: String? = null) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
if (showSpinner) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(8.dp))
}
if (message != null) {
Text(
text = message,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun ReleaseCard(state: UpdateCheckerState, onDownload: () -> Unit, modifier: Modifier = Modifier) {
val release = state.release ?: return
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp, vertical = 10.dp)
) {
Text(
text = stringResource(R.string.update_check_available),
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.update_check_latest, release.versionTag.removePrefix("v")),
fontSize = 14.sp
)
if (release.title.isNotBlank() && release.title != release.versionTag) {
Text(text = release.title, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (release.body.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.update_check_release_notes),
fontWeight = FontWeight.Medium,
fontSize = 13.sp
)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(top = 4.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f))
.verticalScroll(rememberScrollState())
.padding(10.dp)
) {
Text(
text = release.body,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
Spacer(modifier = Modifier.weight(1f))
}
Spacer(modifier = Modifier.height(10.dp))
when {
release.apkUrl == null -> Text(
text = stringResource(R.string.update_check_no_apk),
color = MaterialTheme.colorScheme.error,
fontSize = 12.sp
)
state.isDownloading -> Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
CircularProgressIndicator(modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(R.string.update_check_downloading))
}
else -> CardButton(
onClick = onDownload,
text = stringResource(R.string.update_check_download),
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import com.android.build.api.dsl.LibraryExtension
plugins {
alias(libs.plugins.convention.featurePlugin)
}
android {
namespace = "com.rtbishop.look4sat.feature.status"
compileOptions {
encoding = "UTF-8"
}
}
dependencies {
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
}
@@ -0,0 +1,628 @@
package com.rtbishop.look4sat.feature.status
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.SatDay
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.InfoDialog
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.layoutPadding
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/** Fixed width per day tile — tablet-safe; name column absorbs remaining space. */
private val TILE_WIDTH: Dp = 64.dp
private data class UploadOption(
val apiValue: String,
val labelResId: Int,
val color: Color
)
private val UPLOAD_OPTIONS = listOf(
UploadOption(AMSAT_REPORT_HEARD, R.string.amsat_upload_active, Color(0xFF648FFF)),
UploadOption(AMSAT_REPORT_TELEMETRY_ONLY, R.string.amsat_upload_tlm, Color(0xFFFFB000)),
UploadOption(AMSAT_REPORT_NOT_HEARD, R.string.amsat_upload_not_heard, Color(0xFFDC267F)),
UploadOption(AMSAT_REPORT_CREW_ACTIVE, R.string.amsat_upload_crew_active, Color(0xFFFE6100))
)
/**
* Map AMSAT status text to Material3 colorScheme colors.
* Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants.
*/
@Composable
private fun statusColorOf(statusText: String): Color {
return when {
statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) ->
MaterialTheme.colorScheme.tertiary
statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) ->
MaterialTheme.colorScheme.tertiaryContainer
statusText.contains("Not Heard", ignoreCase = true) ->
Color(0xFFDC267F)
else ->
MaterialTheme.colorScheme.error
}
}
@Composable
fun SatStatusDestination() {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
val lifecycleOwner = LocalLifecycleOwner.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
DisposableEffect(lifecycleOwner, viewModel) {
var skipInitialStart = true
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
if (skipInitialStart) {
skipInitialStart = false
} else {
viewModel.fetch()
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
SatStatusScreen(
uiState = uiState,
refresh = viewModel::refresh,
onToggleUpload = viewModel::toggleUploadPanel,
onUploadReportChange = viewModel::setUploadReport,
onUploadCallsignChange = viewModel::setUploadCallsign,
onUploadGridChange = viewModel::setUploadGrid,
onRequestSubmitUpload = viewModel::requestSubmitConfirmation,
onConfirmSubmitUpload = viewModel::confirmSubmitReport,
onDismissSubmitConfirmation = viewModel::dismissSubmitConfirmation,
onDismissUpload = viewModel::collapseUploadPanel
)
}
@Composable
private fun SatStatusScreen(
uiState: SatStatusUiState,
refresh: () -> Unit,
onToggleUpload: () -> Unit,
onUploadReportChange: (String) -> Unit,
onUploadCallsignChange: (String) -> Unit,
onUploadGridChange: (String) -> Unit,
onRequestSubmitUpload: () -> Unit,
onConfirmSubmitUpload: (String) -> Unit,
onDismissSubmitConfirmation: () -> Unit,
onDismissUpload: () -> Unit
) {
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh)
LegendRow()
when {
uiState.isLoading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
uiState.error != null && uiState.statuses.isEmpty() -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = stringResource(id = R.string.amsat_load_failed), color = MaterialTheme.colorScheme.error)
TextButton(onClick = refresh) { Text(text = stringResource(id = R.string.amsat_retry)) }
}
}
else -> {
HeaderRow(statuses = uiState.statuses)
HorizontalDivider(thickness = 1.dp)
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(uiState.statuses, key = { it.name }) { status ->
StatusRow(status = status, onClickDay = { day -> selectedDay = status to day })
}
}
}
}
}
selectedDay?.let { (status, day) ->
ReportDialog(
statusName = status.name,
day = day,
reports = uiState.reports,
upload = uiState.upload,
onToggleUpload = onToggleUpload,
onReportChange = onUploadReportChange,
onCallsignChange = onUploadCallsignChange,
onGridChange = onUploadGridChange,
onRequestSubmitReport = onRequestSubmitUpload,
onConfirmSubmitReport = { onConfirmSubmitUpload(status.name) },
onDismissSubmitConfirmation = onDismissSubmitConfirmation,
onDismiss = {
selectedDay = null
onDismissUpload()
}
)
}
}
/** Top: update time + refresh button (spinner while loading) */
@Composable
private fun StatusHeader(fetchedAtUtcMs: Long, isRefreshing: Boolean, onRefresh: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (fetchedAtUtcMs > 0)
stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs)
else
stringResource(id = R.string.amsat_title),
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onRefresh),
contentAlignment = Alignment.Center
) {
if (isRefreshing) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Image(
painter = painterResource(id = R.drawable.ic_refresh),
contentDescription = stringResource(id = R.string.amsat_refresh),
modifier = Modifier.size(16.dp),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary)
)
}
}
}
}
/** Legend: FlowRow of colored chips — wraps to two lines on narrow screens, stays one line when wide. */
@Composable
private fun LegendRow() {
val legend = listOf(
stringResource(id = R.string.amsat_active) to Color(0xFF648FFF),
stringResource(id = R.string.amsat_tlm) to Color(0xFFFFB000),
stringResource(id = R.string.amsat_not_heard) to Color(0xFFDC267F),
stringResource(id = R.string.amsat_conflict) to Color(0xFFFE6100)
)
FlowRow(
modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
legend.forEach { (label, color) ->
val alphaColor = color.copy(alpha = 0.25f)
Row(
modifier = Modifier
.clip(RoundedCornerShape(32.dp))
.background(alphaColor)
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(RoundedCornerShape(4.dp))
.background(color)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = label,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
/** Header: satellite name column + fixed-width date labels aligned to tiles. */
@Composable
private fun HeaderRow(statuses: List<SatStatus>) {
val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList()
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(id = R.string.amsat_name),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f).padding(start = 4.dp)
)
dates.forEach { date ->
Text(
text = date,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.width(TILE_WIDTH)
)
}
}
}
/** Satellite row: name takes remaining width; day tiles are fixed-width (tablet-safe). */
@Composable
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
val noReportGray = 0xFFC0C0C0L
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = status.name,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(end = 4.dp)
)
status.days.forEach { day ->
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
DayCell(
slot = slot,
modifier = Modifier.width(TILE_WIDTH).padding(horizontal = 2.dp),
onClick = { onClickDay(day) }
)
}
}
}
/** Day block: newest reported status among the day's 12 slots; gray when none. */
@Composable
private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
val color = Color(slot.statusColor)
Box(
modifier = modifier
.height(28.dp)
.clip(RoundedCornerShape(4.dp))
.background(color)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
if (slot.count > 0) {
Text(text = slot.count.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
}
}
/** Report detail dialog (callsign / date / time / grid) */
@Composable
private fun ReportDialog(
statusName: String,
day: SatDay,
reports: Map<String, SatReport>,
upload: AmSatUploadUiState,
onToggleUpload: () -> Unit,
onReportChange: (String) -> Unit,
onCallsignChange: (String) -> Unit,
onGridChange: (String) -> Unit,
onRequestSubmitReport: () -> Unit,
onConfirmSubmitReport: () -> Unit,
onDismissSubmitConfirmation: () -> Unit,
onDismiss: () -> Unit
) {
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
InfoDialog(
title = "$statusName · ${day.dateLabel}",
onDismiss = onDismiss,
onAccept = onDismiss,
extraAction = {
CardButton(
onClick = onToggleUpload,
text = stringResource(if (upload.isExpanded) R.string.amsat_upload_back else R.string.amsat_upload)
)
}
) {
AnimatedVisibility(visible = upload.isExpanded) {
AmSatUploadPanel(
statusName = statusName,
upload = upload,
onReportChange = onReportChange,
onCallsignChange = onCallsignChange,
onGridChange = onGridChange,
onRequestSubmitReport = onRequestSubmitReport
)
}
if (upload.isConfirmingSubmit) {
AmSatUploadConfirmDialog(
statusName = statusName,
upload = upload,
onConfirm = onConfirmSubmitReport,
onDismiss = onDismissSubmitConfirmation
)
}
if (dayReports.isEmpty()) {
Text(
text = stringResource(id = R.string.amsat_no_reports),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
Spacer(modifier = Modifier.height(8.dp))
} else {
LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) {
items(dayReports) { report ->
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(statusColorOf(report.statusText))
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = report.statusText, fontSize = 14.sp, fontWeight = FontWeight.Bold)
}
Text(
text = "${report.call} ${report.dateUtc} ${report.timeUtc}" +
if (report.grid.isNotBlank() && report.grid != "-") " ${report.grid}" else "",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
HorizontalDivider(thickness = 0.5.dp)
}
}
}
}
}
@Composable
private fun AmSatUploadPanel(
statusName: String,
upload: AmSatUploadUiState,
onReportChange: (String) -> Unit,
onCallsignChange: (String) -> Unit,
onGridChange: (String) -> Unit,
onRequestSubmitReport: () -> Unit
) {
val options = remember(statusName) {
if (statusName.startsWith("ISS")) UPLOAD_OPTIONS else UPLOAD_OPTIONS.filter { it.apiValue != AMSAT_REPORT_CREW_ACTIVE }
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = stringResource(R.string.amsat_upload_report),
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.amsat_upload_time_now, formatUtcNowForUpload(System.currentTimeMillis())),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(R.string.amsat_upload_status),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
options.forEach { option ->
val selected = upload.selectedReport == option.apiValue
FilterChip(
selected = selected,
onClick = { onReportChange(option.apiValue) },
label = {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(if (selected) Color.White else option.color)
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(option.labelResId), maxLines = 1)
}
},
colors = FilterChipDefaults.filterChipColors(
containerColor = option.color.copy(alpha = 0.10f),
labelColor = MaterialTheme.colorScheme.onSurface,
selectedContainerColor = option.color,
selectedLabelColor = Color.White
),
border = FilterChipDefaults.filterChipBorder(
enabled = true,
selected = selected,
borderColor = option.color.copy(alpha = 0.60f),
selectedBorderColor = option.color
)
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = upload.callsign,
onValueChange = onCallsignChange,
label = { Text(stringResource(R.string.amsat_upload_callsign)) },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = upload.gridSquare,
onValueChange = onGridChange,
label = { Text(stringResource(R.string.amsat_upload_grid)) },
singleLine = true,
modifier = Modifier.weight(1f)
)
}
Button(
onClick = onRequestSubmitReport,
enabled = !upload.isSubmitting && upload.callsign.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
if (upload.isSubmitting) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Text(stringResource(R.string.amsat_upload_submit))
}
}
upload.message?.let { message ->
if (message == UPLOAD_MESSAGE_SUCCESS) {
Text(
text = stringResource(R.string.amsat_upload_success),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
upload.error?.let { error ->
Text(
text = uploadErrorText(error),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.error
)
}
}
}
@Composable
private fun AmSatUploadConfirmDialog(
statusName: String,
upload: AmSatUploadUiState,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
val option = UPLOAD_OPTIONS.firstOrNull { it.apiValue == upload.selectedReport } ?: UPLOAD_OPTIONS.first()
val statusLabel = stringResource(option.labelResId)
val grid = upload.gridSquare.ifBlank { stringResource(R.string.amsat_upload_grid_none) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.amsat_upload_confirm_title)) },
text = {
Text(
text = stringResource(
R.string.amsat_upload_confirm_message,
statusName,
statusLabel,
upload.callsign,
grid
)
)
},
confirmButton = {
TextButton(onClick = onConfirm, enabled = !upload.isSubmitting) {
Text(stringResource(R.string.amsat_upload_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !upload.isSubmitting) {
Text(stringResource(R.string.btn_cancel))
}
}
)
}
@Composable
private fun uploadErrorText(error: String): String {
return when (error) {
UPLOAD_ERROR_CALLSIGN_REQUIRED -> stringResource(R.string.amsat_upload_callsign_required)
UPLOAD_ERROR_GRID_INVALID -> stringResource(R.string.amsat_upload_grid_invalid)
else -> stringResource(R.string.amsat_upload_failed, error)
}
}
private val MONTH_ABBR = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
private fun formatFetchedAt(utcMs: Long): String {
val cal = Calendar.getInstance()
cal.timeInMillis = utcMs
val day = cal.get(Calendar.DAY_OF_MONTH)
val monthIndex = cal.get(Calendar.MONTH)
val year = cal.get(Calendar.YEAR)
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
val ss = cal.get(Calendar.SECOND).toString().padStart(2, '0')
return if (Locale.getDefault().language == Locale.CHINESE.language) {
"${year}年${monthIndex + 1}月${day}日 - $hh:$mm:$ss"
} else {
"$day${MONTH_ABBR[monthIndex]} $year - $hh:$mm:$ss"
}
}
private fun formatUtcNowForUpload(utcMs: Long): String {
val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
return formatter.format(Date(utcMs))
}
@@ -0,0 +1,294 @@
package com.rtbishop.look4sat.feature.status
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.Locale
const val AMSAT_REPORT_HEARD = "Heard"
const val AMSAT_REPORT_TELEMETRY_ONLY = "Telemetry Only"
const val AMSAT_REPORT_NOT_HEARD = "Not Heard"
const val AMSAT_REPORT_CREW_ACTIVE = "Crew Active"
const val UPLOAD_ERROR_CALLSIGN_REQUIRED = "callsign_required"
const val UPLOAD_ERROR_GRID_INVALID = "grid_invalid"
const val UPLOAD_MESSAGE_SUCCESS = "success"
data class AmSatUploadUiState(
val isExpanded: Boolean = false,
val selectedReport: String = AMSAT_REPORT_HEARD,
val callsign: String = "",
val gridSquare: String = "",
val isSubmitting: Boolean = false,
val isConfirmingSubmit: Boolean = false,
val message: String? = null,
val error: String? = null
)
data class SatStatusUiState(
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val statuses: List<SatStatus> = emptyList(),
val reports: Map<String, SatReport> = emptyMap(),
val fetchedAtUtcMs: Long = 0L,
val error: String? = null,
val upload: AmSatUploadUiState = AmSatUploadUiState()
)
class SatStatusViewModel(
private val amSatRepo: IAmSatRepository,
private val settingsRepo: ISettingsRepo
) : ViewModel() {
private val cachedStatusPage = amSatRepo.getCachedStatus()
private val _uiState = MutableStateFlow(cachedStatusPage?.toUiState() ?: SatStatusUiState(isLoading = true))
val uiState: StateFlow<SatStatusUiState> = _uiState
init {
if (cachedStatusPage == null) fetch()
}
fun fetch() {
amSatRepo.getCachedStatus()?.let { page ->
applyStatusPage(page)
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val page = amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) {
applyStatusPage(page)
} else {
_uiState.update {
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
}
}
} catch (exception: Exception) {
_uiState.update {
it.copy(isLoading = false, isRefreshing = false, error = exception.message ?: "fetch_failed")
}
}
}
}
fun refresh() {
if (_uiState.value.isRefreshing) return
_uiState.update { it.copy(isRefreshing = true, error = null) }
viewModelScope.launch {
try {
val page = amSatRepo.fetchStatus(forceRefresh = true)
if (page != null && page.statuses.isNotEmpty()) {
applyStatusPage(page)
} else {
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
}
} catch (exception: Exception) {
_uiState.update { it.copy(isRefreshing = false, error = exception.message ?: "fetch_failed") }
}
}
}
private fun applyStatusPage(page: SatStatusPage) {
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs,
error = null
)
}
}
fun toggleUploadPanel() {
val storedCallsign = settingsRepo.getAmSatCallsign()
val defaultGrid = defaultGridSquare()
_uiState.update { state ->
val upload = state.upload
val expanded = !upload.isExpanded
state.copy(
upload = if (expanded) {
upload.copy(
isExpanded = true,
callsign = upload.callsign.ifBlank { storedCallsign },
gridSquare = upload.gridSquare.ifBlank { defaultGrid },
message = null,
error = null
)
} else {
upload.copy(isExpanded = false, isConfirmingSubmit = false, message = null, error = null)
}
)
}
}
fun collapseUploadPanel() {
_uiState.update { it.copy(upload = it.upload.copy(isExpanded = false, isConfirmingSubmit = false, message = null, error = null)) }
}
fun setUploadReport(report: String) {
_uiState.update { it.copy(upload = it.upload.copy(selectedReport = report, isConfirmingSubmit = false, message = null, error = null)) }
}
fun setUploadCallsign(callsign: String) {
val normalized = callsign.trim().uppercase(Locale.US)
settingsRepo.setAmSatCallsign(normalized)
_uiState.update { it.copy(upload = it.upload.copy(callsign = normalized, isConfirmingSubmit = false, message = null, error = null)) }
}
fun setUploadGrid(grid: String) {
val normalized = grid.trim().uppercase(Locale.US)
_uiState.update { it.copy(upload = it.upload.copy(gridSquare = normalized, isConfirmingSubmit = false, message = null, error = null)) }
}
fun requestSubmitConfirmation() {
val prepared = validateUpload(_uiState.value.upload) ?: return
_uiState.update {
it.copy(
upload = it.upload.copy(
callsign = prepared.callsign,
gridSquare = prepared.grid,
isConfirmingSubmit = true,
message = null,
error = null
)
)
}
}
fun dismissSubmitConfirmation() {
_uiState.update { it.copy(upload = it.upload.copy(isConfirmingSubmit = false)) }
}
fun confirmSubmitReport(satelliteName: String) {
val upload = _uiState.value.upload
if (upload.isSubmitting) return
val prepared = validateUpload(upload) ?: return
settingsRepo.setAmSatCallsign(prepared.callsign)
_uiState.update {
it.copy(
upload = it.upload.copy(
callsign = prepared.callsign,
gridSquare = prepared.grid,
isSubmitting = true,
isConfirmingSubmit = false,
error = null,
message = null
)
)
}
viewModelScope.launch {
val result = amSatRepo.submitReport(
AmSatReportSubmission(
name = satelliteName,
report = prepared.report,
callsign = prepared.callsign,
gridSquare = prepared.grid,
reportedAtUtcMillis = System.currentTimeMillis()
)
)
if (result.success) {
_uiState.update {
it.copy(
upload = it.upload.copy(
isSubmitting = false,
callsign = prepared.callsign,
gridSquare = prepared.grid,
message = UPLOAD_MESSAGE_SUCCESS,
error = null
)
)
}
refresh()
} else {
_uiState.update {
it.copy(
upload = it.upload.copy(
isSubmitting = false,
message = null,
error = result.message.ifBlank { "Upload failed" }
)
)
}
}
}
}
private fun validateUpload(upload: AmSatUploadUiState): PreparedUpload? {
val callsign = upload.callsign.trim().uppercase(Locale.US)
val grid = upload.gridSquare.trim().uppercase(Locale.US)
return when {
callsign.isBlank() -> {
_uiState.update {
it.copy(
upload = it.upload.copy(
isConfirmingSubmit = false,
error = UPLOAD_ERROR_CALLSIGN_REQUIRED,
message = null
)
)
}
null
}
grid.isNotBlank() && !MAIDENHEAD_GRID.matches(grid) -> {
_uiState.update {
it.copy(
upload = it.upload.copy(
isConfirmingSubmit = false,
error = UPLOAD_ERROR_GRID_INVALID,
message = null
)
)
}
null
}
else -> PreparedUpload(
report = upload.selectedReport,
callsign = callsign,
grid = grid
)
}
}
private fun defaultGridSquare(): String {
val grid = settingsRepo.stationPosition.value.qthLocator.trim().uppercase(Locale.US)
return grid.takeUnless { it == "JJ00AA" }.orEmpty()
}
companion object {
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
SatStatusViewModel(container.amSatRepo, container.settingsRepo)
}
}
}
}
private fun SatStatusPage.toUiState() = SatStatusUiState(
statuses = statuses,
reports = reports,
fetchedAtUtcMs = fetchedAtUtcMs
)
private data class PreparedUpload(
val report: String,
val callsign: String,
val grid: String
)
private val MAIDENHEAD_GRID = Regex("^[A-R]{2}[0-9]{2}([A-X]{2})?$")
+3 -3
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "443"
appVersionCode = "456"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.3"
appVersionName = "4.4.6-ba7opf.9.4"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry
@@ -23,7 +23,7 @@ compose-activity = "1.13.0"
compose-lifecycle = "2.11.0"
compose-navigation3 = "1.1.5"
google-ksp = "2.3.8"
google-ksp = "2.3.10"
kotlin = "2.4.10"
kotlin-coroutines = "1.11.0"
+2 -1
View File
@@ -27,5 +27,6 @@ include(
":feature:passes",
":feature:radar",
":feature:satellites",
":feature:settings"
":feature:settings",
":feature:status"
)