mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
185
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52b733a450 | ||
|
|
474762cbd3 | ||
|
|
8fe88fbc93 | ||
|
|
7728b5c38f | ||
|
|
a769f7dea7 | ||
|
|
3be2e4b9e8 | ||
|
|
5957f30af2 | ||
|
|
d8d0825f54 | ||
|
|
bc5a8e4bba | ||
|
|
851bc25d73 | ||
|
|
238511aa78 | ||
|
|
e606afb0f3 | ||
|
|
e19f098aa5 | ||
|
|
797b1c19f7 | ||
|
|
8e549cd43c | ||
|
|
d33629f015 | ||
|
|
7081ffaa60 | ||
|
|
3b5b5fe627 | ||
|
|
ebe32ed153 | ||
|
|
091d3e867b | ||
|
|
26e50ddab5 | ||
|
|
5f90eebab4 | ||
|
|
70ada0d608 | ||
|
|
317f6d877f | ||
|
|
865fa9c6f2 | ||
|
|
a082abb72c | ||
|
|
a2e41a0c1a | ||
|
|
e1daf70f11 | ||
|
|
ca9f93c1ed | ||
|
|
42bd55e180 | ||
|
|
7401e1451b | ||
|
|
fa51fb2b44 | ||
|
|
75a251eb98 | ||
|
|
91b5269740 | ||
|
|
522cac25e8 | ||
|
|
732412deb2 | ||
|
|
30000a913e | ||
|
|
e7754a4c9e | ||
|
|
cf1e2b99b4 | ||
|
|
b295500d14 | ||
|
|
0163c7b217 | ||
|
|
85a4b9cfa3 | ||
|
|
c4c4dd682d | ||
|
|
62ad995aeb | ||
|
|
c8fdd2e93e | ||
|
|
dc013f920d | ||
|
|
f36a426855 | ||
|
|
4da59bdd17 | ||
|
|
b8138c948b | ||
|
|
0e28231c4a | ||
|
|
d6bdf5ecb6 | ||
|
|
6962a4bfa1 | ||
|
|
94a1a4f7b7 | ||
|
|
dbf2229774 | ||
|
|
f2fc1b7b28 | ||
|
|
d600836e19 | ||
|
|
94bffe80b0 | ||
|
|
4260fbb096 | ||
|
|
3d5b55faa3 | ||
|
|
466f2f6088 | ||
|
|
14bd984327 | ||
|
|
d5c100903b | ||
|
|
1c6388f3bc | ||
|
|
3e0874e6a8 | ||
|
|
6beaeb3e1a | ||
|
|
91f0cb1c56 | ||
|
|
3b72f8d68a | ||
|
|
3bc2983d26 | ||
|
|
0ec4c5dc6c | ||
|
|
8e8a3849df | ||
|
|
cd65e6c077 | ||
|
|
a3d370b1fa | ||
|
|
bf492e3f8b | ||
|
|
3591960d55 | ||
|
|
3f7a087938 | ||
|
|
4b82332133 | ||
|
|
dcd16a0196 | ||
|
|
9e88609efc | ||
|
|
322becdda8 | ||
|
|
7f93a10556 | ||
|
|
3cd5b49f98 | ||
|
|
4dbe896124 | ||
|
|
b47bd78ecd | ||
|
|
c89e79e28f | ||
|
|
b40a79e3c6 | ||
|
|
6c23696e22 | ||
|
|
f997d95cd4 | ||
|
|
e0a1d279ce | ||
|
|
059c816075 | ||
|
|
60dfa5d600 | ||
|
|
5f1eb239ed | ||
|
|
01ca06b43f | ||
|
|
22363c2549 | ||
|
|
45fe755470 | ||
|
|
0ce177cfbf | ||
|
|
f3fdf61784 | ||
|
|
fc028f852c | ||
|
|
7a865fffd7 | ||
|
|
89220a0b4b | ||
|
|
03d0055ce2 | ||
|
|
afb1e434e8 | ||
|
|
1bed7849a3 | ||
|
|
06f00e1601 | ||
|
|
462c3de155 | ||
|
|
4020381b03 | ||
|
|
b8389f63fe | ||
|
|
e64beee44f | ||
|
|
d2dffc039c | ||
|
|
d7d4a752ed | ||
|
|
6a3ab54226 | ||
|
|
3b3579696b | ||
|
|
826f44829b | ||
|
|
b979524b37 | ||
|
|
9e748f5534 | ||
|
|
25b982ac59 | ||
|
|
6a6399b606 | ||
|
|
c1cc992abd | ||
|
|
212dced3c4 | ||
|
|
7ef555e377 | ||
|
|
2773145e0c | ||
|
|
d8d3d81648 | ||
|
|
25a41def35 | ||
|
|
d9dc0a1735 | ||
|
|
692e917893 | ||
|
|
6371bb114d | ||
|
|
2d76aeda23 | ||
|
|
ecc24fd836 | ||
|
|
689f08881c | ||
|
|
d467200a45 | ||
|
|
6277e36a96 | ||
|
|
08a5c8c460 | ||
|
|
1ffb5ed9e8 | ||
|
|
cc684b6e3a | ||
|
|
6cb5b062d3 | ||
|
|
9c12b1eccb | ||
|
|
d56392f3c6 | ||
|
|
14b98cc7b1 | ||
|
|
200d2274de | ||
|
|
3d950f90ad | ||
|
|
1cc0bc770c | ||
|
|
84ba25c163 | ||
|
|
5bf4546ee3 | ||
|
|
030a6eecc9 | ||
|
|
42174da0be | ||
|
|
06421f8cfc | ||
|
|
9ea5b533c1 | ||
|
|
b6a9331ba3 | ||
|
|
390fd7717d | ||
|
|
ef0a34723b | ||
|
|
36d091a7e1 | ||
|
|
20143cf096 | ||
|
|
a64f9c80b9 | ||
|
|
db0320f521 | ||
|
|
adc9ac008b | ||
|
|
4a33d1b19a | ||
|
|
cce9209b3d | ||
|
|
237e4cb27a | ||
|
|
cd3eded77c | ||
|
|
7d7f8f1eda | ||
|
|
11b46c6710 | ||
|
|
da1facac64 | ||
|
|
cb1f341ed9 | ||
|
|
c2e496c406 | ||
|
|
7afce7ee68 | ||
|
|
86048ee1fd | ||
|
|
42a6ffa4c9 | ||
|
|
cf1d8e6539 | ||
|
|
ab8b072f1f | ||
|
|
1d4036e3d0 | ||
|
|
ab22244f86 | ||
|
|
3b0874d707 | ||
|
|
2c5b1479f1 | ||
|
|
39d4498a54 | ||
|
|
9ed2aebc9d | ||
|
|
72d5e13acf | ||
|
|
736dc23aae | ||
|
|
052ba79c94 | ||
|
|
cb648caf65 | ||
|
|
05170541ed | ||
|
|
e75d2dc410 | ||
|
|
d55a07bc26 | ||
|
|
c372386a77 | ||
|
|
de6ade025c | ||
|
|
2eb6d6cd5b | ||
|
|
c09f718aae |
No files matched your search
@@ -6,11 +6,15 @@
|
||||
|
||||
## 本仓库特色功能
|
||||
|
||||
- **网格地图模式(Grid Mode)** — 地图页内置 VUCC 网格覆盖图:已通联网格绿色填充、漫游网格蓝色 45° 斜纹(疏朗半透明样式)、当前所在方格淡黄加粗框;点击已通联网格弹出该网格内通联明细弹窗;进入网格模式默认 VUCC 视图
|
||||
- **奖状边界模式(Award Boundaries)** — 在网格地图上叠加 DXCC / WAPC / WAJA / WAZ / WAS 国界·省界·分区边界,已确认区域绿色填充
|
||||
- **LoTW 网格同步** — 直连 ARRL Logbook(账号密码验证)下载确认报告,worked / roamed 网格自动更新(兼容任意字段顺序的 ADIF 解析);同步失败按凭据错误/限流/超时分类提示
|
||||
- **双站过境匹配(Mutual Pass)** — 输入友台经纬度/网格,筛选双方同时可见的卫星过境,在地图上同时显示双方仰角曲线和地面轨迹,支持一键跳转雷达页查看详情
|
||||
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
|
||||
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本
|
||||
- **Passband 模式** — 支持通过位置滑块(Passband)自动计算 TX/RX 频率,避免切换时跳变
|
||||
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放
|
||||
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本(arm64 + armeabi-v7a 双架构)
|
||||
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放,启动时预取缓存
|
||||
- **应用内更新检查** — 设置页一键检查 GitHub 最新 Release,展示版本说明并直接下载安装
|
||||
- **自定义 TLE 数据源** — 可添加自定义 TLE/Celestrak CSV 数据源,长按拖拽排序,数据源启停开关与 HTTP 状态码显示
|
||||
- **中文界面优化** — 翻译修正、UI 布局调整,系统语言自动切换
|
||||
|
||||
## 上游仓库
|
||||
|
||||
@@ -18,7 +18,12 @@ android {
|
||||
namespace = libs.versions.packageName.get()
|
||||
defaultConfig {
|
||||
applicationId = "cn.ba7opf.look4sat"
|
||||
ndk { abiFilters.add("armeabi-v7a") }
|
||||
// CW decoder ships for both ABIs (see feature/cw jniLibs); the APK must
|
||||
// not be pinned to 32-bit only or 64-bit devices fall back to ARM32.
|
||||
ndk {
|
||||
abiFilters.add("armeabi-v7a")
|
||||
abiFilters.add("arm64-v8a")
|
||||
}
|
||||
}
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
|
||||
@@ -24,6 +24,14 @@ 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
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
|
||||
import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult
|
||||
import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
|
||||
import com.rtbishop.look4sat.core.domain.repository.lotwSyncToday
|
||||
import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.shouldAutoSyncLoTW
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
@@ -41,6 +49,8 @@ class MainApplication : Application(), IContainerProvider {
|
||||
clearAmSatCacheOnAppBackground()
|
||||
// trigger automatic update every 48 hours
|
||||
container.appScope.launch { checkAutoUpdate() }
|
||||
// automatic LoTW grid sync on every app start (gated, see checkLoTWAutoSync)
|
||||
container.appScope.launch { checkLoTWAutoSync() }
|
||||
// load satellite data on every app start
|
||||
container.appScope.launch { container.satelliteRepo.initRepository() }
|
||||
}
|
||||
@@ -79,4 +89,42 @@ class MainApplication : Application(), IContainerProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatic LoTW grid sync, mirroring [checkAutoUpdate]: checked on every
|
||||
* app start, but only pulls when due — LoTW credentials configured, the
|
||||
* LoTW auto-sync toggle on, at least one prior (manual) sync, and not
|
||||
* already synced today (ARRL limits report pulls, ~once a day per account).
|
||||
* Incremental when the callsign is unchanged; a callsign change falls back
|
||||
* to a full replace so no stale grids from another account linger.
|
||||
* Failures are silent and simply retried on the next app start — the manual
|
||||
* sync button still surfaces the explicit cause to the user.
|
||||
*/
|
||||
private suspend fun checkLoTWAutoSync(timeNow: Long = System.currentTimeMillis()) {
|
||||
val settingsRepo = container.settingsRepo
|
||||
val lotwSettings = settingsRepo.lotwSettings.value
|
||||
if (!shouldAutoSyncLoTW(
|
||||
isConfigured = lotwSettings.isConfigured,
|
||||
autoLotwSyncEnabled = settingsRepo.otherSettings.value.stateOfAutoLotwSync,
|
||||
lastSyncDate = settingsRepo.getLastLotwSyncDate(),
|
||||
today = lotwSyncToday(timeNow)
|
||||
)
|
||||
) return
|
||||
val callsign = lotwSettings.callsign.trim().uppercase()
|
||||
val mode = resolveLoTWSyncMode(settingsRepo.getLastLotwSyncCallsign(), callsign, requested = null)
|
||||
val since = if (mode == LoTWSyncMode.Incremental) lotwCursorApi(settingsRepo.getLastLotwSyncDate()) else ""
|
||||
println("Started periodic LoTW grid sync (${mode.name.lowercase()})")
|
||||
val result = container.lotwRepo.fetchConfirmedGridQsos(callsign, lotwSettings.password, since)
|
||||
if (result is LoTWResult.Success) {
|
||||
val count = applyLoTWGridResult(settingsRepo, result, mode, callsign, timeNow)
|
||||
// Feed the logbook so confirmations appear and local uploads get matched.
|
||||
runCatching {
|
||||
val confirmed = result.qsos.values.flatten().map { it.toConfirmedRecord(callsign) }
|
||||
container.qsoRepository.mergeLoTW(confirmed)
|
||||
}
|
||||
println("Periodic LoTW grid sync finished: $count worked grids")
|
||||
} else {
|
||||
println("Periodic LoTW grid sync skipped (${result::class.simpleName})")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,6 @@ 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
|
||||
@@ -82,6 +81,7 @@ import com.rtbishop.look4sat.core.presentation.Screen
|
||||
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
||||
import com.rtbishop.look4sat.feature.map.MapDestination
|
||||
import com.rtbishop.look4sat.feature.map.MapFilterViewModel
|
||||
import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
||||
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||
@@ -94,74 +94,72 @@ import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
||||
fun NavRoot(deeplink: String? = null) {
|
||||
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||
val deeplinkResolver = DeeplinkResolver()
|
||||
var openMapRequest by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(deeplink) {
|
||||
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
||||
}
|
||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
||||
val navigateToMap: () -> Unit = {
|
||||
while (rootBackStack.size > 1) rootBackStack.removeAt(rootBackStack.size - 1)
|
||||
openMapRequest += 1
|
||||
}
|
||||
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
|
||||
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
||||
slideOutHorizontally(tween(300)) { -it / 3 }
|
||||
// Reverse: outgoing slides out to the right, incoming drifts in from the left
|
||||
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
|
||||
slideOutHorizontally(tween(300)) { it }
|
||||
NavDisplay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
backStack = rootBackStack,
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { pushTransition },
|
||||
popTransitionSpec = { popTransition },
|
||||
predictivePopTransitionSpec = { popTransition },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Passes> {
|
||||
MainScreen(
|
||||
navigateToRadar = navigateToRadar,
|
||||
openMapRequest = openMapRequest,
|
||||
onOpenMapRequestHandled = { openMapRequest = 0 }
|
||||
)
|
||||
}
|
||||
entry<RadarDestination> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
RadarDestination(navigateUp = navigateBack, navigateToMap = navigateToMap)
|
||||
// Elevation color thresholds must be provided at the root level so that BOTH
|
||||
// the tab content (MainScreen) and the root-level RadarDestination see the
|
||||
// user's custom thresholds. RadarDestination is a sibling entry of MainScreen
|
||||
// in this NavDisplay, so a provider inside MainScreen never reaches it.
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||
CompositionLocalProvider(
|
||||
LocalElevationThresholds provides ElevationThresholds(
|
||||
low = otherSettings.lowElevation,
|
||||
high = otherSettings.highElevation
|
||||
)
|
||||
) {
|
||||
NavDisplay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
backStack = rootBackStack,
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { pushTransition },
|
||||
popTransitionSpec = { popTransition },
|
||||
predictivePopTransitionSpec = { popTransition },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Passes> {
|
||||
MainScreen(
|
||||
navigateToRadar = navigateToRadar
|
||||
)
|
||||
}
|
||||
entry<RadarDestination> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
navigateToRadar: () -> Unit = {},
|
||||
openMapRequest: Int = 0,
|
||||
onOpenMapRequestHandled: () -> Unit = {}
|
||||
navigateToRadar: () -> Unit = {}
|
||||
) {
|
||||
val backStack = rememberNavBackStack(Screen.Passes)
|
||||
val currentKey = backStack.lastOrNull()
|
||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||
val navigateToMap: () -> Unit = {
|
||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||
backStack.add(Screen.Map)
|
||||
}
|
||||
LaunchedEffect(openMapRequest) {
|
||||
if (openMapRequest > 0) {
|
||||
navigateToMap()
|
||||
onOpenMapRequestHandled()
|
||||
}
|
||||
}
|
||||
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.AMSAT, Screen.Mutual, Screen.Settings)
|
||||
// Map sits at position 4 from the left, matching the upstream nav order
|
||||
// (Satellites, Passes, AMSAT/Status, Map, ...), with the fork's Mutual tab
|
||||
// appended before Settings.
|
||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.AMSAT, Screen.Map, Screen.Mutual, Screen.Settings)
|
||||
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
@@ -172,6 +170,34 @@ fun MainScreen(
|
||||
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||
factory = MutualViewModel.factory(container)
|
||||
)
|
||||
// Activity-scoped so the map's award filter survives page switches;
|
||||
// resets to VUCC only on cold start (fresh process).
|
||||
val mapFilterViewModel: MapFilterViewModel = viewModel(
|
||||
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||
factory = MapFilterViewModel.factory()
|
||||
)
|
||||
|
||||
// Map grid-QSO dialog "Match" button: pre-fill the match page for that
|
||||
// grid and run the query BEFORE navigating, so the page's first frame
|
||||
// already contains the results (time-range card at the top, pass curves
|
||||
// right below — no scroll flicker). Navigation happens once the query
|
||||
// finishes (see the collector below).
|
||||
val mutualState by mutualViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val matchCalculating = mutualState.pendingNavigation && mutualState.isCalculating
|
||||
|
||||
LaunchedEffect(mutualViewModel) {
|
||||
mutualViewModel.uiState.collect { state ->
|
||||
if (state.pendingNavigation && !state.isCalculating) {
|
||||
mutualViewModel.consumePendingNavigation()
|
||||
// Push Mutual on top of the Map entry instead of replacing the
|
||||
// stack (bottom-nav style): the system back gesture then pops
|
||||
// back to the map page, which is the page the user came from.
|
||||
if (backStack.lastOrNull() !is Screen.Mutual) {
|
||||
backStack.add(Screen.Mutual)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalElevationThresholds provides ElevationThresholds(
|
||||
@@ -185,7 +211,7 @@ fun MainScreen(
|
||||
val isSelected = when (currentKey) {
|
||||
is Screen.Satellites -> screen is Screen.Satellites
|
||||
is Screen.Passes -> screen is Screen.Passes
|
||||
is Screen.Map -> screen is Screen.Passes
|
||||
is Screen.Map -> screen is Screen.Map
|
||||
is Screen.AMSAT -> screen is Screen.AMSAT
|
||||
is Screen.Mutual -> screen is Screen.Mutual
|
||||
is Screen.Settings -> screen is Screen.Settings
|
||||
@@ -196,7 +222,7 @@ fun MainScreen(
|
||||
label = { Text(stringResource(screen.titleResId)) },
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
if (isSelected && !(currentKey is Screen.Map && screen is Screen.Passes)) return@item
|
||||
if (isSelected) return@item
|
||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||
if (screen !is Screen.Passes) backStack.add(screen)
|
||||
}
|
||||
@@ -236,12 +262,22 @@ fun MainScreen(
|
||||
container.setMutualPassData(MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
navigateToRadar()
|
||||
},
|
||||
navigateToMap = navigateToMap
|
||||
}
|
||||
)
|
||||
}
|
||||
entry<Screen.Map> {
|
||||
MapDestination()
|
||||
MapDestination(
|
||||
mapFilterViewModel = mapFilterViewModel,
|
||||
matchCalculating = matchCalculating,
|
||||
onMatchGrid = { grid ->
|
||||
// Grid-QSO dialog "Match" button: pre-fill
|
||||
// the match page for that grid and start the
|
||||
// query. Navigation to the match page is
|
||||
// handled by the uiState collector above,
|
||||
// once the query finishes.
|
||||
mutualViewModel.prefillMatchFromGrid(grid)
|
||||
}
|
||||
)
|
||||
}
|
||||
entry<Screen.Mutual> {
|
||||
MutualScreen(
|
||||
|
||||
+3
@@ -35,6 +35,9 @@ internal class CoreDataPlugin : Plugin<Project> {
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.kotlin.coroutines)
|
||||
implementation(libs.other.okhttp)
|
||||
// android.jar's org.json is a stub ("not mocked") in unit tests;
|
||||
// provide a real implementation so JSON parsing is testable.
|
||||
testImplementation(libs.test.json)
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -45,9 +45,22 @@ interface Look4SatDao {
|
||||
@Query("DELETE FROM entries")
|
||||
suspend fun deleteEntries()
|
||||
|
||||
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
|
||||
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND isAlive = 1")
|
||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||
|
||||
/** Like [getIdsWithModes] but only matches transponder records with an uplink,
|
||||
* excluding downlink-only beacons/telemetry that share the same mode label
|
||||
* (e.g. CW beacons, FM voice-synthesis beacons) from FM/Linear filter fallback. */
|
||||
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND uplinkLow IS NOT NULL AND isAlive = 1")
|
||||
suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int>
|
||||
|
||||
/** Like [getIdsWithModes] but only matches records whose service class is
|
||||
* "Amateur", so the SSTV filter never matches weather birds (TIROS),
|
||||
* launcher debris or other non-amateur transmitters that happen to carry
|
||||
* an SSTV-labelled downlink. */
|
||||
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND service = 'Amateur' AND isAlive = 1")
|
||||
suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM radios")
|
||||
suspend fun getRadiosTotal(): Int
|
||||
|
||||
@@ -60,4 +73,7 @@ interface Look4SatDao {
|
||||
|
||||
@Query("DELETE FROM radios")
|
||||
suspend fun deleteRadios()
|
||||
|
||||
@Query("DELETE FROM radios WHERE isCustom = 0")
|
||||
suspend fun deleteManagedRadios()
|
||||
}
|
||||
@@ -19,19 +19,29 @@ package com.rtbishop.look4sat.core.data.database
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
|
||||
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
|
||||
|
||||
@Database(entities = [SatEntry::class, SatRadio::class], version = 1, exportSchema = false)
|
||||
@Database(entities = [SatEntry::class, SatRadio::class], version = 3, exportSchema = false)
|
||||
abstract class Look4SatDb : RoomDatabase() {
|
||||
abstract fun look4SatDao(): Look4SatDao
|
||||
}
|
||||
|
||||
//val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
// override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// database.execSQL("CREATE TABLE entries_backup (name TEXT NOT NULL, epoch REAL NOT NULL, meanmo REAL NOT NULL, eccn REAL NOT NULL, incl REAL NOT NULL, raan REAL NOT NULL, argper REAL NOT NULL, meanan REAL NOT NULL, catnum INTEGER NOT NULL, bstar REAL NOT NULL, xincl REAL NOT NULL, xnodeo REAL NOT NULL, omegao REAL NOT NULL, xmo REAL NOT NULL, xno REAL NOT NULL, orbitalPeriod REAL NOT NULL, isDeepSpace INTEGER NOT NULL, comment TEXT, PRIMARY KEY(catnum))")
|
||||
// database.execSQL("INSERT INTO entries_backup (name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, orbitalPeriod, isDeepSpace, comment) SELECT name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, 1440 / meanmo, 1440 / meanmo >= 225.0, comment FROM entries")
|
||||
// database.execSQL("DROP TABLE entries")
|
||||
// database.execSQL("ALTER TABLE entries_backup RENAME TO entries")
|
||||
// }
|
||||
//}
|
||||
/** Adds the mean motion derivative, needed to tell decayed satellites apart, and marks the
|
||||
* transceivers that were imported from a file, so that remote updates leave them alone. */
|
||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE entries ADD COLUMN ndot REAL NOT NULL DEFAULT 0.0")
|
||||
db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds the transceiver service class ("Amateur" etc.), used by the SSTV virtual
|
||||
* filter to tell amateur satellites apart from debris/weather/launcher stages. */
|
||||
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE radios ADD COLUMN service TEXT")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.database
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface QsoDao {
|
||||
@Query("SELECT * FROM qso_records ORDER BY startUtcMillis DESC, id DESC")
|
||||
fun observeAll(): Flow<List<QsoEntity>>
|
||||
|
||||
@Query("SELECT * FROM qso_records WHERE id = :id LIMIT 1")
|
||||
suspend fun find(id: Long): QsoEntity?
|
||||
|
||||
@Query("SELECT * FROM qso_records ORDER BY startUtcMillis DESC, id DESC")
|
||||
suspend fun getAll(): List<QsoEntity>
|
||||
|
||||
@Query("SELECT dedupeKey FROM qso_records")
|
||||
suspend fun getDedupeKeys(): List<String>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun save(record: QsoEntity): Long
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun importRecords(records: List<QsoEntity>): List<Long>
|
||||
|
||||
@Transaction
|
||||
suspend fun saveBatch(records: List<QsoEntity>) {
|
||||
records.forEach { save(it) }
|
||||
}
|
||||
|
||||
@Query("DELETE FROM qso_records WHERE id = :id")
|
||||
suspend fun delete(id: Long)
|
||||
|
||||
@Query("UPDATE qso_records SET lotwUploaded = 1 WHERE id IN (:ids)")
|
||||
suspend fun markUploaded(ids: List<Long>)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.database
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
|
||||
|
||||
/**
|
||||
* Separate database for the logbook (QSO records, LoTW confirmations and
|
||||
* upload receipts). Kept apart from [Look4SatDb] so existing installations
|
||||
* never need a migration of the satellite database.
|
||||
*/
|
||||
@Database(entities = [QsoEntity::class], version = 2, exportSchema = false)
|
||||
abstract class QsoDatabase : RoomDatabase() {
|
||||
abstract fun qsoDao(): QsoDao
|
||||
}
|
||||
|
||||
/** v1 → v2: track whether a QSO was uploaded to LoTW (distinct from confirmed). */
|
||||
val MIGRATION_QSO_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE qso_records ADD COLUMN lotwUploaded INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "qso_records",
|
||||
indices = [Index(value = ["dedupeKey"])]
|
||||
)
|
||||
data class QsoEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0L,
|
||||
val startUtcMillis: Long,
|
||||
val endUtcMillis: Long?,
|
||||
val theirCallsign: String,
|
||||
val myCallsign: String,
|
||||
val theirGrid: String,
|
||||
val myGrid: String,
|
||||
val sentReport: String,
|
||||
val receivedReport: String,
|
||||
val txFrequencyHz: Long?,
|
||||
val rxFrequencyHz: Long?,
|
||||
val band: String,
|
||||
val rxBand: String,
|
||||
@ColumnInfo(defaultValue = "'MFSK'") val mode: String,
|
||||
@ColumnInfo(defaultValue = "'FT4'") val submode: String,
|
||||
val satelliteName: String,
|
||||
val transponderName: String,
|
||||
val satelliteMode: String,
|
||||
val passAosUtcMillis: Long?,
|
||||
val automatic: Boolean,
|
||||
val status: String,
|
||||
@ColumnInfo(defaultValue = "''") val dedupeKey: String,
|
||||
@ColumnInfo(defaultValue = "''") val propagationMode: String = "",
|
||||
@ColumnInfo(defaultValue = "0") val lotwConfirmed: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "0") val lotwUploaded: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "''") val lotwQslDate: String = "",
|
||||
@ColumnInfo(defaultValue = "''") val vuccGrids: String = "",
|
||||
val dxcc: Int? = null,
|
||||
@ColumnInfo(defaultValue = "''") val country: String = "",
|
||||
val cqZone: Int? = null,
|
||||
@ColumnInfo(defaultValue = "''") val region: String = "",
|
||||
@ColumnInfo(defaultValue = "''") val comment: String = ""
|
||||
)
|
||||
@@ -30,5 +30,6 @@ data class SatEntry(
|
||||
val argper: Double,
|
||||
val meanan: Double,
|
||||
val catnum: Int,
|
||||
val bstar: Double
|
||||
val bstar: Double,
|
||||
val ndot: Double = 0.0
|
||||
)
|
||||
@@ -32,5 +32,11 @@ data class SatRadio(
|
||||
val uplinkHigh: Long?,
|
||||
val uplinkMode: String?,
|
||||
val isInverted: Boolean,
|
||||
val catnum: Int?
|
||||
val catnum: Int?,
|
||||
/** Service class from the transceiver source ("Amateur"/"Unknown" etc.).
|
||||
* Used to tell amateur satellites apart from debris / weather birds
|
||||
* (TIROS) / launcher stages (Ariane 6 R/B) in the SSTV virtual filter. */
|
||||
val service: String? = null,
|
||||
/** Set for manually imported transceivers, which remote updates must not replace. */
|
||||
val isCustom: Boolean = false
|
||||
)
|
||||
@@ -24,13 +24,19 @@ import android.hardware.display.DisplayManager
|
||||
import android.location.LocationManager
|
||||
import androidx.room.Room
|
||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_1_2
|
||||
import com.rtbishop.look4sat.core.data.database.QsoDatabase
|
||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||
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.lotw.LoTWUploadRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.QsoRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
||||
@@ -44,8 +50,10 @@ import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
||||
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
||||
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
||||
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
||||
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||
@@ -72,6 +80,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MainContainer(private val context: Context) : IMainContainer {
|
||||
|
||||
@@ -87,6 +96,13 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
override val updateRepo by lazy { UpdateRepository(remoteSource) }
|
||||
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
|
||||
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
|
||||
override val qsoRepository: IQsoRepository by lazy {
|
||||
val database = Room.databaseBuilder(context, QsoDatabase::class.java, "Look4SatQsoDB")
|
||||
.addMigrations(MIGRATION_QSO_1_2)
|
||||
.build()
|
||||
QsoRepository(database.qsoDao(), Dispatchers.IO)
|
||||
}
|
||||
override val lotwUploadRepository: ILoTWUploadRepository by lazy { LoTWUploadRepository(context) }
|
||||
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||
@@ -167,12 +183,24 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
|
||||
private fun provideLocalSource(): ILocalSource {
|
||||
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
|
||||
val database = builder.fallbackToDestructiveMigration(false).build()
|
||||
val database = builder
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
||||
.fallbackToDestructiveMigration(false)
|
||||
.build()
|
||||
return LocalSource(database.look4SatDao())
|
||||
}
|
||||
|
||||
private fun provideRemoteSource(): IRemoteSource {
|
||||
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
|
||||
// amsat.org live pages are slow (7-10s+ from some networks); the
|
||||
// OkHttp defaults (10s read timeout) made every AMSAT Live update
|
||||
// time out, silently keeping stale FM/Linear lists forever.
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.callTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
return RemoteSource(Dispatchers.IO, context.contentResolver, client)
|
||||
}
|
||||
|
||||
private fun provideSatelliteRepo(): ISatelliteRepo {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.rtbishop.look4sat.core.data.lotw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWRegionField
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWRegionOption
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWStationMeta
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWZonePair
|
||||
import org.w3c.dom.Element
|
||||
import org.xml.sax.InputSource
|
||||
import org.xml.sax.SAXException
|
||||
import java.io.InputStream
|
||||
import java.io.StringReader
|
||||
import java.util.Locale
|
||||
import java.util.zip.GZIPInputStream
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
|
||||
/** Bundled ARRL configuration 11.34 from https://lotw.arrl.org/lotw/config.tq6. */
|
||||
internal class LoTWConfig(input: InputStream) {
|
||||
private val root = input.use { source -> GZIPInputStream(source).use { gzip ->
|
||||
val xml = gzip.bufferedReader(Charsets.UTF_8).readText()
|
||||
require(!xml.contains("<!DOCTYPE", true) && !xml.contains("<!ENTITY", true))
|
||||
// Android's DOM provider does not support Xerces-specific setFeature flags.
|
||||
DocumentBuilderFactory.newInstance().apply { isExpandEntityReferences = false }.newDocumentBuilder().apply {
|
||||
setEntityResolver { _, _ -> throw SAXException("External entities are not permitted") }
|
||||
}.parse(InputSource(StringReader(xml))).documentElement
|
||||
} }
|
||||
private val config = root.elements("tqslconfig").single()
|
||||
val version = config.getAttribute("majorversion") + "." + config.getAttribute("minorversion")
|
||||
private val spec = config.elements("sigspec").single { it.getAttribute("version") == "2.0" }
|
||||
val stationOrder = spec.elements("tSTATION").single().childElements().map { it.tagName }
|
||||
val contactOrder = spec.elements("tCONTACT").single().childElements().map { it.tagName }
|
||||
private val primaryRegionFields = setOf("US_STATE", "CA_PROVINCE", "RU_OBLAST", "CN_PROVINCE", "AU_STATE", "JA_PREFECTURE", "FI_KUNTA")
|
||||
private val secondaryRegionFields = setOf("US_COUNTY", "JA_CITY_GUN_KU")
|
||||
private val bands = config.elements("bands").single().elements("band")
|
||||
private val satellites = config.elements("satellite").associateBy { it.getAttribute("name").uppercase(Locale.US) }
|
||||
private val modes = config.elements("modes").single().elements("mode").map { it.textContent }.toSet()
|
||||
|
||||
fun mode(record: QsoRecord): String {
|
||||
val display = record.displayMode
|
||||
if (display in modes) return display
|
||||
val mapped = config.elements("adifmode").firstOrNull { it.textContent.equals(display, true) }?.getAttribute("mode")
|
||||
return mapped ?: fail(LoTWProblem.MODE, display)
|
||||
}
|
||||
|
||||
fun band(explicit: String, frequency: Long?, required: Boolean): String {
|
||||
if (frequency != null && frequency <= 0) fail(LoTWProblem.BAND)
|
||||
val frequencyBand = frequency?.let { hz -> bands.firstOrNull { band ->
|
||||
val unit = if (band.getAttribute("spectrum") == "HF") 1_000.0 else 1_000_000.0
|
||||
val mhz = hz / unit
|
||||
mhz >= band.getAttribute("low").toDouble() && mhz <= band.getAttribute("high").toDouble()
|
||||
}?.textContent }
|
||||
val value = explicit.trim().uppercase(Locale.US).ifBlank { frequencyBand.orEmpty() }
|
||||
if (value.isBlank() && !required && frequency == null) return ""
|
||||
if (bands.none { it.textContent == value } || (frequency != null && frequencyBand != value)) fail(LoTWProblem.BAND, value)
|
||||
return value
|
||||
}
|
||||
|
||||
fun satellite(name: String, date: String): String {
|
||||
val normalized = name.trim().uppercase(Locale.US)
|
||||
// Look4Sat satellite names come from SatNOGS (e.g. "SO-50 (SaudiOSCAR 50)");
|
||||
// the bundled config only knows the official LoTW name ("SO-50"). Exact match
|
||||
// first, then accept any satellite whose official name is a substring of the
|
||||
// Look4Sat name (covers SO-50, AO-91, IO-86, CAS-7B …). Failing that the
|
||||
// contact is unavailable for upload.
|
||||
val sat = satellites[normalized]
|
||||
?: satellites.entries.firstOrNull { (official, _) ->
|
||||
official.length > 1 && normalized.contains(official)
|
||||
}?.value
|
||||
?: fail(LoTWProblem.SATELLITE, normalized)
|
||||
val first = sat.getAttribute("startDate")
|
||||
val last = sat.getAttribute("endDate")
|
||||
if ((first.isNotBlank() && date < first) || (last.isNotBlank() && date > last)) fail(LoTWProblem.SATELLITE, normalized)
|
||||
return sat.getAttribute("name").uppercase(Locale.US)
|
||||
}
|
||||
|
||||
fun propagation(value: String): String {
|
||||
val normalized = value.trim().uppercase(Locale.US)
|
||||
if (normalized.isNotBlank() && config.elements("propmode").none { it.getAttribute("name") == normalized || it.textContent == normalized }) {
|
||||
fail(LoTWProblem.INVALID_CONTACT, normalized)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
/** Region-field metadata and the national zonemap for one DXCC entity. */
|
||||
fun stationMeta(dxcc: Int): LoTWStationMeta {
|
||||
val pageFields = config.elements("page").filter { it.getAttribute("dependency") == dxcc.toString() }
|
||||
.flatMap { it.elements("pageField") }.map { it.textContent }
|
||||
val primary = pageFields.firstOrNull { it in primaryRegionFields }
|
||||
val regionField = primary?.let { id ->
|
||||
val field = config.elements("field").single { it.getAttribute("Id") == id }
|
||||
val dependency = if (field.getAttribute("dependsOn") == "DXCC") dxcc.toString() else null
|
||||
val options = field.elements("enums").filter {
|
||||
it.getAttribute("dependency").isBlank() || it.getAttribute("dependency") == dependency
|
||||
}.flatMap { it.elements("enum") }.map { enum ->
|
||||
LoTWRegionOption(
|
||||
code = enum.getAttribute("value").uppercase(Locale.US),
|
||||
name = enum.textContent.trim().ifBlank { enum.getAttribute("value") },
|
||||
zones = parseZonemap(enum.getAttribute("zonemap"))
|
||||
)
|
||||
}
|
||||
LoTWRegionField(id = id, label = field.getAttribute("label"), options = options)
|
||||
}
|
||||
val entity = config.elements("dxcc").flatMap { it.elements("entity") }
|
||||
.firstOrNull { it.getAttribute("arrlId") == dxcc.toString() }
|
||||
val countryZones = entity?.getAttribute("zonemap")?.let(::parseZonemap).orEmpty()
|
||||
return LoTWStationMeta(regionField, countryZones)
|
||||
}
|
||||
|
||||
private fun parseZonemap(zonemap: String): List<LoTWZonePair> = zonemap.split(',').mapNotNull { pair ->
|
||||
val parts = pair.split(':')
|
||||
if (parts.size != 2) return@mapNotNull null
|
||||
val itu = parts[0].trim().toIntOrNull() ?: return@mapNotNull null
|
||||
val cq = parts[1].trim().toIntOrNull() ?: return@mapNotNull null
|
||||
LoTWZonePair(itu, cq)
|
||||
}
|
||||
|
||||
fun stationFields(station: LoTWStation, dxcc: Int): Map<String, String> {
|
||||
fun normalized(value: String) = value.trim().uppercase(Locale.US)
|
||||
val grids = station.grid.split(',').map(::normalized).filter(String::isNotBlank).distinct()
|
||||
if (grids.size !in 1..4 || grids.any { !it.matches(Regex("[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?")) }) {
|
||||
fail(LoTWProblem.STATION_GRID)
|
||||
}
|
||||
// The primary grid goes into GRIDSQUARE (the field the config's tSTATION
|
||||
// order signs); the full multi-grid set goes into MY_VUCC_GRIDS, an extra
|
||||
// station field that TQSL stores but does not sign (same precedent as IOTA).
|
||||
val fields = linkedMapOf("GRIDSQUARE" to grids.first())
|
||||
if (grids.size > 1) fields["MY_VUCC_GRIDS"] = grids.joinToString(",")
|
||||
fun zone(name: String, value: String, maximum: Int) {
|
||||
if (value.isBlank()) return
|
||||
val number = value.trim().toIntOrNull()?.takeIf { it in 1..maximum } ?: fail(LoTWProblem.STATION_ZONE)
|
||||
fields[name] = number.toString()
|
||||
}
|
||||
zone("CQZ", station.cqZone, 40)
|
||||
zone("ITUZ", station.ituZone, 90)
|
||||
if (station.iota.isNotBlank()) {
|
||||
val iota = normalized(station.iota)
|
||||
if (!iota.matches(Regex("(AF|AN|AS|EU|NA|OC|SA)-[0-9]{3}"))) fail(LoTWProblem.STATION_IOTA)
|
||||
fields["IOTA"] = iota
|
||||
}
|
||||
val pageFields = config.elements("page").filter { it.getAttribute("dependency") == dxcc.toString() }
|
||||
.flatMap { it.elements("pageField") }.map { it.textContent }
|
||||
val primary = pageFields.firstOrNull { it in primaryRegionFields }
|
||||
val secondary = pageFields.firstOrNull { it in secondaryRegionFields }
|
||||
fun region(name: String?, value: String) {
|
||||
if (value.isBlank()) return
|
||||
if (name == null) fail(LoTWProblem.STATION_REGION)
|
||||
val field = config.elements("field").single { it.getAttribute("Id") == name }
|
||||
val dependency = when (field.getAttribute("dependsOn")) {
|
||||
"DXCC" -> dxcc.toString()
|
||||
else -> fields[field.getAttribute("dependsOn")]
|
||||
}
|
||||
val options = field.elements("enums").filter {
|
||||
it.getAttribute("dependency").isBlank() || it.getAttribute("dependency") == dependency
|
||||
}.flatMap { it.elements("enum") }.map { it.getAttribute("value").uppercase(Locale.US) }
|
||||
val code = normalized(value)
|
||||
if (code !in options) fail(LoTWProblem.STATION_REGION, name)
|
||||
fields[name] = code
|
||||
}
|
||||
region(primary, station.region)
|
||||
region(secondary, station.county)
|
||||
return fields
|
||||
}
|
||||
}
|
||||
|
||||
private fun Element.elements(tag: String): List<Element> = getElementsByTagName(tag).let { nodes ->
|
||||
(0 until nodes.length).map { nodes.item(it) as Element }
|
||||
}
|
||||
private fun Element.childElements(): List<Element> = (0 until childNodes.length).mapNotNull { childNodes.item(it) as? Element }
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.rtbishop.look4sat.core.data.lotw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||
import java.security.KeyStore
|
||||
import java.security.PrivateKey
|
||||
import java.security.Signature
|
||||
import java.security.cert.X509Certificate
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Collections
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
internal data class LoTWKeyMaterial(val key: PrivateKey, val certificate: X509Certificate, val info: LoTWCertificate) {
|
||||
companion object {
|
||||
fun read(bytes: ByteArray, password: CharArray, now: Long): LoTWKeyMaterial {
|
||||
if (bytes.isEmpty() || bytes.size > MAX_CERTIFICATE_BYTES) fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||
val key: PrivateKey
|
||||
val cert: X509Certificate
|
||||
val store = try {
|
||||
KeyStore.getInstance("PKCS12").apply { bytes.inputStream().use { load(it, password) } }
|
||||
} catch (_: Exception) {
|
||||
// Modern TQSL / OpenSSL 3 exports use PBES2+AES-CBC which Android's legacy
|
||||
// Bouncy Castle parser cannot read. Fall back to our own PBES2 reader; if
|
||||
// that fails too, report the real reason (format vs password).
|
||||
if (isPbes2(bytes)) {
|
||||
try {
|
||||
val parsed = Pkcs12Reader.read(bytes, password)
|
||||
parsed.first to parsed.second
|
||||
} catch (e: Exception) {
|
||||
// Classify by exception type so the user sees the right message:
|
||||
// - BadPadding (BAD_DECRYPT) -> wrong password
|
||||
// - IllegalStateException (error()) -> unsupported algorithm,
|
||||
// message is the algorithm name
|
||||
// - IllegalArgumentException (require()) -> structurally invalid file
|
||||
fail(
|
||||
when {
|
||||
e is javax.crypto.BadPaddingException -> LoTWProblem.CERTIFICATE_PASSWORD
|
||||
e is IllegalStateException && password.isNotEmpty() -> LoTWProblem.CERTIFICATE_FORMAT
|
||||
else -> LoTWProblem.CERTIFICATE_INVALID
|
||||
},
|
||||
if (e is IllegalStateException) (e.message ?: "") else ""
|
||||
)
|
||||
}
|
||||
} else {
|
||||
fail(LoTWProblem.CERTIFICATE_PASSWORD)
|
||||
}
|
||||
}
|
||||
if (store is Pair<*, *>) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
key = store.first as PrivateKey
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
cert = store.second as X509Certificate
|
||||
} else {
|
||||
val ks = store as KeyStore
|
||||
val aliases = Collections.list(ks.aliases()).filter { ks.isKeyEntry(it) }
|
||||
if (aliases.size != 1) fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||
val alias = aliases.single()
|
||||
key = try { ks.getKey(alias, password) as? PrivateKey }
|
||||
catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_PASSWORD) }
|
||||
?: fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||
cert = ks.getCertificate(alias) as? X509Certificate ?: fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||
}
|
||||
if (key.algorithm != "RSA" || cert.publicKey.algorithm != "RSA") fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||
try { cert.checkValidity(Date(now)) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_EXPIRED) }
|
||||
val info = try { metadata(cert) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_INVALID) }
|
||||
// Use the platform's crypto implementation, including PKCS#1 padding. No custom cryptography.
|
||||
val challenge = "Look4Sat LoTW certificate key check".toByteArray(Charsets.US_ASCII)
|
||||
val signed = Signature.getInstance("SHA1withRSA").run { initSign(key); update(challenge); sign() }
|
||||
val valid = Signature.getInstance("SHA1withRSA").run { initVerify(cert); update(challenge); verify(signed) }
|
||||
if (!valid) fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||
return LoTWKeyMaterial(key, cert, info)
|
||||
}
|
||||
|
||||
/** True when the PKCS12 uses PBES2 (OID 1.2.840.113549.1.5.13), the default
|
||||
* algorithm of OpenSSL 3 / modern TQSL. Android's legacy BC parser can't read it. */
|
||||
private fun isPbes2(bytes: ByteArray): Boolean {
|
||||
val oid = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x05, 0x0d)
|
||||
if (bytes.size < oid.size) return false
|
||||
outer@ for (i in 0..bytes.size - oid.size) {
|
||||
for (j in oid.indices) if (bytes[i + j] != oid[j]) continue@outer
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun metadata(cert: X509Certificate): LoTWCertificate {
|
||||
val oid = byteArrayOf(0x2b, 0x06, 0x01, 0x04, 0x01, 0xe0.toByte(), 0x3c, 0x01, 0x01)
|
||||
val subject = DerValue.read(cert.subjectX500Principal.encoded).single()
|
||||
require(subject.tag == 0x30)
|
||||
val calls = subject.children().flatMap { it.children() }.mapNotNull { attribute ->
|
||||
val pair = attribute.children()
|
||||
if (pair.size == 2 && pair[0].tag == 6 && pair[0].data.contentEquals(oid)) pair[1].text() else null
|
||||
}
|
||||
val call = calls.single().trim().uppercase(Locale.US)
|
||||
require(call.matches(Regex("[A-Z0-9]+(/[A-Z0-9]+)*")) && call.any(Char::isDigit) && call.any(Char::isLetter))
|
||||
val first = extension(cert, "2")
|
||||
val last = extension(cert, "3").takeUnless { it == "0000-00-00" }.orEmpty()
|
||||
require(validDate(first) && (last.isBlank() || validDate(last)) && (last.isBlank() || last >= first))
|
||||
val dxcc = extension(cert, "4").toInt()
|
||||
require(dxcc > 0)
|
||||
return LoTWCertificate(call, dxcc, cert.serialNumber.toString(16), utc(cert.notAfter.time, "yyyy-MM-dd"), first, last)
|
||||
}
|
||||
|
||||
private fun extension(cert: X509Certificate, suffix: String): String {
|
||||
val bytes = cert.getExtensionValue("1.3.6.1.4.1.12348.1.$suffix") ?: return ""
|
||||
val outer = DerValue.read(bytes).single()
|
||||
require(outer.tag == 4)
|
||||
// TrustedQSL uses raw ASCII extension values. Also accept DER string-wrapped values.
|
||||
return if (outer.data.all { it.toInt() in 32..126 || it.toInt() == 0 }) {
|
||||
outer.data.toString(Charsets.US_ASCII).trim('\u0000', ' ')
|
||||
} else DerValue.read(outer.data).single().text().trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal const val MAX_CERTIFICATE_BYTES = 1024 * 1024
|
||||
internal fun fail(problem: LoTWProblem, detail: String = ""): Nothing = throw LoTWOperationException(problem, detail)
|
||||
internal fun utc(millis: Long, pattern: String): String = SimpleDateFormat(pattern, Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}.format(Date(millis))
|
||||
internal fun validDate(value: String): Boolean = value.matches(Regex("[0-9]{4}-[0-9]{2}-[0-9]{2}")) && runCatching {
|
||||
SimpleDateFormat("yyyy-MM-dd", Locale.US).apply { isLenient = false }.parse(value) != null
|
||||
}.getOrDefault(false)
|
||||
|
||||
/** Only bounded DER metadata decoding; cryptographic verification is delegated to JCA. */
|
||||
internal data class DerValue(val tag: Int, val data: ByteArray) {
|
||||
fun children(): List<DerValue> = read(data)
|
||||
fun text(): String = when (tag) {
|
||||
12, 19, 20, 22, 23, 24 -> data.toString(Charsets.UTF_8)
|
||||
30 -> data.toString(Charsets.UTF_16BE)
|
||||
else -> error("Not an ASN.1 string")
|
||||
}
|
||||
companion object {
|
||||
fun read(bytes: ByteArray): List<DerValue> {
|
||||
val result = mutableListOf<DerValue>()
|
||||
var offset = 0
|
||||
while (offset < bytes.size) {
|
||||
require(bytes.size - offset >= 2)
|
||||
val tag = bytes[offset++].toInt() and 255
|
||||
var length = bytes[offset++].toInt() and 255
|
||||
if (length and 128 != 0) {
|
||||
val size = length and 127
|
||||
require(size in 1..3 && size <= bytes.size - offset)
|
||||
length = 0
|
||||
repeat(size) { length = (length shl 8) or (bytes[offset++].toInt() and 255) }
|
||||
}
|
||||
require(length <= bytes.size - offset)
|
||||
result += DerValue(tag, bytes.copyOfRange(offset, offset + length))
|
||||
offset += length
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.rtbishop.look4sat.core.data.lotw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||
import com.rtbishop.look4sat.core.domain.logbook.isSatellite
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.math.BigDecimal
|
||||
import java.security.MessageDigest
|
||||
import java.security.Signature
|
||||
import java.util.Locale
|
||||
import java.util.zip.GZIPOutputStream
|
||||
import kotlin.io.encoding.Base64
|
||||
|
||||
internal data class LoTWContact(val record: QsoRecord, val fields: Map<String, String>, val signData: String, val fingerprint: String)
|
||||
|
||||
internal class LoTWSigner(private val config: LoTWConfig) {
|
||||
fun contact(record: QsoRecord, key: LoTWKeyMaterial, station: Map<String, String>, now: Long): LoTWContact {
|
||||
val call = record.theirCallsign.trim().uppercase(Locale.US)
|
||||
if (record.status != QsoStatus.COMPLETE || !call.matches(Regex("[A-Z0-9]+(/[A-Z0-9]+)*")) ||
|
||||
!call.any(Char::isLetter) || !call.any(Char::isDigit)) fail(LoTWProblem.INVALID_CONTACT, call)
|
||||
if (!record.myCallsign.trim().equals(key.info.callsign, true)) fail(LoTWProblem.CALLSIGN_MISMATCH, call)
|
||||
val date = utc(record.startUtcMillis, "yyyy-MM-dd")
|
||||
if (date < key.info.firstQsoDate || (key.info.lastQsoDate.isNotBlank() && date > key.info.lastQsoDate) || record.startUtcMillis > now) {
|
||||
fail(LoTWProblem.QSO_DATE, call)
|
||||
}
|
||||
val grids = buildList {
|
||||
addAll(station.getValue("GRIDSQUARE").split(',').map(String::trim))
|
||||
station["MY_VUCC_GRIDS"]?.split(',')?.map(String::trim)?.let(::addAll)
|
||||
}.filter(String::isNotBlank)
|
||||
if (record.myGrid.isNotBlank() && grids.none { grid ->
|
||||
val local = record.myGrid.trim().uppercase(Locale.US)
|
||||
grid.startsWith(local) || local.startsWith(grid)
|
||||
}) fail(LoTWProblem.LOCATION_MISMATCH, call)
|
||||
fun mhz(hz: Long?): String = hz?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }.orEmpty()
|
||||
val fields = linkedMapOf(
|
||||
"BAND" to config.band(record.band, record.txFrequencyHz, true),
|
||||
"BAND_RX" to config.band(record.rxBand, record.rxFrequencyHz, false),
|
||||
"CALL" to call,
|
||||
"FREQ" to mhz(record.txFrequencyHz),
|
||||
"FREQ_RX" to mhz(record.rxFrequencyHz),
|
||||
"MODE" to config.mode(record),
|
||||
"PROP_MODE" to if (record.isSatellite) "SAT" else config.propagation(record.propagationMode),
|
||||
"QSO_DATE" to date,
|
||||
"QSO_TIME" to utc(record.startUtcMillis, "HH:mm:ss'Z'"),
|
||||
"SAT_NAME" to if (record.isSatellite) config.satellite(record.satelliteName, date) else ""
|
||||
).filterValues { it.isNotBlank() }
|
||||
val signData = (config.stationOrder.map { station[it].orEmpty() } + config.contactOrder.map { fields[it].orEmpty() }).joinToString("")
|
||||
val identity = field("CALL", key.info.callsign) + field("DXCC", key.info.dxcc.toString()) +
|
||||
station.toSortedMap().entries.joinToString("") { field(it.key, it.value) } +
|
||||
fields.entries.joinToString("") { field(it.key, it.value) }
|
||||
val hash = MessageDigest.getInstance("SHA-256").digest(identity.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
|
||||
return LoTWContact(record, fields, signData, hash)
|
||||
}
|
||||
|
||||
fun sign(contacts: List<LoTWContact>, key: LoTWKeyMaterial, station: Map<String, String>): ByteArray {
|
||||
val text = buildString {
|
||||
append(field("TQSL_IDENT", "Look4Sat LoTW Config: V${config.version} AllowDupes: false"))
|
||||
append(field("REC_TYPE", "tCERT")); append(field("CERT_UID", "1"))
|
||||
append(field("CERTIFICATE", Base64.Default.encode(key.certificate.encoded).chunked(64).joinToString("\n")))
|
||||
append("<eor>\n")
|
||||
append(field("REC_TYPE", "tSTATION")); append(field("STATION_UID", "1")); append(field("CERT_UID", "1"))
|
||||
append(field("CALL", key.info.callsign)); append(field("DXCC", key.info.dxcc.toString()))
|
||||
station.forEach { (name, value) -> append(field(name, value)) }
|
||||
append("<eor>\n")
|
||||
contacts.forEach { contact ->
|
||||
append(field("REC_TYPE", "tCONTACT")); append(field("STATION_UID", "1"))
|
||||
contact.fields.forEach { (name, value) -> append(field(name, value)) }
|
||||
// SHA-1 is required by the LoTW V2.0 wire format, not chosen for general-purpose signing.
|
||||
val signature = Signature.getInstance("SHA1withRSA").run {
|
||||
initSign(key.key); update(contact.signData.toByteArray(Charsets.UTF_8)); sign()
|
||||
}
|
||||
val encoded = Base64.Default.encode(signature).chunked(64).joinToString("\n")
|
||||
append("<SIGN_LOTW_V2.0:${encoded.length}:6>$encoded\n")
|
||||
append(field("SIGNDATA", contact.signData)); append("<eor>\n")
|
||||
}
|
||||
}
|
||||
return ByteArrayOutputStream().also { output ->
|
||||
GZIPOutputStream(output).use { it.write(text.toByteArray(Charsets.UTF_8)) }
|
||||
}.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
private fun field(name: String, value: String): String = "<$name:${value.toByteArray(Charsets.UTF_8).size}>$value\n"
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.rtbishop.look4sat.core.data.lotw
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.AtomicFile
|
||||
import java.io.File
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
internal interface LoTWStorage {
|
||||
fun read(name: String): ByteArray?
|
||||
fun write(name: String, data: ByteArray)
|
||||
fun delete(name: String)
|
||||
}
|
||||
|
||||
/** P12 backups and their saved passwords are encrypted at rest and excluded from Android/cloud backup. */
|
||||
internal class AndroidLoTWStorage(context: Context) : LoTWStorage {
|
||||
private val directory = File(context.noBackupFilesDir, "lotw")
|
||||
private val store by lazy { KeyStore.getInstance("AndroidKeyStore").apply { load(null) } }
|
||||
private fun file(name: String): AtomicFile {
|
||||
require(name.matches(Regex("[a-z]+")))
|
||||
check(directory.isDirectory || directory.mkdirs())
|
||||
return AtomicFile(File(directory, name))
|
||||
}
|
||||
private fun key(): SecretKey = (store.getKey(ALIAS, null) as? SecretKey) ?: KeyGenerator.getInstance(
|
||||
KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
|
||||
).run {
|
||||
init(KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256).build())
|
||||
generateKey()
|
||||
}
|
||||
@Synchronized override fun read(name: String): ByteArray? {
|
||||
val file = file(name)
|
||||
if (!file.baseFile.exists() && !File(file.baseFile.path + ".bak").exists()) return null
|
||||
val bytes = file.readFully()
|
||||
require(bytes.size in 29..(16 * 1024 * 1024) && bytes[0] == 1.toByte())
|
||||
return Cipher.getInstance("AES/GCM/NoPadding").run {
|
||||
init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes.copyOfRange(1, 13)))
|
||||
updateAAD(name.toByteArray(Charsets.UTF_8))
|
||||
doFinal(bytes, 13, bytes.size - 13)
|
||||
}
|
||||
}
|
||||
@Synchronized override fun write(name: String, data: ByteArray) {
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
|
||||
init(Cipher.ENCRYPT_MODE, key())
|
||||
updateAAD(name.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
val bytes = byteArrayOf(1) + cipher.iv + cipher.doFinal(data)
|
||||
val file = file(name)
|
||||
val stream = file.startWrite()
|
||||
try { stream.write(bytes); file.finishWrite(stream) }
|
||||
catch (error: Exception) { file.failWrite(stream); throw error }
|
||||
}
|
||||
@Synchronized override fun delete(name: String) = file(name).delete()
|
||||
private companion object { const val ALIAS = "look4sat.lotw.storage.v1" }
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package com.rtbishop.look4sat.core.data.lotw
|
||||
|
||||
import android.content.Context
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWStationMeta
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadAudit
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.CharBuffer
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
private fun readBoundedBody(response: Response, limit: Int): String = response.body.charStream().use { reader ->
|
||||
val result = StringBuilder()
|
||||
val buffer = CharArray(8192)
|
||||
while (true) {
|
||||
val count = reader.read(buffer)
|
||||
if (count < 0) break
|
||||
require(result.length <= limit - count) { "Response exceeds size limit" }
|
||||
result.append(buffer, 0, count)
|
||||
}
|
||||
result.toString()
|
||||
}
|
||||
|
||||
class LoTWUploadRepository internal constructor(
|
||||
private val storage: LoTWStorage,
|
||||
private val config: () -> LoTWConfig,
|
||||
client: OkHttpClient = OkHttpClient(),
|
||||
private val now: () -> Long = System::currentTimeMillis
|
||||
) : ILoTWUploadRepository {
|
||||
constructor(context: Context) : this(AndroidLoTWStorage(context), { LoTWConfig(context.assets.open("lotw/config.tq6")) })
|
||||
private val client = client.newBuilder().connectTimeout(30, TimeUnit.SECONDS).readTimeout(120, TimeUnit.SECONDS)
|
||||
.callTimeout(180, TimeUnit.SECONDS).retryOnConnectionFailure(false).followRedirects(false).followSslRedirects(false).build()
|
||||
private val mutex = Mutex()
|
||||
@Volatile private var pending: Pending? = null
|
||||
private data class Pending(val preview: LoTWUploadPreview, val data: ByteArray, val hashes: List<String>, val created: Long)
|
||||
private data class SigningContext(val key: LoTWKeyMaterial, val signer: LoTWSigner, val location: Map<String, String>)
|
||||
|
||||
override suspend fun certificate(): LoTWCertificate? = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
val bytes = storage.read("certificate") ?: return@withLock null
|
||||
try {
|
||||
readBundle(bytes).let { bundle ->
|
||||
bundle.p12.fill(0)
|
||||
bundle.password?.fill(0)
|
||||
bundle.info.copy(passwordSaved = bundle.password != null)
|
||||
}
|
||||
} finally { bytes.fill(0) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun importCertificate(data: ByteArray, password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
val passwordBytes = encodePassword(password)
|
||||
try {
|
||||
if (passwordBytes.size > MAX_CERTIFICATE_PASSWORD_BYTES) fail(LoTWProblem.CERTIFICATE_PASSWORD)
|
||||
val key = LoTWKeyMaterial.read(data, password, now())
|
||||
val bytes = encodeBundle(key.info, data, passwordBytes)
|
||||
try { storage.write("certificate", bytes) } finally { bytes.fill(0) }
|
||||
discardPreview()
|
||||
key.info.copy(passwordSaved = true)
|
||||
} finally {
|
||||
passwordBytes.fill(0)
|
||||
password.fill('\u0000')
|
||||
data.fill(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
|
||||
val bundle = try { readBundle(stored) } finally { stored.fill(0) }
|
||||
val passwordBytes = encodePassword(password)
|
||||
try {
|
||||
if (passwordBytes.size > MAX_CERTIFICATE_PASSWORD_BYTES) fail(LoTWProblem.CERTIFICATE_PASSWORD)
|
||||
val key = LoTWKeyMaterial.read(bundle.p12, password, now())
|
||||
val bytes = encodeBundle(key.info, bundle.p12, passwordBytes)
|
||||
try { storage.write("certificate", bytes) } finally { bytes.fill(0) }
|
||||
discardPreview()
|
||||
key.info.copy(passwordSaved = true)
|
||||
} finally {
|
||||
bundle.p12.fill(0)
|
||||
bundle.password?.fill(0)
|
||||
passwordBytes.fill(0)
|
||||
password.fill('\u0000')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun station(): LoTWStation? = withContext(Dispatchers.IO) {
|
||||
mutex.withLock { readStation() }
|
||||
}
|
||||
|
||||
override suspend fun saveStation(station: LoTWStation): LoTWStation = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
|
||||
val bundle = try { readBundle(stored) } finally { stored.fill(0) }
|
||||
val normalized = station.normalized()
|
||||
try {
|
||||
config().stationFields(normalized, bundle.info.dxcc)
|
||||
} catch (e: LoTWOperationException) {
|
||||
// A region saved under a different certificate no longer applies to
|
||||
// this DXCC entity — drop it instead of failing the whole save.
|
||||
if (e.reason != LoTWProblem.STATION_REGION) throw e
|
||||
val cleaned = normalized.copy(region = "", county = "")
|
||||
config().stationFields(cleaned, bundle.info.dxcc)
|
||||
writeStation(cleaned)
|
||||
discardPreview()
|
||||
return@withLock cleaned
|
||||
} finally {
|
||||
bundle.p12.fill(0)
|
||||
bundle.password?.fill(0)
|
||||
}
|
||||
writeStation(normalized)
|
||||
discardPreview()
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeCertificate() = withContext(Dispatchers.IO) {
|
||||
mutex.withLock { discardPreview(); storage.delete("certificate") }
|
||||
}
|
||||
|
||||
override suspend fun stationMeta(dxcc: Int): LoTWStationMeta = withContext(Dispatchers.IO) {
|
||||
config().stationMeta(dxcc)
|
||||
}
|
||||
|
||||
override suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
val signing = signingContext()
|
||||
val ledger = ledger()
|
||||
val unique = hashSetOf<String>()
|
||||
var pendingCount = 0
|
||||
var uploaded = 0
|
||||
var unknown = 0
|
||||
var unavailable = 0
|
||||
records.sortedBy { it.startUtcMillis }.forEach { record ->
|
||||
coroutineContext.ensureActive()
|
||||
when {
|
||||
record.status != QsoStatus.COMPLETE -> unavailable++
|
||||
record.lotwReceived -> uploaded++
|
||||
else -> {
|
||||
val contact = try {
|
||||
signing.signer.contact(record, signing.key, signing.location, now())
|
||||
} catch (_: LoTWOperationException) {
|
||||
unavailable++
|
||||
null
|
||||
}
|
||||
if (contact != null) when {
|
||||
!unique.add(contact.fingerprint) -> unavailable++
|
||||
ledger[contact.fingerprint] == "accepted" -> uploaded++
|
||||
ledger[contact.fingerprint] == "unknown" -> unknown++
|
||||
else -> pendingCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LoTWUploadAudit(records.size, pendingCount, uploaded, unknown, unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun prepare(records: List<QsoRecord>, resubmit: Boolean): LoTWUploadPreview = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
discardPreview()
|
||||
if (records.size > 10_000) fail(LoTWProblem.TOO_MANY_CONTACTS)
|
||||
val signing = signingContext()
|
||||
val ledger = ledger()
|
||||
var skipped = 0
|
||||
var unknown = 0
|
||||
val unique = hashSetOf<String>()
|
||||
val contacts = records.sortedBy { it.startUtcMillis }.mapNotNull { record ->
|
||||
coroutineContext.ensureActive()
|
||||
if (record.status != QsoStatus.COMPLETE || (record.lotwReceived && !resubmit)) { skipped++; return@mapNotNull null }
|
||||
val contact = signing.signer.contact(record, signing.key, signing.location, now())
|
||||
val previous = ledger[contact.fingerprint]
|
||||
when {
|
||||
(previous == "accepted" && !resubmit) || !unique.add(contact.fingerprint) -> { skipped++; null }
|
||||
previous == "unknown" && !resubmit -> { skipped++; unknown++; null }
|
||||
else -> contact
|
||||
}
|
||||
}
|
||||
val preview = LoTWUploadPreview(
|
||||
UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"),
|
||||
contacts.size, skipped,
|
||||
contacts.firstOrNull()?.record?.let { utc(it.startUtcMillis, "yyyy-MM-dd HH:mm:ss") }.orEmpty(),
|
||||
contacts.lastOrNull()?.record?.let { utc(it.startUtcMillis, "yyyy-MM-dd HH:mm:ss") }.orEmpty(),
|
||||
contacts.map { "${utc(it.record.startUtcMillis, "MM-dd HH:mm")} ${it.record.theirCallsign} ${it.fields["MODE"]} ${it.fields["SAT_NAME"].orEmpty()}" },
|
||||
unknown
|
||||
)
|
||||
if (contacts.isNotEmpty()) pending = Pending(
|
||||
preview,
|
||||
signing.signer.sign(contacts, signing.key, signing.location),
|
||||
contacts.map { it.fingerprint },
|
||||
now()
|
||||
)
|
||||
preview
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun upload(previewId: String): LoTWUploadResult = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
val payload = pending?.takeIf { it.preview.id == previewId && now() - it.created in 0..900_000 }
|
||||
?: return@withLock LoTWUploadResult.ExpiredPreview
|
||||
pending = null // A preview is a one-shot authorization, including across failures.
|
||||
val ledger = ledger()
|
||||
payload.hashes.forEach { ledger[it] = "unknown" }
|
||||
saveLedger(ledger) // Persist BEFORE the POST, so process death/cancellation cannot cause an automatic retry.
|
||||
try {
|
||||
val result = post(payload.data, payload.preview.count)
|
||||
when (result) {
|
||||
is LoTWUploadResult.Accepted -> payload.hashes.forEach { ledger[it] = "accepted" }
|
||||
is LoTWUploadResult.Rejected -> payload.hashes.forEach { ledger.remove(it) }
|
||||
else -> Unit
|
||||
}
|
||||
saveLedger(ledger)
|
||||
result
|
||||
} finally { payload.data.fill(0) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun discardPreview() { pending?.data?.fill(0); pending = null }
|
||||
|
||||
private fun signingContext(): SigningContext {
|
||||
val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
|
||||
val bundle = try { readBundle(stored) } finally { stored.fill(0) }
|
||||
val passwordBytes = bundle.password ?: run {
|
||||
bundle.p12.fill(0)
|
||||
fail(LoTWProblem.CERTIFICATE_PASSWORD)
|
||||
}
|
||||
val password = decodePassword(passwordBytes)
|
||||
return try {
|
||||
val key = LoTWKeyMaterial.read(bundle.p12, password, now())
|
||||
val tqslConfig = config()
|
||||
val signer = LoTWSigner(tqslConfig)
|
||||
val station = readStation() ?: fail(LoTWProblem.STATION_GRID)
|
||||
SigningContext(key, signer, tqslConfig.stationFields(station, key.info.dxcc))
|
||||
} finally {
|
||||
bundle.p12.fill(0)
|
||||
passwordBytes.fill(0)
|
||||
password.fill('\u0000')
|
||||
}
|
||||
}
|
||||
|
||||
private fun readStation(): LoTWStation? = storage.read("station")?.inputStream()?.let { stream ->
|
||||
DataInputStream(stream).use { input ->
|
||||
require(input.readInt() == 1)
|
||||
LoTWStation(input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF())
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeStation(station: LoTWStation) {
|
||||
val bytes = ByteArrayOutputStream().also { stream -> DataOutputStream(stream).use { output ->
|
||||
output.writeInt(1)
|
||||
listOf(station.grid, station.cqZone, station.ituZone, station.region, station.county, station.iota).forEach(output::writeUTF)
|
||||
} }.toByteArray()
|
||||
try { storage.write("station", bytes) } finally { bytes.fill(0) }
|
||||
}
|
||||
|
||||
private suspend fun post(data: ByteArray, count: Int): LoTWUploadResult = suspendCancellableCoroutine { continuation ->
|
||||
val body = MultipartBody.Builder().setType(MultipartBody.FORM)
|
||||
.addFormDataPart("upfile", "look4sat-${UUID.randomUUID()}.tq8", data.toRequestBody("application/octet-stream".toMediaType())).build()
|
||||
val call = client.newCall(Request.Builder().url("https://lotw.arrl.org/lotw/upload").post(body).build())
|
||||
continuation.invokeOnCancellation { call.cancel() }
|
||||
call.enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
if (continuation.isActive) continuation.resume(LoTWUploadResult.Unknown)
|
||||
}
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
val result = try {
|
||||
response.use {
|
||||
if (!it.isSuccessful) LoTWUploadResult.Unknown else parseLoTWUploadResponse(readBoundedBody(it, 256 * 1024), count)
|
||||
}
|
||||
} catch (_: Exception) { LoTWUploadResult.Unknown }
|
||||
if (continuation.isActive) continuation.resume(result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun ledger(): MutableMap<String, String> = storage.read("receipts")?.inputStream()?.let { stream ->
|
||||
DataInputStream(stream).use { input ->
|
||||
require(input.readInt() == 1)
|
||||
val size = input.readInt().also { require(it in 0..200_000) }
|
||||
val entries = linkedMapOf<String, String>()
|
||||
repeat(size) { entries[input.readUTF()] = input.readUTF() }
|
||||
entries
|
||||
}
|
||||
} ?: linkedMapOf()
|
||||
|
||||
private fun saveLedger(entries: Map<String, String>) {
|
||||
val bytes = ByteArrayOutputStream().also { stream -> DataOutputStream(stream).use { output ->
|
||||
output.writeInt(1); output.writeInt(entries.size)
|
||||
entries.forEach { (hash, status) -> output.writeUTF(hash); output.writeUTF(status) }
|
||||
} }.toByteArray()
|
||||
storage.write("receipts", bytes)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parseLoTWUploadResponse(body: String, count: Int): LoTWUploadResult {
|
||||
val markers = Regex("<!--\\s*\\.UPL\\.\\s*(accepted|rejected)\\s*-->", RegexOption.IGNORE_CASE)
|
||||
.findAll(body).map { it.groupValues[1].lowercase() }.toList()
|
||||
return when (markers.singleOrNull()) {
|
||||
"accepted" -> LoTWUploadResult.Accepted(count)
|
||||
"rejected" -> {
|
||||
val message = Regex("<!--\\s*\\.UPLMESSAGE\\.\\s*(.*?)-->", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
|
||||
.find(body)?.groupValues?.get(1).orEmpty().replace(Regex("<[^>]*>"), " ")
|
||||
.replace(Regex("[\\p{Cc}\\s]+"), " ").trim().take(500)
|
||||
LoTWUploadResult.Rejected(message)
|
||||
}
|
||||
else -> LoTWUploadResult.Unknown
|
||||
}
|
||||
}
|
||||
|
||||
private data class StoredCertificate(val info: LoTWCertificate, val p12: ByteArray, val password: ByteArray?)
|
||||
|
||||
private fun encodeBundle(info: LoTWCertificate, p12: ByteArray, password: ByteArray): ByteArray = ByteArrayOutputStream().also { stream ->
|
||||
DataOutputStream(stream).use { output ->
|
||||
output.writeInt(2)
|
||||
output.writeUTF(info.callsign); output.writeInt(info.dxcc); output.writeUTF(info.serial)
|
||||
output.writeUTF(info.expires); output.writeUTF(info.firstQsoDate); output.writeUTF(info.lastQsoDate)
|
||||
output.writeInt(password.size); output.write(password)
|
||||
output.writeInt(p12.size); output.write(p12)
|
||||
}
|
||||
}.toByteArray()
|
||||
|
||||
private fun readBundle(bytes: ByteArray): StoredCertificate = DataInputStream(bytes.inputStream()).use { input ->
|
||||
val version = input.readInt().also { require(it in 1..2) }
|
||||
val info = LoTWCertificate(input.readUTF(), input.readInt(), input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF())
|
||||
val password = if (version >= 2) {
|
||||
val size = input.readInt().also { require(it in 0..MAX_CERTIFICATE_PASSWORD_BYTES) }
|
||||
ByteArray(size).also { input.readFully(it) }
|
||||
} else null
|
||||
val p12Size = input.readInt().also { require(it in 1..MAX_CERTIFICATE_BYTES) }
|
||||
StoredCertificate(info, ByteArray(p12Size).also { input.readFully(it) }, password)
|
||||
}
|
||||
|
||||
private fun encodePassword(password: CharArray): ByteArray {
|
||||
val buffer = Charsets.UTF_8.encode(CharBuffer.wrap(password))
|
||||
return ByteArray(buffer.remaining()).also(buffer::get)
|
||||
}
|
||||
|
||||
private fun decodePassword(password: ByteArray): CharArray {
|
||||
val buffer = Charsets.UTF_8.decode(ByteBuffer.wrap(password))
|
||||
return CharArray(buffer.remaining()).also(buffer::get)
|
||||
}
|
||||
|
||||
private fun LoTWStation.normalized() = LoTWStation(
|
||||
grid.trim().uppercase(Locale.US),
|
||||
cqZone.trim(),
|
||||
ituZone.trim(),
|
||||
region.trim().uppercase(Locale.US),
|
||||
county.trim().uppercase(Locale.US),
|
||||
iota.trim().uppercase(Locale.US)
|
||||
)
|
||||
|
||||
private const val MAX_CERTIFICATE_PASSWORD_BYTES = 16 * 1024
|
||||
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.rtbishop.look4sat.core.data.lotw
|
||||
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.security.KeyFactory
|
||||
import java.security.PrivateKey
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* Minimal PKCS#12 reader for the PBES2/AES-CBC format that OpenSSL 3 and modern
|
||||
* TQSL produce by default. Android's legacy bundled Bouncy Castle PKCS12 parser
|
||||
* cannot handle PBES2, so we parse the DER structure ourselves and decrypt with
|
||||
* the platform JCE (PBKDF2WithHmacSHA1/256 + AES/CBC), which ships on Android.
|
||||
*
|
||||
* Handles both layouts:
|
||||
* - OpenSSL `-certpbe NONE`: plaintext certificate bags + a PBES2-shrouded key
|
||||
* inside a plaintext `data` ContentInfo.
|
||||
* - Modern TQSL: the certificate SafeContents wrapped in an `encryptedData`
|
||||
* ContentInfo (PBES2), plus the PBES2-shrouded key in a plaintext `data`
|
||||
* ContentInfo.
|
||||
*/
|
||||
internal object Pkcs12Reader {
|
||||
|
||||
private val OID_DATA = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x07, 0x01)
|
||||
private val OID_ENCRYPTED_DATA = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x07, 0x06)
|
||||
private val OID_PKCS8_SHROUDED_KEY_BAG = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x0c, 0x0a, 0x01, 0x02)
|
||||
private val OID_CERT_BAG = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x0c, 0x0a, 0x01, 0x03)
|
||||
private val OID_X509_CERT = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x09, 0x16, 0x01)
|
||||
private val OID_PBES2 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x05, 0x0d)
|
||||
private val OID_PBKDF2 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x05, 0x0c)
|
||||
private val OID_HMAC_SHA1 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x07)
|
||||
private val OID_HMAC_SHA256 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x09)
|
||||
private val OID_HMAC_SHA384 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x0a)
|
||||
private val OID_HMAC_SHA512 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x0b)
|
||||
private val OID_AES_256_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x2a)
|
||||
private val OID_AES_192_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x16)
|
||||
private val OID_AES_128_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x02)
|
||||
private val OID_DES_EDE3_CBC = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x03, 0x07)
|
||||
|
||||
fun read(bytes: ByteArray, password: CharArray): Pair<PrivateKey, X509Certificate> {
|
||||
val pfx = DerReader.read(bytes)
|
||||
require(pfx.tag == 0x30) { "not a PFX" }
|
||||
val pfxChildren = DerReader.children(pfx.content)
|
||||
require(pfxChildren.size >= 2) { "PFX too small" }
|
||||
// authSafe ContentInfo
|
||||
val authSafeCi = DerReader.children(pfxChildren[1].content)
|
||||
require(authSafeCi.size >= 2) { "authSafe malformed" }
|
||||
// content: [0] EXPLICIT OCTET STRING -> AuthenticatedSafe
|
||||
val explicit = DerReader.children(authSafeCi[1].content).first()
|
||||
val octet = DerReader.read(explicit.content)
|
||||
// octet.content is the AuthenticatedSafe body: a sequence of ContentInfo.
|
||||
var privateKey: PrivateKey? = null
|
||||
val certs = mutableListOf<X509Certificate>()
|
||||
for (info in DerReader.children(octet.content)) {
|
||||
val parts = DerReader.children(info.content)
|
||||
if (parts.isEmpty()) continue
|
||||
when {
|
||||
parts[0].content.contentEquals(OID_DATA) ->
|
||||
parseSafeContentsContent(parts, password, certs) { privateKey = it }
|
||||
parts[0].content.contentEquals(OID_ENCRYPTED_DATA) ->
|
||||
parseEncryptedData(parts, password, certs)
|
||||
}
|
||||
}
|
||||
val key = privateKey ?: error("no private key bag found")
|
||||
require(certs.isNotEmpty()) { "no certificate bag found" }
|
||||
return key to certs[0]
|
||||
}
|
||||
|
||||
/** A plaintext ContentInfo: [0] EXPLICIT OCTET STRING -> full SafeContents DER. */
|
||||
private fun parseSafeContentsContent(
|
||||
parts: List<Der>,
|
||||
password: CharArray,
|
||||
certs: MutableList<X509Certificate>,
|
||||
onKey: (PrivateKey) -> Unit
|
||||
) {
|
||||
val explicit = DerReader.children(parts[1].content).first()
|
||||
parseSafeBags(explicit.content, password, certs, onKey)
|
||||
}
|
||||
|
||||
/** EncryptedData ContentInfo: version, EncryptedContentInfo{ oid, PBES2 alg, [0] IMPLICIT OCTET }. */
|
||||
private fun parseEncryptedData(
|
||||
parts: List<Der>,
|
||||
password: CharArray,
|
||||
certs: MutableList<X509Certificate>
|
||||
) {
|
||||
// parts[0] = encryptedData OID; parts[1] = [0] EXPLICIT EncryptedData SEQ
|
||||
val explicit = DerReader.children(parts[1].content).first()
|
||||
val eciParts = DerReader.children(explicit.content)
|
||||
// eciParts = [version INT, EncryptedContentInfo SEQ]
|
||||
require(eciParts.size >= 2) { "EncryptedData malformed" }
|
||||
val eci = DerReader.children(eciParts[1].content)
|
||||
// eci = [contentType OID, contentEncryptionAlgorithm, [0] IMPLICIT OCTET STRING]
|
||||
require(eci.size >= 3) { "EncryptedContentInfo malformed" }
|
||||
val alg = eci[1]
|
||||
val ciphertext = eci[2].content // [0] IMPLICIT OCTET STRING -> raw bytes
|
||||
val safeContents = decryptPbes2(alg, ciphertext, password)
|
||||
// The decrypted SafeContents holds certificate bags.
|
||||
parseSafeBags(safeContents, password, certs) {}
|
||||
}
|
||||
|
||||
/** Decrypt a PBES2-encrypted blob given its AlgorithmIdentifier. */
|
||||
private fun decryptPbes2(algorithm: Der, encrypted: ByteArray, password: CharArray): ByteArray {
|
||||
val algParts = DerReader.children(algorithm.content)
|
||||
require(algParts.size >= 2 && algParts[0].content.contentEquals(OID_PBES2)) { "not PBES2" }
|
||||
// PBES2-params ::= SEQUENCE { kdf AlgorithmIdentifier, enc AlgorithmIdentifier }
|
||||
val pbes2 = DerReader.children(algParts[1].content)
|
||||
val kdf = DerReader.children(pbes2[0].content)
|
||||
// KDF must be PBKDF2 (OpenSSL 3 also supports scrypt — not available on the
|
||||
// platform JCE, so report it by name instead of a generic failure).
|
||||
if (kdf.isEmpty() || !kdf[0].content.contentEquals(OID_PBKDF2)) {
|
||||
error(oidName(kdf.firstOrNull()?.content))
|
||||
}
|
||||
val kdfParams = DerReader.children(kdf[1].content)
|
||||
val salt = kdfParams[0].content
|
||||
val iterations = readInt(kdfParams[1].content)
|
||||
require(iterations in 1..10_000_000) { "implausible PBKDF2 iteration count" }
|
||||
// Optional PBKDF2-params elements: keyLength (INTEGER) and/or prf (SEQUENCE),
|
||||
// in either order. Distinguish by DER tag — mistaking prf for keyLength yields
|
||||
// an absurd key size and a PBKDF2 that runs for hours.
|
||||
var keyBits = -1 // -1 = not written; defaulted below from the cipher
|
||||
var prfName = "PBKDF2WithHmacSHA1"
|
||||
for (i in 2 until kdfParams.size) {
|
||||
val param = kdfParams[i]
|
||||
when (param.tag) {
|
||||
0x02 -> keyBits = readInt(param.content) * 8
|
||||
0x30 -> {
|
||||
val prf = DerReader.children(param.content)
|
||||
prfName = when {
|
||||
prf.isEmpty() || prf[0].content.contentEquals(OID_HMAC_SHA1) -> "PBKDF2WithHmacSHA1"
|
||||
prf[0].content.contentEquals(OID_HMAC_SHA256) -> "PBKDF2WithHmacSHA256"
|
||||
prf[0].content.contentEquals(OID_HMAC_SHA384) -> "PBKDF2WithHmacSHA384"
|
||||
prf[0].content.contentEquals(OID_HMAC_SHA512) -> "PBKDF2WithHmacSHA512"
|
||||
else -> error(oidName(prf[0].content))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val enc = DerReader.children(pbes2[1].content)
|
||||
val encOid = enc[0].content
|
||||
val iv = enc[1].content
|
||||
val (cipherName, aesKeyBits) = when {
|
||||
encOid.contentEquals(OID_AES_256_CBC) -> "AES/CBC/PKCS5Padding" to 256
|
||||
encOid.contentEquals(OID_AES_192_CBC) -> "AES/CBC/PKCS5Padding" to 192
|
||||
encOid.contentEquals(OID_AES_128_CBC) -> "AES/CBC/PKCS5Padding" to 128
|
||||
encOid.contentEquals(OID_DES_EDE3_CBC) -> "DESede/CBC/PKCS5Padding" to 192
|
||||
else -> error(oidName(encOid))
|
||||
}
|
||||
val finalKeyBits = if (keyBits > 0) keyBits else aesKeyBits
|
||||
require(finalKeyBits in 128..512) { "implausible PBKDF2 key size" }
|
||||
|
||||
val spec = PBEKeySpec(password, salt, iterations, finalKeyBits)
|
||||
val secretKey = try {
|
||||
SecretKeyFactory.getInstance(prfName).generateSecret(spec)
|
||||
} catch (e: java.security.NoSuchAlgorithmException) {
|
||||
// e.g. PBKDF2WithHmacSHA512 needs API 26+; surface the real reason.
|
||||
error(prfName.replace("PBKDF2WithHmac", "PBKDF2-HMAC-") + " (needs Android 8.0+)")
|
||||
}
|
||||
val cipher = Cipher.getInstance(cipherName)
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
if (cipherName == "AES/CBC/PKCS5Padding") SecretKeySpec(secretKey.encoded, "AES")
|
||||
else SecretKeySpec(secretKey.encoded, "DESede"),
|
||||
IvParameterSpec(iv)
|
||||
)
|
||||
return cipher.doFinal(encrypted)
|
||||
}
|
||||
|
||||
/** Human-readable name for a known algorithm OID. */
|
||||
private fun oidName(oid: ByteArray?): String = when {
|
||||
oid == null -> "unknown algorithm"
|
||||
oid.contentEquals(OID_PBKDF2) -> "PBKDF2"
|
||||
oid.contentEquals(OID_HMAC_SHA1) -> "PBKDF2-HMAC-SHA1"
|
||||
oid.contentEquals(OID_HMAC_SHA256) -> "PBKDF2-HMAC-SHA256"
|
||||
oid.contentEquals(OID_HMAC_SHA384) -> "PBKDF2-HMAC-SHA384"
|
||||
oid.contentEquals(OID_HMAC_SHA512) -> "PBKDF2-HMAC-SHA512"
|
||||
oid.contentEquals(OID_AES_256_CBC) -> "AES-256-CBC"
|
||||
oid.contentEquals(OID_AES_192_CBC) -> "AES-192-CBC"
|
||||
oid.contentEquals(OID_AES_128_CBC) -> "AES-128-CBC"
|
||||
oid.contentEquals(OID_DES_EDE3_CBC) -> "DES-EDE3-CBC"
|
||||
else -> "unknown algorithm"
|
||||
}
|
||||
|
||||
private fun parseSafeBags(
|
||||
safeContentsDer: ByteArray,
|
||||
password: CharArray,
|
||||
certs: MutableList<X509Certificate>,
|
||||
onKey: (PrivateKey) -> Unit
|
||||
) {
|
||||
val safeContents = DerReader.read(safeContentsDer)
|
||||
require(safeContents.tag == 0x30) { "SafeContents malformed" }
|
||||
for (bag in DerReader.children(safeContents.content)) {
|
||||
val bagParts = DerReader.children(bag.content)
|
||||
if (bagParts.size < 2) continue
|
||||
val bagOid = bagParts[0].content
|
||||
val bagValue = DerReader.children(bagParts[1].content).first()
|
||||
when {
|
||||
bagOid.contentEquals(OID_PKCS8_SHROUDED_KEY_BAG) ->
|
||||
onKey(decryptShroudedKeyBag(bagValue, password))
|
||||
bagOid.contentEquals(OID_CERT_BAG) -> parseCertBag(bagValue, certs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun decryptShroudedKeyBag(bagValue: Der, password: CharArray): PrivateKey {
|
||||
// bagValue = the SafeBag value SEQ (EncryptedPrivateKeyInfo): children are
|
||||
// [AlgorithmIdentifier, encryptedData OCTET STRING].
|
||||
val parts = DerReader.children(bagValue.content)
|
||||
require(parts.size == 2) { "EncryptedPrivateKeyInfo malformed" }
|
||||
val pkcs8 = decryptPbes2(parts[0], parts[1].content, password)
|
||||
return KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(pkcs8))
|
||||
}
|
||||
|
||||
private fun parseCertBag(bagValue: Der, certs: MutableList<X509Certificate>) {
|
||||
// bagValue = the SafeBag value SEQ (CertBag): children are
|
||||
// [certType OID, [0] EXPLICIT OCTET STRING (full X.509 DER)].
|
||||
val parts = DerReader.children(bagValue.content)
|
||||
if (parts.size < 2) return
|
||||
if (!parts[0].content.contentEquals(OID_X509_CERT)) return
|
||||
// [0] EXPLICIT -> OCTET STRING -> full X.509 certificate DER.
|
||||
val explicit = DerReader.children(parts[1].content).first()
|
||||
val certDer = explicit.content
|
||||
val factory = CertificateFactory.getInstance("X.509")
|
||||
certs.add(factory.generateCertificate(ByteArrayInputStream(certDer)) as X509Certificate)
|
||||
}
|
||||
|
||||
private fun readInt(bytes: ByteArray): Int {
|
||||
var value = 0
|
||||
var start = 0
|
||||
if (bytes.size > 1 && bytes[0].toInt() == 0x00) start = 1 // strip leading zero for positive
|
||||
for (i in start until bytes.size) value = (value shl 8) or (bytes[i].toInt() and 0xff)
|
||||
return value
|
||||
}
|
||||
|
||||
/** Minimal DER element parser. */
|
||||
private data class Der(val tag: Int, val content: ByteArray)
|
||||
|
||||
private object DerReader {
|
||||
fun read(der: ByteArray, offset: Int = 0): Der {
|
||||
require(offset < der.size) { "DER truncated" }
|
||||
val tag = der[offset].toInt() and 0xff
|
||||
var i = offset + 1
|
||||
var len = der[i].toInt() and 0xff
|
||||
i++
|
||||
if (len and 0x80 != 0) {
|
||||
val numBytes = len and 0x7f
|
||||
len = 0
|
||||
repeat(numBytes) {
|
||||
len = (len shl 8) or (der[i].toInt() and 0xff)
|
||||
i++
|
||||
}
|
||||
}
|
||||
require(i + len <= der.size) { "DER length overflow" }
|
||||
return Der(tag, der.copyOfRange(i, i + len))
|
||||
}
|
||||
|
||||
fun children(content: ByteArray): List<Der> {
|
||||
val out = mutableListOf<Der>()
|
||||
var off = 0
|
||||
while (off < content.size) {
|
||||
val d = read(content, off)
|
||||
out.add(d)
|
||||
val step = headerSize(content, off) + d.content.size
|
||||
if (step <= 0) break // defensive: never spin on malformed input
|
||||
off += step
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun headerSize(der: ByteArray, offset: Int): Int {
|
||||
var i = offset + 1
|
||||
var len = der[i].toInt() and 0xff
|
||||
i++
|
||||
if (len and 0x80 != 0) i += len and 0x7f
|
||||
return i - offset
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -221,7 +221,22 @@ class AmSatRepository(
|
||||
}
|
||||
}
|
||||
val days = (0 until 3).map { d ->
|
||||
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
|
||||
// 当天最近连续相同状态报告数:从最新一条往回数,遇状态不同即停。
|
||||
// 空时段(无报告)不打断;按状态文本严格比较,不跨天。
|
||||
val dayStart = nowSec - (d + 1) * 86400L
|
||||
val dayEnd = nowSec - d * 86400L
|
||||
val dayReports = byName[name].orEmpty()
|
||||
.filter { it.reportedTimeUtcSec in dayStart until dayEnd }
|
||||
.sortedByDescending { it.reportedTimeUtcSec }
|
||||
val streakCount = if (dayReports.isEmpty()) 0 else {
|
||||
val newestStatus = dayReports.first().report
|
||||
dayReports.takeWhile { it.report == newestStatus }.size
|
||||
}
|
||||
SatDay(
|
||||
dateLabel = labels[d],
|
||||
slots = slots.subList(d * 12, (d + 1) * 12),
|
||||
streakCount = streakCount
|
||||
)
|
||||
}
|
||||
SatStatus(name = name, days = days)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||
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.CoroutineDispatcher
|
||||
@@ -58,7 +59,7 @@ class DatabaseRepo(
|
||||
var importedCount = 0
|
||||
remoteSource.getFileStream(uri)?.let { stream ->
|
||||
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
||||
localSource.insertRadios(transceivers)
|
||||
localSource.insertRadios(transceivers, isCustom = true)
|
||||
importedCount = transceivers.size
|
||||
}
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
@@ -105,9 +106,16 @@ class DatabaseRepo(
|
||||
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)
|
||||
// insert parsed data into the database.
|
||||
// Transceivers are a full snapshot: sources publish active entries only, so a retired
|
||||
// transceiver simply disappears from the feed and has to be dropped locally as well.
|
||||
// Manually imported ones (isCustom) are kept: no source can refresh them, so nothing
|
||||
// would bring them back if they were replaced here.
|
||||
if (importedRadios.isNotEmpty()) {
|
||||
localSource.deleteManagedRadios()
|
||||
localSource.insertRadios(importedRadios)
|
||||
}
|
||||
if (importedEntries.isNotEmpty()) localSource.insertEntries(importedEntries)
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
}
|
||||
|
||||
|
||||
+462
-42
@@ -18,11 +18,19 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWPhase
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProgress
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
import java.io.IOException
|
||||
import javax.net.ssl.SSLException
|
||||
|
||||
/**
|
||||
* Fetches confirmed gridsquares directly from ARRL LoTW via the official report endpoint:
|
||||
@@ -34,69 +42,481 @@ import java.net.URLEncoder
|
||||
* 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.
|
||||
* ARRL rate-limits the report endpoint: one download in progress per user id, and
|
||||
* frequent full-report pulls get refused. Failures are reported with an explicit
|
||||
* cause (see LoTWResult) so the UI can tell the user what to do next.
|
||||
*/
|
||||
class LoTWRepository : ILoTWRepository {
|
||||
|
||||
override suspend fun fetchConfirmedGrids(callsign: String, password: String): Set<String>? =
|
||||
override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult =
|
||||
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() }
|
||||
fetchReportBody(callsign, password, since = "", onProgress = {}).fold(
|
||||
onSuccess = { body ->
|
||||
parseBoth(body)?.let { (grids, _, roamed) ->
|
||||
LoTWResult.Success(grids, emptyMap(), roamed)
|
||||
} ?: LoTWResult.RateLimited
|
||||
},
|
||||
onFailure = { toResult(it) }
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun fetchConfirmedGridQsos(
|
||||
callsign: String,
|
||||
password: String,
|
||||
since: String,
|
||||
onProgress: (LoTWProgress) -> Unit
|
||||
): LoTWResult = withContext(Dispatchers.IO) {
|
||||
fetchReportBody(callsign, password, since, onProgress).fold(
|
||||
onSuccess = { body ->
|
||||
parseBoth(body)?.let { (grids, qsos, roamed) ->
|
||||
LoTWResult.Success(grids, qsos, roamed)
|
||||
} ?: LoTWResult.RateLimited
|
||||
},
|
||||
onFailure = { toResult(it) }
|
||||
)
|
||||
}
|
||||
|
||||
internal fun toResult(e: Throwable): LoTWResult = when (e) {
|
||||
is CredentialsException -> LoTWResult.BadCredentials
|
||||
is RateLimitException -> LoTWResult.RateLimited
|
||||
is TimeoutException -> LoTWResult.Timeout
|
||||
else -> LoTWResult.NetworkError(e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
|
||||
/** Single report fetch feeding the grid set, the per-QSO detail and the roamed set. */
|
||||
private fun parseBoth(
|
||||
body: String
|
||||
): Triple<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>, Set<String>>? {
|
||||
val grids = parseConfirmedGrids(body) ?: return null
|
||||
val qsos = parseConfirmedGridQsos(body) ?: return null
|
||||
val roamed = parseRoamedGrids(body) ?: return null
|
||||
return Triple(grids, qsos, roamed)
|
||||
}
|
||||
|
||||
private suspend fun fetchReportBody(
|
||||
callsign: String,
|
||||
password: String,
|
||||
since: String,
|
||||
onProgress: (LoTWProgress) -> Unit
|
||||
): Result<String> {
|
||||
val call = callsign.trim().uppercase()
|
||||
val pwd = password.trim()
|
||||
if (call.isBlank() || pwd.isBlank()) return Result.failure(IOException("empty credentials"))
|
||||
// Empty since -> full report. 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 be incremental/empty.
|
||||
val effectiveSince = since.ifBlank { "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(effectiveSince, "UTF-8"))
|
||||
}
|
||||
return try {
|
||||
val connection = URL("$BASE_URL?$query").openConnection() as HttpURLConnection
|
||||
// ARRL can be slow to accept connections from mobile networks
|
||||
// (long TLS handshakes across the Pacific, occasional server-side
|
||||
// queueing — a busy server took 36.5 s to accept a connection in
|
||||
// testing, so 30 s connect would have killed it).
|
||||
//
|
||||
// NOTE: readTimeout is NOT a total-download cap. It is the longest
|
||||
// wait for the NEXT chunk of data (a stall timeout). A full report
|
||||
// for a huge account (tens of thousands of QSLs ≈ tens of MB at
|
||||
// ARRL's ~10 KB/s stream rate) legitimately takes 30+ minutes;
|
||||
// as long as chunks keep arriving it must never be cut off.
|
||||
// 600 s tolerates long server-side pauses while it generates the
|
||||
// report; a truly dead link (no data at all) still fails within it.
|
||||
connection.connectTimeout = 60_000
|
||||
connection.readTimeout = 600_000
|
||||
connection.requestMethod = "GET"
|
||||
connection.setRequestProperty("Accept-Encoding", "gzip")
|
||||
connection.instanceFollowRedirects = true
|
||||
onProgress(LoTWProgress(LoTWPhase.Connecting))
|
||||
val code = connection.responseCode
|
||||
if (code !in 200..299) {
|
||||
connection.disconnect()
|
||||
if (body.contains(" password=") && !body.startsWith("ARRL")) return@withContext null
|
||||
parseConfirmedGrids(body)
|
||||
} catch (e: Exception) {
|
||||
println("LoTWRepository fetch failure: $e")
|
||||
null
|
||||
// Observed in the wild (CQRLOG #2422): LoTW answers a throttled
|
||||
// report pull with HTTP 503 "Page request limit".
|
||||
return if (code == 503 || code == 429) Result.failure(RateLimitException())
|
||||
else Result.failure(IOException("HTTP $code"))
|
||||
}
|
||||
val stream = connection.inputStream
|
||||
val isGzip = "gzip".equals(connection.contentEncoding, ignoreCase = true)
|
||||
val body = readBodyWithProgress(stream, isGzip, onProgress)
|
||||
connection.disconnect()
|
||||
when {
|
||||
// Login failure: HTTP 200 + HTML login page with the error text.
|
||||
body.contains("incorrect", ignoreCase = true) &&
|
||||
body.contains("assword", ignoreCase = true) -> Result.failure(CredentialsException())
|
||||
// Any other non-ADIF body: rate-limit refusal / server error page.
|
||||
!body.contains("<eoh>", ignoreCase = true) -> Result.failure(RateLimitException())
|
||||
else -> Result.success(body)
|
||||
}
|
||||
} catch (e: java.util.concurrent.CancellationException) {
|
||||
// Cancellation must propagate — it is not a network failure.
|
||||
throw e
|
||||
} catch (e: SocketTimeoutException) {
|
||||
Result.failure(TimeoutException(e.message ?: "timed out"))
|
||||
} catch (e: SSLException) {
|
||||
Result.failure(IOException("TLS: ${e.message ?: "handshake failed"}"))
|
||||
} catch (e: Exception) {
|
||||
println("LoTWRepository fetch failure: $e")
|
||||
Result.failure(IOException(e.message ?: e.javaClass.simpleName))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams the report body into a string while reporting download progress.
|
||||
* Once the header's record count (<APP_LoTW_NUMREC>) is seen, the total
|
||||
* body size is estimated as NUMREC * AVG_RECORD_BYTES so the UI can show a
|
||||
* meaningful progress bar and remaining-time estimate.
|
||||
*/
|
||||
private suspend fun readBodyWithProgress(
|
||||
stream: java.io.InputStream,
|
||||
isGzip: Boolean,
|
||||
onProgress: (LoTWProgress) -> Unit
|
||||
): String {
|
||||
val input = if (isGzip) java.util.zip.GZIPInputStream(stream) else stream
|
||||
val reader = java.io.InputStreamReader(input, Charsets.UTF_8)
|
||||
val sb = StringBuilder()
|
||||
val buf = CharArray(8192)
|
||||
var bytesRead = 0L
|
||||
var expected = 0L
|
||||
var qsoCount = 0L
|
||||
var speed = 0L
|
||||
var headerSeen = false
|
||||
var lastSampleMs = System.currentTimeMillis()
|
||||
var lastSampleBytes = 0L
|
||||
var lastEmitMs = 0L
|
||||
while (true) {
|
||||
// Cooperative cancellation: lets the user abort a full sync that
|
||||
// was started by mistake. Throws CancellationException once the
|
||||
// job is cancelled; the blocking read below is fast while chunks
|
||||
// keep arriving, so the abort lands on the next chunk boundary.
|
||||
coroutineContext.ensureActive()
|
||||
val n = reader.read(buf)
|
||||
if (n <= 0) break
|
||||
sb.append(buf, 0, n)
|
||||
bytesRead += n
|
||||
// The record count sits in the report header, long before the data.
|
||||
if (!headerSeen) {
|
||||
NUMREC_REGEX.find(sb)?.let { m ->
|
||||
qsoCount = m.groupValues[1].toLongOrNull() ?: 0L
|
||||
expected = qsoCount * AVG_RECORD_BYTES
|
||||
headerSeen = true
|
||||
}
|
||||
}
|
||||
// Sample speed every 500ms, EMA-smoothed.
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastSampleMs >= 500) {
|
||||
val elapsedS = (now - lastSampleMs) / 1000.0
|
||||
if (elapsedS > 0) {
|
||||
val inst = ((bytesRead - lastSampleBytes) / elapsedS).toLong()
|
||||
speed = if (speed == 0L) inst else (speed * 7 + inst * 3) / 10
|
||||
lastSampleMs = now
|
||||
lastSampleBytes = bytesRead
|
||||
}
|
||||
}
|
||||
// Throttle callbacks to ~4/s so StateFlow updates stay cheap.
|
||||
if (now - lastEmitMs >= 250) {
|
||||
onProgress(LoTWProgress(LoTWPhase.Downloading, bytesRead, expected, speed, qsoCount))
|
||||
lastEmitMs = now
|
||||
}
|
||||
}
|
||||
onProgress(LoTWProgress(LoTWPhase.Downloading, bytesRead, expected, speed, qsoCount))
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
internal class CredentialsException : Exception("bad callsign/password")
|
||||
internal class RateLimitException : Exception("report refused (rate limit / server error)")
|
||||
internal class TimeoutException(message: String) : Exception(message)
|
||||
|
||||
internal fun parseConfirmedGridQsos(
|
||||
body: String
|
||||
): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>? {
|
||||
if (!body.contains("<eoh>", ignoreCase = true)) return null
|
||||
// One ADIF record = fields up to <EOR>. We buffer the fields we care
|
||||
// about, then emit a GridQso on <EOR> when the record is a satellite
|
||||
// QSO (PROP_MODE=SAT) carrying a grid.
|
||||
val result = mutableMapOf<String, MutableList<com.rtbishop.look4sat.core.domain.model.GridQso>>()
|
||||
var propMode: String? = null
|
||||
var call = ""
|
||||
var qsoDate = ""
|
||||
var timeOn = ""
|
||||
var satName = ""
|
||||
var mode = ""
|
||||
var bandUp = ""
|
||||
var bandDown = ""
|
||||
var dxcc: Int? = null
|
||||
var country: String? = null
|
||||
var cqz: Int? = null
|
||||
var state: String? = null
|
||||
// Own-station (台址) fields: MY_GRIDSQUARE + MY_VUCC_GRIDS give the
|
||||
// full grid set of the station location; the remaining MY_* fields
|
||||
// together identify the station location (one callsign can own
|
||||
// several locations, and one location can span several grids).
|
||||
var myGrids = mutableSetOf<String>()
|
||||
var myCallsign: String? = null
|
||||
var myDxcc: String? = null
|
||||
var myState: String? = null
|
||||
var myCq: String? = null
|
||||
var myItu: String? = null
|
||||
var myIota: String? = null
|
||||
var myCountry: String? = null
|
||||
val gridsInRecord = mutableListOf<String>()
|
||||
|
||||
fun emitRecord() {
|
||||
if (propMode != "SAT" || gridsInRecord.isEmpty()) return
|
||||
val epochMs = adifTimestampToEpoch(qsoDate, timeOn)
|
||||
val stationKey = buildString {
|
||||
append(myCallsign ?: "")
|
||||
append('|').append(myDxcc ?: "")
|
||||
append('|').append(myState ?: "")
|
||||
append('|').append(myCq ?: "")
|
||||
append('|').append(myItu ?: "")
|
||||
append('|').append(myIota ?: "")
|
||||
append('|').append(myCountry ?: "")
|
||||
}.ifBlank { null }
|
||||
val qso = com.rtbishop.look4sat.core.domain.model.GridQso(
|
||||
call = call, epochMs = epochMs, satName = satName,
|
||||
mode = mode, bandUp = bandUp, bandDown = bandDown,
|
||||
dxcc = dxcc, country = country, cqz = cqz, state = state,
|
||||
myGrid = myGrids.firstOrNull(),
|
||||
myGrids = myGrids,
|
||||
myCallsign = myCallsign,
|
||||
stationKey = stationKey
|
||||
)
|
||||
for (grid in gridsInRecord) {
|
||||
result.getOrPut(grid) { mutableListOf() }.add(qso)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetRecord() {
|
||||
propMode = null; call = ""; qsoDate = ""; timeOn = ""
|
||||
satName = ""; mode = ""; bandUp = ""; bandDown = ""
|
||||
dxcc = null; country = null; cqz = null; state = null
|
||||
myGrids = mutableSetOf()
|
||||
myCallsign = null; myDxcc = null; myState = null
|
||||
myCq = null; myItu = null; myIota = null; myCountry = null
|
||||
gridsInRecord.clear()
|
||||
}
|
||||
|
||||
for (raw in body.lineSequence()) {
|
||||
val line = raw.trim()
|
||||
when {
|
||||
line.equals("<EOR>", ignoreCase = true) -> {
|
||||
emitRecord()
|
||||
resetRecord()
|
||||
}
|
||||
line.startsWith("<PROP_MODE:") ->
|
||||
propMode = adifValue(line).uppercase()
|
||||
line.startsWith("<CALL:") ->
|
||||
call = adifValue(line).uppercase()
|
||||
line.startsWith("<QSO_DATE:") ->
|
||||
qsoDate = adifValue(line)
|
||||
line.startsWith("<TIME_ON:") ->
|
||||
timeOn = adifValue(line)
|
||||
line.startsWith("<SAT_NAME:") ->
|
||||
satName = adifValue(line)
|
||||
line.startsWith("<MODE:") ->
|
||||
mode = adifValue(line)
|
||||
line.startsWith("<BAND_RX:") ->
|
||||
bandUp = adifValue(line).uppercase()
|
||||
line.startsWith("<BAND:") && !line.startsWith("<BAND_RX:") ->
|
||||
bandDown = adifValue(line).uppercase()
|
||||
line.startsWith("<DXCC:") ->
|
||||
dxcc = adifValue(line).toIntOrNull()
|
||||
line.startsWith("<COUNTRY:") ->
|
||||
country = adifValue(line).ifBlank { null }
|
||||
line.startsWith("<CQZ:") ->
|
||||
cqz = adifValue(line).toIntOrNull()
|
||||
line.startsWith("<STATE:") ->
|
||||
state = adifValue(line).trim().ifBlank { null }?.let { normalizeState(it) }
|
||||
line.startsWith("<MY_GRIDSQUARE:") -> {
|
||||
// Own-station grid (must not be confused with GRIDSQUARE —
|
||||
// the opposite station's grid). Recorded per QSO so awards
|
||||
// can be counted per operated grid. A station location may
|
||||
// also carry MY_VUCC_GRIDS (below) for multi-grid roaming.
|
||||
val value = adifValue(line)
|
||||
if (value.length >= 4) myGrids.add(value.take(4).uppercase())
|
||||
}
|
||||
line.startsWith("<MY_VUCC_GRIDS:") -> {
|
||||
// Own-station VUCC grids: a comma-separated list LoTW emits
|
||||
// when one station location spans several grid squares
|
||||
// (e.g. "OM60,OM50" with MY_GRIDSQUARE absent). Without
|
||||
// this field the extra grids of a multi-grid 台址 silently
|
||||
// vanished from both the blue stripes and the per-台址 VUCC
|
||||
// count. Same split/truncate as VUCC_GRIDS below.
|
||||
adifValue(line).split(',').forEach { grid ->
|
||||
val field = grid.trim().uppercase()
|
||||
if (field.length >= 4) myGrids.add(field.take(4))
|
||||
}
|
||||
}
|
||||
// Remaining MY_* fields identify the station location (台址):
|
||||
// grouped together they form the stationKey used by the map's
|
||||
// operated-grid selector. One callsign can own several 台址.
|
||||
line.startsWith("<STATION_CALLSIGN:") ->
|
||||
myCallsign = adifValue(line).trim().uppercase().ifBlank { null }
|
||||
line.startsWith("<MY_DXCC:") ->
|
||||
myDxcc = adifValue(line).trim().ifBlank { null }
|
||||
line.startsWith("<MY_STATE:") ->
|
||||
myState = adifValue(line).trim().ifBlank { null }?.let { normalizeState(it) }
|
||||
line.startsWith("<MY_CQ_ZONE:") ->
|
||||
myCq = adifValue(line).trim().ifBlank { null }
|
||||
line.startsWith("<MY_ITU_ZONE:") ->
|
||||
myItu = adifValue(line).trim().ifBlank { null }
|
||||
line.startsWith("<MY_IOTA:") ->
|
||||
myIota = adifValue(line).trim().uppercase().ifBlank { null }
|
||||
line.startsWith("<MY_COUNTRY:") ->
|
||||
myCountry = adifValue(line).trim().ifBlank { null }
|
||||
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
|
||||
// VUCC_GRIDS holds a comma-separated list of grids
|
||||
// ("EN52en,EN53fa"), up to four for contacts spanning
|
||||
// several squares; split and keep every 4-char field.
|
||||
adifValue(line).split(',').forEach { grid ->
|
||||
val field = grid.trim().uppercase()
|
||||
if (field.length >= 4) gridsInRecord.add(field.take(4))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** ADIF field value: "<GRIDSQUARE:4>OL62" -> "OL62". */
|
||||
private fun adifValue(line: String): String =
|
||||
line.substringAfter('>').substringBefore("E<").trim()
|
||||
|
||||
/**
|
||||
* LoTW returns STATE as "CODE // NAME" (e.g. "HB // Hubei" for China,
|
||||
* "34 // Tottori-ken" for Japan, "CA // California" for the US). The award
|
||||
* statistics match on the short CODE only (China 2-letter province pinyin,
|
||||
* Japan 2-digit prefecture, US 2-letter state), so strip the " // NAME"
|
||||
* suffix here once, storing the clean code for every downstream consumer
|
||||
* (persistence, AwardCalculator).
|
||||
*/
|
||||
private fun normalizeState(raw: String): String = raw.substringBefore(" // ").trim()
|
||||
|
||||
/** "20260820" + "1130" (or "113000") -> UTC epoch ms; 0 when unparseable. */
|
||||
private fun adifTimestampToEpoch(date: String, time: String): Long = try {
|
||||
val d = date.trim()
|
||||
val t = time.trim().padEnd(6, '0').take(6)
|
||||
val fmt = java.time.format.DateTimeFormatterBuilder()
|
||||
.appendPattern("yyyyMMddHHmmss")
|
||||
.toFormatter()
|
||||
.withZone(java.time.ZoneOffset.UTC)
|
||||
java.time.Instant.from(fmt.parse(d + t)).toEpochMilli()
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
|
||||
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>()
|
||||
// ADIF fields of one QSO record span multiple lines and are terminated by
|
||||
// <EOR>. Satellite QSOs carry <PROP_MODE:3>SAT (plus <SAT_NAME>); ground
|
||||
// QSOs omit it. Grid fields (GRIDSQUARE / VUCC_GRIDS) must only be
|
||||
// collected for records whose PROP_MODE is SAT, otherwise the map mixes
|
||||
// in terrestrial contacts.
|
||||
//
|
||||
// Buffered per-record pattern (same as parseConfirmedGridQsos): field order
|
||||
// is NOT reliable — ADIF producers (incl. LoTW) emit fields alphabetically,
|
||||
// so <GRIDSQUARE> (G) arrives BEFORE <PROP_MODE> (P) within a record. A
|
||||
// sequential gate ("only add while propMode == SAT") silently drops every
|
||||
// grid on real reports. Buffer the record and decide at <EOR> instead.
|
||||
var propMode: String? = null
|
||||
var pendingGrids = mutableListOf<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))
|
||||
when {
|
||||
line.equals("<EOR>", ignoreCase = true) -> {
|
||||
if (propMode == "SAT") grids.addAll(pendingGrids)
|
||||
propMode = null
|
||||
pendingGrids = mutableListOf()
|
||||
}
|
||||
line.startsWith("<PROP_MODE:") -> {
|
||||
propMode = adifValue(line).uppercase()
|
||||
}
|
||||
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
|
||||
// VUCC_GRIDS holds a comma-separated PAIR of grids
|
||||
// ("EN52en,EN53fa") for contacts spanning two squares —
|
||||
// split on ',' and take the 4-char field of each, or the
|
||||
// second grid is silently dropped.
|
||||
val value = adifValue(line)
|
||||
value.split(',').forEach { grid ->
|
||||
val field = grid.trim().uppercase()
|
||||
if (field.length >= 4) pendingGrids.add(field.take(4))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return grids
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct 4-char gridsquares the account itself operated from, taken from
|
||||
* ADIF <MY_GRIDSQUARE> of satellite QSO records only. This is the
|
||||
* "roamed / activated" set shown as blue stripes on the map — the grids
|
||||
* where the operator's own station was located during confirmed QSOs (a
|
||||
* rover may log several distinct grids; the home grid is included too).
|
||||
* Returns null when the body is not an ADIF report (mirrors the other parsers).
|
||||
*/
|
||||
internal fun parseRoamedGrids(body: String): Set<String>? {
|
||||
if (!body.contains("<eoh>", ignoreCase = true)) return null
|
||||
val grids = mutableSetOf<String>()
|
||||
// Buffered per-record pattern (same as parseConfirmedGridQsos): field
|
||||
// order is NOT reliable — ADIF producers (incl. LoTW) emit fields
|
||||
// alphabetically, so <MY_GRIDSQUARE> (M) arrives BEFORE <PROP_MODE> (P)
|
||||
// within a record. A sequential gate ("only add while propMode == SAT")
|
||||
// would silently drop every own grid on real reports; buffer the record
|
||||
// and decide at <EOR> instead.
|
||||
var propMode: String? = null
|
||||
var myGrids = mutableSetOf<String>()
|
||||
for (raw in body.lineSequence()) {
|
||||
val line = raw.trim()
|
||||
when {
|
||||
line.equals("<EOR>", ignoreCase = true) -> {
|
||||
if (propMode == "SAT" && myGrids.isNotEmpty()) grids.addAll(myGrids)
|
||||
propMode = null
|
||||
myGrids = mutableSetOf()
|
||||
}
|
||||
line.startsWith("<PROP_MODE:") -> {
|
||||
propMode = adifValue(line).uppercase()
|
||||
}
|
||||
line.startsWith("<MY_GRIDSQUARE:") -> {
|
||||
val value = adifValue(line)
|
||||
if (value.length >= 4) myGrids.add(value.take(4).uppercase())
|
||||
}
|
||||
line.startsWith("<MY_VUCC_GRIDS:") -> {
|
||||
// Multi-grid 台址: each field of MY_VUCC_GRIDS is a grid the
|
||||
// station location covered; without this the extra grids
|
||||
// never reached the blue stripes.
|
||||
adifValue(line).split(',').forEach { grid ->
|
||||
val field = grid.trim().uppercase()
|
||||
if (field.length >= 4) myGrids.add(field.take(4))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return grids
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val BASE_URL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
|
||||
|
||||
/** Record count in the report header: `<APP_LoTW_NUMREC:4>2367`. */
|
||||
val NUMREC_REGEX = Regex("<APP_LoTW_NUMREC:\\d+>(\\d+)")
|
||||
/**
|
||||
* Measured 2026-09-16 (BH6RJD, 2367 QSLs): 1.7 MB body streamed at
|
||||
* ~9.5 KB/s ≈ 180 s, i.e. ≈ 720 bytes per ADIF record. Use a
|
||||
* CONSERVATIVE (larger) estimate so the progress bar never hits 100%
|
||||
* before the download truly ends — an under-estimate made the bar sit
|
||||
* at 100% while data was still flowing, read as "finished but stuck".
|
||||
*/
|
||||
const val AVG_RECORD_BYTES = 900L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.data.database.QsoDao
|
||||
import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
|
||||
import com.rtbishop.look4sat.core.domain.logbook.AdifCodec
|
||||
import com.rtbishop.look4sat.core.domain.logbook.AdifImportResult
|
||||
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||
import com.rtbishop.look4sat.core.domain.logbook.sameConfirmedContact
|
||||
import com.rtbishop.look4sat.core.domain.logbook.withConfirmation
|
||||
import com.rtbishop.look4sat.core.domain.logbook.confirmationLookupKey
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
|
||||
class QsoRepository(
|
||||
private val dao: QsoDao,
|
||||
private val dispatcher: CoroutineDispatcher
|
||||
) : IQsoRepository {
|
||||
private val importMutex = Mutex()
|
||||
|
||||
override val records: Flow<List<QsoRecord>> = dao.observeAll().map { records -> records.map(QsoEntity::toDomain) }
|
||||
|
||||
override suspend fun find(id: Long): QsoRecord? = withContext(dispatcher) { dao.find(id)?.toDomain() }
|
||||
|
||||
override suspend fun save(record: QsoRecord): Long = withContext(dispatcher) {
|
||||
importMutex.withLock {
|
||||
val previous = if (record.id != 0L) dao.find(record.id)?.toDomain() else null
|
||||
val saved = if (previous?.lotwConfirmed == true && sameConfirmedContact(record, previous)) {
|
||||
record.withConfirmation(previous)
|
||||
} else if (previous?.lotwConfirmed == true) record.copy(
|
||||
lotwConfirmed = false, lotwQslDate = "", vuccGrids = emptyList(),
|
||||
dxcc = null, country = "", cqZone = null, region = ""
|
||||
) else record
|
||||
val received = if (previous != null && (
|
||||
stableQsoKey(record) != stableQsoKey(previous) || record.myGrid != previous.myGrid ||
|
||||
record.rxFrequencyHz != previous.rxFrequencyHz || record.band != previous.band ||
|
||||
record.rxBand != previous.rxBand || record.propagationMode != previous.propagationMode
|
||||
)) false else saved.lotwReceived
|
||||
dao.save(saved.copy(lotwReceived = received).toEntity())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } }
|
||||
|
||||
override suspend fun markUploaded(ids: List<Long>) = withContext(dispatcher) {
|
||||
if (ids.isEmpty()) return@withContext
|
||||
dao.markUploaded(ids)
|
||||
}
|
||||
|
||||
override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
|
||||
val records = dao.getAll()
|
||||
.asSequence()
|
||||
.filter { ids == null || it.id in ids }
|
||||
.map(QsoEntity::toDomain)
|
||||
.filter { includeIncomplete || it.status == QsoStatus.COMPLETE }
|
||||
.toList()
|
||||
AdifCodec.encode(records)
|
||||
}
|
||||
|
||||
override suspend fun importAdi(content: String): AdifImportResult = withContext(dispatcher) {
|
||||
importMutex.withLock {
|
||||
mergeRecords(AdifCodec.decode(content))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult = withContext(dispatcher) {
|
||||
importMutex.withLock { mergeRecords(records.filter { it.lotwConfirmed }) }
|
||||
}
|
||||
|
||||
override suspend fun mergeLoTW(records: List<QsoRecord>): AdifImportResult = withContext(dispatcher) {
|
||||
importMutex.withLock { mergeRecords(records, fromLoTW = true) }
|
||||
}
|
||||
|
||||
private suspend fun mergeRecords(records: List<QsoRecord>, fromLoTW: Boolean = false): AdifImportResult {
|
||||
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
|
||||
val lookup = working.indices.groupBy { working[it].confirmationLookupKey() }
|
||||
.mapValues { it.value.toMutableList() }.toMutableMap()
|
||||
val knownKeys = working.mapTo(mutableSetOf(), ::stableQsoKey)
|
||||
val changes = linkedMapOf<Int, QsoRecord>()
|
||||
var imported = 0
|
||||
var updated = 0
|
||||
var skipped = 0
|
||||
records.forEach { remote ->
|
||||
if (!fromLoTW && !remote.lotwConfirmed && stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
|
||||
val candidates = if (fromLoTW || remote.lotwConfirmed) lookup[remote.confirmationLookupKey()].orEmpty()
|
||||
.filter { sameConfirmedContact(working[it], remote) } else emptyList()
|
||||
val exact = candidates.filter { working[it].startUtcMillis == remote.startUtcMillis }
|
||||
val match = exact.singleOrNull() ?: candidates.singleOrNull()
|
||||
if (match == null) {
|
||||
if (stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
|
||||
val added = remote.copy(id = 0L)
|
||||
changes[working.size] = added
|
||||
lookup.getOrPut(added.confirmationLookupKey()) { mutableListOf() }.add(working.size)
|
||||
knownKeys += stableQsoKey(added)
|
||||
working += added
|
||||
imported++
|
||||
} else {
|
||||
val previous = working[match]
|
||||
val merged = previous.withConfirmation(remote)
|
||||
if (merged == previous) skipped++ else {
|
||||
working[match] = merged
|
||||
changes[match] = merged
|
||||
updated++
|
||||
}
|
||||
}
|
||||
}
|
||||
dao.saveBatch(changes.values.map(QsoRecord::toEntity))
|
||||
return AdifImportResult(imported, skipped, updated)
|
||||
}
|
||||
}
|
||||
|
||||
private fun QsoEntity.toDomain() = QsoRecord(
|
||||
id = id,
|
||||
startUtcMillis = startUtcMillis,
|
||||
endUtcMillis = endUtcMillis,
|
||||
theirCallsign = theirCallsign,
|
||||
myCallsign = myCallsign,
|
||||
theirGrid = theirGrid,
|
||||
myGrid = myGrid,
|
||||
sentReport = sentReport,
|
||||
receivedReport = receivedReport,
|
||||
txFrequencyHz = txFrequencyHz,
|
||||
rxFrequencyHz = rxFrequencyHz,
|
||||
band = band,
|
||||
rxBand = rxBand,
|
||||
mode = mode,
|
||||
submode = submode,
|
||||
satelliteName = satelliteName,
|
||||
transponderName = transponderName,
|
||||
satelliteMode = satelliteMode,
|
||||
passAosUtcMillis = passAosUtcMillis,
|
||||
automatic = automatic,
|
||||
status = runCatching { QsoStatus.valueOf(status) }.getOrDefault(QsoStatus.DRAFT),
|
||||
propagationMode = propagationMode,
|
||||
lotwConfirmed = lotwConfirmed,
|
||||
lotwUploaded = lotwUploaded,
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
|
||||
dxcc = dxcc,
|
||||
country = country,
|
||||
cqZone = cqZone,
|
||||
region = region,
|
||||
comment = comment
|
||||
)
|
||||
|
||||
private fun QsoRecord.toEntity() = QsoEntity(
|
||||
id = id,
|
||||
startUtcMillis = startUtcMillis,
|
||||
endUtcMillis = endUtcMillis,
|
||||
theirCallsign = theirCallsign.trim().uppercase(Locale.US),
|
||||
myCallsign = myCallsign.trim().uppercase(Locale.US),
|
||||
theirGrid = theirGrid.trim().uppercase(Locale.US),
|
||||
myGrid = myGrid.trim().uppercase(Locale.US),
|
||||
sentReport = sentReport.trim(),
|
||||
receivedReport = receivedReport.trim(),
|
||||
txFrequencyHz = txFrequencyHz,
|
||||
rxFrequencyHz = rxFrequencyHz,
|
||||
band = band,
|
||||
rxBand = rxBand,
|
||||
mode = mode.trim(),
|
||||
submode = submode.trim(),
|
||||
satelliteName = satelliteName,
|
||||
transponderName = transponderName,
|
||||
satelliteMode = satelliteMode,
|
||||
passAosUtcMillis = passAosUtcMillis,
|
||||
automatic = automatic,
|
||||
status = status.name,
|
||||
dedupeKey = stableQsoKey(this),
|
||||
propagationMode = propagationMode.ifBlank { if (satelliteName.isNotBlank()) "SAT" else "" },
|
||||
lotwConfirmed = lotwConfirmed,
|
||||
lotwUploaded = lotwUploaded,
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.joinToString(","),
|
||||
dxcc = dxcc,
|
||||
country = country,
|
||||
cqZone = cqZone,
|
||||
region = region,
|
||||
comment = comment
|
||||
)
|
||||
|
||||
internal fun stableQsoKey(record: QsoRecord): String = listOf(
|
||||
record.startUtcMillis.toString(),
|
||||
record.theirCallsign.trim().uppercase(Locale.US),
|
||||
record.myCallsign.trim().uppercase(Locale.US),
|
||||
record.txFrequencyHz?.toString().orEmpty(),
|
||||
record.mode.trim().uppercase(Locale.US),
|
||||
record.submode.trim().uppercase(Locale.US),
|
||||
record.satelliteName.trim().uppercase(Locale.US)
|
||||
).joinToString("|")
|
||||
+27
-5
@@ -35,6 +35,8 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.TimeZone
|
||||
@@ -63,17 +65,28 @@ class SatelliteRepo(
|
||||
|
||||
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||
|
||||
override suspend fun getSatelliteIdsWithModes(modes: List<String>) = localStorage.getIdsWithModes(modes)
|
||||
|
||||
override suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>) = localStorage.getIdsWithModesAndUplink(modes)
|
||||
|
||||
override suspend fun getSatelliteIdsWithModesAndAmateur(modes: List<String>) = localStorage.getIdsWithModesAndAmateur(modes)
|
||||
|
||||
override suspend fun initRepository() = withContext(dispatcher) {
|
||||
combine(
|
||||
settingsRepo.selectedIds,
|
||||
settingsRepo.stationPosition
|
||||
) { selectedIds, _ -> selectedIds }
|
||||
settingsRepo.stationPosition,
|
||||
// Recalculate passes when the UTC display toggle changes, so an existing
|
||||
// AOS time window is reapplied in the newly selected timezone.
|
||||
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged()
|
||||
) { selectedIds, _, _ -> selectedIds }
|
||||
.collect { selectedIds ->
|
||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||
val settings = settingsRepo.passesSettings.value
|
||||
val now = System.currentTimeMillis()
|
||||
// 历史过境: 回看窗口 hoursBefore 小时, 过境列表从更早时间开始计算.
|
||||
calculatePasses(
|
||||
time = System.currentTimeMillis(),
|
||||
hoursAhead = settings.hoursAhead,
|
||||
time = now - settings.hoursBefore * 60L * 60L * 1000L,
|
||||
hoursAhead = settings.hoursBefore + settings.hoursAhead,
|
||||
minElevation = settings.minElevation,
|
||||
aosStartMinute = settings.aosStartMinute,
|
||||
aosEndMinute = settings.aosEndMinute,
|
||||
@@ -175,7 +188,16 @@ class SatelliteRepo(
|
||||
aosEndMinute: Int,
|
||||
invertAosTimeWindow: Boolean
|
||||
): Boolean {
|
||||
val offsetMillis = TimeZone.getDefault().getOffset(aosTime).toLong()
|
||||
// Follow the UTC display toggle: with UTC enabled the window is interpreted
|
||||
// in UTC, otherwise in the device's local timezone. Otherwise the pass list
|
||||
// shows UTC times while the filter silently uses local time — the two
|
||||
// disagree by the timezone offset (e.g. 8h for China).
|
||||
val tz = if (settingsRepo.otherSettings.value.stateOfUtc) {
|
||||
TimeZone.getTimeZone("UTC")
|
||||
} else {
|
||||
TimeZone.getDefault()
|
||||
}
|
||||
val offsetMillis = tz.getOffset(aosTime).toLong()
|
||||
val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L)
|
||||
val aosMinute = (localMillis / 60_000L).toInt()
|
||||
val inRange = if (aosStartMinute <= aosEndMinute) {
|
||||
|
||||
+64
-6
@@ -43,12 +43,15 @@ class SelectionRepo(
|
||||
|
||||
// Resolve type IDs once when types change, then filter items reactively.
|
||||
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
||||
// The three virtual types ("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV")
|
||||
// are resolved from hardcoded FM/Linear catnum sets (Sources) or live
|
||||
// SSTV transponder data instead of SharedPreferences.
|
||||
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
||||
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
||||
null // null = no filtering
|
||||
} else {
|
||||
val ids = settingsRepo.getSatelliteTypesIds(types)
|
||||
if (ids.isEmpty()) null else ids.toHashSet()
|
||||
val ids = resolveTypeIds(types)
|
||||
if (ids.isEmpty()) emptySet() else ids.toHashSet()
|
||||
}
|
||||
currentItems.map { items ->
|
||||
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
|
||||
@@ -61,8 +64,46 @@ class SelectionRepo(
|
||||
|
||||
override fun getCurrentTypes() = currentTypes.value
|
||||
|
||||
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
|
||||
removeAt(0)
|
||||
/**
|
||||
* Resolves a list of type names to satellite catnums. The three virtual
|
||||
* transponder/activity types are resolved from hardcoded FM/Linear lists
|
||||
* (Sources.amSatFmCatnums / amSatLinearCatnums) or local SSTV transponder
|
||||
* data; all other types come from the per-type ID lists persisted by
|
||||
* [ISettingsRepo].
|
||||
*/
|
||||
private suspend fun resolveTypeIds(types: List<String>): List<Int> {
|
||||
val idsSet = mutableSetOf<Int>()
|
||||
types.forEach { type ->
|
||||
when (type) {
|
||||
Sources.virtualTypeNames[0] -> idsSet.addAll(Sources.amSatFmCatnums)
|
||||
Sources.virtualTypeNames[1] -> idsSet.addAll(Sources.amSatLinearCatnums)
|
||||
// Live SSTV: mode=SSTV transponder records whose service class
|
||||
// is "Amateur", excluding launcher debris / rocket bodies
|
||||
// (names ending in "R/B" or "DEB" — e.g. Ariane 6 R/B) that
|
||||
// carry an amateur payload transponder but are not satellites.
|
||||
Sources.virtualTypeNames[2] -> {
|
||||
val sstvIds = localSource.getIdsWithModesAndAmateur(listOf("SSTV")).toSet()
|
||||
val inOrbitNames = currentItems.value.associate { it.catnum to it.name }
|
||||
val filtered = sstvIds.filter { catnum ->
|
||||
val name = inOrbitNames[catnum]?.uppercase().orEmpty()
|
||||
!name.endsWith(" R/B") && !name.endsWith(" DEB") &&
|
||||
!name.endsWith("R/B") && !name.endsWith("DEB")
|
||||
}
|
||||
idsSet.addAll(filtered)
|
||||
}
|
||||
else -> idsSet.addAll(settingsRepo.getSatelliteTypesIds(listOf(type)))
|
||||
}
|
||||
}
|
||||
return idsSet.toList()
|
||||
}
|
||||
|
||||
override fun getTypesList() = buildList {
|
||||
// 三个转发器/活动虚拟类型排在最前.
|
||||
addAll(Sources.virtualTypeNames)
|
||||
// 所有真实 TLE 源类型均可选(含 "All" = CelesTrak active);只排除
|
||||
// "Other"(空 URL 占位,无数据)。不要用 removeAt(0) —— 字母序第一个
|
||||
// 是 "All"/"ARISS" 这类真实源,删掉会让用户勾不到它们。
|
||||
addAll(Sources.satelliteDataUrls.keys.sorted().filterNot { it == "Other" })
|
||||
}
|
||||
|
||||
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
||||
@@ -111,12 +152,29 @@ class SelectionRepo(
|
||||
/**
|
||||
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
|
||||
* and lowercases the query once up front instead of per-item.
|
||||
*
|
||||
* Fuzzy search: the query is split into space-separated tokens and every
|
||||
* token must appear in the satellite name after both sides are normalized
|
||||
* (lowercased, non-alphanumeric separators such as dashes, spaces, brackets
|
||||
* and dots stripped). This makes "ao7" match "AO-7 (AMSAT-OSCAR 7)" and
|
||||
* "iss zarya" match "ISS (ZARYA)" — exact continuous-substring matching
|
||||
* previously failed whenever the name contained a separator the query lacked.
|
||||
*/
|
||||
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
|
||||
if (query.isBlank()) return items
|
||||
val catnum = query.toIntOrNull()
|
||||
if (catnum != null) return items.filter { it.catnum == catnum }
|
||||
val lowerQuery = query.lowercase()
|
||||
return items.filter { it.name.lowercase().contains(lowerQuery) }
|
||||
val tokens = query.split(' ')
|
||||
.map { normalizeForSearch(it) }
|
||||
.filter { it.isNotEmpty() }
|
||||
if (tokens.isEmpty()) return items
|
||||
return items.filter { item ->
|
||||
val normalizedName = normalizeForSearch(item.name)
|
||||
tokens.all { normalizedName.contains(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Lowercases and strips all non-alphanumeric chars for fuzzy matching. */
|
||||
private fun normalizeForSearch(text: String): String =
|
||||
text.lowercase().filter { it.isLetterOrDigit() }
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import android.view.Display
|
||||
import android.view.Surface
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
||||
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -41,7 +42,12 @@ class SensorsRepo(
|
||||
) : ISensorsRepo, SensorEventListener {
|
||||
|
||||
private val _sensorData = MutableStateFlow(Pair(0f, 0f))
|
||||
private val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
||||
private val rotationSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
||||
private val magneticSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
|
||||
private val _compassAccuracy = MutableStateFlow(
|
||||
if (rotationSensor == null || magneticSensor == null) CompassAccuracy.UNAVAILABLE
|
||||
else CompassAccuracy.UNRELIABLE
|
||||
)
|
||||
private val rotationMatrix = FloatArray(9)
|
||||
private val tempMatrix = FloatArray(9)
|
||||
private val orientationValues = FloatArray(3)
|
||||
@@ -50,6 +56,7 @@ class SensorsRepo(
|
||||
private var hasInitialReading = false
|
||||
|
||||
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
|
||||
override val compassAccuracy: StateFlow<CompassAccuracy> = _compassAccuracy
|
||||
|
||||
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
||||
return GeomagneticField(
|
||||
@@ -62,15 +69,41 @@ class SensorsRepo(
|
||||
|
||||
override fun enableSensor() {
|
||||
hasInitialReading = false
|
||||
sensor?.let { sensorManager.registerListener(this, it, SENSOR_RATE_US) }
|
||||
if (rotationSensor == null || magneticSensor == null) {
|
||||
_compassAccuracy.value = CompassAccuracy.UNAVAILABLE
|
||||
return
|
||||
}
|
||||
_compassAccuracy.value = CompassAccuracy.UNRELIABLE
|
||||
val rotationRegistered = sensorManager.registerListener(this, rotationSensor, SENSOR_RATE_US)
|
||||
val magneticRegistered = sensorManager.registerListener(this, magneticSensor, SENSOR_RATE_US)
|
||||
if (!rotationRegistered || !magneticRegistered) {
|
||||
sensorManager.unregisterListener(this)
|
||||
_compassAccuracy.value = CompassAccuracy.UNAVAILABLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun disableSensor() = sensorManager.unregisterListener(this)
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
|
||||
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
|
||||
if (sensor.type == Sensor.TYPE_MAGNETIC_FIELD) {
|
||||
updateAccuracy(accuracy)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) handleSensorEvent(event)
|
||||
when (event.sensor.type) {
|
||||
Sensor.TYPE_ROTATION_VECTOR -> handleSensorEvent(event)
|
||||
Sensor.TYPE_MAGNETIC_FIELD -> updateAccuracy(event.accuracy)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAccuracy(accuracy: Int) {
|
||||
_compassAccuracy.value = when (accuracy) {
|
||||
SensorManager.SENSOR_STATUS_ACCURACY_HIGH -> CompassAccuracy.HIGH
|
||||
SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM -> CompassAccuracy.MEDIUM
|
||||
SensorManager.SENSOR_STATUS_ACCURACY_LOW -> CompassAccuracy.LOW
|
||||
else -> CompassAccuracy.UNRELIABLE
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDisplayRotation(): Int {
|
||||
|
||||
+254
-5
@@ -25,6 +25,7 @@ import androidx.core.location.LocationListenerCompat
|
||||
import androidx.core.location.LocationManagerCompat
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.MapSource
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
@@ -58,6 +59,7 @@ class SettingsRepo(
|
||||
private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat"
|
||||
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
|
||||
private val keyFilterHoursAhead = "filterHoursAhead"
|
||||
private val keyFilterHoursBefore = "filterHoursBefore"
|
||||
private val keyFilterMinElevation = "filterMinElevation"
|
||||
private val keyFilterAosStartMinute = "filterAosStartMinute"
|
||||
private val keyFilterAosEndMinute = "filterAosEndMinute"
|
||||
@@ -77,12 +79,15 @@ class SettingsRepo(
|
||||
private val keySelectedTypes = "selectedTypes"
|
||||
private val keySelectedModes = "selectedModes"
|
||||
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
||||
private val keyStateOfAutoLotwSync = "stateOfAutoLotwSync"
|
||||
private val keyStateOfSensors = "stateOfSensors"
|
||||
private val keyCompassOffsetDegrees = "compassOffsetDegrees"
|
||||
private val keyStateOfSweep = "stateOfSweep"
|
||||
private val keyStateOfUtc = "stateOfUtc"
|
||||
private val keyStateOfLightTheme = "stateOfLightTheme"
|
||||
private val keyStateOfNightMode = "stateOfNightMode"
|
||||
private val keyStateOfMapGrid = "stateOfMapGrid"
|
||||
private val keyStateOfMapFirstCall = "stateOfMapFirstCall"
|
||||
private val keyStationAltitude = "stationAltitude"
|
||||
private val keyStationLatitude = "stationLatitude"
|
||||
private val keyStationLongitude = "stationLongitude"
|
||||
@@ -94,6 +99,8 @@ class SettingsRepo(
|
||||
private val keySstvMode = "sstvMode"
|
||||
private val keyLowElevation = "lowElevation"
|
||||
private val keyHighElevation = "highElevation"
|
||||
private val keyMapSource = "mapSource"
|
||||
private val keyTiandituKey = "tiandituKey"
|
||||
private val keyUseCustomTle = "useCustomTle"
|
||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||
private val keyTleUrl = "tleUrl"
|
||||
@@ -103,6 +110,7 @@ class SettingsRepo(
|
||||
private val keySatelliteEnabled = "satelliteEnabled"
|
||||
private val keyTransceiversEnabled = "transceiversEnabled"
|
||||
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
|
||||
private val keyAutoTleSourceMigration = "autoTleSourceMigration"
|
||||
private val separatorComma = ","
|
||||
private val separatorUrl = "\n"
|
||||
private val legacyCelestrakSatnogsUrl =
|
||||
@@ -179,6 +187,161 @@ class SettingsRepo(
|
||||
preferences.edit { putString(keyWorkedGrids, array.toString()) }
|
||||
}
|
||||
|
||||
// Confirmed satellite QSOs per worked gridsquare, persisted as a single JSON
|
||||
// object: {"OL62":[{"c":call,"t":epochMs,"s":sat,"m":mode,"bu":up,"bd":down}]}.
|
||||
private val keyWorkedGridQsos = "workedGridQsos"
|
||||
|
||||
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> {
|
||||
val json = preferences.getString(keyWorkedGridQsos, null).orEmpty()
|
||||
if (json.isBlank()) return emptyMap()
|
||||
return try {
|
||||
val root = org.json.JSONObject(json)
|
||||
val result = mutableMapOf<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>()
|
||||
for (grid in root.keys()) {
|
||||
val array = root.optJSONArray(grid) ?: continue
|
||||
val list = (0 until array.length()).mapNotNull { i ->
|
||||
val o = array.optJSONObject(i) ?: return@mapNotNull null
|
||||
com.rtbishop.look4sat.core.domain.model.GridQso(
|
||||
call = o.optString("c"),
|
||||
epochMs = o.optLong("t"),
|
||||
satName = o.optString("s"),
|
||||
mode = o.optString("m"),
|
||||
bandUp = o.optString("bu"),
|
||||
bandDown = o.optString("bd"),
|
||||
// New award fields: absent in pre-award data -> null.
|
||||
dxcc = o.optInt("dx", 0).takeIf { it > 0 },
|
||||
country = o.optString("cty").ifBlank { null },
|
||||
cqz = o.optInt("cq", 0).takeIf { it > 0 },
|
||||
state = o.optString("st").ifBlank { null },
|
||||
// Own-grid field: absent in pre-myGrid data -> null.
|
||||
myGrid = o.optString("mg").ifBlank { null },
|
||||
// Multi-grid 台址 fields (v4.4.7-ba7opf.16+): "mgs" is
|
||||
// the full grid set (MY_GRIDSQUARE + MY_VUCC_GRIDS);
|
||||
// pre-multi-grid data has only "mg" and falls back to a
|
||||
// single-element set so every consumer sees myGrids.
|
||||
myGrids = o.optJSONArray("mgs")?.let { arr ->
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
arr.optString(i).takeIf { it.isNotBlank() }
|
||||
}.toSet()
|
||||
}?.takeIf { it.isNotEmpty() }
|
||||
?: o.optString("mg").ifBlank { null }?.let { setOf(it) }
|
||||
?: emptySet(),
|
||||
myCallsign = o.optString("mc").ifBlank { null },
|
||||
stationKey = o.optString("sk").ifBlank { null }
|
||||
)
|
||||
}
|
||||
if (list.isNotEmpty()) result[grid] = list
|
||||
}
|
||||
result
|
||||
} catch (_: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) {
|
||||
val root = org.json.JSONObject()
|
||||
for ((grid, list) in qsos) {
|
||||
val array = org.json.JSONArray()
|
||||
for (q in list) {
|
||||
array.put(
|
||||
org.json.JSONObject()
|
||||
.put("c", q.call)
|
||||
.put("t", q.epochMs)
|
||||
.put("s", q.satName)
|
||||
.put("m", q.mode)
|
||||
.put("bu", q.bandUp)
|
||||
.put("bd", q.bandDown)
|
||||
.put("dx", q.dxcc ?: 0)
|
||||
.put("cty", q.country ?: "")
|
||||
.put("cq", q.cqz ?: 0)
|
||||
.put("st", q.state ?: "")
|
||||
.put("mg", q.myGrid ?: "")
|
||||
.put("mgs", org.json.JSONArray(q.myGrids.sorted()))
|
||||
.put("mc", q.myCallsign ?: "")
|
||||
.put("sk", q.stationKey ?: "")
|
||||
)
|
||||
}
|
||||
root.put(grid, array)
|
||||
}
|
||||
preferences.edit { putString(keyWorkedGridQsos, root.toString()) }
|
||||
}
|
||||
|
||||
// Distinct 4-char gridsquares the account operated from (LoTW <MY_GRIDSQUARE>,
|
||||
// satellite QSOs only). Stored as a JSONArray like workedGrids.
|
||||
private val keyRoamedGrids = "roamedGrids"
|
||||
|
||||
override fun getRoamedGrids(): Set<String> {
|
||||
val json = preferences.getString(keyRoamedGrids, 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 setRoamedGrids(grids: Set<String>) {
|
||||
val array = org.json.JSONArray()
|
||||
grids.sorted().forEach { array.put(it) }
|
||||
preferences.edit { putString(keyRoamedGrids, array.toString()) }
|
||||
}
|
||||
|
||||
// Marked stations in unworked gridsquares, persisted as a single JSON
|
||||
// object: {"OL62":[{"c":call,"t":epochMs}, ...]}. Each grid holds an
|
||||
// ordered list (first = pinned/top). v14.1 stored a single object per
|
||||
// grid; reads transparently migrate that old shape to a one-element list.
|
||||
private val keyMarkedGridStations = "markedGridStations"
|
||||
|
||||
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> {
|
||||
val json = preferences.getString(keyMarkedGridStations, null).orEmpty()
|
||||
if (json.isBlank()) return emptyMap()
|
||||
return try {
|
||||
val root = org.json.JSONObject(json)
|
||||
val result = mutableMapOf<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>()
|
||||
for (grid in root.keys()) {
|
||||
val value = root.opt(grid)
|
||||
val list = when (value) {
|
||||
// v14.2+ shape: JSON array of station objects.
|
||||
is org.json.JSONArray -> (0 until value.length()).mapNotNull { i ->
|
||||
val o = value.optJSONObject(i) ?: return@mapNotNull null
|
||||
val call = o.optString("c").ifBlank { return@mapNotNull null }
|
||||
com.rtbishop.look4sat.core.domain.model.MarkedStation(call = call, epochMs = o.optLong("t"))
|
||||
}
|
||||
// v14.1 legacy shape: a single station object per grid.
|
||||
is org.json.JSONObject -> {
|
||||
val call = value.optString("c").ifBlank { null }
|
||||
if (call == null) emptyList()
|
||||
else listOf(com.rtbishop.look4sat.core.domain.model.MarkedStation(call = call, epochMs = value.optLong("t")))
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
if (list.isNotEmpty()) result[grid] = list
|
||||
}
|
||||
result
|
||||
} catch (_: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) {
|
||||
val root = org.json.JSONObject()
|
||||
for ((grid, list) in stations) {
|
||||
val array = org.json.JSONArray()
|
||||
for (station in list) {
|
||||
array.put(
|
||||
org.json.JSONObject()
|
||||
.put("c", station.call)
|
||||
.put("t", station.epochMs)
|
||||
)
|
||||
}
|
||||
root.put(grid, array)
|
||||
}
|
||||
preferences.edit { putString(keyMarkedGridStations, root.toString()) }
|
||||
}
|
||||
|
||||
// LoTW credentials (stored locally on the device only)
|
||||
private val keyLoTWCall = "lotwCallsign"
|
||||
private val keyLoTWPass = "lotwPassword"
|
||||
@@ -203,6 +366,24 @@ class SettingsRepo(
|
||||
callsign = preferences.getString(keyLoTWCall, null).orEmpty(),
|
||||
password = preferences.getString(keyLoTWPass, null).orEmpty()
|
||||
)
|
||||
|
||||
// Last successful sync bookkeeping: date ("yyyyMMdd") and callsign. Used to
|
||||
// decide incremental (same callsign) vs full (first time / callsign change)
|
||||
// report requests and to merge increments into the stored grid data.
|
||||
private val keyLastLotwSyncDate = "lotwLastSyncDate"
|
||||
private val keyLastLotwSyncCallsign = "lotwLastSyncCallsign"
|
||||
|
||||
override fun getLastLotwSyncDate(): String =
|
||||
preferences.getString(keyLastLotwSyncDate, null).orEmpty()
|
||||
|
||||
override fun setLastLotwSyncDate(date: String) =
|
||||
preferences.edit { putString(keyLastLotwSyncDate, date) }
|
||||
|
||||
override fun getLastLotwSyncCallsign(): String =
|
||||
preferences.getString(keyLastLotwSyncCallsign, null).orEmpty()
|
||||
|
||||
override fun setLastLotwSyncCallsign(callsign: String) =
|
||||
preferences.edit { putString(keyLastLotwSyncCallsign, callsign.trim().uppercase()) }
|
||||
//endregion
|
||||
|
||||
//region # Transceivers settings
|
||||
@@ -212,6 +393,7 @@ class SettingsRepo(
|
||||
override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
|
||||
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
|
||||
putInt(keyFilterHoursAhead, settings.hoursAhead)
|
||||
putInt(keyFilterHoursBefore, settings.hoursBefore)
|
||||
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
|
||||
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
||||
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
||||
@@ -223,6 +405,7 @@ class SettingsRepo(
|
||||
private fun getPassesSettings(): PassesSettings {
|
||||
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
|
||||
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
|
||||
val hoursBefore = preferences.getInt(keyFilterHoursBefore, 0).coerceIn(0, 240)
|
||||
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
|
||||
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
||||
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
||||
@@ -236,7 +419,8 @@ class SettingsRepo(
|
||||
aosStartMinute,
|
||||
aosEndMinute,
|
||||
invertAosTimeWindow,
|
||||
selectedModes
|
||||
selectedModes,
|
||||
hoursBefore
|
||||
)
|
||||
}
|
||||
//endregion
|
||||
@@ -331,7 +515,9 @@ class SettingsRepo(
|
||||
}
|
||||
|
||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
||||
if (type == "All") return
|
||||
// "All" is a real TLE source type (CelesTrak active group) whose ids
|
||||
// must be persisted like any other type — skipping it made the "All"
|
||||
// filter resolve to an empty set and show nothing.
|
||||
val typesString = ids.joinToString(separatorComma)
|
||||
preferences.edit { putString("type$type", typesString) }
|
||||
}
|
||||
@@ -431,20 +617,30 @@ class SettingsRepo(
|
||||
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) {
|
||||
_otherSettings.update { current ->
|
||||
val new = transform(current)
|
||||
val new = transform(current).let {
|
||||
it.copy(
|
||||
mapSource = MapSource.normalize(it.mapSource),
|
||||
tiandituKey = it.tiandituKey.trim()
|
||||
)
|
||||
}
|
||||
preferences.edit {
|
||||
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
|
||||
putBoolean(keyStateOfAutoLotwSync, new.stateOfAutoLotwSync)
|
||||
putBoolean(keyStateOfSensors, new.stateOfSensors)
|
||||
putFloat(keyCompassOffsetDegrees, new.compassOffsetDegrees.coerceIn(-180f, 180f))
|
||||
putBoolean(keyStateOfSweep, new.stateOfSweep)
|
||||
putBoolean(keyStateOfUtc, new.stateOfUtc)
|
||||
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
|
||||
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
|
||||
putBoolean(keyStateOfMapGrid, new.stateOfMapGrid)
|
||||
putBoolean(keyStateOfMapFirstCall, new.stateOfMapFirstCall)
|
||||
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
||||
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
||||
putString(keySstvMode, new.sstvMode)
|
||||
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
||||
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||
putString(keyMapSource, new.mapSource)
|
||||
putString(keyTiandituKey, new.tiandituKey)
|
||||
}
|
||||
new
|
||||
}
|
||||
@@ -452,17 +648,22 @@ class SettingsRepo(
|
||||
|
||||
private fun getOtherSettings(): OtherSettings = OtherSettings(
|
||||
stateOfAutoUpdate = preferences.getBoolean(keyStateOfAutoUpdate, true),
|
||||
stateOfAutoLotwSync = preferences.getBoolean(keyStateOfAutoLotwSync, true),
|
||||
stateOfSensors = preferences.getBoolean(keyStateOfSensors, true),
|
||||
compassOffsetDegrees = preferences.getFloat(keyCompassOffsetDegrees, 0f).coerceIn(-180f, 180f),
|
||||
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
|
||||
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
|
||||
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
|
||||
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
||||
stateOfMapGrid = preferences.getBoolean(keyStateOfMapGrid, false),
|
||||
stateOfMapFirstCall = preferences.getBoolean(keyStateOfMapFirstCall, false),
|
||||
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
|
||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
|
||||
mapSource = MapSource.normalize(preferences.getString(keyMapSource, null).orEmpty()),
|
||||
tiandituKey = preferences.getString(keyTiandituKey, null).orEmpty().trim()
|
||||
)
|
||||
//endregion
|
||||
|
||||
@@ -484,6 +685,7 @@ class SettingsRepo(
|
||||
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
|
||||
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
|
||||
putBoolean(keySatnogsTleSourceMigration, true)
|
||||
putBoolean(keyAutoTleSourceMigration, true)
|
||||
}
|
||||
_dataSourcesSettings.value = normalized
|
||||
}
|
||||
@@ -494,7 +696,7 @@ class SettingsRepo(
|
||||
defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
|
||||
legacyEnabledKey = keyUseCustomTle,
|
||||
legacyUrlKey = keyTleUrl
|
||||
).migrateSatnogsTleSource(),
|
||||
).migrateSatnogsTleSource().migrateAutoTleSources(),
|
||||
transceiversUrls = getDataSourceUrls(
|
||||
key = keyTransceiversUrls,
|
||||
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
|
||||
@@ -555,6 +757,28 @@ class SettingsRepo(
|
||||
return migrated
|
||||
}
|
||||
|
||||
private fun List<String>.migrateAutoTleSources(): List<String> {
|
||||
if (preferences.getBoolean(keyAutoTleSourceMigration, false)) return this
|
||||
val autoTleUrls = listOfNotNull(
|
||||
Sources.satelliteDataUrls["BI4PYM AutoTLE (GitHub)"],
|
||||
Sources.satelliteDataUrls["BI4PYM AutoTLE (Mirror)"]
|
||||
).filter { it.isNotBlank() }
|
||||
val missingUrls = autoTleUrls.filterNot { containsSourceUrl(it) }
|
||||
if (missingUrls.isEmpty()) {
|
||||
preferences.edit { putBoolean(keyAutoTleSourceMigration, true) }
|
||||
return this
|
||||
}
|
||||
|
||||
val migrated = this + missingUrls
|
||||
val enabled = alignFlags(migrated, readEnabledFlags(keySatelliteEnabled))
|
||||
preferences.edit {
|
||||
putString(keySatelliteUrls, migrated.joinToString(separatorUrl))
|
||||
putString(keySatelliteEnabled, enabled.joinToString(separatorComma))
|
||||
putBoolean(keyAutoTleSourceMigration, true)
|
||||
}
|
||||
return migrated
|
||||
}
|
||||
|
||||
private fun List<String>.containsSourceUrl(url: String): Boolean = any { isSameSourceUrl(it, url) }
|
||||
|
||||
private fun isSameSourceUrl(first: String, second: String): Boolean =
|
||||
@@ -653,6 +877,31 @@ class SettingsRepo(
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # Per-satellite logbook mode preset settings
|
||||
private val keySatelliteModes = "satelliteModes"
|
||||
|
||||
override fun getSatelliteMode(catnum: Int): String {
|
||||
val json = preferences.getString(keySatelliteModes, "{}") ?: "{}"
|
||||
return try {
|
||||
JSONObject(json).optString(catnum.toString(), "")
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
override fun setSatelliteMode(catnum: Int, mode: String) {
|
||||
val json = preferences.getString(keySatelliteModes, "{}") ?: "{}"
|
||||
val updated = try {
|
||||
val obj = JSONObject(json)
|
||||
if (mode.isBlank()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), mode.uppercase())
|
||||
obj.toString()
|
||||
} catch (_: Exception) {
|
||||
if (mode.isBlank()) "{}" else """{"$catnum": "${mode.uppercase()}"}"""
|
||||
}
|
||||
preferences.edit { putString(keySatelliteModes, updated) }
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # AMSAT status report settings
|
||||
private val keyAmSatCallsign = "amSatCallsign"
|
||||
|
||||
|
||||
+6
-4
@@ -66,16 +66,18 @@ class UpdateRepository(
|
||||
.replace("<", "<").replace(">", ">").replace("&", "&")
|
||||
.replace(""", "\"").replace("'", "'")
|
||||
.trim()
|
||||
// The release APK asset follows the fixed naming scheme used by the build:
|
||||
// Look4Sat-<tag without leading v>-release.apk. When the page was fetched
|
||||
// The release APK asset naming: Look4Sat-<tag without leading v>.apk
|
||||
// (since v4.4.6-ba7opf.9.9 the releases are uploaded with the bare
|
||||
// versioned filename, no "-release" suffix). 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 apkName = "Look4Sat-${tag.removePrefix("v")}.apk"
|
||||
val apkUrl = if (mirrorPrefix.isEmpty()) {
|
||||
"$DOWNLOAD_BASE_URL/$tag/Look4Sat-${tag.removePrefix("v")}-release.apk"
|
||||
"$DOWNLOAD_BASE_URL/$tag/$apkName"
|
||||
} else {
|
||||
"${mirrorPrefix}https://github.com/atsunatsu/Look4Sat/releases/download/$tag/Look4Sat-${tag.removePrefix("v")}-release.apk"
|
||||
"${mirrorPrefix}https://github.com/atsunatsu/Look4Sat/releases/download/$tag/$apkName"
|
||||
}
|
||||
return LatestRelease(
|
||||
versionTag = tag,
|
||||
|
||||
@@ -48,15 +48,17 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
||||
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
|
||||
|
||||
override suspend fun getIdsWithModes(modes: List<String>) = look4SatDao.getIdsWithModes(modes)
|
||||
override suspend fun getIdsWithModesAndUplink(modes: List<String>) = look4SatDao.getIdsWithModesAndUplink(modes)
|
||||
override suspend fun getIdsWithModesAndAmateur(modes: List<String>) = look4SatDao.getIdsWithModesAndAmateur(modes)
|
||||
|
||||
private fun FrameworkEntry.toDomain() = OrbitalData(
|
||||
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar
|
||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
|
||||
)
|
||||
|
||||
private fun OrbitalData.toEntity() = FrameworkEntry(
|
||||
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar
|
||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
|
||||
)
|
||||
|
||||
private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() }
|
||||
@@ -73,20 +75,24 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
||||
return look4SatDao.getRadiosWithId(id).toDomainRadios()
|
||||
}
|
||||
|
||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
||||
look4SatDao.insertRadios(radios.toFrameworkRadios())
|
||||
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
|
||||
look4SatDao.insertRadios(radios.map { it.copy(isCustom = isCustom) }.toFrameworkRadios())
|
||||
}
|
||||
|
||||
override suspend fun deleteManagedRadios() = look4SatDao.deleteManagedRadios()
|
||||
|
||||
override suspend fun deleteRadios() = look4SatDao.deleteRadios()
|
||||
|
||||
private fun DomainRadio.toFramework() = FrameworkRadio(
|
||||
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
|
||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum,
|
||||
this.service, this.isCustom
|
||||
)
|
||||
|
||||
private fun FrameworkRadio.toDomain() = DomainRadio(
|
||||
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
|
||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum,
|
||||
this.service, this.isCustom
|
||||
)
|
||||
|
||||
private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() }
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.lotw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWZonePair
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Parses the real bundled ARRL config.tq6 to verify region-field and zonemap extraction. */
|
||||
class LoTWConfigTest {
|
||||
|
||||
private val config = LoTWConfig(File("src/main/assets/lotw/config.tq6").inputStream())
|
||||
|
||||
@Test
|
||||
fun `china exposes province field with all 31 options and zonemaps`() {
|
||||
val meta = config.stationMeta(318)
|
||||
assertEquals("CN_PROVINCE", meta.regionField?.id)
|
||||
assertEquals("Province", meta.regionField?.label)
|
||||
assertEquals(31, meta.regionField?.options?.size)
|
||||
val gd = meta.regionField?.options?.first { it.code == "GD" }
|
||||
assertEquals("Guangdong", gd?.name)
|
||||
assertEquals(listOf(LoTWZonePair(44, 24)), gd?.zones)
|
||||
val gx = meta.regionField?.options?.first { it.code == "GX" }
|
||||
assertEquals(listOf(LoTWZonePair(43, 24), LoTWZonePair(44, 24)), gx?.zones)
|
||||
// China spans 8 zone pairs — the UI must NOT prefill from the national map.
|
||||
assertTrue(meta.countryZones.size > 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `usa exposes state field with 49 continental options`() {
|
||||
val meta = config.stationMeta(291)
|
||||
assertEquals("US_STATE", meta.regionField?.id)
|
||||
assertEquals("State", meta.regionField?.label)
|
||||
// AK and HI are separate DXCC entities (6 and 110) with their own 1-option blocks.
|
||||
assertEquals(49, meta.regionField?.options?.size)
|
||||
assertTrue(meta.regionField!!.options.none { it.code == "AK" || it.code == "HI" })
|
||||
assertTrue(meta.countryZones.size > 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alaska and hawaii each expose their own single-state block`() {
|
||||
val ak = config.stationMeta(6)
|
||||
assertEquals(listOf("AK"), ak.regionField?.options?.map { it.code })
|
||||
val hi = config.stationMeta(110)
|
||||
assertEquals(listOf("HI"), hi.regionField?.options?.map { it.code })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `germany has no region field and one national zone pair`() {
|
||||
val meta = config.stationMeta(230)
|
||||
assertNull(meta.regionField)
|
||||
assertEquals(listOf(LoTWZonePair(28, 14)), meta.countryZones)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `india has no region field and one national zone pair`() {
|
||||
val meta = config.stationMeta(324)
|
||||
assertNull(meta.regionField)
|
||||
assertEquals(listOf(LoTWZonePair(41, 22)), meta.countryZones)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown entity yields empty meta`() {
|
||||
val meta = config.stationMeta(999999)
|
||||
assertNull(meta.regionField)
|
||||
assertTrue(meta.countryZones.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* 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.lotw
|
||||
|
||||
import java.security.Signature
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Verifies the PBES2/AES-256-CBC PKCS12 reader against a fixture generated with
|
||||
* `openssl pkcs12 -export -certpbe NONE` — same layout as modern TQSL exports
|
||||
* (plain certificate bags + PBES2-shrouded private key), which Android's legacy
|
||||
* Bouncy Castle parser cannot read.
|
||||
*/
|
||||
class Pkcs12ReaderTest {
|
||||
|
||||
private fun fixture(): ByteArray =
|
||||
javaClass.classLoader!!.getResourceAsStream("test_pbes2.p12")!!.use { it.readBytes() }
|
||||
|
||||
private fun fixture(name: String): ByteArray =
|
||||
javaClass.classLoader!!.getResourceAsStream(name)!!.use { it.readBytes() }
|
||||
|
||||
@Test
|
||||
fun readsAes192Variant() {
|
||||
val (key, cert) = Pkcs12Reader.read(fixture("test_pbes2_aes192.p12"), "testpass123".toCharArray())
|
||||
assertEquals("RSA", key.algorithm)
|
||||
assertEquals("RSA", cert.publicKey.algorithm)
|
||||
assertTrue("key/cert pair", keyMatches(key, cert))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsDesEde3CbcVariant() {
|
||||
val (key, cert) = Pkcs12Reader.read(fixture("test_pbes2_desede3.p12"), "testpass123".toCharArray())
|
||||
assertEquals("RSA", key.algorithm)
|
||||
assertEquals("RSA", cert.publicKey.algorithm)
|
||||
assertTrue("key/cert pair", keyMatches(key, cert))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unsupportedCipherNamesTheAlgorithm() {
|
||||
// A valid PBES2 file re-encrypted with an unsupported cipher must report the
|
||||
// algorithm name (so the user can see exactly what to re-export with).
|
||||
val fixtureBytes = fixture("test_pbes2.p12")
|
||||
val e = assertThrows(IllegalStateException::class.java) {
|
||||
// Simulate: patch the AES-256-CBC OID inside the file to an unknown OID.
|
||||
val bogus = ByteArray(fixtureBytes.size) { fixtureBytes[it] }
|
||||
// find AES-256-CBC OID bytes 0x60 86 48 01 65 03 04 01 2a
|
||||
val oid = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x2a)
|
||||
var idx = -1
|
||||
outer@ for (i in 0..bogus.size - oid.size) {
|
||||
for (j in oid.indices) if (bogus[i + j] != oid[j]) continue@outer
|
||||
idx = i; break
|
||||
}
|
||||
require(idx >= 0) { "AES-256-CBC OID not found in fixture" }
|
||||
bogus[idx] = 0x7f.toByte() // corrupt the OID tag byte → unknown cipher
|
||||
Pkcs12Reader.read(bogus, "testpass123".toCharArray())
|
||||
}
|
||||
assertTrue("mentions algorithm", e.message.orEmpty().contains("algorithm"))
|
||||
}
|
||||
|
||||
private fun keyMatches(key: java.security.PrivateKey, cert: java.security.cert.X509Certificate): Boolean {
|
||||
val challenge = "Look4Sat LoTW certificate key check".toByteArray(Charsets.US_ASCII)
|
||||
val signed = Signature.getInstance("SHA1withRSA").run {
|
||||
initSign(key); update(challenge); sign()
|
||||
}
|
||||
return Signature.getInstance("SHA1withRSA").run {
|
||||
initVerify(cert); update(challenge); verify(signed)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wrongPasswordFailsWithBadPadding() {
|
||||
// A wrong password must surface as a decryption failure (BadPadding), which
|
||||
// LoTWKeyMaterial maps to CERTIFICATE_PASSWORD — not a format error.
|
||||
val e = assertThrows(Exception::class.java) {
|
||||
Pkcs12Reader.read(fixture(), "definitely-wrong-password".toCharArray())
|
||||
}
|
||||
assertTrue("BadPadding", e is javax.crypto.BadPaddingException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsPbes2KeyAndCertificate() {
|
||||
val (key, cert) = Pkcs12Reader.read(fixture(), "testpass123".toCharArray())
|
||||
assertEquals("RSA", key.algorithm)
|
||||
assertEquals("RSA", cert.publicKey.algorithm)
|
||||
assertNotNull(cert.subjectX500Principal)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keyPairsWithCertificate() {
|
||||
val (key, cert) = Pkcs12Reader.read(fixture(), "testpass123".toCharArray())
|
||||
val challenge = "Look4Sat LoTW certificate key check".toByteArray(Charsets.US_ASCII)
|
||||
val signed = Signature.getInstance("SHA1withRSA").run {
|
||||
initSign(key); update(challenge); sign()
|
||||
}
|
||||
val valid = Signature.getInstance("SHA1withRSA").run {
|
||||
initVerify(cert); update(challenge); verify(signed)
|
||||
}
|
||||
assertTrue("private key must match certificate", valid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wrongPasswordThrows() {
|
||||
assertThrows(Exception::class.java) {
|
||||
Pkcs12Reader.read(fixture(), "wrongpass".toCharArray())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun garbageBytesThrows() {
|
||||
assertThrows(Exception::class.java) {
|
||||
Pkcs12Reader.read("not a p12 file at all".toByteArray(), "x".toCharArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
-2
@@ -12,6 +12,10 @@ import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
import java.io.InputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
class AmSatRepositoryTest {
|
||||
|
||||
@@ -75,6 +79,52 @@ class AmSatRepositoryTest {
|
||||
assertEquals(1, remoteSource.catalogRequests)
|
||||
assertEquals(1, remoteSource.reportRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildStatusesStreakCountsConsecutiveSameStatusReportsPerDay() = runTest {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
val remoteSource = FakeAmSatRemoteSource(
|
||||
reportsJson = amSatReportsJson(
|
||||
// 当天(day 0)最新槽(slot 0)混合状态:最新 Heard、次新 Heard、再旧 Not Heard → 连续=2
|
||||
report("r1", "AO-7", "Heard", nowSec - 3600),
|
||||
report("r2", "AO-7", "Heard", nowSec - 5400),
|
||||
report("r3", "AO-7", "Not Heard", nowSec - 7000),
|
||||
// 空时段(无报告)不打断:slot 8 的 Heard 更旧,不应计入(被 r3 打断)
|
||||
report("r4", "AO-7", "Heard", nowSec - 63000),
|
||||
// 前一天(day 1):最新 Heard,更旧的 Not Heard → 连续=1,且不跨天合并
|
||||
report("r5", "AO-7", "Heard", nowSec - 90000),
|
||||
report("r6", "AO-7", "Not Heard", nowSec - 100000)
|
||||
// day 2 无报告 → 0
|
||||
)
|
||||
)
|
||||
val repository = AmSatRepository(remoteSource)
|
||||
|
||||
val page = repository.fetchStatus()
|
||||
val status = page?.statuses?.single()
|
||||
assertEquals("AO-7", status?.name)
|
||||
assertEquals(3, status?.days?.size)
|
||||
|
||||
assertEquals(2, status?.days?.get(0)?.streakCount)
|
||||
assertEquals(1, status?.days?.get(1)?.streakCount)
|
||||
assertEquals(0, status?.days?.get(2)?.streakCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildStatusesStreakCountsAllReportsWhenStatusNeverChanges() = runTest {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
val remoteSource = FakeAmSatRemoteSource(
|
||||
reportsJson = amSatReportsJson(
|
||||
report("r1", "AO-7", "Heard", nowSec - 1800),
|
||||
report("r2", "AO-7", "Heard", nowSec - 3600),
|
||||
report("r3", "AO-7", "Heard", nowSec - 5400),
|
||||
report("r4", "AO-7", "Heard", nowSec - 30000)
|
||||
)
|
||||
)
|
||||
val repository = AmSatRepository(remoteSource)
|
||||
|
||||
val page = repository.fetchStatus()
|
||||
assertEquals(4, page?.statuses?.single()?.days?.get(0)?.streakCount)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
|
||||
@@ -83,7 +133,10 @@ private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
|
||||
cacheField.set(this, page)
|
||||
}
|
||||
|
||||
private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L) : IRemoteSource {
|
||||
private class FakeAmSatRemoteSource(
|
||||
private val responseDelayMillis: Long = 0L,
|
||||
private val reportsJson: String = """{"data":[]}"""
|
||||
) : IRemoteSource {
|
||||
var catalogRequests = 0
|
||||
var reportRequests = 0
|
||||
|
||||
@@ -100,8 +153,19 @@ private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L)
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? {
|
||||
if (responseDelayMillis > 0L) delay(responseDelayMillis)
|
||||
reportRequests += 1
|
||||
return """{"data":[]}"""
|
||||
return reportsJson
|
||||
}
|
||||
|
||||
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
||||
}
|
||||
|
||||
private fun amSatReportsJson(vararg reports: String): String =
|
||||
"""{"data":[${reports.joinToString(",")}]}"""
|
||||
|
||||
private fun report(id: String, name: String, status: String, epochSec: Long): String =
|
||||
"""{"id":"$id","name":"$name","callsign":"BA7OPF","report":"$status","grid_square":"OL62","reported_time":"${isoUtc(epochSec)}"}"""
|
||||
|
||||
private fun isoUtc(epochSec: Long): String =
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}.format(Date(epochSec * 1000))
|
||||
+78
-4
@@ -19,6 +19,7 @@ package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
@@ -126,6 +127,29 @@ class DatabaseRepoTest {
|
||||
assertEquals(listOf(98248), settingsRepo.satelliteTypeIdsByType["SatNOGS"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remote update persists All source type ids`() = runTest(dispatcher) {
|
||||
// Regression: "All" (CelesTrak active) is a real TLE source type whose
|
||||
// ids must be persisted on sync — the old `if (type == "All") return`
|
||||
// silently dropped them, making the "All" filter resolve to nothing.
|
||||
val allUrl = Sources.satelliteDataUrls.getValue("All")
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[allUrl] = { validCsvStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(allUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
repository.updateFromRemote()
|
||||
|
||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
|
||||
}
|
||||
|
||||
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
|
||||
@@ -166,7 +190,8 @@ private class FakeLocalSource : ILocalSource {
|
||||
|
||||
override suspend fun getEntriesTotal(): Int = insertedEntries.size
|
||||
|
||||
override suspend fun getEntriesList(): List<SatItem> = emptyList()
|
||||
override suspend fun getEntriesList(): List<SatItem> =
|
||||
insertedEntries.map { SatItem(it.catnum, it.name) }
|
||||
|
||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||
|
||||
@@ -179,13 +204,19 @@ private class FakeLocalSource : ILocalSource {
|
||||
}
|
||||
|
||||
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||
override suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int> = emptyList()
|
||||
override suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int> = emptyList()
|
||||
|
||||
override suspend fun getRadiosTotal(): Int = insertedRadios.size
|
||||
|
||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||
|
||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
||||
insertedRadios += radios
|
||||
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
|
||||
insertedRadios += radios.map { it.copy(isCustom = isCustom) }
|
||||
}
|
||||
|
||||
override suspend fun deleteManagedRadios() {
|
||||
insertedRadios.removeAll { !it.isCustom }
|
||||
}
|
||||
|
||||
override suspend fun deleteRadios() {
|
||||
@@ -214,7 +245,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
)
|
||||
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
OtherSettings(false, false, false, false, false, false, false, false)
|
||||
OtherSettings(
|
||||
false, false, false, false, false, false, false,
|
||||
shouldSeeWarning = false, shouldSeeWhatsNew = false
|
||||
)
|
||||
)
|
||||
|
||||
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
|
||||
@@ -264,12 +298,52 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
override fun getSatelliteMode(catnum: Int): String = ""
|
||||
override fun setSatelliteMode(catnum: Int, mode: String) {}
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
|
||||
override fun setAmSatCallsign(callsign: String) = Unit
|
||||
|
||||
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
|
||||
|
||||
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
|
||||
|
||||
override fun getWorkedGrids(): Set<String> = emptySet()
|
||||
|
||||
private val workedGrids = MutableStateFlow(emptySet<String>())
|
||||
|
||||
override fun setWorkedGrids(grids: Set<String>) {
|
||||
workedGrids.value = grids
|
||||
}
|
||||
|
||||
override val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
|
||||
|
||||
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit
|
||||
override fun getLastLotwSyncDate(): String = ""
|
||||
override fun setLastLotwSyncDate(date: String) = Unit
|
||||
override fun getLastLotwSyncCallsign(): String = ""
|
||||
override fun setLastLotwSyncCallsign(callsign: String) = Unit
|
||||
|
||||
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
|
||||
|
||||
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) = Unit
|
||||
|
||||
override fun getRoamedGrids(): Set<String> = emptySet()
|
||||
|
||||
private val roamedGrids = MutableStateFlow(emptySet<String>())
|
||||
|
||||
override fun setRoamedGrids(grids: Set<String>) {
|
||||
roamedGrids.value = grids
|
||||
}
|
||||
|
||||
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
|
||||
|
||||
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) = Unit
|
||||
}
|
||||
|
||||
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class LoTWRepositoryTest {
|
||||
|
||||
private val repo = LoTWRepository()
|
||||
|
||||
private fun report(vararg records: String): String =
|
||||
buildString {
|
||||
append("<ADIF_VERS:5>3.1.4\n")
|
||||
append("<PROGRAMID:8>look4sat\n")
|
||||
append("<eoh>\n")
|
||||
records.forEach { append(it).append("\n") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseRejectsBodyWithoutEoh() {
|
||||
assertNull(repo.parseConfirmedGrids("<HTML>Username/password incorrect</HTML>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseCollectsOnlySatelliteGrids() {
|
||||
// Satellite QSO: PROP_MODE SAT, confirmed, opponent grid.
|
||||
val satQso = "<CALL:6>BA7OPF\n" +
|
||||
"<QSO_DATE:8>20260820\n" +
|
||||
"<PROP_MODE:3>SAT\n" +
|
||||
"<SAT_NAME:5>FO-29\n" +
|
||||
"<GRIDSQUARE:4>NL47\n" +
|
||||
"<EOR>\n"
|
||||
// Ground QSO: same grid but no PROP_MODE -> must be excluded.
|
||||
val groundQso = "<CALL:6>BA7OPF\n" +
|
||||
"<QSO_DATE:8>20260821\n" +
|
||||
"<GRIDSQUARE:4>NL47\n" +
|
||||
"<EOR>\n"
|
||||
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
|
||||
assertEquals(setOf("NL47"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseSupportsVuccGridPairsAndSixCharGrids() {
|
||||
val satQso = "<PROP_MODE:3>SAT\n" +
|
||||
"<SAT_NAME:5>SO-50\n" +
|
||||
"<GRIDSQUARE:6>OM60IL\n" +
|
||||
"<VUCC_GRIDS:11>EN52en,EN53fa\n" +
|
||||
"<EOR>\n"
|
||||
val result = repo.parseConfirmedGrids(report(satQso))
|
||||
assertEquals(setOf("OM60", "EN52", "EN53"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsePropModeDoesNotLeakAcrossRecords() {
|
||||
// PROP_MODE SAT record first, then a ground QSO with a grid — the second
|
||||
// record must not inherit the satellite flag after its own <EOR>.
|
||||
val satQso = "<PROP_MODE:3>SAT\n<GRIDSQUARE:4>OL62\n<EOR>\n"
|
||||
val groundQso = "<GRIDSQUARE:4>PM00\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
|
||||
assertEquals(setOf("OL62"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseHandlesAlphabeticalFieldOrder() {
|
||||
// Real lotwreport.adi emits fields in ALPHABETICAL order: GRIDSQUARE (G)
|
||||
// and VUCC_GRIDS come BEFORE PROP_MODE (P) inside each record. The old
|
||||
// sequential gate (add only while propMode == SAT) dropped every grid on
|
||||
// real reports — the buffered parser must collect them regardless of
|
||||
// field order and filter at <EOR>.
|
||||
val satQso = "<CALL:6>BA7OPF\n" +
|
||||
"<GRIDSQUARE:4>NL47\n" +
|
||||
"<MODE:3>FM\n" +
|
||||
"<PROP_MODE:3>SAT\n" +
|
||||
"<SAT_NAME:5>FO-29\n" +
|
||||
"<VUCC_GRIDS:11>EN52en,EN53fa\n" +
|
||||
"<EOR>\n"
|
||||
val groundQso = "<CALL:6>BA7OPF\n" +
|
||||
"<GRIDSQUARE:4>PM95\n" +
|
||||
"<QSO_DATE:8>20260821\n" +
|
||||
"<EOR>\n"
|
||||
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
|
||||
assertEquals(setOf("NL47", "EN52", "EN53"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseReturnsEmptySetForReportWithoutGrids() {
|
||||
val satQso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>AO-07\n<EOR>\n"
|
||||
assertEquals(emptySet<String>(), repo.parseConfirmedGrids(report(satQso)))
|
||||
}
|
||||
|
||||
// region parseConfirmedGridQsos
|
||||
|
||||
private val satQsoFull = "<CALL:5>A50QO\n" +
|
||||
"<QSO_DATE:8>20260820\n" +
|
||||
"<TIME_ON:6>113045\n" +
|
||||
"<PROP_MODE:3>SAT\n" +
|
||||
"<SAT_NAME:5>FO-29\n" +
|
||||
"<MODE:2>CW\n" +
|
||||
"<BAND:3>70CM\n" +
|
||||
"<BAND_RX:3>2M\n" +
|
||||
"<GRIDSQUARE:4>NL47\n" +
|
||||
"<EOR>\n"
|
||||
|
||||
@Test
|
||||
fun parseQsosExtractsSatelliteDetail() {
|
||||
val result = repo.parseConfirmedGridQsos(report(satQsoFull))!!
|
||||
val qsos = result["NL47"]!!
|
||||
assertEquals(1, qsos.size)
|
||||
val qso = qsos[0]
|
||||
assertEquals("A50QO", qso.call)
|
||||
assertEquals("FO-29", qso.satName)
|
||||
assertEquals("CW", qso.mode)
|
||||
assertEquals("2M", qso.bandUp)
|
||||
assertEquals("70CM", qso.bandDown)
|
||||
assertEquals("V/U", qso.bandLabel)
|
||||
assertEquals(2026, java.time.Instant.ofEpochMilli(qso.epochMs).atZone(java.time.ZoneOffset.UTC).year)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosExcludesGroundQsos() {
|
||||
val groundQso = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(satQsoFull, groundQso))!!
|
||||
assertEquals(1, result["NL47"]!!.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosVuccPairFeedsBothGrids() {
|
||||
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
|
||||
"<QSO_DATE:8>20260819\n<TIME_ON:4>1231\n" +
|
||||
"<VUCC_GRIDS:11>EN52en,EN53fa\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
||||
assertEquals(setOf("EN52", "EN53"), result.keys)
|
||||
assertEquals(result["EN52"]!!.first(), result["EN53"]!!.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosVuccQuadrupleFeedsAllFourGrids() {
|
||||
// VUCC allows up to four grids in one contact.
|
||||
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
|
||||
"<QSO_DATE:8>20260819\n<TIME_ON:4>1231\n" +
|
||||
"<VUCC_GRIDS:23>EN52en,EN53fa,EN42gj,EN43kh\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
||||
assertEquals(setOf("EN52", "EN53", "EN42", "EN43"), result.keys)
|
||||
result.values.forEach { assertEquals(1, it.size) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosRejectsBodyWithoutEoh() {
|
||||
assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosCapturesOwnGridPerQso() {
|
||||
// MY_GRIDSQUARE arrives BEFORE PROP_MODE (ADIF fields are emitted
|
||||
// alphabetically); the per-record buffer must still attach it to the QSO.
|
||||
val qso1 = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<MY_GRIDSQUARE:4>OL62\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<MODE:2>CW\n" +
|
||||
"<BAND:3>70CM\n<BAND_RX:3>2M\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
val qso2 = "<CALL:6>BG7ZFK\n<QSO_DATE:8>20260819\n<MY_GRIDSQUARE:4>OL72\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<MODE:3>FM\n" +
|
||||
"<BAND:3>70CM\n<BAND_RX:3>2M\n<GRIDSQUARE:4>OK48\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(qso1, qso2))!!
|
||||
assertEquals("OL62", result["NL47"]!!.first().myGrid)
|
||||
assertEquals("OL72", result["OK48"]!!.first().myGrid)
|
||||
// Ground QSO (no PROP_MODE=SAT) must not leak its MY_GRIDSQUARE.
|
||||
val ground = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<MY_GRIDSQUARE:4>OL62\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
assertTrue(repo.parseConfirmedGridQsos(report(qso1, ground))!!.values.all { it.all { q -> q.myGrid == "OL62" } })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosCapturesMyVuccGridsMultiGridStation() {
|
||||
// A station location (台址) can span several grids: LoTW emits
|
||||
// <MY_VUCC_GRIDS> (comma-separated) with MY_GRIDSQUARE absent. Both
|
||||
// grids must land in myGrids, and myGrid (back-compat) = the first.
|
||||
val qso = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<MODE:2>CW\n" +
|
||||
"<MY_VUCC_GRIDS:11>OM60,OM50\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
||||
val parsed = result["NL47"]!!.first()
|
||||
assertEquals(setOf("OM60", "OM50"), parsed.myGrids)
|
||||
assertEquals("OM60", parsed.myGrid)
|
||||
// 6-char fields are truncated to 4 like every other grid path.
|
||||
val six = "<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
|
||||
"<MY_VUCC_GRIDS:17>OM60IL70,OM50MA20\n<GRIDSQUARE:4>OK48\n<EOR>\n"
|
||||
assertEquals(setOf("OM60", "OM50"), repo.parseConfirmedGridQsos(report(six))!!["OK48"]!!.first().myGrids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosBuildsStationKeyFromMyFields() {
|
||||
// 台址 identity = the MY_* station snapshot (callsign + dxcc + state +
|
||||
// CQ/ITU + IOTA + country). A record with the full snapshot yields the
|
||||
// stable key the operated-grid selector groups by.
|
||||
val qso = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
|
||||
"<STATION_CALLSIGN:6>BH6RJD\n<MY_DXCC:3>318\n<MY_STATE:11>JS // Jiangsu\n" +
|
||||
"<MY_CQ_ZONE:2>24\n<MY_ITU_ZONE:2>44\n<MY_COUNTRY:5>CHINA\n" +
|
||||
"<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
val parsed = repo.parseConfirmedGridQsos(report(qso))!!["NL47"]!!.first()
|
||||
assertEquals("BH6RJD", parsed.myCallsign)
|
||||
assertEquals("BH6RJD|318|JS|24|44||CHINA", parsed.stationKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosStationKeyDistinguishesLocationsUnderSameCallsign() {
|
||||
// One callsign can own several 台址 (e.g. BH6RJD with a Hubei location
|
||||
// and a Zhejiang location); different MY_STATE -> different stationKey.
|
||||
val hb = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
|
||||
"<STATION_CALLSIGN:6>BH6RJD\n<MY_STATE:11>HB // Hubei\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
val zj = "<CALL:5>BG7ZFK\n<QSO_DATE:8>20260819\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
|
||||
"<STATION_CALLSIGN:6>BH6RJD\n<MY_STATE:11>ZJ // Zhejiang\n<GRIDSQUARE:4>OK48\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(hb, zj))!!
|
||||
assertTrue(result["NL47"]!!.first().stationKey != result["OK48"]!!.first().stationKey)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region parseRoamedGrids (MY_GRIDSQUARE = grids the account operated from)
|
||||
|
||||
@Test
|
||||
fun parseRoamedGridsCollectsOwnStationGrids() {
|
||||
// Satellite QSOs carry MY_GRIDSQUARE = the grid the account itself
|
||||
// operated from (the "roamed/activated" set shown as blue stripes).
|
||||
val qso1 = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260820\n<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n" +
|
||||
"<MY_GRIDSQUARE:4>OL62\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
||||
val qso2 = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260901\n<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n" +
|
||||
"<MY_GRIDSQUARE:4>OL72\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
assertEquals(setOf("OL62", "OL72"), repo.parseRoamedGrids(report(qso1, qso2)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseRoamedGridsIgnoresGroundQsos() {
|
||||
val ground = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<MY_GRIDSQUARE:4>OL72\n<EOR>\n"
|
||||
assertEquals(emptySet<String>(), repo.parseRoamedGrids(report(ground)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseRoamedGridsDoesNotConfuseGridsquareWithMyGridsquare() {
|
||||
// GRIDSQUARE (opposite station's grid) must never leak into the roamed set.
|
||||
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<MY_GRIDSQUARE:4>OL62\n" +
|
||||
"<GRIDSQUARE:6>OM60IL\n<EOR>\n"
|
||||
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseRoamedGridsTruncatesSixCharToFour() {
|
||||
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n<MY_GRIDSQUARE:6>OL62AA\n<EOR>\n"
|
||||
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseRoamedGridsHandlesAlphabeticalFieldOrder() {
|
||||
// Real lotwreport.adi emits fields alphabetically, so <MY_GRIDSQUARE>
|
||||
// (M) arrives BEFORE <PROP_MODE> (P). The old sequential gate (add only
|
||||
// while propMode == SAT) silently returned an EMPTY set on real reports —
|
||||
// this is the exact bug that made the blue roamed stripes never appear.
|
||||
val qso = "<CALL:6>BA7OPF\n" +
|
||||
"<GRIDSQUARE:4>PM95\n" +
|
||||
"<MY_GRIDSQUARE:6>OL72XX\n" +
|
||||
"<PROP_MODE:3>SAT\n" +
|
||||
"<SAT_NAME:5>IO-86\n" +
|
||||
"<EOR>\n"
|
||||
assertEquals(setOf("OL72"), repo.parseRoamedGrids(report(qso)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseRoamedGridsRejectsBodyWithoutEoh() {
|
||||
assertNull(repo.parseRoamedGrids("<HTML>Username/password incorrect</HTML>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseRoamedGridsCollectsMyVuccGrids() {
|
||||
// Multi-grid 台址: MY_VUCC_GRIDS carries the extra grids (MY_GRIDSQUARE
|
||||
// may be absent); every field must reach the roamed set for the stripes.
|
||||
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
|
||||
"<MY_VUCC_GRIDS:11>OM60,OM50\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
assertEquals(setOf("OM60", "OM50"), repo.parseRoamedGrids(report(qso)))
|
||||
// Combined with MY_GRIDSQUARE the union is kept.
|
||||
val both = "<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
|
||||
"<MY_GRIDSQUARE:4>OL62\n<MY_VUCC_GRIDS:11>OM60,OM50\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
assertEquals(setOf("OL62", "OM60", "OM50"), repo.parseRoamedGrids(report(both)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseRoamedGridsUppercasesLowercaseMyGrid() {
|
||||
// LoTW emits the <eor> tag lowercase, so field CASE is not guaranteed:
|
||||
// a lowercase MY_GRIDSQUARE must still match the overlay's uppercase
|
||||
// labels (regression: take(4) without .uppercase() silently dropped
|
||||
// stripes for lowercase grids).
|
||||
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n" +
|
||||
"<MY_GRIDSQUARE:4>ol62\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
||||
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region failure classification (fetchReportBody -> LoTWResult mapping)
|
||||
|
||||
@Test
|
||||
fun failureClassificationMapsEachException() {
|
||||
// LoTWRepository.toResult() must keep each failure cause distinct so
|
||||
// the UI can show a specific message per cause.
|
||||
assertEquals(
|
||||
LoTWResult.BadCredentials,
|
||||
repo.toResult(LoTWRepository.CredentialsException())
|
||||
)
|
||||
assertEquals(
|
||||
LoTWResult.RateLimited,
|
||||
repo.toResult(LoTWRepository.RateLimitException())
|
||||
)
|
||||
assertEquals(
|
||||
LoTWResult.Timeout,
|
||||
repo.toResult(LoTWRepository.TimeoutException("read timed out"))
|
||||
)
|
||||
val net = repo.toResult(java.io.IOException("HTTP 500"))
|
||||
assertEquals(LoTWResult.NetworkError("HTTP 500"), net)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun parseQsosExtractsAwardFields() {
|
||||
// Award statistics rely on the DXCC/CQZ/STATE fields coming straight
|
||||
// from LoTW's qso_qsldetail report (STATE depends on DXCC). LoTW
|
||||
// writes STATE as "CODE // NAME" (verified with a real report 2026-09);
|
||||
// the parser must strip the name suffix and keep the bare code.
|
||||
val qso = "<CALL:5>BG7XYZ\n" +
|
||||
"<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<MODE:2>CW\n" +
|
||||
"<DXCC:3>318\n<COUNTRY:5>CHINA\n<CQZ:2>24\n<STATE:13>GD // Guangdong\n" +
|
||||
"<GRIDSQUARE:4>OL62\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
||||
val parsed = result["OL62"]!!.first()
|
||||
assertEquals(318, parsed.dxcc)
|
||||
assertEquals("CHINA", parsed.country)
|
||||
assertEquals(24, parsed.cqz)
|
||||
assertEquals("GD", parsed.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosStripsStateNameSuffixForAllEntities() {
|
||||
// Japan prefecture: "34 // Tottori-ken" -> "34" (WAJA matching needs
|
||||
// the 2-digit code). US states also arrive as "CODE // Name".
|
||||
val jp = "<CALL:5>JH0ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<DXCC:3>339\n<COUNTRY:7>JAPAN\n" +
|
||||
"<STATE:18>34 // Tottori-ken\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
||||
val us = "<CALL:5>K1ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>AO-07\n<DXCC:3>291\n<COUNTRY:12>UNITED STATES\n" +
|
||||
"<STATE:22>CA // California\n<GRIDSQUARE:4>EM40\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(jp, us))!!
|
||||
assertEquals("34", result["PM95"]!!.first().state)
|
||||
assertEquals("CA", result["EM40"]!!.first().state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosLeavesAwardFieldsNullWhenAbsent() {
|
||||
val qso = "<CALL:5>JH0ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
||||
val parsed = result["PM95"]!!.first()
|
||||
assertNull(parsed.dxcc)
|
||||
assertNull(parsed.country)
|
||||
assertNull(parsed.cqz)
|
||||
assertNull(parsed.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosAwardFieldsDoNotLeakAcrossRecords() {
|
||||
val withFields = "<CALL:5>BG7AAA\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<DXCC:3>318\n<CQZ:2>24\n<STATE:2>GD\n" +
|
||||
"<GRIDSQUARE:4>OL62\n<EOR>\n"
|
||||
val withoutFields = "<CALL:5>JH0BBB\n<QSO_DATE:8>20260821\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(withFields, withoutFields))!!
|
||||
assertNull(result["PM95"]!!.first().dxcc)
|
||||
assertNull(result["PM95"]!!.first().cqz)
|
||||
assertNull(result["PM95"]!!.first().state)
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.SatItem
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SelectionRepoSearchTest {
|
||||
|
||||
private val sampleItems = listOf(
|
||||
SatItem(catnum = 25544, name = "ISS (ZARYA)"),
|
||||
SatItem(catnum = 7530, name = "AO-7 (AMSAT-OSCAR 7)"),
|
||||
SatItem(catnum = 39444, name = "AO-73 (FUNcube-1)"),
|
||||
SatItem(catnum = 43803, name = "JO-97 (BIRDS-3)"),
|
||||
SatItem(catnum = 99999, name = "FO-29"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `query without separators matches name with dashes spaces brackets`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("ao7")
|
||||
val results = repo.getEntriesFlow().first()
|
||||
// "ao7" is a substring of the normalized "AO-73 (FUNcube-1)" too, so a
|
||||
// fuzzy search legitimately returns both AO-7 (first) and AO-73. The
|
||||
// key guarantee is that AO-7 — which was unreachable before because of
|
||||
// the dashes/brackets — is now found.
|
||||
assertTrue(results.map { it.catnum }.contains(7530))
|
||||
assertEquals(7530, results.first().catnum)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query without separators matches name with only dashes`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("fo29")
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(listOf(99999), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `space-separated tokens all must match in any order`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("zarya iss")
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(listOf(25544), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exact name query still matches`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("AO-7 (AMSAT-OSCAR 7)")
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(listOf(7530), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `partial token query matches substring`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("funcube")
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(listOf(39444), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `numeric query matches catnum exactly`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("25544")
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(listOf(25544), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no query returns all items`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("")
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(sampleItems.size, results.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unmatched query returns nothing`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("zzzznomatch")
|
||||
assertTrue(repo.getEntriesFlow().first().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FM virtual type shows only hardcoded FM satellites`() = runTest {
|
||||
// AO-123 ASRTU-1 (61781) is on the hardcoded FM list; a random
|
||||
// satellite (99999) is not.
|
||||
val items = sampleItems + SatItem(catnum = 61781, name = "ASRTU-1")
|
||||
val repo = createRepo(items)
|
||||
repo.setTypes(listOf("AMSAT Live FM"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(setOf(25544, 61781), results.map { it.catnum }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Linear virtual type shows only hardcoded linear satellites`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setTypes(listOf("AMSAT Live Linear"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
// Hardcoded linear list: RS-44 (44909), FO-29 (24278), AO-7 (7530),
|
||||
// AO-73 (39444), JO-97 (43803). Present in the sample DB: 7530, 39444, 43803.
|
||||
assertEquals(setOf(7530, 39444, 43803), results.map { it.catnum }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SSTV virtual type shows only satellites with SSTV radios`() = runTest {
|
||||
val repo = createRepo(
|
||||
items = sampleItems,
|
||||
sstvIds = listOf(43803)
|
||||
)
|
||||
repo.setTypes(listOf("Live SSTV"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(listOf(43803), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SSTV virtual type uses the amateur-only radio query`() = runTest {
|
||||
// The repo delegates to getIdsWithModesAndAmateur: the fake returns
|
||||
// only catnums the DAO would have filtered to Amateur service (43803).
|
||||
val repo = createRepo(
|
||||
items = sampleItems,
|
||||
sstvIds = listOf(43803)
|
||||
)
|
||||
repo.setTypes(listOf("Live SSTV"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(listOf(43803), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SSTV virtual type excludes rocket-body debris by name`() = runTest {
|
||||
// 60239 is "ARIANE 6 R/B" (launcher debris carrying an amateur SSTV
|
||||
// payload); it must be dropped even though its radio is Amateur.
|
||||
val items = sampleItems + SatItem(catnum = 60239, name = "ARIANE 6 R/B")
|
||||
val repo = createRepo(
|
||||
items = items,
|
||||
sstvIds = listOf(43803, 60239)
|
||||
)
|
||||
repo.setTypes(listOf("Live SSTV"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(listOf(43803), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple virtual types union their satellites`() = runTest {
|
||||
val repo = createRepo(
|
||||
items = sampleItems,
|
||||
sstvIds = listOf(43803)
|
||||
)
|
||||
repo.setTypes(listOf("AMSAT Live FM", "Live SSTV"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(setOf(25544, 43803), results.map { it.catnum }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearing types restores full list`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setTypes(listOf("AMSAT Live FM"))
|
||||
assertTrue(repo.getEntriesFlow().first().isNotEmpty())
|
||||
repo.setTypes(emptyList())
|
||||
assertEquals(sampleItems.size, repo.getEntriesFlow().first().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `virtual type with no matching satellites shows empty not everything`() = runTest {
|
||||
// 硬编码 FM 列表 (27607/25544/61781) 里没有样本库中的卫星时, 选虚拟
|
||||
// 类型应显示空列表, 而非全部卫星.
|
||||
val items = listOf(SatItem(catnum = 7530, name = "AO-7 (AMSAT-OSCAR 7)"))
|
||||
val repo = createRepo(items)
|
||||
repo.setTypes(listOf("AMSAT Live FM"))
|
||||
assertTrue(repo.getEntriesFlow().first().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `virtual type combined with regular type unions both lists`() = runTest {
|
||||
val repo = createRepo(items = sampleItems)
|
||||
repo.setTypes(listOf("AMSAT Live FM", "AMSAT Live Linear"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(
|
||||
setOf(25544, 7530, 39444, 43803),
|
||||
results.map { it.catnum }.toSet()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `types list has virtual transponder types first and keeps All`() {
|
||||
val repo = createRepo(sampleItems)
|
||||
val types = repo.getTypesList()
|
||||
assertTrue(types.size >= 4)
|
||||
assertEquals("AMSAT Live FM", types[0])
|
||||
assertEquals("AMSAT Live Linear", types[1])
|
||||
assertEquals("Live SSTV", types[2])
|
||||
// "All" must be kept in the list (first original type shown after virtual ones).
|
||||
assertTrue("All" in types)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `All type resolves to persisted CelesTrak active ids`() = runTest {
|
||||
// Regression: "All" is a real TLE source type (CelesTrak active). Its
|
||||
// ids are persisted on sync like any other type; selecting it must
|
||||
// show the synced satellites, NOT an empty list.
|
||||
val fake = FakeSettingsRepoForSearch()
|
||||
fake.setSatelliteTypeIds("All", sampleItems.map { it.catnum })
|
||||
val repo = SelectionRepo(
|
||||
dispatcher = Dispatchers.Unconfined,
|
||||
localSource = FakeLocalSourceForSearch(sampleItems),
|
||||
settingsRepo = fake
|
||||
)
|
||||
repo.setTypes(listOf("All"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getTypesList excludes only the Other placeholder`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
val types = repo.getTypesList()
|
||||
// All real TLE source types must be selectable, including "All"
|
||||
// (CelesTrak active) and "ARISS" (removed by the old removeAt(0)).
|
||||
assertTrue("All" in types)
|
||||
assertTrue("ARISS" in types)
|
||||
assertTrue("R4UAB" in types)
|
||||
assertTrue("SatNOGS" in types)
|
||||
assertTrue("Other" !in types)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all types shows all items`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setTypes(emptyList()) // "All" = no type filtering
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(sampleItems.size, results.size)
|
||||
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
|
||||
}
|
||||
|
||||
private fun createRepo(
|
||||
items: List<SatItem>,
|
||||
sstvIds: List<Int> = emptyList()
|
||||
): ISelectionRepo {
|
||||
return SelectionRepo(
|
||||
dispatcher = Dispatchers.Unconfined,
|
||||
localSource = FakeLocalSourceForSearch(items, sstvIds),
|
||||
settingsRepo = FakeSettingsRepoForSearch()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeLocalSourceForSearch(
|
||||
private val items: List<SatItem>,
|
||||
private val sstvIds: List<Int> = emptyList()
|
||||
) : ILocalSource {
|
||||
override suspend fun getEntriesTotal(): Int = items.size
|
||||
override suspend fun getEntriesList(): List<SatItem> = items
|
||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||
override suspend fun insertEntries(entries: List<OrbitalData>) = Unit
|
||||
override suspend fun deleteEntries() = Unit
|
||||
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = sstvIds
|
||||
override suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int> = sstvIds
|
||||
override suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int> = sstvIds
|
||||
override suspend fun getRadiosTotal(): Int = 0
|
||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) = Unit
|
||||
override suspend fun deleteManagedRadios() = Unit
|
||||
override suspend fun deleteRadios() = Unit
|
||||
}
|
||||
|
||||
private class FakeSettingsRepoForSearch : ISettingsRepo {
|
||||
private val typeIds = mutableMapOf<String, List<Int>>()
|
||||
override val appVersionName: String = "test"
|
||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
|
||||
)
|
||||
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||
)
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
OtherSettings(
|
||||
false, false, false, false, false, false, false,
|
||||
shouldSeeWarning = false, shouldSeeWhatsNew = false
|
||||
)
|
||||
)
|
||||
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> =
|
||||
MutableStateFlow(DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList()))
|
||||
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
|
||||
override val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||
override fun setSelectedTypes(types: List<String>) = Unit
|
||||
override fun setPassesSettings(settings: PassesSettings) = Unit
|
||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
|
||||
override fun setStationPosition(): Boolean = true
|
||||
override fun setStationPosition(locator: String): Boolean = true
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> =
|
||||
types.flatMap { typeIds[it].orEmpty() }.distinct()
|
||||
|
||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
||||
typeIds[type] = ids
|
||||
}
|
||||
override fun updateDatabaseState(state: DatabaseState) = Unit
|
||||
override fun updateRCSettings(settings: RCSettings) = Unit
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
override fun getSatelliteMode(catnum: Int): String = ""
|
||||
override fun setSatelliteMode(catnum: Int, mode: String) {}
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
override fun setAmSatCallsign(callsign: String) = Unit
|
||||
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
|
||||
override fun getWorkedGrids(): Set<String> = emptySet()
|
||||
override fun setWorkedGrids(grids: Set<String>) = Unit
|
||||
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
|
||||
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) = Unit
|
||||
override fun getRoamedGrids(): Set<String> = emptySet()
|
||||
override fun setRoamedGrids(grids: Set<String>) = Unit
|
||||
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
|
||||
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) = Unit
|
||||
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit
|
||||
override fun getLastLotwSyncDate(): String = ""
|
||||
override fun setLastLotwSyncDate(date: String) = Unit
|
||||
override fun getLastLotwSyncCallsign(): String = ""
|
||||
override fun setLastLotwSyncCallsign(callsign: String) = Unit
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/** Small ADI codec for the logbook's actual field set (ADIF 3.1.7). */
|
||||
object AdifCodec {
|
||||
private val utc = TimeZone.getTimeZone("UTC")
|
||||
|
||||
fun encode(records: List<QsoRecord>): String = buildString {
|
||||
append(field("ADIF_VER", "3.1.7"))
|
||||
append(field("PROGRAMID", "Look4Sat"))
|
||||
append("<EOH>\r\n")
|
||||
records.forEach { record ->
|
||||
val startDate = format(record.startUtcMillis, "yyyyMMdd")
|
||||
val startTime = format(record.startUtcMillis, "HHmmss")
|
||||
append(field("QSO_DATE", startDate))
|
||||
append(field("TIME_ON", startTime))
|
||||
record.endUtcMillis?.let {
|
||||
append(field("QSO_DATE_OFF", format(it, "yyyyMMdd")))
|
||||
append(field("TIME_OFF", format(it, "HHmmss")))
|
||||
}
|
||||
append(field("CALL", record.theirCallsign))
|
||||
append(field("STATION_CALLSIGN", record.myCallsign))
|
||||
appendOptional("MODE", record.mode)
|
||||
appendOptional("SUBMODE", record.submode)
|
||||
appendOptional("GRIDSQUARE", record.theirGrid)
|
||||
appendOptional("MY_GRIDSQUARE", record.myGrid)
|
||||
appendOptional("RST_SENT", record.sentReport)
|
||||
appendOptional("RST_RCVD", record.receivedReport)
|
||||
record.txFrequencyHz?.let { append(field("FREQ", hzToMhz(it))) }
|
||||
record.rxFrequencyHz?.let { append(field("FREQ_RX", hzToMhz(it))) }
|
||||
appendOptional("BAND", record.band)
|
||||
appendOptional("BAND_RX", record.rxBand)
|
||||
appendOptional("PROP_MODE", record.propagationMode.ifBlank { if (record.satelliteName.isNotBlank()) "SAT" else "" })
|
||||
appendOptional("SAT_NAME", record.satelliteName)
|
||||
appendOptional("SAT_MODE", record.satelliteMode)
|
||||
if (record.lotwConfirmed) append(field("LOTW_QSL_RCVD", "Y"))
|
||||
if (record.lotwReceived) append(field("LOTW_QSL_SENT", "Y"))
|
||||
appendOptional("LOTW_QSLRDATE", record.lotwQslDate)
|
||||
appendOptional("VUCC_GRIDS", record.vuccGrids.joinToString(","))
|
||||
record.dxcc?.let { append(field("DXCC", it.toString())) }
|
||||
appendOptional("COUNTRY", record.country)
|
||||
record.cqZone?.let { append(field("CQZ", it.toString())) }
|
||||
appendOptional("STATE", record.region)
|
||||
appendOptional("COMMENT", record.comment)
|
||||
appendOptional("APP_LOOK4SAT_TRANSPONDER", record.transponderName)
|
||||
record.passAosUtcMillis?.let { append(field("APP_LOOK4SAT_PASS_AOS", it.toString())) }
|
||||
append(field("APP_LOOK4SAT_AUTOMATIC", if (record.automatic) "Y" else "N"))
|
||||
append(field("APP_LOOK4SAT_STATUS", record.status.name))
|
||||
append("<EOR>\r\n")
|
||||
}
|
||||
}
|
||||
|
||||
fun decode(content: String): List<QsoRecord> = decodeRecords(splitRecords(content))
|
||||
|
||||
fun decodeRecords(records: List<Map<String, String>>): List<QsoRecord> = records.mapNotNull { values ->
|
||||
val call = values["CALL"].orEmpty().trim().uppercase(Locale.US)
|
||||
val date = values["QSO_DATE"].orEmpty()
|
||||
val time = values["TIME_ON"].orEmpty()
|
||||
if (call.isBlank() || date.length != 8 || time.length !in setOf(4, 6)) return@mapNotNull null
|
||||
val start = parse(date, time) ?: return@mapNotNull null
|
||||
val end = values["QSO_DATE_OFF"]?.let { endDate ->
|
||||
values["TIME_OFF"]?.let { endTime -> parse(endDate, endTime) }
|
||||
}
|
||||
QsoRecord(
|
||||
startUtcMillis = start,
|
||||
endUtcMillis = end,
|
||||
theirCallsign = call,
|
||||
myCallsign = values["STATION_CALLSIGN"].orEmpty().uppercase(Locale.US),
|
||||
theirGrid = values["GRIDSQUARE"].orEmpty().uppercase(Locale.US),
|
||||
myGrid = values["MY_GRIDSQUARE"].orEmpty().uppercase(Locale.US),
|
||||
sentReport = values["RST_SENT"].orEmpty(),
|
||||
receivedReport = values["RST_RCVD"].orEmpty(),
|
||||
txFrequencyHz = mhzToHz(values["FREQ"]),
|
||||
rxFrequencyHz = mhzToHz(values["FREQ_RX"]),
|
||||
band = values["BAND"].orEmpty(),
|
||||
rxBand = values["BAND_RX"].orEmpty(),
|
||||
mode = values["MODE"].orEmpty(),
|
||||
submode = values["SUBMODE"].orEmpty(),
|
||||
satelliteName = values["SAT_NAME"].orEmpty(),
|
||||
transponderName = values["APP_LOOK4SAT_TRANSPONDER"].orEmpty(),
|
||||
satelliteMode = values["SAT_MODE"].orEmpty(),
|
||||
passAosUtcMillis = values["APP_LOOK4SAT_PASS_AOS"]?.toLongOrNull(),
|
||||
automatic = values["APP_LOOK4SAT_AUTOMATIC"].equals("Y", true),
|
||||
status = values["APP_LOOK4SAT_STATUS"]?.let { runCatching { QsoStatus.valueOf(it) }.getOrNull() }
|
||||
?: QsoStatus.COMPLETE,
|
||||
propagationMode = values["PROP_MODE"].orEmpty().trim().uppercase(Locale.US),
|
||||
lotwConfirmed = values["LOTW_QSL_RCVD"].equals("Y", true),
|
||||
lotwReceived = values["LOTW_QSL_SENT"].equals("Y", true),
|
||||
lotwQslDate = values["LOTW_QSLRDATE"].orEmpty(),
|
||||
vuccGrids = values["VUCC_GRIDS"].orEmpty().split(',').map { it.trim().uppercase(Locale.US) }
|
||||
.filter { it.matches(Regex("[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?")) }.distinct(),
|
||||
dxcc = values["DXCC"]?.toIntOrNull(),
|
||||
country = values["COUNTRY"].orEmpty(),
|
||||
cqZone = values["CQZ"]?.toIntOrNull()?.takeIf { it in 1..40 },
|
||||
region = values["STATE"].orEmpty().substringBefore(" // ").trim().uppercase(Locale.US),
|
||||
comment = values["COMMENT"].orEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
private fun splitRecords(content: String): List<Map<String, String>> {
|
||||
val records = mutableListOf<Map<String, String>>()
|
||||
var values = linkedMapOf<String, String>()
|
||||
var index = 0
|
||||
while (index < content.length) {
|
||||
val start = content.indexOf('<', index)
|
||||
if (start < 0) break
|
||||
val end = content.indexOf('>', start + 1)
|
||||
if (end < 0) break
|
||||
val descriptor = content.substring(start + 1, end)
|
||||
val parts = descriptor.split(':')
|
||||
val name = parts.first().trim().uppercase(Locale.US)
|
||||
index = end + 1
|
||||
when (name) {
|
||||
"EOH" -> values.clear()
|
||||
"EOR" -> {
|
||||
if (values.isNotEmpty()) records += values.toMap()
|
||||
values = linkedMapOf()
|
||||
}
|
||||
else -> {
|
||||
val length = parts.getOrNull(1)?.toIntOrNull() ?: continue
|
||||
if (length < 0 || index + length > content.length) continue
|
||||
values[name] = content.substring(index, index + length)
|
||||
index += length
|
||||
}
|
||||
}
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
private fun StringBuilder.appendOptional(name: String, value: String) {
|
||||
if (value.isNotBlank()) append(field(name, value))
|
||||
}
|
||||
|
||||
private fun field(name: String, value: String): String = "<$name:${value.length}>$value"
|
||||
|
||||
private fun hzToMhz(value: Long): String = String.format(Locale.US, "%.6f", value / 1_000_000.0)
|
||||
|
||||
private fun mhzToHz(value: String?): Long? = value?.toDoubleOrNull()?.let { (it * 1_000_000.0).toLong() }
|
||||
|
||||
private fun format(value: Long, pattern: String): String =
|
||||
SimpleDateFormat(pattern, Locale.US).apply { timeZone = utc }.format(Date(value))
|
||||
|
||||
private fun parse(date: String, time: String): Long? = runCatching {
|
||||
val normalizedTime = if (time.length == 4) "${time}00" else time
|
||||
SimpleDateFormat("yyyyMMddHHmmss", Locale.US).apply {
|
||||
isLenient = false
|
||||
timeZone = utc
|
||||
}.parse(date + normalizedTime)?.time
|
||||
}.getOrNull()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface IQsoRepository {
|
||||
val records: Flow<List<QsoRecord>>
|
||||
|
||||
suspend fun find(id: Long): QsoRecord?
|
||||
suspend fun save(record: QsoRecord): Long
|
||||
suspend fun delete(id: Long)
|
||||
suspend fun markUploaded(ids: List<Long>)
|
||||
suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
|
||||
suspend fun importAdi(content: String): AdifImportResult
|
||||
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
|
||||
suspend fun mergeLoTW(records: List<QsoRecord>): AdifImportResult
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
|
||||
/** Bridge a LoTW report QSO (confirmed, download side) into a logbook record
|
||||
* so the logbook can show downloaded confirmations and mark local uploads
|
||||
* as confirmed (matched by [sameConfirmedContact]). */
|
||||
fun GridQso.toConfirmedRecord(accountCallsign: String): QsoRecord = QsoRecord(
|
||||
startUtcMillis = epochMs,
|
||||
theirCallsign = call,
|
||||
myCallsign = accountCallsign.trim().uppercase(),
|
||||
myGrid = (myGrids.firstOrNull() ?: myGrid).orEmpty(),
|
||||
vuccGrids = myGrids.toList(),
|
||||
band = bandUp,
|
||||
rxBand = bandDown,
|
||||
mode = mode,
|
||||
// On satellites, MODE=MFSK means FT4 (ADIF pairing); keep the sub-mode so
|
||||
// confirmations match local FT4 records (mode=MFSK, submode=FT4).
|
||||
submode = if (mode.equals("MFSK", true)) "FT4" else "",
|
||||
satelliteName = satName,
|
||||
propagationMode = "SAT",
|
||||
status = QsoStatus.COMPLETE,
|
||||
lotwConfirmed = true,
|
||||
dxcc = dxcc,
|
||||
country = country.orEmpty(),
|
||||
cqZone = cqz,
|
||||
region = state.orEmpty()
|
||||
)
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
|
||||
/** LoTW official name vs SatNOGS name ("SO-50 (SaudiOSCAR 50)"): compare the
|
||||
* parenthetical-free prefix so local records and downloaded confirmations match. */
|
||||
fun satMatchKey(name: String): String = name.substringBefore('(').trim().uppercase(Locale.US)
|
||||
|
||||
/** LoTW omits frequency and may round time to a minute. Only merge an unambiguous contact. */
|
||||
fun sameConfirmedContact(local: QsoRecord, remote: QsoRecord): Boolean =
|
||||
local.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) &&
|
||||
(local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) &&
|
||||
satMatchKey(local.satelliteName) == satMatchKey(remote.satelliteName) &&
|
||||
local.isSatellite == remote.isSatellite &&
|
||||
local.displayMode == remote.displayMode &&
|
||||
(local.band.isBlank() || remote.band.isBlank() || local.band.equals(remote.band, true)) &&
|
||||
abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L
|
||||
|
||||
fun QsoRecord.confirmationLookupKey(): String = listOf(
|
||||
theirCallsign.trim().uppercase(Locale.US), satMatchKey(satelliteName), displayMode
|
||||
).joinToString("|")
|
||||
|
||||
fun QsoRecord.withConfirmation(confirmed: QsoRecord): QsoRecord = copy(
|
||||
myCallsign = myCallsign.ifBlank { confirmed.myCallsign },
|
||||
theirGrid = confirmed.theirGrid.ifBlank { theirGrid },
|
||||
myGrid = myGrid.ifBlank { confirmed.myGrid },
|
||||
sentReport = sentReport.ifBlank { confirmed.sentReport },
|
||||
receivedReport = receivedReport.ifBlank { confirmed.receivedReport },
|
||||
txFrequencyHz = txFrequencyHz ?: confirmed.txFrequencyHz,
|
||||
rxFrequencyHz = rxFrequencyHz ?: confirmed.rxFrequencyHz,
|
||||
band = band.ifBlank { confirmed.band },
|
||||
rxBand = rxBand.ifBlank { confirmed.rxBand },
|
||||
propagationMode = propagationMode.ifBlank { confirmed.propagationMode },
|
||||
status = QsoStatus.COMPLETE,
|
||||
lotwConfirmed = lotwConfirmed || confirmed.lotwConfirmed,
|
||||
lotwReceived = lotwReceived || confirmed.lotwReceived || confirmed.lotwConfirmed,
|
||||
lotwQslDate = confirmed.lotwQslDate.ifBlank { lotwQslDate },
|
||||
vuccGrids = confirmed.vuccGrids.ifEmpty { vuccGrids },
|
||||
dxcc = confirmed.dxcc ?: dxcc,
|
||||
country = confirmed.country.ifBlank { country },
|
||||
cqZone = confirmed.cqZone ?: cqZone,
|
||||
region = confirmed.region.ifBlank { region }
|
||||
)
|
||||
|
||||
val QsoRecord.displayMode: String
|
||||
get() {
|
||||
// Satellite FT4 is MODE=MFSK + SUBMODE=FT4; any other mode keeps its own
|
||||
// label even when a stale submode default ("FT4") was persisted.
|
||||
val label = if (mode.equals("MFSK", true) && submode.isNotBlank()) submode else mode
|
||||
return label.trim().uppercase(Locale.US)
|
||||
}
|
||||
|
||||
val QsoRecord.isSatellite: Boolean
|
||||
get() = propagationMode.equals("SAT", true) || (propagationMode.isBlank() && satelliteName.isNotBlank())
|
||||
|
||||
fun frequencyBand(hz: Long?): String = when (hz) {
|
||||
null -> ""
|
||||
in 28_000_000L..29_700_000L -> "10M"
|
||||
in 50_000_000L..54_000_000L -> "6M"
|
||||
in 144_000_000L..148_000_000L -> "2M"
|
||||
in 219_000_000L..225_000_000L -> "1.25M"
|
||||
in 420_000_000L..450_000_000L -> "70CM"
|
||||
in 902_000_000L..928_000_000L -> "33CM"
|
||||
in 1_240_000_000L..1_300_000_000L -> "23CM"
|
||||
in 2_300_000_000L..2_450_000_000L -> "13CM"
|
||||
in 5_650_000_000L..5_925_000_000L -> "6CM"
|
||||
in 10_000_000_000L..10_500_000_000L -> "3CM"
|
||||
else -> ""
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
enum class QsoStatus { DRAFT, COMPLETE, ABORTED }
|
||||
|
||||
data class QsoRecord(
|
||||
val id: Long = 0L,
|
||||
val startUtcMillis: Long,
|
||||
val endUtcMillis: Long? = null,
|
||||
val theirCallsign: String,
|
||||
val myCallsign: String,
|
||||
val theirGrid: String = "",
|
||||
val myGrid: String = "",
|
||||
val sentReport: String = "",
|
||||
val receivedReport: String = "",
|
||||
val txFrequencyHz: Long? = null,
|
||||
val rxFrequencyHz: Long? = null,
|
||||
val band: String = "",
|
||||
val rxBand: String = "",
|
||||
val mode: String = "FM",
|
||||
val submode: String = "",
|
||||
val satelliteName: String = "",
|
||||
val transponderName: String = "",
|
||||
val satelliteMode: String = "",
|
||||
val passAosUtcMillis: Long? = null,
|
||||
val automatic: Boolean = false,
|
||||
val status: QsoStatus = QsoStatus.DRAFT,
|
||||
val propagationMode: String = if (satelliteName.isNotBlank()) "SAT" else "",
|
||||
val lotwConfirmed: Boolean = false,
|
||||
val lotwUploaded: Boolean = false,
|
||||
val lotwReceived: Boolean = false,
|
||||
val lotwQslDate: String = "",
|
||||
val vuccGrids: List<String> = emptyList(),
|
||||
val dxcc: Int? = null,
|
||||
val country: String = "",
|
||||
val cqZone: Int? = null,
|
||||
val region: String = "",
|
||||
val comment: String = ""
|
||||
)
|
||||
|
||||
data class AdifImportResult(val imported: Int, val skipped: Int, val updated: Int = 0)
|
||||
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Award progress computed from the confirmed-QSO store.
|
||||
*
|
||||
* @param type which award
|
||||
* @param workedKeys matching keys for the boundary assets: each is compared
|
||||
* against the region "code" field of the award's GeoJSON
|
||||
* (e.g. WAPC -> "GD", WAJA -> "34", WAZ -> "24",
|
||||
* WAS -> "CA", DXCC -> "318", VUCC -> "OL62").
|
||||
* @param count distinct worked units (grids / entities / provinces / ...)
|
||||
* @param target units required for the award
|
||||
*/
|
||||
data class AwardProgress(
|
||||
val type: AwardType,
|
||||
val workedKeys: Set<String>,
|
||||
val count: Int,
|
||||
val target: Int
|
||||
)
|
||||
|
||||
enum class AwardType { DXCC, VUCC, WAPC, WAJA, WAZ, WAS }
|
||||
|
||||
object AwardTargets {
|
||||
const val DXCC = 100
|
||||
const val VUCC = 100
|
||||
const val WAPC = 34
|
||||
const val WAJA = 47
|
||||
const val WAZ = 40
|
||||
const val WAS = 50
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives six-award progress from the confirmed QSO store.
|
||||
*
|
||||
* Award rules (ADIF / award-program semantics):
|
||||
* - VUCC: distinct 4-char gridsquares.
|
||||
* - DXCC: distinct ARRL DXCC entities (from the QSO's dxcc field; falls back
|
||||
* to a callsign-prefix guess when LoTW omitted it).
|
||||
* - WAPC: distinct Chinese provinces (31 mainland STATE codes) plus the
|
||||
* Hong Kong / Macao / Taiwan entities (counted as their own provinces).
|
||||
* - WAJA: distinct Japanese prefectures (STATE code "01".."47").
|
||||
* - WAZ: distinct CQ zones.
|
||||
* - WAS: distinct US states (STATE abbreviations).
|
||||
*
|
||||
* The ADIF STATE field's meaning depends on the DXCC entity
|
||||
* ("STATE depends on DXCC"), so every QSO is first classified by entity.
|
||||
*/
|
||||
object AwardCalculator {
|
||||
|
||||
fun calculate(qsosByGrid: Map<String, List<GridQso>>): List<AwardProgress> {
|
||||
val all = qsosByGrid.values.flatten()
|
||||
return listOf(
|
||||
AwardProgress(
|
||||
AwardType.VUCC,
|
||||
workedKeys = qsosByGrid.keys,
|
||||
count = qsosByGrid.size,
|
||||
target = AwardTargets.VUCC
|
||||
),
|
||||
calculateDxcc(all),
|
||||
calculateWapc(all),
|
||||
calculateWaja(all),
|
||||
calculateWaz(all),
|
||||
calculateWas(all)
|
||||
)
|
||||
}
|
||||
|
||||
private fun calculateDxcc(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
val code = q.dxcc ?: prefixEntityCode(q.call)
|
||||
if (code != null) keys.add(code.toString())
|
||||
}
|
||||
return AwardProgress(AwardType.DXCC, keys, keys.size, AwardTargets.DXCC)
|
||||
}
|
||||
|
||||
private fun calculateWapc(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
when (entityOf(q)) {
|
||||
Entity.CHINA -> q.state?.trim()?.uppercase()?.takeIf { it in CN_STATES }?.let { keys.add(it) }
|
||||
Entity.HONG_KONG -> keys.add(HK_CODE)
|
||||
Entity.MACAO -> keys.add(MO_CODE)
|
||||
Entity.TAIWAN -> keys.add(TW_CODE)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
return AwardProgress(AwardType.WAPC, keys, keys.size, AwardTargets.WAPC)
|
||||
}
|
||||
|
||||
private fun calculateWaja(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
if (entityOf(q) != Entity.JAPAN) continue
|
||||
q.state?.trim()?.takeIf { it.length == 2 && it.all(Char::isDigit) }?.let { keys.add(it) }
|
||||
}
|
||||
return AwardProgress(AwardType.WAJA, keys, keys.size, AwardTargets.WAJA)
|
||||
}
|
||||
|
||||
private fun calculateWaz(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
q.cqz?.takeIf { it in 1..40 }?.let { keys.add(it.toString()) }
|
||||
}
|
||||
return AwardProgress(AwardType.WAZ, keys, keys.size, AwardTargets.WAZ)
|
||||
}
|
||||
|
||||
private fun calculateWas(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
if (entityOf(q) != Entity.USA) continue
|
||||
q.state?.trim()?.uppercase()?.takeIf { it in US_STATES }?.let { keys.add(it) }
|
||||
}
|
||||
return AwardProgress(AwardType.WAS, keys, keys.size, AwardTargets.WAS)
|
||||
}
|
||||
|
||||
private enum class Entity { CHINA, HONG_KONG, MACAO, TAIWAN, JAPAN, USA, OTHER }
|
||||
|
||||
private fun entityOf(q: GridQso): Entity = when (q.dxcc) {
|
||||
318 -> Entity.CHINA
|
||||
321 -> Entity.HONG_KONG
|
||||
152 -> Entity.MACAO
|
||||
386 -> Entity.TAIWAN
|
||||
339 -> Entity.JAPAN
|
||||
291 -> Entity.USA
|
||||
else -> prefixEntity(q.call)
|
||||
}
|
||||
|
||||
/** Coarse entity from callsign prefix, used when LoTW omitted the DXCC field. */
|
||||
private fun prefixEntity(call: String): Entity {
|
||||
val c = call.uppercase().substringBefore('/')
|
||||
return when {
|
||||
CN_PREFIXES.any { c.startsWith(it) } -> Entity.CHINA
|
||||
c.startsWith("VR") -> Entity.HONG_KONG
|
||||
c.startsWith("XX9") -> Entity.MACAO
|
||||
TW_PREFIXES.any { c.startsWith(it) } -> Entity.TAIWAN
|
||||
JP_PREFIXES.any { c.startsWith(it) } -> Entity.JAPAN
|
||||
US_PREFIXES.any { c.startsWith(it) } -> Entity.USA
|
||||
else -> Entity.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/** ARRL DXCC entity code guessed from callsign prefix (very coarse). */
|
||||
private fun prefixEntityCode(call: String): Int? {
|
||||
return when (prefixEntity(call)) {
|
||||
Entity.CHINA -> 318
|
||||
Entity.HONG_KONG -> 321
|
||||
Entity.MACAO -> 152
|
||||
Entity.TAIWAN -> 386
|
||||
Entity.JAPAN -> 339
|
||||
Entity.USA -> 291
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private val CN_PREFIXES = listOf("BA", "BD", "BG", "BH", "BI", "BY", "BJ", "BK", "BL", "BQ", "BR", "BS")
|
||||
private val TW_PREFIXES = listOf("BM", "BV", "BU", "BW", "BX", "BN", "BO", "BT", "BZ")
|
||||
private val JP_PREFIXES =
|
||||
listOf("7J", "7K", "7L", "7M", "7N") + ('A'..'Z').map { "J$it" }
|
||||
private val US_PREFIXES =
|
||||
listOf("AA", "AB", "AC", "AD", "AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "K", "N", "W")
|
||||
|
||||
private val CN_STATES = setOf(
|
||||
"AH", "BJ", "CQ", "FJ", "GD", "GS", "GX", "GZ", "HA", "HB", "HE", "HI", "HL", "HN", "JL",
|
||||
"JS", "JX", "LN", "NM", "NX", "QH", "SC", "SD", "SH", "SN", "SX", "TJ", "XJ", "XZ", "YN", "ZJ"
|
||||
)
|
||||
|
||||
private val US_STATES = setOf(
|
||||
"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", "HI", "ID", "IL", "IN", "IA",
|
||||
"KS", "KY", "LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ",
|
||||
"NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT",
|
||||
"VA", "WA", "WV", "WI", "WY"
|
||||
)
|
||||
|
||||
private const val HK_CODE = "HK"
|
||||
private const val MO_CODE = "MO"
|
||||
private const val TW_CODE = "TW"
|
||||
}
|
||||
@@ -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.domain.model
|
||||
|
||||
/**
|
||||
* One confirmed satellite QSO as reported by LoTW, kept per worked gridsquare
|
||||
* so the map can show, for any worked grid, which callsigns were worked there
|
||||
* and when.
|
||||
*
|
||||
* @param call opposite station callsign (as logged by the opposite op)
|
||||
* @param epochMs QSO date+time in UTC milliseconds
|
||||
* @param satName satellite name (e.g. "FO-29")
|
||||
* @param mode ADIF mode (FM / CW / SSB ...)
|
||||
* @param bandUp uplink band as reported by LoTW (BAND_RX: "70CM", "2M", "10M"...)
|
||||
* @param bandDown downlink band (BAND: "2M", "70CM"...) — may be empty when
|
||||
* LoTW did not include it
|
||||
* @param dxcc ARRL DXCC entity code of the opposite station (from ADIF
|
||||
* <DXCC>), or null when LoTW omitted it
|
||||
* @param country DXCC entity name (ADIF <COUNTRY>), or null
|
||||
* @param cqz CQ zone of the opposite station (ADIF <CQZ>), or null
|
||||
* @param state Primary administrative subdivision of the opposite station
|
||||
* (ADIF <STATE>). Interpretation depends on the DXCC entity:
|
||||
* China -> province code ("GD"), Japan -> prefecture number
|
||||
* ("34"), USA -> state abbreviation ("CA"). Null when absent.
|
||||
*/
|
||||
data class GridQso(
|
||||
val call: String,
|
||||
val epochMs: Long,
|
||||
val satName: String,
|
||||
val mode: String,
|
||||
val bandUp: String,
|
||||
val bandDown: String,
|
||||
val dxcc: Int? = null,
|
||||
val country: String? = null,
|
||||
val cqz: Int? = null,
|
||||
val state: String? = null,
|
||||
/** 4-char grid the station itself operated from (ADIF MY_GRIDSQUARE).
|
||||
* Kept as the first entry of [myGrids] for backward compatibility with
|
||||
* consumers that predate multi-grid station locations. */
|
||||
val myGrid: String? = null,
|
||||
/** Every 4-char grid the station operated from for this QSO — ADIF
|
||||
* <MY_GRIDSQUARE> plus every field of <MY_VUCC_GRIDS> (a comma-separated
|
||||
* list LoTW emits when one station location roams across several grid
|
||||
* squares; MY_GRIDSQUARE may be absent on those records). A station
|
||||
* location (台址) can legitimately span multiple grids, and the map's
|
||||
* stripes and the operated-grid selector must show all of them. */
|
||||
val myGrids: Set<String> = emptySet(),
|
||||
/** Callsign of the station location this QSO was logged under (ADIF
|
||||
* <STATION_CALLSIGN>; e.g. "BH6RJD", "BH6RJD/P"). NOT the 台址 identity
|
||||
* on its own — one callsign can have several station locations. */
|
||||
val myCallsign: String? = null,
|
||||
/** Stable key of the station location (台址) this QSO was logged under,
|
||||
* derived from the MY_* station fields (STATION_CALLSIGN + MY_DXCC +
|
||||
* MY_STATE + MY_CQ_ZONE + MY_ITU_ZONE + MY_IOTA + MY_COUNTRY), so the
|
||||
* operated-grid selector can group QSOs per 台址 and show each 台址 with
|
||||
* its full grid set. Null for data synced before this field existed. */
|
||||
val stationKey: String? = null
|
||||
) {
|
||||
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
|
||||
val bandLabel: String
|
||||
get() {
|
||||
val up = bandAbbrev(bandUp)
|
||||
val down = bandAbbrev(bandDown)
|
||||
return when {
|
||||
up.isNotEmpty() && down.isNotEmpty() -> "$up/$down"
|
||||
up.isNotEmpty() -> up
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun bandAbbrev(band: String): String = when (band.trim().uppercase()) {
|
||||
"70CM" -> "U"
|
||||
"2M" -> "V"
|
||||
"10M" -> "A"
|
||||
"6M" -> "V"
|
||||
"1.2G" -> "L"
|
||||
"2.4G" -> "S"
|
||||
"23CM" -> "L"
|
||||
"13CM" -> "S"
|
||||
else -> band.trim().uppercase()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* A station the user marked in a gridsquare they have NOT yet worked — a
|
||||
* reminder to try to contact that callsign. Stored per 4-char grid (one mark
|
||||
* per grid).
|
||||
*
|
||||
* @param call the callsign the user wants to work (uppercase)
|
||||
* @param epochMs the moment the mark was created, UTC milliseconds
|
||||
*/
|
||||
data class MarkedStation(
|
||||
val call: String,
|
||||
val epochMs: Long
|
||||
)
|
||||
@@ -32,5 +32,11 @@ data class SatRadio(
|
||||
@SerialName("uplink_high") val uplinkHigh: Long?,
|
||||
@SerialName("uplink_mode") val uplinkMode: String?,
|
||||
@SerialName("invert") val isInverted: Boolean,
|
||||
@SerialName("norad_cat_id") val catnum: Int?
|
||||
@SerialName("norad_cat_id") val catnum: Int?,
|
||||
/** Service class from the transceiver source ("Amateur"/"Unknown" etc.).
|
||||
* Used by the SSTV virtual filter to tell amateur satellites apart from
|
||||
* debris / weather birds (TIROS) / launcher stages (Ariane 6 R/B). */
|
||||
@SerialName("service") val service: String? = null,
|
||||
/** Set for manually imported transceivers, which remote updates must not replace. */
|
||||
val isCustom: Boolean = false
|
||||
)
|
||||
@@ -20,7 +20,8 @@ data class SatSlot(
|
||||
/** One satellite day (12 two-hour slots) */
|
||||
data class SatDay(
|
||||
val dateLabel: String, // "Aug 4"
|
||||
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
||||
val slots: List<SatSlot>, // 12 槽(00-02 ... 22-24)
|
||||
val streakCount: Int = 0 // 当天最近连续相同状态报告数(0 = 当天无报告)
|
||||
)
|
||||
|
||||
/** One satellite, 3 days of state */
|
||||
|
||||
@@ -30,7 +30,9 @@ data class PassesSettings(
|
||||
val aosStartMinute: Int = 0,
|
||||
val aosEndMinute: Int = 23 * 60 + 59,
|
||||
val invertAosTimeWindow: Boolean = false,
|
||||
val selectedModes: List<String>
|
||||
val selectedModes: List<String>,
|
||||
/** 历史过境回看窗口(小时): 过境列表从 now-hoursBefore 开始计算. */
|
||||
val hoursBefore: Int = 0
|
||||
)
|
||||
|
||||
data class RCSettings(
|
||||
@@ -60,13 +62,36 @@ data class OtherSettings(
|
||||
val stateOfLightTheme: Boolean,
|
||||
val stateOfNightMode: Boolean = false,
|
||||
val stateOfMapGrid: Boolean = false,
|
||||
/** Grid mode: label worked (green) cells with the FIRST callsign worked
|
||||
* in that grid instead of the Maidenhead code; non-worked cells get no
|
||||
* label at all. */
|
||||
val stateOfMapFirstCall: Boolean = false,
|
||||
val shouldSeeWarning: Boolean,
|
||||
val shouldSeeWhatsNew: Boolean,
|
||||
val sstvMode: String = "Auto",
|
||||
val lowElevation: Double = 15.0,
|
||||
val highElevation: Double = 45.0
|
||||
val highElevation: Double = 45.0,
|
||||
/** Independent auto-sync toggle for LoTW confirmed grids (separate from the
|
||||
* ephemeris auto-update; only effective after at least one manual sync). */
|
||||
val stateOfAutoLotwSync: Boolean = true,
|
||||
/** 指南针手动航向偏置(度, -180..180): 雷达方位 = 传感器方位 + 磁偏角 + 该偏置. */
|
||||
val compassOffsetDegrees: Float = 0f,
|
||||
val mapSource: String = MapSource.OSM,
|
||||
val tiandituKey: String = ""
|
||||
)
|
||||
|
||||
object MapSource {
|
||||
const val OSM = "osm"
|
||||
const val TIANDITU_VECTOR = "tianditu_vector"
|
||||
const val TIANDITU_IMAGE = "tianditu_image"
|
||||
private const val LEGACY_TIANDITU = "tianditu"
|
||||
|
||||
fun normalize(source: String): String = when (source) {
|
||||
OSM, TIANDITU_VECTOR, TIANDITU_IMAGE -> source
|
||||
LEGACY_TIANDITU -> TIANDITU_VECTOR
|
||||
else -> OSM
|
||||
}
|
||||
}
|
||||
data class DataSourcesSettings(
|
||||
val satelliteUrls: List<String>,
|
||||
val transceiversUrls: List<String>,
|
||||
|
||||
+46
-2
@@ -22,7 +22,51 @@ 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).
|
||||
* Returns the 4-char grid set with an explicit failure cause on error.
|
||||
*/
|
||||
suspend fun fetchConfirmedGrids(callsign: String, password: String): Set<String>?
|
||||
suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult
|
||||
|
||||
/**
|
||||
* Same report, but keeps the per-QSO detail of every confirmed satellite
|
||||
* QSO (call / time / satellite / mode / bands), grouped by worked 4-char
|
||||
* gridsquare. Failure cause is kept explicit.
|
||||
*
|
||||
* @param since QSL-since date ("yyyyMMdd" or "yyyy-MM-dd"); use an early
|
||||
* date for a full report, or the last sync date for an
|
||||
* incremental pull. Empty falls back to a full report.
|
||||
* @param onProgress streamed download progress (phase + bytes + estimate),
|
||||
* invoked from the IO dispatcher; may be called on every
|
||||
* read chunk.
|
||||
*/
|
||||
suspend fun fetchConfirmedGridQsos(
|
||||
callsign: String,
|
||||
password: String,
|
||||
since: String = "",
|
||||
onProgress: (LoTWProgress) -> Unit = {}
|
||||
): LoTWResult
|
||||
}
|
||||
|
||||
/** Streamed progress of a LoTW report download. */
|
||||
data class LoTWProgress(
|
||||
val phase: LoTWPhase,
|
||||
/** Bytes read so far (uncompressed body). */
|
||||
val bytesRead: Long = 0,
|
||||
/** Estimated total body bytes, derived from <APP_LoTW_NUMREC>; 0 before known. */
|
||||
val expectedBytes: Long = 0,
|
||||
/** Smoothed download speed in bytes/second; 0 while unknown. */
|
||||
val speedBps: Long = 0,
|
||||
/** Total QSL records reported in the header (<APP_LoTW_NUMREC>); 0 before known. */
|
||||
val qsoCount: Long = 0
|
||||
) {
|
||||
/** Fraction 0..1 of the estimated body already read; 0 while unknown. */
|
||||
val fraction: Float
|
||||
get() = if (expectedBytes > 0 && bytesRead > 0)
|
||||
(bytesRead.toFloat() / expectedBytes).coerceIn(0f, 1f) else 0f
|
||||
|
||||
/** Estimated seconds remaining; 0 while size or speed is unknown. */
|
||||
val remainingSeconds: Long
|
||||
get() = if (expectedBytes > 0 && speedBps > 0)
|
||||
((expectedBytes - bytesRead) / speedBps).coerceAtLeast(0) else 0L
|
||||
}
|
||||
|
||||
enum class LoTWPhase { Connecting, Downloading }
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
|
||||
interface ILoTWUploadRepository {
|
||||
suspend fun certificate(): LoTWCertificate?
|
||||
suspend fun station(): LoTWStation?
|
||||
suspend fun importCertificate(data: ByteArray, password: CharArray): LoTWCertificate
|
||||
suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate
|
||||
suspend fun saveStation(station: LoTWStation): LoTWStation
|
||||
suspend fun removeCertificate()
|
||||
/** Region field (State/Province/Prefecture…) and national CQZ/ITUZ map for a DXCC entity. */
|
||||
suspend fun stationMeta(dxcc: Int): LoTWStationMeta
|
||||
suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit
|
||||
suspend fun prepare(records: List<QsoRecord>, resubmit: Boolean): LoTWUploadPreview
|
||||
suspend fun upload(previewId: String): LoTWUploadResult
|
||||
fun discardPreview()
|
||||
}
|
||||
|
||||
data class LoTWCertificate(
|
||||
val callsign: String,
|
||||
val dxcc: Int,
|
||||
val serial: String,
|
||||
val expires: String,
|
||||
val firstQsoDate: String,
|
||||
val lastQsoDate: String,
|
||||
val passwordSaved: Boolean = false
|
||||
)
|
||||
|
||||
/** The location is explicitly confirmed for the selected contacts, not inferred from today's GPS. */
|
||||
data class LoTWStation(
|
||||
val grid: String = "",
|
||||
val cqZone: String = "",
|
||||
val ituZone: String = "",
|
||||
val region: String = "",
|
||||
val county: String = "",
|
||||
val iota: String = ""
|
||||
)
|
||||
|
||||
/** One (ITU:CQ) pair from a LoTW zonemap, e.g. Guangdong is 44:24. */
|
||||
data class LoTWZonePair(val itu: Int, val cq: Int)
|
||||
|
||||
/** One selectable region code with the zones it maps to (cross-zone regions carry several pairs). */
|
||||
data class LoTWRegionOption(val code: String, val name: String, val zones: List<LoTWZonePair>)
|
||||
|
||||
/** Country-dependent region field (US_STATE, CN_PROVINCE, …) with its selectable options. */
|
||||
data class LoTWRegionField(val id: String, val label: String, val options: List<LoTWRegionOption>)
|
||||
|
||||
/**
|
||||
* Everything the station-location UI needs for one DXCC entity:
|
||||
* the region field to show (null when the country has none) and the national
|
||||
* zonemap (used to prefill CQZ/ITUZ when the country has a single zone pair).
|
||||
*/
|
||||
data class LoTWStationMeta(
|
||||
val regionField: LoTWRegionField? = null,
|
||||
val countryZones: List<LoTWZonePair> = emptyList()
|
||||
)
|
||||
|
||||
data class LoTWUploadPreview(
|
||||
val id: String,
|
||||
val callsign: String,
|
||||
val dxcc: Int,
|
||||
val grid: String,
|
||||
val count: Int,
|
||||
val skipped: Int,
|
||||
val firstUtc: String,
|
||||
val lastUtc: String,
|
||||
val contacts: List<String>,
|
||||
val unknownSkipped: Int = 0
|
||||
)
|
||||
|
||||
/** Local comparison against downloaded LoTW receipt flags and this app's durable upload receipts. */
|
||||
data class LoTWUploadAudit(
|
||||
val total: Int,
|
||||
val pending: Int,
|
||||
val uploaded: Int,
|
||||
val unknown: Int,
|
||||
val unavailable: Int
|
||||
)
|
||||
|
||||
sealed interface LoTWUploadResult {
|
||||
data class Accepted(val count: Int) : LoTWUploadResult
|
||||
data class Rejected(val message: String = "") : LoTWUploadResult
|
||||
/** May have reached LoTW. Do not retry automatically. */
|
||||
data object Unknown : LoTWUploadResult
|
||||
data object ExpiredPreview : LoTWUploadResult
|
||||
}
|
||||
|
||||
class LoTWOperationException(val reason: LoTWProblem, val detail: String = "") : Exception(reason.name)
|
||||
|
||||
enum class LoTWProblem {
|
||||
CERTIFICATE_PASSWORD, CERTIFICATE_INVALID, CERTIFICATE_EXPIRED, CERTIFICATE_MISSING,
|
||||
CERTIFICATE_FORMAT, STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, STATION_GRID,
|
||||
STATION_REGION, STATION_ZONE, STATION_IOTA, MODE, BAND, SATELLITE, INVALID_CONTACT,
|
||||
LOCATION_MISMATCH, TOO_MANY_CONTACTS
|
||||
}
|
||||
+3
@@ -16,6 +16,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||
@@ -34,6 +35,8 @@ interface IMainContainer {
|
||||
val updateRepo: IUpdateRepository
|
||||
val wavelogRepo: IWavelogRepository
|
||||
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||
val qsoRepository: IQsoRepository
|
||||
val lotwUploadRepository: ILoTWUploadRepository
|
||||
val radioTrackingService: IRadioTrackingService
|
||||
val mutualPassData: StateFlow<MutualPassData>
|
||||
fun setMutualPassData(data: MutualPassData)
|
||||
|
||||
+15
@@ -65,4 +65,19 @@ interface ISatelliteRepo {
|
||||
|
||||
/** Fetch radio transceivers for a satellite by its catalog number. */
|
||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||
|
||||
/** Satellite catnums whose radios carry any of the given downlink modes.
|
||||
* Empty modes -> empty result; used to filter the mutual-match satellite
|
||||
* set to transponder satellites (FM voice / linear). */
|
||||
suspend fun getSatelliteIdsWithModes(modes: List<String>): List<Int>
|
||||
|
||||
/** Like [getSatelliteIdsWithModes] but only matches records with an uplink
|
||||
* (real transponders), excluding downlink-only beacons/telemetry that share
|
||||
* the same mode label. Used by the FM/Linear fallback filter. */
|
||||
suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>): List<Int>
|
||||
|
||||
/** Like [getSatelliteIdsWithModes] but only matches records whose service
|
||||
* class is "Amateur", so SSTV never matches weather birds (TIROS),
|
||||
* launcher debris or other non-amateur transmitters. */
|
||||
suspend fun getSatelliteIdsWithModesAndAmateur(modes: List<String>): List<Int>
|
||||
}
|
||||
+11
@@ -20,8 +20,19 @@ package com.rtbishop.look4sat.core.domain.repository
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/** 指南针校准精度等级, 由磁场传感器 accuracy 事件映射而来. */
|
||||
enum class CompassAccuracy {
|
||||
UNAVAILABLE,
|
||||
UNRELIABLE,
|
||||
LOW,
|
||||
MEDIUM,
|
||||
HIGH
|
||||
}
|
||||
|
||||
interface ISensorsRepo {
|
||||
val sensorData: StateFlow<Pair<Float, Float>>
|
||||
/** 指南针当前校准精度 (磁场传感器 accuracy), 用于校准对话框进度. */
|
||||
val compassAccuracy: StateFlow<CompassAccuracy>
|
||||
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
||||
fun enableSensor()
|
||||
fun disableSensor()
|
||||
|
||||
+23
@@ -89,6 +89,12 @@ interface ISettingsRepo {
|
||||
fun setSatelliteOffset(catnum: Int, offset: String)
|
||||
//endregion
|
||||
|
||||
//region # Per-satellite logbook mode preset settings
|
||||
/** Last selected logbook mode (CW/SSB/FT4) for a satellite; empty when unset. */
|
||||
fun getSatelliteMode(catnum: Int): String
|
||||
fun setSatelliteMode(catnum: Int, mode: String)
|
||||
//endregion
|
||||
|
||||
//region # AMSAT status report settings
|
||||
fun getAmSatCallsign(): String
|
||||
fun setAmSatCallsign(callsign: String)
|
||||
@@ -99,10 +105,27 @@ interface ISettingsRepo {
|
||||
fun updateWavelogSettings(settings: WavelogSettings)
|
||||
fun getWorkedGrids(): Set<String>
|
||||
fun setWorkedGrids(grids: Set<String>)
|
||||
/** Confirmed satellite QSOs grouped by worked 4-char gridsquare (map tap detail). */
|
||||
fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>
|
||||
fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>)
|
||||
/** Distinct 4-char gridsquares the account operated from (LoTW <MY_GRIDSQUARE>). */
|
||||
fun getRoamedGrids(): Set<String>
|
||||
fun setRoamedGrids(grids: Set<String>)
|
||||
/** Stations the user marked as "want to contact" in unworked gridsquares
|
||||
* (grid -> ordered list of marks; the first is the pinned/top one). The
|
||||
* whole grid clears automatically once it becomes worked. */
|
||||
fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>
|
||||
fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>)
|
||||
//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)
|
||||
/** Last successful LoTW sync date ("yyyyMMdd", empty when never synced). */
|
||||
fun getLastLotwSyncDate(): String
|
||||
fun setLastLotwSyncDate(date: String)
|
||||
/** Callsign of the last successful LoTW sync (empty when never synced). */
|
||||
fun getLastLotwSyncCallsign(): String
|
||||
fun setLastLotwSyncCallsign(callsign: String)
|
||||
//endregion
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* Shared LoTW sync orchestration used by BOTH the manual sync (settings screen)
|
||||
* and the automatic sync on app start (MainApplication), so the two paths can
|
||||
* never drift apart:
|
||||
*
|
||||
* - [LoTWSyncMode.Incremental] pulls only the confirmations since the last sync
|
||||
* and MERGES them into the stored grids/QSOs (grids only grow; QSO detail is
|
||||
* deduped by call + QSO time).
|
||||
* - [LoTWSyncMode.Full] pulls everything and REPLACES the stored data.
|
||||
*/
|
||||
|
||||
/**
|
||||
* UTC date ("yyyyMMdd") of the given instant — the "already synced today"
|
||||
* gate granularity.
|
||||
*/
|
||||
fun lotwSyncToday(now: Long = System.currentTimeMillis()): String =
|
||||
SimpleDateFormat("yyyyMMdd", Locale.US)
|
||||
.apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||
.format(Date(now))
|
||||
|
||||
/**
|
||||
* Full UTC timestamp cursor ("yyyy-MM-dd HH:mm:ss") written after a
|
||||
* successful sync. This is exactly the format LoTW's qso_qslsince accepts
|
||||
* (measured live 2026-09-17: the legacy "yyyyMMdd" cursor is silently
|
||||
* ignored by LoTW, which falls back to its own system-supplied default),
|
||||
* and it keeps enough precision to display the last-sync time like the
|
||||
* ephemeris update time.
|
||||
*/
|
||||
fun lotwSyncCursor(now: Long = System.currentTimeMillis()): String =
|
||||
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
.apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||
.format(Date(now))
|
||||
|
||||
/** Date part of a stored cursor as "yyyyMMdd", tolerating the legacy
|
||||
* "yyyyMMdd" format and the current "yyyy-MM-dd HH:mm:ss" format. */
|
||||
fun lotwCursorDate(cursor: String): String = when {
|
||||
cursor.length >= 10 && cursor[4] == '-' -> cursor.substring(0, 10).replace("-", "")
|
||||
else -> cursor.take(8)
|
||||
}
|
||||
|
||||
/** qso_qslsince value for the API: passes the full timestamp through and
|
||||
* normalizes a legacy "yyyyMMdd" cursor to the documented "yyyy-MM-dd". */
|
||||
fun lotwCursorApi(cursor: String): String = when {
|
||||
cursor.isBlank() -> ""
|
||||
cursor.length >= 10 && cursor[4] == '-' -> cursor
|
||||
else -> "${cursor.take(4)}-${cursor.substring(4, 6)}-${cursor.substring(6, 8)}"
|
||||
}
|
||||
|
||||
/** Stored cursor parsed to UTC epoch ms for display; 0 when unparseable. */
|
||||
fun lotwCursorEpochMs(cursor: String): Long = try {
|
||||
val pattern = if (cursor.length >= 10 && cursor[4] == '-') "yyyy-MM-dd HH:mm:ss" else "yyyyMMdd"
|
||||
SimpleDateFormat(pattern, Locale.US)
|
||||
.apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||
.parse(cursor)?.time ?: 0L
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the effective sync mode for a given request:
|
||||
* - an explicit [LoTWSyncMode.Full] request is always honored;
|
||||
* - an [LoTWSyncMode.Incremental] request is honored only when the callsign
|
||||
* matches the last sync — a first sync, an unknown/empty record or a
|
||||
* callsign change falls back to [LoTWSyncMode.Full] so no stale grids from
|
||||
* another account linger.
|
||||
* With [requested] == null (automatic sync) it picks incremental whenever
|
||||
* possible and full otherwise.
|
||||
*/
|
||||
fun resolveLoTWSyncMode(
|
||||
lastSyncCallsign: String,
|
||||
callsign: String,
|
||||
requested: LoTWSyncMode?
|
||||
): LoTWSyncMode {
|
||||
if (requested == LoTWSyncMode.Full) return LoTWSyncMode.Full
|
||||
val sameCall = lastSyncCallsign.isNotBlank() && lastSyncCallsign == callsign
|
||||
return if (sameCall) LoTWSyncMode.Incremental else LoTWSyncMode.Full
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the automatic LoTW sync should run now — mirrors the ephemeris
|
||||
* auto-update check: credentials configured, the LoTW auto-sync toggle on, at
|
||||
* least one prior manual sync (the first sync stays manual/full), and not
|
||||
* already synced today (ARRL limits report pulls, ~once a day per account).
|
||||
*/
|
||||
fun shouldAutoSyncLoTW(
|
||||
isConfigured: Boolean,
|
||||
autoLotwSyncEnabled: Boolean,
|
||||
lastSyncDate: String,
|
||||
today: String
|
||||
): Boolean {
|
||||
val lastDate = lotwCursorDate(lastSyncDate)
|
||||
return isConfigured && autoLotwSyncEnabled && lastDate.isNotBlank() && lastDate != today
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a successful LoTW report to the stored grid data and advances the
|
||||
* sync cursor so the next incremental pull asks LoTW for only the
|
||||
* confirmations since today. Persistence runs on the IO dispatcher — building
|
||||
* the per-grid QSO JSON and writing SharedPreferences blocks the calling
|
||||
* thread, and for large accounts (multi-MB JSON) that froze the main thread
|
||||
* after a manual sync ('bar done but app stuck').
|
||||
*
|
||||
* @return the resulting number of worked grids.
|
||||
*/
|
||||
suspend fun applyLoTWGridResult(
|
||||
settingsRepo: ISettingsRepo,
|
||||
result: LoTWResult.Success,
|
||||
mode: LoTWSyncMode,
|
||||
callsign: String,
|
||||
now: Long = System.currentTimeMillis()
|
||||
): Int {
|
||||
val existingGrids = settingsRepo.getWorkedGrids()
|
||||
val existingQsos = settingsRepo.getWorkedGridQsos()
|
||||
val existingRoamed = settingsRepo.getRoamedGrids()
|
||||
val mergedGrids = if (mode == LoTWSyncMode.Incremental) existingGrids + result.grids else result.grids
|
||||
val mergedQsos = if (mode == LoTWSyncMode.Incremental) mergeGridQsos(existingQsos, result.qsos) else result.qsos
|
||||
val mergedRoamed = if (mode == LoTWSyncMode.Incremental) existingRoamed + result.roamedGrids else result.roamedGrids
|
||||
settingsRepo.setLastLotwSyncDate(lotwSyncCursor(now))
|
||||
settingsRepo.setLastLotwSyncCallsign(callsign)
|
||||
withContext(Dispatchers.IO) {
|
||||
settingsRepo.setWorkedGrids(mergedGrids)
|
||||
settingsRepo.setWorkedGridQsos(mergedQsos)
|
||||
settingsRepo.setRoamedGrids(mergedRoamed)
|
||||
}
|
||||
return mergedGrids.size
|
||||
}
|
||||
|
||||
/** Merge fresh QSO detail into existing per-grid lists, dedup by call + QSO time. */
|
||||
fun mergeGridQsos(
|
||||
existing: Map<String, List<GridQso>>,
|
||||
fresh: Map<String, List<GridQso>>
|
||||
): Map<String, List<GridQso>> {
|
||||
val merged = existing.mapValues { (_, list) -> list.toMutableList() }.toMutableMap()
|
||||
fresh.forEach { (grid, list) ->
|
||||
val target = merged.getOrPut(grid) { mutableListOf() }
|
||||
val known = target.mapTo(mutableSetOf()) { it.call to it.epochMs }
|
||||
list.forEach { qso ->
|
||||
if (known.add(qso.call to qso.epochMs)) target.add(qso)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/** Result of a LoTW report fetch, with the failure cause kept explicit. */
|
||||
sealed class LoTWResult {
|
||||
|
||||
/** Report downloaded and parsed successfully. */
|
||||
data class Success(
|
||||
val grids: Set<String>,
|
||||
val qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>,
|
||||
/** Distinct 4-char gridsquares the account itself operated from
|
||||
* (ADIF <MY_GRIDSQUARE>, satellite QSOs only) — "roamed/activated" grids. */
|
||||
val roamedGrids: Set<String> = emptySet()
|
||||
) : LoTWResult()
|
||||
|
||||
/** HTTP 200 but LoTW replied with its login-error page (bad callsign/password). */
|
||||
data object BadCredentials : LoTWResult()
|
||||
|
||||
/**
|
||||
* Report endpoint refused the request without a usable error page. LoTW
|
||||
* limits downloads to one in progress per user id and flags accounts that
|
||||
* pull the full report too often; the server answers those with a bare
|
||||
* non-ADIF body (no <eoh>).
|
||||
*/
|
||||
data object RateLimited : LoTWResult()
|
||||
|
||||
/** Connect/read timed out — typically a slow route to the ARRL servers. */
|
||||
data object Timeout : LoTWResult()
|
||||
|
||||
/** Any other network-level failure (DNS, TLS, connection reset, HTTP 5xx). */
|
||||
data class NetworkError(val detail: String) : LoTWResult()
|
||||
}
|
||||
|
||||
/** What a LoTW sync does: pull everything, or only new QSLs since the last sync. */
|
||||
enum class LoTWSyncMode { Full, Incremental }
|
||||
@@ -29,8 +29,13 @@ interface ILocalSource {
|
||||
suspend fun insertEntries(entries: List<OrbitalData>)
|
||||
suspend fun deleteEntries()
|
||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||
suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int>
|
||||
suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int>
|
||||
suspend fun getRadiosTotal(): Int
|
||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||
suspend fun insertRadios(radios: List<SatRadio>)
|
||||
suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean = false)
|
||||
|
||||
/** Delete all non-custom transceivers, keeping manually imported ones. */
|
||||
suspend fun deleteManagedRadios()
|
||||
suspend fun deleteRadios()
|
||||
}
|
||||
@@ -21,6 +21,8 @@ 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",
|
||||
"BI4PYM AutoTLE (GitHub)" to "https://raw.githubusercontent.com/BI4PYM/AutoTLE/refs/heads/master/AutoTLE.txt",
|
||||
"BI4PYM AutoTLE (Mirror)" to "https://autotle.bi4pym.cn/AutoTLE.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",
|
||||
@@ -55,4 +57,25 @@ object Sources {
|
||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
|
||||
"R4UAB" to "https://r4uab.ru/transmitters.json"
|
||||
)
|
||||
/**
|
||||
* Hardcoded AMSAT Live FM satellites (NORAD catnums):
|
||||
* SO-50 (27607), ISS ZARYA (25544), AO-123 ASRTU-1 (61781).
|
||||
* Replaces the AMSAT live-page fetch: stable on any network, no
|
||||
* sync-time dependency, no stale-list or timeout failure modes.
|
||||
*/
|
||||
val amSatFmCatnums = setOf(27607, 25544, 61781)
|
||||
|
||||
/**
|
||||
* Hardcoded AMSAT Live linear (SSB/CW) satellites:
|
||||
* RS-44 (44909), FO-29 (24278), AO-7 (7530),
|
||||
* AO-73 FUNcube-1 (39444), JO-97 JY1SAT (43803).
|
||||
*/
|
||||
val amSatLinearCatnums = setOf(44909, 24278, 7530, 39444, 43803)
|
||||
|
||||
/** Virtual satellite-selection types: transponder/activity filters shown
|
||||
* at the top of the type picker. They resolve to live lists (hardcoded
|
||||
* FM/Linear catnum sets or mode=SSTV radios) instead of persisted
|
||||
* per-type IDs, and are mutually exclusive with the regular TLE-source
|
||||
* types in the picker. */
|
||||
val virtualTypeNames = listOf("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV")
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AdifCodecTest {
|
||||
|
||||
@Test
|
||||
fun encodeDecode_roundTrip() {
|
||||
val record = QsoRecord(
|
||||
startUtcMillis = 1_700_000_000_000L,
|
||||
endUtcMillis = 1_700_000_000_100L,
|
||||
theirCallsign = "BG5JVM",
|
||||
myCallsign = "BA7OPF",
|
||||
theirGrid = "OM60",
|
||||
myGrid = "OL62TI",
|
||||
sentReport = "59",
|
||||
receivedReport = "59",
|
||||
txFrequencyHz = 145_850_000L,
|
||||
rxFrequencyHz = 436_805_000L,
|
||||
band = "2M",
|
||||
rxBand = "70CM",
|
||||
mode = "FM",
|
||||
satelliteName = "SO-50",
|
||||
satelliteMode = "FM",
|
||||
propagationMode = "SAT",
|
||||
status = QsoStatus.COMPLETE,
|
||||
lotwConfirmed = true,
|
||||
lotwQslDate = "2026-09-20",
|
||||
vuccGrids = listOf("OM60", "OM50")
|
||||
)
|
||||
val decoded = AdifCodec.decode(AdifCodec.encode(listOf(record))).single()
|
||||
assertEquals(record.startUtcMillis, decoded.startUtcMillis)
|
||||
assertEquals(record.theirCallsign, decoded.theirCallsign)
|
||||
assertEquals(record.myCallsign, decoded.myCallsign)
|
||||
assertEquals(record.satelliteName, decoded.satelliteName)
|
||||
assertEquals("2M", decoded.band)
|
||||
assertEquals("70CM", decoded.rxBand)
|
||||
assertTrue(decoded.lotwConfirmed)
|
||||
assertEquals(listOf("OM60", "OM50"), decoded.vuccGrids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decode_lotwStyleReportFields() {
|
||||
// LoTW report subset: lowercase eor, alphabetical fields, VUCC_GRIDS list.
|
||||
val adi = """
|
||||
<EOH>
|
||||
<CALL:6>BH6RJD
|
||||
<QSO_DATE:8>20260916
|
||||
<TIME_ON:6>051300
|
||||
<MODE:2>FM
|
||||
<SAT_NAME:5>SO-50
|
||||
<PROP_MODE:3>SAT
|
||||
<BAND:3>2M
|
||||
<BAND_RX:4>70CM
|
||||
<GRIDSQUARE:4>OM60
|
||||
<MY_GRIDSQUARE:8>OM60IL70
|
||||
<VUCC_GRIDS:9>OM60,OM50
|
||||
<STATION_CALLSIGN:6>BH6RJD
|
||||
<DXCC:3>318
|
||||
<COUNTRY:5>China
|
||||
<LOTW_QSL_RCVD:1>Y
|
||||
<eor>
|
||||
""".trimIndent()
|
||||
val records = AdifCodec.decode(adi)
|
||||
assertEquals(1, records.size)
|
||||
val record = records.single()
|
||||
assertEquals("BH6RJD", record.theirCallsign)
|
||||
assertEquals("SO-50", record.satelliteName)
|
||||
assertEquals("FM", record.displayMode)
|
||||
assertEquals("OM60IL70", record.myGrid)
|
||||
assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
|
||||
assertTrue(record.lotwConfirmed)
|
||||
assertTrue(record.isSatellite)
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class LogbookMergeTest {
|
||||
|
||||
private val local = QsoRecord(
|
||||
startUtcMillis = 1_700_000_000_000L,
|
||||
theirCallsign = "bg5jvm",
|
||||
myCallsign = "ba7opf",
|
||||
txFrequencyHz = 145_850_000L,
|
||||
mode = "FM",
|
||||
submode = "",
|
||||
satelliteName = "SO-50 (SaudiOSCAR 50)",
|
||||
propagationMode = "SAT",
|
||||
status = QsoStatus.COMPLETE
|
||||
)
|
||||
|
||||
private val confirmed = QsoRecord(
|
||||
startUtcMillis = 1_700_000_000_000L,
|
||||
theirCallsign = "BG5JVM",
|
||||
myCallsign = "BA7OPF",
|
||||
mode = "FM",
|
||||
submode = "",
|
||||
satelliteName = "SO-50",
|
||||
propagationMode = "SAT",
|
||||
status = QsoStatus.COMPLETE,
|
||||
lotwConfirmed = true,
|
||||
vuccGrids = listOf("OM60", "OM50")
|
||||
)
|
||||
|
||||
@Test
|
||||
fun satMatchKey_stripsParenthetical() {
|
||||
assertEquals("SO-50", satMatchKey("SO-50 (SaudiOSCAR 50)"))
|
||||
assertEquals("SO-50", satMatchKey("SO-50"))
|
||||
assertEquals("AO-91", satMatchKey("AO-91 (FUNcube-1)"))
|
||||
assertEquals("IO-86", satMatchKey("IO-86"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameConfirmedContact_matchesAcrossSatNOGSAndOfficialName() {
|
||||
assertTrue(sameConfirmedContact(local, confirmed))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameConfirmedContact_rejectsDifferentSatellite() {
|
||||
val other = confirmed.copy(satelliteName = "AO-91")
|
||||
assertFalse(sameConfirmedContact(local, other))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameConfirmedContact_rejectsTimeSkew() {
|
||||
val skewed = confirmed.copy(startUtcMillis = confirmed.startUtcMillis + 120_000L)
|
||||
assertFalse(sameConfirmedContact(local, skewed))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun confirmationLookupKey_usesNormalizedSatellite() {
|
||||
assertEquals(local.confirmationLookupKey(), confirmed.confirmationLookupKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withConfirmation_marksConfirmedAndMergesVuccGrids() {
|
||||
val merged = local.withConfirmation(confirmed)
|
||||
assertTrue(merged.lotwConfirmed)
|
||||
assertTrue(merged.lotwReceived)
|
||||
assertEquals(listOf("OM60", "OM50"), merged.vuccGrids)
|
||||
// Local myCallsign is kept when present; only blanks are backfilled.
|
||||
assertEquals("ba7opf", merged.myCallsign)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toConfirmedRecord_mapsGridQso() {
|
||||
val qso = GridQso(
|
||||
call = "BH6RJD",
|
||||
epochMs = 1_700_000_000_000L,
|
||||
satName = "SO-50",
|
||||
mode = "FM",
|
||||
bandUp = "70CM",
|
||||
bandDown = "2M",
|
||||
dxcc = 318,
|
||||
country = "China",
|
||||
cqz = 24,
|
||||
state = "GD",
|
||||
myGrid = "OM60",
|
||||
myGrids = setOf("OM60", "OM50")
|
||||
)
|
||||
val record = qso.toConfirmedRecord("ba7opf")
|
||||
assertEquals("BH6RJD", record.theirCallsign)
|
||||
assertEquals("BA7OPF", record.myCallsign)
|
||||
assertEquals("OM60", record.myGrid)
|
||||
assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
|
||||
assertTrue(record.lotwConfirmed)
|
||||
assertTrue(record.isSatellite)
|
||||
assertEquals("70CM", record.band)
|
||||
assertEquals("2M", record.rxBand)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun frequencyBand_edges() {
|
||||
assertEquals("2M", frequencyBand(144_000_000L))
|
||||
assertEquals("70CM", frequencyBand(450_000_000L))
|
||||
assertEquals("10M", frequencyBand(29_000_000L))
|
||||
assertEquals("", frequencyBand(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun displayMode_prefersSubmode() {
|
||||
val ft4 = QsoRecord(startUtcMillis = 0, theirCallsign = "x", myCallsign = "y", mode = "MFSK", submode = "FT4")
|
||||
assertEquals("FT4", ft4.displayMode)
|
||||
assertEquals("FM", local.displayMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun displayMode_ignoresStaleSubmodeForOtherModes() {
|
||||
// Regression: records persisted with the old default submode="FT4" must not
|
||||
// show FT4 when the actual mode is FM/CW/SSB.
|
||||
val staleFm = QsoRecord(startUtcMillis = 0, theirCallsign = "x", myCallsign = "y", mode = "FM", submode = "FT4")
|
||||
val staleCw = QsoRecord(startUtcMillis = 0, theirCallsign = "x", myCallsign = "y", mode = "CW", submode = "FT4")
|
||||
assertEquals("FM", staleFm.displayMode)
|
||||
assertEquals("CW", staleCw.displayMode)
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AwardCalculatorTest {
|
||||
|
||||
private fun qso(
|
||||
call: String,
|
||||
dxcc: Int? = null,
|
||||
cqz: Int? = null,
|
||||
state: String? = null,
|
||||
grid: String = "OL62"
|
||||
) = GridQso(
|
||||
call = call, epochMs = 0L, satName = "SO-50", mode = "FM",
|
||||
bandUp = "70CM", bandDown = "2M", dxcc = dxcc, cqz = cqz, state = state
|
||||
)
|
||||
|
||||
private fun byGrid(vararg qsos: GridQso) =
|
||||
qsos.groupBy { "OL62" } // all share one grid for award-math tests
|
||||
|
||||
@Test
|
||||
fun `vucc counts distinct grid keys`() {
|
||||
val qsos = mapOf(
|
||||
"OL62" to listOf(qso("BG7XYZ")),
|
||||
"PM95" to listOf(qso("JH0ABC")),
|
||||
"OL62" to listOf(qso("BA7AAA"))
|
||||
)
|
||||
// map literal dedupes keys: OL62/PM95 -> 2 grids
|
||||
val progress = AwardCalculator.calculate(qsos).first { it.type == AwardType.VUCC }
|
||||
assertEquals(2, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("OL62", "PM95")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dxcc counts distinct entities from dxcc field`() {
|
||||
val all = listOf(
|
||||
qso("BG7XYZ", dxcc = 318),
|
||||
qso("JH0ABC", dxcc = 339),
|
||||
qso("YD1XYZ", dxcc = 327),
|
||||
qso("BG7ABC", dxcc = 318)
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.DXCC }
|
||||
assertEquals(3, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("318", "339", "327")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dxcc falls back to prefix when dxcc missing`() {
|
||||
val all = listOf(qso("JH0ABC"), qso("BG7XYZ"), qso("VR2XYZ"), qso("XX9A"))
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.DXCC }
|
||||
assertEquals(4, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("339", "318", "321", "152")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wapc counts mainland provinces plus hk mo tw`() {
|
||||
val all = listOf(
|
||||
qso("BG7XYZ", dxcc = 318, state = "GD"), // Guangdong
|
||||
qso("BD5ABC", dxcc = 318, state = "ZJ"), // Zhejiang
|
||||
qso("BD5ABC", dxcc = 318, state = "ZJ"), // dup
|
||||
qso("VR2X", dxcc = 321), // Hong Kong
|
||||
qso("XX9A", dxcc = 152), // Macao
|
||||
qso("BM4X", dxcc = 386) // Taiwan
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAPC }
|
||||
assertEquals(5, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("GD", "ZJ", "HK", "MO", "TW")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wapc ignores non-china states and missing states`() {
|
||||
val all = listOf(
|
||||
qso("JH0ABC", dxcc = 339, state = "34"), // Japan state ignored
|
||||
qso("BG7XYZ", dxcc = 318, state = null), // missing province
|
||||
qso("K1ABC", dxcc = 291, state = "HI") // Hawaii != Hainan
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAPC }
|
||||
assertEquals(0, progress.count)
|
||||
assertTrue(progress.workedKeys.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `waja counts japanese prefectures only`() {
|
||||
val all = listOf(
|
||||
qso("JH0ABC", dxcc = 339, state = "10"), // Tokyo
|
||||
qso("JH0ABC", dxcc = 339, state = "47"), // Okinawa
|
||||
qso("JH0ABC", dxcc = 339, state = "10"), // dup
|
||||
qso("BG7XYZ", dxcc = 318, state = "10"), // Chinese call, state=10 must NOT count
|
||||
qso("JE2XYZ", dxcc = 339, state = null) // missing
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAJA }
|
||||
assertEquals(2, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("10", "47")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `waz counts distinct cq zones in range`() {
|
||||
val all = listOf(
|
||||
qso("BG7XYZ", cqz = 24),
|
||||
qso("JH0ABC", cqz = 25),
|
||||
qso("YD1XYZ", cqz = 28),
|
||||
qso("BG7AAA", cqz = 24),
|
||||
qso("BG7BBB", cqz = null),
|
||||
qso("BG7CCC", cqz = 0)
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAZ }
|
||||
assertEquals(3, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("24", "25", "28")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `was counts us states only`() {
|
||||
val all = listOf(
|
||||
qso("K1ABC", dxcc = 291, state = "CA"),
|
||||
qso("W2XYZ", dxcc = 291, state = "NY"),
|
||||
qso("N3QRS", dxcc = 291, state = "ca"), // lowercase normalized
|
||||
qso("BG7XYZ", dxcc = 318, state = "CA"), // not US
|
||||
qso("K1AAA", dxcc = 291, state = "XX") // invalid state
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAS }
|
||||
assertEquals(2, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("CA", "NY")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all six awards present with correct targets`() {
|
||||
val progress = AwardCalculator.calculate(emptyMap())
|
||||
assertEquals(6, progress.size)
|
||||
assertEquals(100, progress.first { it.type == AwardType.DXCC }.target)
|
||||
assertEquals(100, progress.first { it.type == AwardType.VUCC }.target)
|
||||
assertEquals(34, progress.first { it.type == AwardType.WAPC }.target)
|
||||
assertEquals(47, progress.first { it.type == AwardType.WAJA }.target)
|
||||
assertEquals(40, progress.first { it.type == AwardType.WAZ }.target)
|
||||
assertEquals(50, progress.first { it.type == AwardType.WAS }.target)
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.predict
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
import kotlin.math.abs
|
||||
|
||||
class CelestialComputerTest {
|
||||
|
||||
private data class RiseSetCase(
|
||||
val name: String,
|
||||
val observer: GeoPos,
|
||||
val startIso: String
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `findSunRiseSet returns distinct sunrise and sunset for representative locations`() {
|
||||
val cases = listOf(
|
||||
RiseSetCase("Equator at March equinox", GeoPos(0.0, 0.0), "2026-03-20T00:00:00Z"),
|
||||
RiseSetCase("Equator at September equinox", GeoPos(0.0, 0.0), "2026-09-23T00:00:00Z"),
|
||||
RiseSetCase("Sydney winter", GeoPos(-33.8688, 151.2093), "2026-06-21T00:00:00Z"),
|
||||
RiseSetCase("Buenos Aires winter", GeoPos(-34.6037, -58.3816), "2026-06-21T00:00:00Z"),
|
||||
RiseSetCase("Cape Town winter", GeoPos(-33.9249, 18.4241), "2026-06-21T00:00:00Z"),
|
||||
RiseSetCase("London summer", GeoPos(51.5074, -0.1278), "2026-06-21T00:00:00Z")
|
||||
)
|
||||
|
||||
cases.forEach { testCase ->
|
||||
val result = CelestialComputer.findSunRiseSet(testCase.observer, testCase.startIso.toMillis())
|
||||
val daylightDuration = result.setTimeMillis - result.riseTimeMillis
|
||||
|
||||
assertTrue("${testCase.name}: sunrise should be non-zero", result.riseTimeMillis > 0L)
|
||||
assertTrue("${testCase.name}: sunset should be non-zero", result.setTimeMillis > 0L)
|
||||
assertTrue("${testCase.name}: sunset should be after sunrise", result.setTimeMillis > result.riseTimeMillis)
|
||||
assertTrue("${testCase.name}: daylight duration should be longer than 1 hour", daylightDuration > HOUR_MILLIS)
|
||||
assertTrue("${testCase.name}: daylight duration should be shorter than 24 hours", daylightDuration < DAY_MILLIS)
|
||||
|
||||
val riseElevation = CelestialComputer.getSunPosition(testCase.observer, result.riseTimeMillis).elevation
|
||||
val setElevation = CelestialComputer.getSunPosition(testCase.observer, result.setTimeMillis).elevation
|
||||
assertEquals("${testCase.name}: sunrise should converge near the standard threshold", SUNRISE_SET_THRESHOLD, riseElevation, 0.02)
|
||||
assertEquals("${testCase.name}: sunset should converge near the standard threshold", SUNRISE_SET_THRESHOLD, setElevation, 0.02)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findSunRiseSet does not return the same instant for equinox regression cases`() {
|
||||
listOf("2026-03-20T00:00:00Z", "2026-09-23T00:00:00Z").forEach { startIso ->
|
||||
val result = CelestialComputer.findSunRiseSet(GeoPos(0.0, 0.0), startIso.toMillis())
|
||||
val separationMillis = abs(result.setTimeMillis - result.riseTimeMillis)
|
||||
|
||||
assertTrue("$startIso: sunrise and sunset should be separated", separationMillis > HOUR_MILLIS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toMillis(): Long = Instant.parse(this).toEpochMilli()
|
||||
|
||||
private companion object {
|
||||
private const val SUNRISE_SET_THRESHOLD = -0.8333
|
||||
private const val HOUR_MILLIS = 60L * 60L * 1000L
|
||||
private const val DAY_MILLIS = 24L * HOUR_MILLIS
|
||||
}
|
||||
}
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
import com.rtbishop.look4sat.core.domain.model.LoTWSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.MarkedStation
|
||||
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.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class LoTWGridSyncTest {
|
||||
|
||||
// 2026-09-16T00:00:00Z — fixed "now" for the cursor assertions.
|
||||
private val now = 1_789_516_800_000L
|
||||
|
||||
// region resolveLoTWSyncMode
|
||||
|
||||
@Test
|
||||
fun resolveModeHonorsExplicitFull() {
|
||||
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("BA7OPF", "BA7OPF", LoTWSyncMode.Full))
|
||||
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("", "BA7OPF", LoTWSyncMode.Full))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveModeAllowsIncrementalOnlyForSameCallsign() {
|
||||
assertEquals(
|
||||
LoTWSyncMode.Incremental,
|
||||
resolveLoTWSyncMode("BA7OPF", "BA7OPF", LoTWSyncMode.Incremental)
|
||||
)
|
||||
assertEquals(
|
||||
LoTWSyncMode.Full,
|
||||
resolveLoTWSyncMode("BG7ABC", "BA7OPF", LoTWSyncMode.Incremental)
|
||||
)
|
||||
assertEquals(
|
||||
LoTWSyncMode.Full,
|
||||
resolveLoTWSyncMode("", "BA7OPF", LoTWSyncMode.Incremental)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveModeForAutoSyncPicksIncrementalWhenPossible() {
|
||||
assertEquals(LoTWSyncMode.Incremental, resolveLoTWSyncMode("BA7OPF", "BA7OPF", null))
|
||||
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("BG7ABC", "BA7OPF", null))
|
||||
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("", "BA7OPF", null))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region shouldAutoSyncLoTW
|
||||
|
||||
@Test
|
||||
fun autoSyncGateRequiresEverything() {
|
||||
val today = "20260916"
|
||||
// Not configured.
|
||||
assertFalse(shouldAutoSyncLoTW(false, true, "2026-09-15 10:00:00", today))
|
||||
// LoTW auto-sync toggle off.
|
||||
assertFalse(shouldAutoSyncLoTW(true, false, "2026-09-15 10:00:00", today))
|
||||
// Never synced manually — first sync stays manual/full.
|
||||
assertFalse(shouldAutoSyncLoTW(true, true, "", today))
|
||||
// Already synced today (both legacy and current cursor formats) — ARRL rate-limit guard.
|
||||
assertFalse(shouldAutoSyncLoTW(true, true, "20260916", today))
|
||||
assertFalse(shouldAutoSyncLoTW(true, true, "2026-09-16 08:00:00", today))
|
||||
// All gates open — yesterday in either format.
|
||||
assertTrue(shouldAutoSyncLoTW(true, true, "20260915", today))
|
||||
assertTrue(shouldAutoSyncLoTW(true, true, "2026-09-15 10:00:00", today))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region cursor helpers
|
||||
|
||||
@Test
|
||||
fun cursorApiNormalizesLegacyAndPassesFullThrough() {
|
||||
assertEquals("2026-09-16", lotwCursorApi("20260916"))
|
||||
assertEquals("2026-09-16 08:16:02", lotwCursorApi("2026-09-16 08:16:02"))
|
||||
assertEquals("", lotwCursorApi(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cursorDateToleratesBothFormats() {
|
||||
assertEquals("20260916", lotwCursorDate("20260916"))
|
||||
assertEquals("20260916", lotwCursorDate("2026-09-16 08:16:02"))
|
||||
assertEquals("", lotwCursorDate(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cursorEpochMsParsesBothFormatsUtc() {
|
||||
// 2026-09-16T00:00:00Z in both spellings.
|
||||
assertEquals(now, lotwCursorEpochMs("20260916"))
|
||||
assertEquals(now, lotwCursorEpochMs("2026-09-16 00:00:00"))
|
||||
assertEquals(0L, lotwCursorEpochMs(""))
|
||||
assertEquals(0L, lotwCursorEpochMs("garbage"))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region mergeGridQsos
|
||||
|
||||
@Test
|
||||
fun mergeGridQsosDedupsByCallAndTime() {
|
||||
val existing = mapOf(
|
||||
"OL62" to listOf(qso("BA7ABC", 1000L)),
|
||||
"OL63" to listOf(qso("BA7DEF", 2000L))
|
||||
)
|
||||
val fresh = mapOf(
|
||||
// Same grid, same QSO re-delivered by an overlapping since-date.
|
||||
"OL62" to listOf(qso("BA7ABC", 1000L)),
|
||||
// Same grid, brand-new QSO.
|
||||
"OL62" to listOf(qso("BA7GHI", 3000L)),
|
||||
// New grid entirely.
|
||||
"PM95" to listOf(qso("BA7JKL", 4000L))
|
||||
)
|
||||
val merged = mergeGridQsos(existing, fresh)
|
||||
assertEquals(setOf("OL62", "OL63", "PM95"), merged.keys)
|
||||
assertEquals(2, merged["OL62"]?.size) // BA7ABC once, BA7GHI added
|
||||
assertEquals(1, merged["OL63"]?.size)
|
||||
assertEquals(1, merged["PM95"]?.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeGridQsosHandlesEmptySides() {
|
||||
val fresh = mapOf("OL62" to listOf(qso("BA7ABC", 1000L)))
|
||||
assertEquals(fresh, mergeGridQsos(emptyMap(), fresh))
|
||||
// An empty incremental pull must leave the stored side untouched.
|
||||
assertEquals(fresh, mergeGridQsos(fresh, emptyMap()))
|
||||
assertEquals(emptyMap<String, List<GridQso>>(), mergeGridQsos(emptyMap(), emptyMap()))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region applyLoTWGridResult
|
||||
|
||||
@Test
|
||||
fun applyIncrementalMergesAndAdvancesCursor() = runBlocking {
|
||||
val repo = FakeSettingsRepo().apply {
|
||||
setWorkedGrids(setOf("OL62"))
|
||||
setWorkedGridQsos(mapOf("OL62" to listOf(qso("BA7ABC", 1000L))))
|
||||
setRoamedGrids(setOf("OL62"))
|
||||
setLastLotwSyncDate("20260915")
|
||||
setLastLotwSyncCallsign("BA7OPF")
|
||||
}
|
||||
val result = LoTWResult.Success(
|
||||
grids = setOf("OL62", "PM95"),
|
||||
qsos = mapOf("OL62" to listOf(qso("BA7ABC", 1000L)), "PM95" to listOf(qso("BA7GHI", 2000L))),
|
||||
roamedGrids = setOf("PM95")
|
||||
)
|
||||
val count = applyLoTWGridResult(repo, result, LoTWSyncMode.Incremental, "BA7OPF", now)
|
||||
|
||||
assertEquals(2, count)
|
||||
assertEquals(setOf("OL62", "PM95"), repo.getWorkedGrids())
|
||||
assertEquals(1, repo.getWorkedGridQsos()["OL62"]?.size) // deduped
|
||||
assertEquals(setOf("OL62", "PM95"), repo.getRoamedGrids())
|
||||
assertEquals("2026-09-16 00:00:00", repo.getLastLotwSyncDate())
|
||||
assertEquals("BA7OPF", repo.getLastLotwSyncCallsign())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun applyFullReplacesEverythingAndAdvancesCursor() = runBlocking {
|
||||
val repo = FakeSettingsRepo().apply {
|
||||
setWorkedGrids(setOf("OL62", "STALE"))
|
||||
setWorkedGridQsos(mapOf("STALE" to listOf(qso("BG7OLD", 1L))))
|
||||
setRoamedGrids(setOf("STALE"))
|
||||
setLastLotwSyncDate("20260910")
|
||||
setLastLotwSyncCallsign("BG7OLD")
|
||||
}
|
||||
val result = LoTWResult.Success(
|
||||
grids = setOf("PM95"),
|
||||
qsos = mapOf("PM95" to listOf(qso("BA7GHI", 2000L))),
|
||||
roamedGrids = setOf("PM95")
|
||||
)
|
||||
val count = applyLoTWGridResult(repo, result, LoTWSyncMode.Full, "BA7OPF", now)
|
||||
|
||||
assertEquals(1, count)
|
||||
assertEquals(setOf("PM95"), repo.getWorkedGrids())
|
||||
assertEquals(setOf("PM95"), repo.getWorkedGridQsos().keys)
|
||||
assertEquals(setOf("PM95"), repo.getRoamedGrids())
|
||||
assertEquals("2026-09-16 00:00:00", repo.getLastLotwSyncDate())
|
||||
assertEquals("BA7OPF", repo.getLastLotwSyncCallsign())
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
private fun qso(call: String, epochMs: Long): GridQso =
|
||||
GridQso(call, epochMs, "FO-29", "FM", "70CM", "2M")
|
||||
|
||||
/**
|
||||
* Minimal ISettingsRepo fake backing only the LoTW sync surface; the rest
|
||||
* throws (mirrors the FakeSettingsRepo pattern used in feature tests).
|
||||
*/
|
||||
private class FakeSettingsRepo : ISettingsRepo {
|
||||
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(23.13, 113.26))
|
||||
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 = 10.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(
|
||||
stateOfAutoUpdate = true,
|
||||
stateOfSensors = false,
|
||||
stateOfSweep = false,
|
||||
stateOfUtc = false,
|
||||
stateOfLightTheme = false,
|
||||
stateOfNightMode = false,
|
||||
stateOfMapGrid = false,
|
||||
shouldSeeWarning = false,
|
||||
shouldSeeWhatsNew = false
|
||||
)
|
||||
)
|
||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
|
||||
DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList())
|
||||
)
|
||||
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
|
||||
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
|
||||
|
||||
private var workedGrids: Set<String> = emptySet()
|
||||
private var workedGridQsos: Map<String, List<GridQso>> = emptyMap()
|
||||
private var roamedGrids: Set<String> = emptySet()
|
||||
private var lastSyncDate: String = ""
|
||||
private var lastSyncCallsign: String = ""
|
||||
|
||||
override fun getWorkedGrids(): Set<String> = workedGrids
|
||||
override fun setWorkedGrids(grids: Set<String>) { workedGrids = grids }
|
||||
override fun getWorkedGridQsos(): Map<String, List<GridQso>> = workedGridQsos
|
||||
override fun setWorkedGridQsos(qsos: Map<String, List<GridQso>>) { workedGridQsos = qsos }
|
||||
override fun getRoamedGrids(): Set<String> = roamedGrids
|
||||
override fun setRoamedGrids(grids: Set<String>) { roamedGrids = grids }
|
||||
private var markedGridStations: Map<String, List<MarkedStation>> = emptyMap()
|
||||
override fun getMarkedGridStations(): Map<String, List<MarkedStation>> = markedGridStations
|
||||
override fun setMarkedGridStations(stations: Map<String, List<MarkedStation>>) { markedGridStations = stations }
|
||||
override fun getLastLotwSyncDate(): String = lastSyncDate
|
||||
override fun setLastLotwSyncDate(date: String) { lastSyncDate = date }
|
||||
override fun getLastLotwSyncCallsign(): String = lastSyncCallsign
|
||||
override fun setLastLotwSyncCallsign(callsign: String) { lastSyncCallsign = callsign }
|
||||
|
||||
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 fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
override fun getSatelliteMode(catnum: Int): String = ""
|
||||
override fun setSatelliteMode(catnum: Int, mode: String) {}
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
override fun setAmSatCallsign(callsign: String) = TODO()
|
||||
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
|
||||
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
|
||||
}
|
||||
}
|
||||
+185
-6
@@ -17,9 +17,15 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.presentation
|
||||
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.MarqueeSpacing
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -29,7 +35,9 @@ import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
@@ -49,16 +57,28 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||
@@ -82,6 +102,8 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
@@ -210,12 +232,20 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
||||
fun CardButton(
|
||||
onClick: () -> Unit,
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
isEnabled: Boolean = true
|
||||
) {
|
||||
ElevatedButton(
|
||||
onClick = onClick,
|
||||
enabled = isEnabled,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||
),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = modifier,
|
||||
@@ -316,9 +346,21 @@ fun InfoDialog(
|
||||
|
||||
@Composable
|
||||
fun SharedDialog(
|
||||
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
||||
title: String,
|
||||
onCancel: () -> Unit,
|
||||
onAccept: () -> Unit,
|
||||
acceptText: String? = null,
|
||||
acceptEnabled: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
SharedDialog(title = title, onDismissRequest = onCancel, onCancel = onCancel, onAccept = onAccept) { _ ->
|
||||
SharedDialog(
|
||||
title = title,
|
||||
onDismissRequest = onCancel,
|
||||
onCancel = onCancel,
|
||||
onAccept = onAccept,
|
||||
acceptText = acceptText,
|
||||
acceptEnabled = acceptEnabled
|
||||
) { _ ->
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -329,6 +371,8 @@ fun SharedDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
onCancel: (() -> Unit)? = null,
|
||||
onAccept: (() -> Unit)? = null,
|
||||
acceptText: String? = null,
|
||||
acceptEnabled: Boolean = true,
|
||||
titleFontSize: Int = 16,
|
||||
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
|
||||
content: @Composable (padding: Dp) -> Unit
|
||||
@@ -353,10 +397,17 @@ fun SharedDialog(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp)
|
||||
.padding(
|
||||
start = if (onCancel != null) padding else 0.dp,
|
||||
end = if (onAccept != null) padding else 0.dp
|
||||
)
|
||||
)
|
||||
if (onAccept != null) {
|
||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||
CardButton(
|
||||
onClick = onAccept,
|
||||
text = acceptText ?: stringResource(R.string.btn_accept),
|
||||
isEnabled = acceptEnabled
|
||||
)
|
||||
}
|
||||
}
|
||||
content(padding)
|
||||
@@ -418,6 +469,134 @@ fun isVerticalLayout(): Boolean = !hasEnoughWidth()
|
||||
fun Modifier.infiniteMarquee(): Modifier =
|
||||
basicMarquee(iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(16.dp))
|
||||
|
||||
/** 共享的滑动行展开状态:同一列表内同时只允许一行展开(短信式联动)。 */
|
||||
class SwipeController {
|
||||
var openKey by mutableStateOf<String?>(null)
|
||||
private set
|
||||
fun open(key: String) {
|
||||
openKey = key
|
||||
}
|
||||
fun close() {
|
||||
openKey = null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberSwipeController(): SwipeController = remember { SwipeController() }
|
||||
|
||||
/** 短信式滑动行:右划露出 [revealButton](默认红底垃圾桶+文字),点击按钮触发 [revealAction]。
|
||||
* 通过 [controller] 联动:滑开新行时其他行自动收回;点按已展开的行内容也可收回(无涟漪)。 */
|
||||
@Composable
|
||||
fun SwipeRevealRow(
|
||||
key: String,
|
||||
controller: SwipeController,
|
||||
modifier: Modifier = Modifier,
|
||||
revealWidth: Dp = 64.dp,
|
||||
revealAction: () -> Unit,
|
||||
revealButton: @Composable () -> Unit = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.error),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_delete),
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val revealPx = with(density) { revealWidth.toPx() }
|
||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val isOpen = controller.openKey == key
|
||||
|
||||
fun settle(target: Float) {
|
||||
settleJob?.cancel()
|
||||
settleJob = scope.launch {
|
||||
animate(offsetX, target, animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing)) { value, _ ->
|
||||
offsetX = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 另一行展开(或全部收回)时,本行随之收回。
|
||||
LaunchedEffect(controller.openKey) {
|
||||
if (!isOpen && offsetX < 0f) settle(0f)
|
||||
}
|
||||
|
||||
Box(modifier.fillMaxWidth().clipToBounds()) {
|
||||
// 背景删除按钮:初始位于内容右侧屏幕外,与内容同步平移(跟手滑入)。
|
||||
// 父 Box clipToBounds 裁剪容器外部分——即便外层 item 带 padding、按钮
|
||||
// 左缘落在容器外的屏幕内,也因裁剪而不可见、不可点。
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.fillMaxHeight()
|
||||
.width(revealWidth)
|
||||
.offset(x = revealWidth)
|
||||
.graphicsLayer { translationX = offsetX }
|
||||
.clickable {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
controller.close()
|
||||
settle(0f)
|
||||
revealAction()
|
||||
}
|
||||
) { revealButton() }
|
||||
// 前景内容:右划平移露出按钮
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer { translationX = offsetX }
|
||||
.pointerInput(revealPx) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { settleJob?.cancel() },
|
||||
onHorizontalDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
offsetX = (offsetX + dragAmount).coerceIn(-revealPx, 0f)
|
||||
},
|
||||
onDragEnd = {
|
||||
if (offsetX < -revealPx / 2) {
|
||||
controller.open(key)
|
||||
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||
settle(-revealPx)
|
||||
} else {
|
||||
controller.close()
|
||||
settle(0f)
|
||||
}
|
||||
},
|
||||
onDragCancel = {
|
||||
if (offsetX < -revealPx / 2) {
|
||||
controller.open(key)
|
||||
settle(-revealPx)
|
||||
} else {
|
||||
controller.close()
|
||||
settle(0f)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
// 已展开的行,点按内容直接收回(无涟漪,避免整行出现点击反馈)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
if (isOpen) {
|
||||
controller.close()
|
||||
settle(0f)
|
||||
}
|
||||
}
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Modifier.layoutPadding(): Modifier {
|
||||
val spacing = LocalSpacing.current.extraSmall
|
||||
|
||||
+4
-8
@@ -53,7 +53,8 @@ fun GridTargetChip(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val configured = grid != null && distanceKm != null && bearingDeg != null
|
||||
val accent = MaterialTheme.colorScheme.tertiaryContainer
|
||||
// The app's signature accent yellow (MaterialTheme primary in both themes).
|
||||
val accent = MaterialTheme.colorScheme.primary
|
||||
val placeholder = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -83,13 +84,8 @@ fun GridTargetChip(
|
||||
Text(
|
||||
text = distanceKm?.let { "${"%,.0f".format(it)} km" } ?: "-- km",
|
||||
fontSize = 14.sp,
|
||||
color = if (configured) Color.White else placeholder,
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = if (configured) MaterialTheme.colorScheme.onSurfaceVariant else Color.Transparent,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<!-- 冖 (秃宝盖): 顶横 + 两侧短竖, 笔画型 -->
|
||||
<path
|
||||
android:pathData="M5,4 L19,4 M5,4 L5,8.5 M19,4 L19,8.5"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:fillColor="#00000000" />
|
||||
<!-- 笔画型向上箭头 ↑: 竖杆 + 两斜线箭头头 -->
|
||||
<path
|
||||
android:pathData="M12,9 L12,20 M12,9 L7.5,14 M12,9 L16.5,14"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:fillColor="#00000000" />
|
||||
</vector>
|
||||
@@ -32,6 +32,7 @@
|
||||
<string name="pass_filter_title">Filtrar pases</string>
|
||||
<string name="pass_filter_elev">Elevación mínima</string>
|
||||
<string name="pass_filter_hours">Tiempo por delante</string>
|
||||
<string name="pass_filter_history_hours">Tiempo anterior</string>
|
||||
<string name="pass_filter_aos_time">Ventana AOS</string>
|
||||
<string name="pass_filter_invert_time">Invertir ventana AOS</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (período >225min)</string>
|
||||
@@ -147,6 +148,7 @@
|
||||
<string name="prefs_other_title">Otros ajustes</string>
|
||||
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
|
||||
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
|
||||
<string name="prefs_other_switch_lotw_sync">Sincronizar automáticamente las cuadrículas LoTW confirmadas</string>
|
||||
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
|
||||
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
|
||||
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<string name="pass_filter_title">Фильтровать пролеты</string>
|
||||
<string name="pass_filter_elev">Минимальная элевация</string>
|
||||
<string name="pass_filter_hours">Период расчёта</string>
|
||||
<string name="pass_filter_history_hours">Прошедший период</string>
|
||||
<string name="pass_filter_aos_time">Окно AOS</string>
|
||||
<string name="pass_filter_invert_time">Инвертировать окно AOS</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (период >225мин)</string>
|
||||
@@ -146,6 +147,7 @@
|
||||
<string name="prefs_other_title">Другие настройки</string>
|
||||
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
|
||||
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
|
||||
<string name="prefs_other_switch_lotw_sync">Автосинхронизация подтверждённых сеток LoTW</string>
|
||||
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
|
||||
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
|
||||
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<string name="pass_filter_title">පසුකිරිම් පිරිපහදුව</string>
|
||||
<string name="pass_filter_elev">අවම උත්තෝලනය</string>
|
||||
<string name="pass_filter_hours">ඉදිරි කාලය</string>
|
||||
<string name="pass_filter_history_hours">පෙර කාලය</string>
|
||||
<string name="pass_filter_aos_time">AOS කවුළුව</string>
|
||||
<string name="pass_filter_invert_time">AOS කවුළුව ප්රතිවිරුද්ධ කරන්න</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (කාලය >225min)</string>
|
||||
@@ -70,7 +71,7 @@
|
||||
<string name="map_next">ඊළග</string>
|
||||
<string name="map_azimuth">දිගංශය: %.1f°</string>
|
||||
<string name="map_elevation">උත්තෝලනය: %.1f°</string>
|
||||
<string name="map_altitude">උන්නතාශය: %.0f km</string>,
|
||||
<string name="map_altitude">උන්නතාශය: %.0f km</string>
|
||||
<string name="map_distance">පරතරය: %.0f km</string>
|
||||
<string name="map_latitude">අක්ෂාංශ: %.1f°</string>
|
||||
<string name="map_longitude">දේශාංශ: %.1f°</string>
|
||||
@@ -147,6 +148,7 @@
|
||||
<string name="prefs_other_title">වෙනත් සැකසුම්</string>
|
||||
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
|
||||
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
|
||||
<string name="prefs_other_switch_lotw_sync">LoTW තහවුරු කළ ජාල ස්වයං සමමුහුර්තකරණය</string>
|
||||
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
|
||||
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
|
||||
<string name="prefs_other_switch_night_mode">රතු රාත්රී පෙරහන සබල කරන්න</string>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<string name="pass_filter_title">Geçişleri filtrele</string>
|
||||
<string name="pass_filter_elev">Minimum yükseklik açısı</string>
|
||||
<string name="pass_filter_hours">Gösterilecek zaman</string>
|
||||
<string name="pass_filter_history_hours">Geçmiş süre</string>
|
||||
<string name="pass_filter_aos_time">AOS aralığı</string>
|
||||
<string name="pass_filter_invert_time">AOS aralığını tersine çevir</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (periyot >225dk)</string>
|
||||
@@ -87,7 +88,7 @@
|
||||
<!-- Map screen -->
|
||||
<string name="map_prev">Önceki</string>
|
||||
<string name="map_next">Sonraki</string>
|
||||
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
|
||||
<string name="map_copyright_osm" translatable="false">© OpenStreetMap contributors</string>
|
||||
<string name="map_azimuth">Azimut: %.1f°</string>
|
||||
<string name="map_elevation">Yükseklik açısı: %.1f°</string>
|
||||
<string name="map_altitude">İrtifa: %.0f km</string>
|
||||
@@ -189,6 +190,7 @@
|
||||
<string name="prefs_other_title">Diğer ayarlar</string>
|
||||
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
|
||||
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
|
||||
<string name="prefs_other_switch_lotw_sync">LoTW onaylı kareleri otomatik senkronize et</string>
|
||||
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
|
||||
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
|
||||
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string>
|
||||
@@ -207,7 +209,8 @@
|
||||
\n• Alexandru Csete (Gpredict)
|
||||
\n• Dr T.S. Kelso (Celestrak)
|
||||
\n• Libre Space Foundation (SatNOGS)
|
||||
\n• BG7NTA (CW decoding feature)</string>
|
||||
\n• BG7NTA BG5JSU (CW decoding feature)
|
||||
\n• BH2VSQ BG5JSU (TianDiTu rendering support)</string>
|
||||
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
|
||||
|
||||
</resources>
|
||||
@@ -32,6 +32,7 @@
|
||||
<string name="pass_filter_title">Фільтр прольотів</string>
|
||||
<string name="pass_filter_elev">Мінімальне піднесень</string>
|
||||
<string name="pass_filter_hours">Час вперед</string>
|
||||
<string name="pass_filter_history_hours">Час назад</string>
|
||||
<string name="pass_filter_aos_time">Вікно AOS</string>
|
||||
<string name="pass_filter_invert_time">Інвертувати вікно AOS</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (період >225хв)</string>
|
||||
@@ -147,6 +148,7 @@
|
||||
<string name="prefs_other_title">Інші налаштування</string>
|
||||
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
|
||||
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
|
||||
<string name="prefs_other_switch_lotw_sync">Автосинхронізація підтверджених сіток LoTW</string>
|
||||
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
|
||||
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
|
||||
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<string name="pass_filter_title">筛选过境</string>
|
||||
<string name="pass_filter_elev">仰角值</string>
|
||||
<string name="pass_filter_hours">提前时间</string>
|
||||
<string name="pass_filter_history_hours">历史过境时间</string>
|
||||
<string name="pass_filter_aos_time">AOS 时间段</string>
|
||||
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace(周期 >225分钟)</string>
|
||||
@@ -40,7 +41,9 @@
|
||||
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
||||
|
||||
<!-- AMSAT Status screen -->
|
||||
<string name="nav_amsat">AMSAT</string>
|
||||
<string name="pass_whatsnew_message">4.4.6 以来的全部更新(4.4.7 系列):\n\n• 网格模式地图:LoTW 已确认网格绿色填充、漫游网格蓝色条纹、当前网格加粗框;右上角网格/卫星模式开关。\n\n• 首通呼号(VUCC 视图):绿格显示该格最早通联的呼号,非绿格空白。\n\n• worked 网格可按操作网格筛选(含“全部”);点击网格弹出 QSO 明细;每格 QSO 计数。\n\n• 各操作网格独立 VUCC 计数;极区网格标签;反经线修复;漫游条纹密度一致。\n\n• WAPC/WAJA 地图标签随系统语言。\n\n• 网格模式视口与筛选跨页保持。\n\n• LoTW 同步:账号直连、全量/增量模式、实时进度、可取消、启动自动同步(独立开关)、设置页显示上次同步时间。\n\n• 历史过境:过境筛选可回看过去数小时(历史过境时间)。\n\n• 指南针校准:8 字动作精度指示 + 手动航向偏置。\n\n• AMSAT 状态页:连续相同状态报告计数。\n\n• 过境/雷达:UTC 切换重算过境;日出日落与仰角颜色修复;卫星模糊搜索;相互过境页滚动保持、跟随 UTC、雷达快捷入口。\n\n• CW 解码器支持 32/64 位设备;Android 17 (API 37) 适配。</string>
|
||||
|
||||
<string name="nav_amsat">AMSAT</string>
|
||||
<string name="amsat_title">AMSAT 卫星状态</string>
|
||||
<string name="amsat_refresh">刷新</string>
|
||||
<string name="amsat_retry">重试</string>
|
||||
@@ -107,6 +110,9 @@
|
||||
<!-- Map screen -->
|
||||
<string name="map_prev">上一个</string>
|
||||
<string name="map_next">下一个</string>
|
||||
<string name="map_grid_mode">网格模式</string>
|
||||
<string name="map_satellite_mode">卫星模式</string>
|
||||
<string name="map_first_call_mode">首通呼号</string>
|
||||
<string name="map_azimuth">方位角:%.1f°</string>
|
||||
<string name="map_elevation">仰角:%.1f°</string>
|
||||
<string name="map_altitude">高度:%.0f km</string>
|
||||
@@ -118,6 +124,20 @@
|
||||
<string name="map_period">周期:% .0f min</string>
|
||||
<string name="map_velocity">速度:%.2f km/s</string>
|
||||
<string name="map_visibility">能见度:%s</string>
|
||||
<!-- Worked-grid QSO dialog -->
|
||||
<string name="grid_qso_calls_count" formatted="false">%d 个呼号 · %d 次通联</string>
|
||||
<string name="grid_qso_count_one">%d QSO</string>
|
||||
<string name="grid_qso_count_many">%d QSOs</string>
|
||||
<string name="grid_qso_first">首通</string>
|
||||
<string name="grid_qso_no_sat">卫星未知</string>
|
||||
<string name="grid_qso_match">匹配</string>
|
||||
<!-- 未通联网格中的“标记想通联的电台”提醒 -->
|
||||
<string name="grid_mark_station">标记你想通联的电台</string>
|
||||
<string name="grid_mark_callsign">呼号</string>
|
||||
<string name="grid_mark_confirm">标记</string>
|
||||
<string name="grid_mark_label">标记</string>
|
||||
<string name="grid_mark_delete">删除标记</string>
|
||||
<string name="grid_mark_pin">置顶</string>
|
||||
<string name="map_visible">日照中</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
@@ -125,7 +145,7 @@
|
||||
<string name="prefs_privacy_title">隐私政策</string>
|
||||
|
||||
<string name="prefs_updated_title">更新:%s</string>
|
||||
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
|
||||
<string name="prefs_updated_time">yyyy年M月d日 HH:mm:ss</string>
|
||||
|
||||
<string name="prefs_loc_title">站点位置</string>
|
||||
<string name="prefs_loc_gps_title">GPS 定位</string>
|
||||
@@ -151,13 +171,56 @@
|
||||
<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_hint">直接从 ARRL LoTW 拉取该呼号已确认 QSO 的网格并覆盖本地已存集合(当前 %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="lotw_sync_error_not_configured">LoTW 呼号/密码未配置</string>
|
||||
<string name="lotw_sync_error_credentials">LoTW 同步失败——呼号或密码错误</string>
|
||||
<string name="lotw_sync_error_rate_limited">LoTW 同步失败——服务器限流,请等几分钟后再试</string>
|
||||
<string name="lotw_sync_error_timeout">LoTW 同步失败——连接超时,请更换网络重试</string>
|
||||
<string name="lotw_sync_error_network">LoTW 同步失败——网络错误(%1$s)</string>
|
||||
<string name="lotw_sync_full">全量同步</string>
|
||||
<string name="lotw_sync_incremental">增量合并</string>
|
||||
<string name="lotw_sync_rate_hint">下载速度约每 100 条 QSO 需 8 秒(另有 5–40 秒服务器生成时间)</string>
|
||||
<string name="lotw_sync_progress_connecting">正在连接 LoTW 服务器…</string>
|
||||
<string name="lotw_sync_progress_downloading">正在下载报告…</string>
|
||||
<string name="lotw_sync_progress_saving">正在处理下载的数据…</string>
|
||||
<string name="lotw_sync_progress_count">本次同步 %1$d 条 QSO…</string>
|
||||
<string name="lotw_sync_progress_qso">本次同步 %1$d 条 QSO,预计还需 %2$d 秒</string>
|
||||
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
|
||||
|
||||
<string name="btn_delete">删除</string>
|
||||
<string name="prefs_logbook_title">日志本</string>
|
||||
<string name="prefs_logbook_count">%1$d 条记录 — 本地通联与 LoTW 确认</string>
|
||||
<string name="prefs_logbook_empty">暂无记录 — 在雷达页 Log 标签记录通联</string>
|
||||
<string name="prefs_logbook_close">关闭</string>
|
||||
<string name="prefs_logbook_upload">上传</string>
|
||||
<string name="prefs_logbook_upload_title">上传到 LoTW</string>
|
||||
<string name="prefs_logbook_upload_confirm">确认上传</string>
|
||||
<string name="prefs_lotw_upload_title">LoTW 上传证书</string>
|
||||
<string name="prefs_lotw_upload_configured">证书已导入 · 台址网格 %1$s</string>
|
||||
<string name="prefs_lotw_upload_not_configured">未配置 — 导入 TrustedQSL 证书后即可上传通联</string>
|
||||
<string name="prefs_lotw_upload_busy">处理中…</string>
|
||||
<string name="prefs_lotw_upload_cert_hint">导入你的 TrustedQSL .p12 证书(与 TQSL 同款)用于签名上传通联。密码仅加密保存在手机本地。</string>
|
||||
<string name="prefs_lotw_upload_password">证书密码</string>
|
||||
<string name="prefs_lotw_upload_import">选择 .p12 文件</string>
|
||||
<string name="prefs_lotw_upload_import_confirm">导入</string>
|
||||
<string name="prefs_lotw_upload_error_password">证书密码错误,请检查 TQSL 导出 .p12 时设置的密码</string>
|
||||
<string name="prefs_lotw_upload_error_expired">证书已过期或尚未生效</string>
|
||||
<string name="prefs_lotw_upload_error_invalid">不是有效的 LoTW 证书文件</string>
|
||||
<string name="prefs_lotw_upload_error_format">不支持的 .p12 加密,请用 TQSL 默认加密重新导出后再试</string>
|
||||
<string name="prefs_lotw_upload_error_format_alg">不支持的 .p12 加密(%1$s),请用 TQSL 默认加密重新导出后再试</string>
|
||||
<string name="prefs_lotw_upload_error_unknown">导入失败,请重试</string>
|
||||
<string name="prefs_lotw_upload_cert_info">%1$s · DXCC %2$d · 有效期至 %3$s</string>
|
||||
<string name="prefs_lotw_upload_remove">移除证书</string>
|
||||
<string name="prefs_lotw_upload_station_title">台址</string>
|
||||
<string name="prefs_lotw_upload_region_hint">未选择</string>
|
||||
<string name="prefs_lotw_upload_grid">网格(逗号分隔,可多格)</string>
|
||||
<string name="prefs_lotw_upload_save">保存</string>
|
||||
<string name="prefs_lotw_upload_close">关闭</string>
|
||||
|
||||
<string name="prefs_data_title">卫星数据更新</string>
|
||||
<string name="prefs_data_entries">卫星:%s</string>
|
||||
<string name="prefs_data_radios">收发器:%s</string>
|
||||
@@ -214,10 +277,33 @@
|
||||
<string name="rc_enable_switch">启用 CAT 控制</string>
|
||||
|
||||
<string name="prefs_other_title">其他设置</string>
|
||||
<string name="prefs_map_title">地图设置</string>
|
||||
<string name="prefs_map_configure">配置地图</string>
|
||||
<string name="prefs_map_source_title">地图来源</string>
|
||||
<string name="prefs_map_source_osm">离线 OSM 地图</string>
|
||||
<string name="prefs_map_source_tianditu_vector">天地图(矢量)</string>
|
||||
<string name="prefs_map_source_tianditu_image">天地图(影像)</string>
|
||||
<string name="prefs_map_tianditu_key">天地图访问密钥</string>
|
||||
<string name="prefs_map_tianditu_key_help">使用天地图前,需先在天地图开发者控制台申请服务端应用 Key。</string>
|
||||
<string name="prefs_map_tianditu_key_required">选择天地图需要填写访问密钥;保存密钥前将继续使用离线 OSM 地图。</string>
|
||||
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
||||
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
||||
<string name="prefs_other_switch_lotw_sync">自动同步 LoTW 已确认网格</string>
|
||||
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
|
||||
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
|
||||
<string name="prefs_compass_calibration_button">校准指南针</string>
|
||||
<string name="prefs_compass_calibration_title">指南针校准</string>
|
||||
<string name="prefs_compass_calibration_instruction">缓慢移动手机画“8”字,直到精度提高。请远离磁铁和金属物体。</string>
|
||||
<string name="prefs_compass_accuracy">精度:%s</string>
|
||||
<string name="prefs_compass_accuracy_high">高——校准完成</string>
|
||||
<string name="prefs_compass_accuracy_medium">中——请继续移动</string>
|
||||
<string name="prefs_compass_accuracy_low">低——请继续移动</string>
|
||||
<string name="prefs_compass_accuracy_unreliable">不可靠——请开始“8”字动作</string>
|
||||
<string name="prefs_compass_accuracy_unavailable">设备没有可用的磁场传感器</string>
|
||||
<string name="prefs_compass_heading">校正后航向:%.1f°</string>
|
||||
<string name="prefs_compass_offset">手动航向偏置(°)</string>
|
||||
<string name="prefs_compass_offset_support">范围 −180° 至 +180°;正值为顺时针偏移。</string>
|
||||
<string name="prefs_compass_offset_reset">偏置归零</string>
|
||||
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
|
||||
<string name="prefs_other_switch_map_grid">地图显示 Maidenhead 网格</string>
|
||||
|
||||
@@ -235,7 +321,10 @@
|
||||
\n• Dr T.S. Kelso (Celestrak)
|
||||
\n• Libre Space Foundation (SatNOGS)
|
||||
\n• xdsopl 和 Robot36 贡献者!
|
||||
\n• BG7NTA(CW解码功能)</string>
|
||||
\n• BG7NTA BG5JSU(CW解码功能)
|
||||
\n• BH2VSQ BG5JSU(天地图渲染支持)
|
||||
\n• BG5JSU(LoTW上传支持)
|
||||
</string>
|
||||
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
||||
|
||||
<!-- 匹配页 Mutual match page -->
|
||||
@@ -261,6 +350,9 @@
|
||||
<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_filter_transponder">转发器</string>
|
||||
<string name="mutual_filter_fm">FM</string>
|
||||
<string name="mutual_filter_linear">线性</string>
|
||||
<string name="mutual_calculating">计算中…</string>
|
||||
<string name="mutual_query">查询匹配</string>
|
||||
<string name="mutual_elevation_you">你:%d°</string>
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<string name="pass_filter_title">Filter passes</string>
|
||||
<string name="pass_filter_elev">Minimal elevation</string>
|
||||
<string name="pass_filter_hours">Time ahead</string>
|
||||
<string name="pass_filter_history_hours">Time before</string>
|
||||
<string name="pass_filter_aos_time">AOS window</string>
|
||||
<string name="pass_filter_invert_time">Invert AOS window</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (period >225min)</string>
|
||||
@@ -51,10 +52,7 @@
|
||||
Make sure to set your position via GPS, Lat/Lon or QTH in Settings.
|
||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* Added required tweaks to support Android 17 (API 37)
|
||||
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
||||
</string>
|
||||
<string name="pass_whatsnew_message">All changes since 4.4.6 (the 4.4.7 series):\n\n• Grid-mode map: LoTW confirmed grids in green, roamed grids with blue stripes, current grid highlighted; mode switch pill at the map\'s top-right.\n\n• First-call labels (VUCC view): show the first callsign worked in each green grid instead of the grid code; non-worked cells stay empty.\n\n• Worked grids filtered by operated grid (with an All option); tap a grid to see its QSO detail dialog; per-grid QSO counts.\n\n• Per-operated-grid VUCC counting; polar grid labels; antimeridian fixes; consistent roamed-stripe density.\n\n• WAPC/WAJA map labels follow the system language.\n\n• Grid-mode viewport and award filter survive page switches.\n\n• LoTW sync: direct login, full/incremental modes, live progress, cancel button, auto-sync on app start with its own toggle, last-sync time shown.\n\n• Pass history: filter can look back over past passes (Time before).\n\n• Compass calibration: figure-eight accuracy meter plus manual heading offset.\n\n• AMSAT status page: consecutive same-status report counts.\n\n• Passes/radar: UTC toggle recalculates passes; sunrise/sunset and elevation color fixes; satellite fuzzy search; mutual page keeps scroll, follows UTC, radar shortcut.\n\n• CW decoder for 32/64-bit devices; Android 17 (API 37) support.</string>
|
||||
|
||||
<!-- AMSAT Status screen -->
|
||||
<string name="nav_amsat">AMSAT</string>
|
||||
@@ -130,7 +128,11 @@
|
||||
<!-- Map screen -->
|
||||
<string name="map_prev">Prev</string>
|
||||
<string name="map_next">Next</string>
|
||||
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
|
||||
<string name="map_copyright_osm" translatable="false">© OpenStreetMap contributors</string>
|
||||
<string name="map_copyright_tianditu" translatable="false">© Tianditu</string>
|
||||
<string name="map_grid_mode">Grid mode</string>
|
||||
<string name="map_satellite_mode">Satellite mode</string>
|
||||
<string name="map_first_call_mode">First call</string>
|
||||
<string name="map_azimuth">Azimuth: %.1f°</string>
|
||||
<string name="map_elevation">Elevation: %.1f°</string>
|
||||
<string name="map_altitude">Altitude: %.0f km</string>
|
||||
@@ -143,6 +145,20 @@
|
||||
<string name="map_period">Period: %.0f min</string>
|
||||
<string name="map_velocity">Velocity: %.2f km/s</string>
|
||||
<string name="map_visibility">Visibility: %s</string>
|
||||
<!-- Worked-grid QSO dialog -->
|
||||
<string name="grid_qso_calls_count" formatted="false">%d callsigns · %d QSOs</string>
|
||||
<string name="grid_qso_count_one">%d QSO</string>
|
||||
<string name="grid_qso_count_many">%d QSOs</string>
|
||||
<string name="grid_qso_first">First</string>
|
||||
<string name="grid_qso_no_sat">satellite unknown</string>
|
||||
<string name="grid_qso_match">Match</string>
|
||||
<!-- Mark-a-station reminder in unworked grids -->
|
||||
<string name="grid_mark_station">Mark a station you want to contact</string>
|
||||
<string name="grid_mark_callsign">Callsign</string>
|
||||
<string name="grid_mark_confirm">Mark</string>
|
||||
<string name="grid_mark_label">Marked</string>
|
||||
<string name="grid_mark_delete">Remove mark</string>
|
||||
<string name="grid_mark_pin">Pin to top</string>
|
||||
<string name="map_visible">Visible</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
@@ -186,13 +202,56 @@
|
||||
<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_hint">Fetch confirmed-QSO grids directly from ARRL LoTW for this callsign and replace the stored set (currently %1$d). The password is stored on this device only. Tapping Sync saves the credentials and fetches in one step.</string>
|
||||
<string name="prefs_lotw_sync">Sync</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="lotw_sync_error_not_configured">LoTW callsign/password not configured</string>
|
||||
<string name="lotw_sync_error_credentials">LoTW sync failed — callsign or password incorrect</string>
|
||||
<string name="lotw_sync_error_rate_limited">LoTW sync failed — rate limited by server, wait a few minutes and retry</string>
|
||||
<string name="lotw_sync_error_timeout">LoTW sync failed — connection timed out, try another network</string>
|
||||
<string name="lotw_sync_error_network">LoTW sync failed — network error (%1$s)</string>
|
||||
<string name="lotw_sync_full">Full sync</string>
|
||||
<string name="lotw_sync_incremental">Incremental merge</string>
|
||||
<string name="lotw_sync_rate_hint">Download runs at ~8 s per 100 QSOs (plus 5–40 s server generation time)</string>
|
||||
<string name="lotw_sync_progress_connecting">Connecting to LoTW…</string>
|
||||
<string name="lotw_sync_progress_downloading">Downloading report…</string>
|
||||
<string name="lotw_sync_progress_saving">Processing downloaded data…</string>
|
||||
<string name="lotw_sync_progress_count">Syncing %1$d QSOs…</string>
|
||||
<string name="lotw_sync_progress_qso">Syncing %1$d QSOs, ~%2$d s remaining</string>
|
||||
<string name="prefs_locator_text">Set station\'s position using locator</string>
|
||||
|
||||
<string name="btn_delete">Delete</string>
|
||||
<string name="prefs_logbook_title">Logbook</string>
|
||||
<string name="prefs_logbook_count">%1$d records — local QSOs and LoTW confirmations</string>
|
||||
<string name="prefs_logbook_empty">No records yet — record QSOs from the Radar Log tab</string>
|
||||
<string name="prefs_logbook_close">Close</string>
|
||||
<string name="prefs_logbook_upload">Upload</string>
|
||||
<string name="prefs_logbook_upload_title">Upload to LoTW</string>
|
||||
<string name="prefs_logbook_upload_confirm">Upload</string>
|
||||
<string name="prefs_lotw_upload_title">LoTW upload certificate</string>
|
||||
<string name="prefs_lotw_upload_configured">Certificate imported · station grid %1$s</string>
|
||||
<string name="prefs_lotw_upload_not_configured">Not configured — import your TrustedQSL certificate to upload QSOs</string>
|
||||
<string name="prefs_lotw_upload_busy">Working…</string>
|
||||
<string name="prefs_lotw_upload_cert_hint">Import your TrustedQSL .p12 certificate (same one you use in TQSL) to sign and upload QSOs. The password is stored encrypted on this device only.</string>
|
||||
<string name="prefs_lotw_upload_password">Certificate password</string>
|
||||
<string name="prefs_lotw_upload_import">Choose .p12 file</string>
|
||||
<string name="prefs_lotw_upload_import_confirm">Import</string>
|
||||
<string name="prefs_lotw_upload_error_password">Incorrect certificate password. Check the password you set when exporting the .p12 from TQSL.</string>
|
||||
<string name="prefs_lotw_upload_error_expired">Certificate is expired or not yet valid.</string>
|
||||
<string name="prefs_lotw_upload_error_invalid">Not a valid LoTW certificate file.</string>
|
||||
<string name="prefs_lotw_upload_error_format">Unsupported .p12 encryption. Re-export from TQSL and try again.</string>
|
||||
<string name="prefs_lotw_upload_error_format_alg">Unsupported .p12 encryption (%1$s). Re-export from TQSL and try again.</string>
|
||||
<string name="prefs_lotw_upload_error_unknown">Import failed. Try again.</string>
|
||||
<string name="prefs_lotw_upload_cert_info">%1$s · DXCC %2$d · expires %3$s</string>
|
||||
<string name="prefs_lotw_upload_remove">Remove certificate</string>
|
||||
<string name="prefs_lotw_upload_station_title">Station location</string>
|
||||
<string name="prefs_lotw_upload_region_hint">Not selected</string>
|
||||
<string name="prefs_lotw_upload_grid">Grid(s), comma-separated</string>
|
||||
<string name="prefs_lotw_upload_save">Save</string>
|
||||
<string name="prefs_lotw_upload_close">Close</string>
|
||||
|
||||
<string name="prefs_data_title">Satellite data</string>
|
||||
<string name="prefs_data_entries">Satellites: %s</string>
|
||||
<string name="prefs_data_radios">Transceivers: %s</string>
|
||||
@@ -248,10 +307,33 @@
|
||||
<string name="prefs_net_perm_error">Check your network permission</string>
|
||||
|
||||
<string name="prefs_other_title">Other settings</string>
|
||||
<string name="prefs_map_title">Map settings</string>
|
||||
<string name="prefs_map_configure">Configure map</string>
|
||||
<string name="prefs_map_source_title">Map source</string>
|
||||
<string name="prefs_map_source_osm">Offline OSM</string>
|
||||
<string name="prefs_map_source_tianditu_vector">TianDiTu vector</string>
|
||||
<string name="prefs_map_source_tianditu_image">TianDiTu imagery</string>
|
||||
<string name="prefs_map_tianditu_key">TianDiTu access key</string>
|
||||
<string name="prefs_map_tianditu_key_help">Request a server-side application key from the TianDiTu developer console before using TianDiTu maps.</string>
|
||||
<string name="prefs_map_tianditu_key_required">A TianDiTu key is required. Offline OSM remains active until it is entered.</string>
|
||||
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
|
||||
<string name="prefs_other_switch_update">Enable automatic data update</string>
|
||||
<string name="prefs_other_switch_lotw_sync">Auto-sync LoTW confirmed grids</string>
|
||||
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
|
||||
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
|
||||
<string name="prefs_compass_calibration_button">Calibrate compass</string>
|
||||
<string name="prefs_compass_calibration_title">Compass calibration</string>
|
||||
<string name="prefs_compass_calibration_instruction">Move the phone slowly in a figure-eight until accuracy improves. Keep it away from magnets and metal.</string>
|
||||
<string name="prefs_compass_accuracy">Accuracy: %s</string>
|
||||
<string name="prefs_compass_accuracy_high">High — calibration complete</string>
|
||||
<string name="prefs_compass_accuracy_medium">Medium — keep moving</string>
|
||||
<string name="prefs_compass_accuracy_low">Low — keep moving</string>
|
||||
<string name="prefs_compass_accuracy_unreliable">Unreliable — start the figure-eight motion</string>
|
||||
<string name="prefs_compass_accuracy_unavailable">Magnetic sensor unavailable</string>
|
||||
<string name="prefs_compass_heading">Corrected heading: %.1f°</string>
|
||||
<string name="prefs_compass_offset">Manual heading offset (°)</string>
|
||||
<string name="prefs_compass_offset_support">Range −180° to +180°. Positive values rotate clockwise.</string>
|
||||
<string name="prefs_compass_offset_reset">Reset offset</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>
|
||||
|
||||
@@ -269,7 +351,9 @@
|
||||
\n* Dr T.S. Kelso (Celestrak)
|
||||
\n* Libre Space Foundation (SatNOGS)
|
||||
\n* xdsopl and Robot36 contributors!
|
||||
\n* BG7NTA (CW decoding feature)
|
||||
\n* BG7NTA BG5JSU (CW decoding feature)
|
||||
\n* BH2VSQ BG5JSU (TianDiTu rendering support)
|
||||
\n* BG5JSU (LoTW upload support)
|
||||
</string>
|
||||
<string name="prefs_outro_license">The app comes with no warranty</string>
|
||||
|
||||
@@ -296,6 +380,9 @@
|
||||
<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_filter_transponder">Transponders</string>
|
||||
<string name="mutual_filter_fm">FM</string>
|
||||
<string name="mutual_filter_linear">Linear</string>
|
||||
<string name="mutual_calculating">Calculating…</string>
|
||||
<string name="mutual_query">Query Match</string>
|
||||
<string name="mutual_elevation_you">You: %d°</string>
|
||||
|
||||
@@ -10,10 +10,11 @@ android {
|
||||
compileOptions {
|
||||
encoding = "UTF-8"
|
||||
}
|
||||
// 照搬 Morse Expert 1.15: native 解码器仅 armeabi-v7a
|
||||
// Morse Expert 1.15 官方 Play 分包提供 ARM32/ARM64 原生解码器
|
||||
// (jniLibs/armeabi-v7a + arm64-v8a), 两个 ABI 都打进 APK
|
||||
defaultConfig {
|
||||
ndk {
|
||||
abiFilters += listOf("armeabi-v7a")
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{"type":"dxcc","regions":[{"id":0,"code":176,"name":"Fiji","label_lon":177.9759,"label_lat":-17.9376,"rings":[[[180.0,-16.0671],[180.0,-16.5552],[179.3641,-16.8014],[178.7251,-17.012],[178.5968,-16.6392],[179.0966,-16.434],[179.4135,-16.3791],[180.0,-16.0671]],[[178.1256,-17.5048],[178.3736,-17.3399],[178.7181,-17.6285],[178.5527,-18.1506],[177.9327,-18.288],[177.3815,-18.1643],[177.285,-17.7247],[177.6709,-17.3811]],[[-179.7933,-16.0209],[-179.9174,-16.5018],[-180.0,-16.5552],[-180.0,-16.0671]]]},{"id":1,"code":470,"name":"Tanzania","label_lon":34.1421,"label_lat":-6.2078,"rings":[[[33.9037,-0.95],[34.0726,-1.0598],[37.6987,-3.097],[37.7669,-3.6771],[39.2022,-4.6768],[38.7405,-5.9089],[38.7998,-6.4757],[39.44,-6.84],[39.47,-7.1],[39.1947,-7.7039],[39.252,-8.0078],[39.1865,-8.4855],[39.5357,-9.1124],[39.9496,-10.0984],[40.3166,-10.3171],[40.3166,-10.3171],[39.521,-10.8969],[38.4276,-11.2852],[37.8276,-11.2688],[37.4713,-11.5688],[36.7752,-11.5945],[36.5141,-11.7209],[35.3124,-11.4391],[34.56,-11.52],[34.28,-10.16],[33.9408,-9.6937],[33.7397,-9.4171],[32.7594,-9.2306],[32.1919,-8.9304],[31.5563,-8.762],[31.1578,-8.5946],[30.74,-8.34],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.7535,-4.4524],[30.1163,-4.0901],[30.5055,-3.5686],[30.7522,-3.3593],[30.743,-3.0343],[30.5277,-2.8076],[30.4697,-2.4139],[30.4697,-2.4138],[30.7583,-2.2872],[30.8161,-1.6989],[30.4191,-1.1347],[30.7699,-1.0146],[31.8662,-1.0274]]]},{"id":2,"code":302,"name":"W. Sahara","label_lon":-12.572,"label_lat":24.2306,"rings":[[[-8.6656,27.6564],[-8.6651,27.5895],[-8.6844,27.3957],[-8.6873,25.8811],[-11.9694,25.9334],[-11.9372,23.3746],[-12.8742,23.2848],[-13.1188,22.7712],[-12.9291,21.3271],[-16.8452,21.3333],[-17.0634,20.9998],[-17.0204,21.4223],[-17.003,21.4207],[-14.751,21.5006],[-14.6308,21.8609],[-14.2212,22.3102],[-13.8911,23.691],[-12.501,24.7701],[-12.0308,26.0309],[-11.7182,26.1041],[-11.3926,26.8834],[-10.5513,26.9908],[-10.1894,26.8609],[-9.7353,26.8609],[-9.413,27.0885],[-8.7949,27.1207],[-8.8178,27.6564]]]},{"id":3,"code":1,"name":"Canada","label_lon":-110.2438,"label_lat":56.7019,"rings":[[[-122.84,49.0],[-122.9742,49.0025],[-124.9102,49.9846],[-125.6246,50.4166],[-127.4356,50.8306],[-127.9928,51.7158],[-127.8503,52.3296],[-129.1298,52.7554],[-129.3052,53.5616],[-130.515,54.2876],[-130.5361,54.8028],[-130.5361,54.8028],[-129.98,55.285],[-130.0078,55.9158],[-131.7078,56.5521],[-132.7304,57.6929],[-133.3556,58.4103],[-134.2711,58.8611],[-134.945,59.2706],[-135.4758,59.7878],[-136.4797,59.4639],[-137.4525,58.905],[-138.3409,59.5621],[-139.039,60.0],[-140.013,60.2768],[-140.9978,60.3064],[-140.9925,66.0],[-140.986,69.712],[-140.986,69.712],[-139.1205,69.471],[-137.5464,68.99],[-136.5036,68.898],[-135.6258,69.3151],[-134.4146,69.6274],[-132.9292,69.5053],[-131.4314,69.9445],[-129.7947,70.1937],[-129.1077,69.7793],[-128.3616,70.0129],[-128.1382,70.4838],[-127.4471,70.3772],[-125.7563,69.4806],[-124.4248,70.1584],[-124.2897,69.3997],[-123.0611,69.5637],[-122.6835,69.8555],[-121.4723,69.7978],[-119.9429,69.3779],[-117.6027,69.0113],[-116.2264,68.8415],[-115.2469,68.9059],[-113.8979,68.3989],[-115.3049,67.9026],[-113.4973,67.6881],[-110.798,67.8061],[-109.9462,67.981],[-108.8802,67.3814],[-107.7924,67.8874],[-108.813,68.3116],[-108.1672,68.6539],[-106.95,68.7],[-106.15,68.8],[-105.3428,68.5612],[-104.3379,68.018],[-103.2211,68.0978],[-101.4543,67.6469],[-99.9019,67.8057],[-98.4432,67.7816],[-98.5586,68.4039],[-97.6695,68.5786],[-96.1199,68.2394],[-96.1259,67.2934],[-95.4894,68.0907],[-94.685,68.0638],[-94.2328,69.069],[-95.3041,69.6857],[-96.4713,70.0898],[-96.3911,71.1948],[-95.2088,71.9205],[-93.89,71.7601],[-92.8782,71.3187],[-91.5196,70.1913],[-92.4069,69.7],[-90.5471,69.4977],[-90.5515,68.475],[-89.2151,69.2587],[-88.0197,68.6151],[-88.3175,67.8734],[-87.3502,67.1987],[-86.3061,67.9215],[-85.5766,68.7846],[-85.522,69.8821],[-84.1008,69.8054],[-82.6226,69.6583],[-81.2804,69.162],[-81.2202,68.6657],[-81.9644,68.1325],[-81.2593,67.5972],[-81.3865,67.1108],[-83.3446,66.4115],[-84.7354,66.2573],[-85.7694,66.5583],[-86.0676,66.0563],[-87.0314,65.213],[-87.3232,64.7756],[-88.483,64.099],[-89.9144,64.0327],[-90.704,63.6102],[-90.77,62.9602],[-91.9334,62.8351],[-93.157,62.0247],[-94.2415,60.8987],[-94.6293,60.1102],[-94.6846,58.9488],[-93.215,58.7821],[-92.7646,57.8457],[-92.297,57.0871],[-90.8977,57.2847],[-89.0395,56.8517],[-88.0398,56.4716],[-87.3242,55.9991],[-86.0712,55.7238],[-85.0118,55.3026],[-83.3606,55.2449],[-82.2729,55.1483],[-82.4362,54.2823],[-82.125,53.277],[-81.4008,52.1579],[-79.9129,51.2084],[-79.143,51.5339],[-78.6019,52.5621],[-79.1242,54.1414],[-79.8296,54.6677],[-78.2287,55.1365],[-77.0956,55.8374],[-76.5414,56.5342],[-76.6232,57.2026],[-77.3023,58.0521],[-78.5169,58.8046],[-77.3368,59.8526],[-77.7727,60.7579],[-78.1069,62.3196],[-77.4107,62.5505],[-75.6962,62.2784],[-74.6682,62.1811],[-73.8399,62.4438],[-72.9085,62.1051],[-71.6771,61.5254],[-71.3737,61.1372],[-69.5904,61.0614],[Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"waja","regions":[{"id":0,"code":"22","name":"京都府","label_lon":135.5014,"label_lat":35.2429,"rings":[[[135.0367,35.5373],[134.9265,35.5125],[134.9143,35.5448],[134.8542,35.5881],[134.8725,35.6197],[134.8671,35.6581],[134.9353,35.6505],[134.9849,35.6923],[135.0222,35.6895],[135.0607,35.7081],[135.0857,35.7398],[135.223,35.7792],[135.308,35.6944],[135.3029,35.6628],[135.2874,35.6756],[135.268,35.6674],[135.2443,35.6205],[135.1926,35.5711],[135.1931,35.5383],[135.2547,35.5989],[135.2507,35.5702],[135.2739,35.5609],[135.2449,35.5613],[135.2442,35.5359],[135.2882,35.5155],[135.3303,35.517],[135.3163,35.4512],[135.3467,35.4753],[135.3468,35.494],[135.3975,35.4788],[135.3963,35.5152],[135.3445,35.5041],[135.3423,35.5518],[135.4263,35.5665],[135.4337,35.5907],[135.4619,35.6035],[135.4521,35.5675],[135.4865,35.558],[135.4599,35.5472],[135.449,35.5235],[135.4748,35.5049],[135.4591,35.4675],[135.4958,35.4251],[135.5253,35.4207],[135.5285,35.3797],[135.6419,35.3668],[135.7007,35.3437],[135.7684,35.3561],[135.7637,35.3445],[135.809,35.3161],[135.8313,35.2761],[135.8581,35.2891],[135.8291,35.2207],[135.8558,35.1595],[135.8355,35.059],[135.815,35.0461],[135.8334,35.0223],[135.8199,35.0129],[135.8351,35.0032],[135.8268,34.9911],[135.8493,34.991],[135.8782,34.9475],[135.8664,34.8923],[135.898,34.8712],[135.9405,34.8932],[135.9457,34.8517],[136.005,34.8427],[136.0047,34.8282],[136.0281,34.8192],[136.0104,34.8007],[136.0335,34.7789],[136.0285,34.7572],[136.0552,34.7384],[136.0285,34.7062],[135.9859,34.7151],[135.9754,34.7409],[135.9254,34.7403],[135.9254,34.7575],[135.89,34.7087],[135.7551,34.7275],[135.7284,34.7779],[135.7447,34.8043],[135.6955,34.8482],[135.6749,34.8987],[135.6307,34.9353],[135.5999,34.928],[135.6129,34.9713],[135.5733,34.9717],[135.5783,34.9557],[135.5568,34.9381],[135.58,34.937],[135.5807,34.9236],[135.5418,34.9153],[135.5132,34.9439],[135.481,34.9495],[135.4874,34.9919],[135.3821,35.0101],[135.3688,35.0536],[135.4031,35.0894],[135.3885,35.0971],[135.3894,35.1288],[135.3435,35.1357],[135.3405,35.1499],[135.2822,35.1449],[135.2898,35.1699],[135.2738,35.1771],[135.2067,35.1609],[135.1869,35.1718],[135.1967,35.2029],[135.1556,35.2262],[135.1583,35.2599],[135.109,35.2663],[135.0675,35.2372],[135.0565,35.264],[135.01,35.2698],[134.9988,35.2911],[134.9256,35.3112],[134.9185,35.3645],[134.9338,35.4074],[134.9586,35.4091],[134.9895,35.3863],[135.0483,35.4074]],[[135.4273,35.6825],[135.4214,35.6797],[135.4243,35.6882]],[[135.4809,35.5943],[135.4749,35.5956],[135.4779,35.6059]],[[135.3472,35.4959],[135.3404,35.4986],[135.3474,35.4971]]],"name_en":"Kyoto"},{"id":1,"code":"41","name":"佐賀県","label_lon":130.0903,"label_lat":33.2574,"rings":[[[129.8671,33.5967],[129.8479,33.5998],[129.8612,33.6183]],[[129.9007,33.594],[129.8967,33.6037],[129.9018,33.6029]],[[129.8447,33.5858],[129.8351,33.5891],[129.8459,33.5915]],[[129.757,33.5634],[129.7394,33.5728],[129.7412,33.5781],[129.7679,33.582]],[[129.8741,33.5438],[129.8729,33.5581],[129.8804,33.5675],[129.892,33.5561]],[[130.0387,33.4697],[130.2766,33.4783],[130.3555,33.4335],[130.3953,33.4273],[130.4144,33.4103],[130.4102,33.395],[130.4962,33.4448],[130.5362,33.4382],[130.5352,33.3428],[130.479,33.3307],[130.4863,33.3123],[130.4554,33.3017],[130.4668,33.2886],[130.4489,33.265],[130.431,33.2925],[130.4146,33.2532],[130.3907,33.2599],[130.3843,33.2403],[130.3713,33.243],[130.3766,33.2204],[130.3386,33.2033],[130.3549,33.1404],[130.2878,33.1469],[130.2454,33.2029],[130.2465,33.1923],[130.2149,33.1993],[130.2306,33.1887],[130.2217,33.1653],[130.149,33.1127],[130.1219,33.1247],[130.1277,33.1085],[130.1123,33.1011],[130.1398,33.0943],[130.2198,32.9595],[130.0857,32.9748],[130.0087,33.019],[129.9935,33.0477],[129.9215,33.0904],[129.9256,33.1075],[129.9545,33.1166],[129.9425,33.1643],[129.8877,33.1563],[129.818,33.1835],[129.8134,33.2421],[129.7588,33.2903],[129.7899,33.3403],[129.836,33.2867],[129.8528,33.2921],[129.8274,33.3124],[129.8339,33.3113],[129.8484,33.3287],[129.8273,33.3373],[129.8474,33.3369],[129.868,33.4075],[129.8227,33.4118],[129.8307,33.4218],[129.7828,33.4541],[129.8062,33.4573],[129.7936,33.4713],[129.8055,33.485],[129.8382,33.4621],[129.8352,33.4436],[129.8579,33.4538],[129.8497,33.4751],[129.8399,33.4698],[129.8314,33.4762],[129.8443,33.4786],[129.8273,33.5176],[129.8563,33.5055],[129.8396,33.5222],[129.8562,33.5191],[129.8451,33.5553],[129.8749,33.5371],[129.8744,33.5161],[129.8958,33.5494],[129.9278,33.5507],[129.9288,33.537],[129.956,33.5295],[129.967,33.5086],[129.9362,33.4786],[129.9546,33.4693],[129.9624,33.4835],[129.9671,33.4552],[130.0194,33.4463],[130.0402,33.4558]],[[129.9674,33.5271],[129.9645,33.5389],[129.9721,33.5432],[129.9772,33.5349]],[[129.9928,33.4777],[129.9846,33.4779],[129.9888,33.4806]],[[129.8563,33.4021],[129.8558,33.4049],[129.858,33.4024]],[[129.8601,33.3995],[129.8614,33.4017],[129.8656,33.4027]],[[129.8386,33.3191],[129.8351,33.3215],[129.8352,33.3227]],[[130.2242,32.9565],[130.2127,32.9546],[130.222,32.9584]]Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"wapc","regions":[{"id":0,"code":"BJ","name":"北京市","label_lon":116.42,"label_lat":40.1899,"rings":[[[117.3486,40.5811],[117.3899,40.5616],[117.4299,40.5761],[117.4127,40.6052],[117.4675,40.6497],[117.4675,40.6497],[117.5014,40.6366],[117.5149,40.6602],[117.494,40.6752],[117.409,40.687],[117.3425,40.6738],[117.3197,40.6579],[117.2784,40.6643],[117.2082,40.6947],[117.117,40.7001],[117.1121,40.7074],[117.0123,40.6938],[116.9649,40.7096],[116.9267,40.745],[116.9242,40.7736],[116.8485,40.8393],[116.8134,40.8483],[116.7598,40.89],[116.7136,40.9099],[116.7222,40.9275],[116.6779,40.9709],[116.6988,41.0215],[116.6883,41.0445],[116.6477,41.0594],[116.6156,41.0531],[116.623,41.021],[116.5984,40.9745],[116.5676,40.9926],[116.5196,40.9813],[116.5196,40.9813],[116.4555,40.9808],[116.4475,40.9537],[116.4771,40.8999],[116.3982,40.9062],[116.3705,40.9438],[116.3397,40.9293],[116.3342,40.9044],[116.4383,40.8193],[116.466,40.7745],[116.4537,40.7659],[116.3169,40.7722],[116.3114,40.755],[116.2732,40.7627],[116.2473,40.7917],[116.2202,40.7441],[116.2048,40.74],[116.1716,40.6956],[116.1629,40.6625],[116.134,40.6665],[116.0989,40.6307],[116.0052,40.5839],[115.9825,40.5789],[115.972,40.6025],[115.9079,40.6175],[115.8851,40.5952],[115.8279,40.5875],[115.8198,40.5593],[115.7847,40.5584],[115.7552,40.5402],[115.7361,40.5034],[115.781,40.4934],[115.7718,40.4437],[115.8642,40.3594],[115.9178,40.3544],[115.9517,40.2819],[115.9689,40.264],[115.8987,40.2344],[115.8704,40.1859],[115.8556,40.1887],[115.8476,40.147],[115.8063,40.1534],[115.7737,40.1763],[115.7546,40.1457],[115.7546,40.1457],[115.6,40.1196],[115.5907,40.0962],[115.5273,40.0761],[115.4854,40.0404],[115.4546,40.0298],[115.4503,39.9927],[115.4287,39.9844],[115.4263,39.9505],[115.4811,39.9358],[115.523,39.8991],[115.5156,39.8922],[115.5156,39.8922],[115.526,39.8757],[115.5143,39.8375],[115.5673,39.8164],[115.5525,39.7948],[115.5057,39.7842],[115.4835,39.7985],[115.4835,39.7985],[115.4435,39.7856],[115.4398,39.752],[115.4866,39.7419],[115.4916,39.6701],[115.4786,39.6507],[115.4786,39.6507],[115.5224,39.6401],[115.518,39.5973],[115.5458,39.6189],[115.587,39.5899],[115.6338,39.5996],[115.6338,39.5996],[115.6677,39.6152],[115.6985,39.5779],[115.6985,39.5779],[115.6991,39.57],[115.6991,39.57],[115.717,39.5603],[115.717,39.5603],[115.7188,39.5539],[115.7188,39.5539],[115.7207,39.5511],[115.7207,39.5511],[115.7225,39.5442],[115.7213,39.5437],[115.7225,39.5442],[115.7225,39.5437],[115.7213,39.5437],[115.7225,39.5437],[115.725,39.5442],[115.725,39.5442],[115.7385,39.54],[115.7385,39.5396],[115.7385,39.54],[115.7385,39.5396],[115.7527,39.5156],[115.8063,39.51],[115.8063,39.51],[115.8211,39.523],[115.8211,39.523],[115.8285,39.5414],[115.8679,39.5465],[115.8679,39.5465],[115.9153,39.583],[115.9153,39.583],[115.9104,39.6005],[115.9104,39.6005],[115.9572,39.5608],[115.9781,39.5959],[115.9954,39.577],[116.0262,39.5876],[116.036,39.5719],[116.0989,39.5751],[116.1303,39.5677],[116.1518,39.5834],[116.1987,39.5894],[116.2405,39.564],[116.2578,39.5003],[116.3071,39.4883],[116.3379,39.4555],[116.3619,39.4551],[116.3619,39.4551],[116.4346,39.4426],[116.4549,39.4532],[116.4444,39.4823],[116.4118,39.4828],[116.4019,39.528],[116.4438,39.51],[116.4376,39.5267],[116.4783,39.5354],[116.4734,39.553],[116.5085,39.5511],[116.5245,39.5963],[116.5922,39.6212],[116.5922,39.6212],[116.6206,39.6019],[116.6649,39.6056],[116.7234,39.5926],[116.724,39.5926],[116.7234,39.5926],[116.724,39.5926],[116.7265,39.5954],[116.7265,39.5954],[116.7093,39.618],[116.7487,39.6198],[116.7906,39.5959],[116.8121,39.6157],[116.8497,39.6678],[116.9064,39.6774],[116.9057,39.688],[116.8897,39.6876],[116.8873,39.7253],[116.9168,39.7313],[116.9021,39.7635],[116.9495,39.7787],[116.9181,39.8463],[116.9076,39.8325],[116.8657,39.844],[116.8121,39.8899],[116.7844,39.8913],[116.7826,39.9477],[116.7579,39.9679],[116.7813,40.0349],[116.8201,40.0284],[116.8312,40.0514],[116.8676,40.0417],[116.9279,40.055],[116.9452,40.0413],[117.0252,40.0303],[117.0517,40.0596],[117.1053,40.0743],[117.1053,40.0743],[117.1404,40.0642],[117.1595,40.077],[117.2045,40.0697],[117.21,40.082],[117.2242,40.0949],[117.2242,40.0949],[117.255,40.1145],[117.255,40.1145],[117.255,40.1145],[117.2741,40.1059],[117.3073,40.137],[117.3492,40.1365],[117.3671,40.1726],[117.3671,40.1731],[117.3671,40.1731],[117.3671,40.1726],[117.3837,40.1882],[117.3899,40.228],[117.3511,40.2298],[117.3314,40.2896],[117.295,40.2782],[117.2716,40.3252],[117.2716,40.3252],[117.2433,40.3695],[117.226,40.369],[117.234,40.4173],[117.2636,40.4424],[117.2088,40.5016],[117.263,40.5129],[117.2476,40.5398],[117.2698,40.5607],[117.3486,40.5811]]],"name_en":"Beijing"},{"id":1,"code":"TJ","name":"天津市","label_lon":117.347,"label_lat":39.288,"rings":[[[117.7656,39.4005],[117.8469,39.4079],[117.8715,39.4116],[117.8703,39.4551],[117.8999,39.4745],[117.9122,39.5174],[117.9122,39.5174],[117.9048,39.5336],[117.9338,39.5742],[117.8685,39.5968],[117.8297,39.5899],[117.7668,39.5986],[117.7539,39.5797],[1Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"was","regions":[{"id":0,"code":"AL","name":"Alabama","label_lon":-86.7494,"label_lat":32.72,"rings":[[[-87.3593,35.0012],[-85.6067,34.9847],[-85.4314,34.1249],[-85.185,32.8597],[-85.0699,32.5804],[-84.9604,32.4215],[-85.0042,32.323],[-84.8892,32.2627],[-85.059,32.1367],[-85.0535,32.0108],[-85.1411,31.841],[-85.0426,31.5398],[-85.1138,31.2769],[-85.0042,31.003],[-85.4971,30.9975],[-87.6003,30.9975],[-87.6331,30.8661],[-87.4086,30.6744],[-87.4469,30.5101],[-87.3702,30.4279],[-87.5181,30.2801],[-87.6551,30.2472],[-87.907,30.4115],[-87.9344,30.658],[-88.0111,30.6854],[-88.1042,30.4991],[-88.137,30.3184],[-88.3944,30.3677],[-88.4711,31.8958],[-88.2411,33.7963],[-88.0987,34.8916],[-88.2027,34.9957]]]},{"id":1,"code":"AK","name":"Alaska","label_lon":-152.8897,"label_lat":62.9226,"rings":[[[-131.602,55.118],[-131.5692,55.2823],[-131.3556,55.1837],[-131.3884,55.0139],[-131.6458,55.0358]],[[-131.8321,55.4247],[-131.6458,55.3042],[-131.7499,55.1289],[-131.8321,55.1892]],[[-132.9767,56.4379],[-132.7357,56.4598],[-132.6317,56.4215],[-132.6645,56.2736],[-132.8781,56.2408],[-133.0698,56.3339]],[[-133.5956,56.3503],[-133.1629,56.3174],[-133.0534,56.1257],[-132.6207,55.9121],[-132.4729,55.7807],[-132.4619,55.6712],[-132.3578,55.6492],[-132.3414,55.5068],[-132.1661,55.3644],[-132.1442,55.2385],[-132.0292,55.2768],[-131.9799,55.1782],[-131.958,54.7894],[-132.0292,54.7017],[-132.3085,54.7182],[-132.3852,54.9153],[-132.4838,54.8989],[-132.6865,55.0468],[-132.7467,54.9975],[-132.9165,55.0468],[-132.8891,54.8989],[-132.7303,54.9372],[-132.6262,54.8825],[-132.6755,54.6798],[-132.8672,54.7017],[-133.1575,54.9592],[-133.2396,55.0906],[-133.2232,55.2275],[-133.4532,55.2166],[-133.4532,55.3206],[-133.278,55.3316],[-133.1027,55.4247],[-133.1794,55.589],[-133.3875,55.6219],[-133.4204,55.8848],[-133.497,56.0162],[-133.6394,55.9231],[-133.6942,56.071],[-133.5463,56.1422],[-133.6668,56.312]],[[-133.738,55.5561],[-133.5463,55.4904],[-133.4149,55.5726],[-133.2834,55.5342],[-133.4204,55.3864],[-133.634,55.4302]],[[-133.9078,56.9308],[-134.0502,57.0294],[-133.8859,57.0952],[-133.3437,57.002],[-133.1027,57.0075],[-132.9329,56.8213],[-132.6207,56.668],[-132.6536,56.5529],[-132.8179,56.4927],[-133.0425,56.5201],[-133.2013,56.4489],[-133.4204,56.4927],[-133.6613,56.4489],[-133.7106,56.6844],[-133.6887,56.8377],[-133.8695,56.8432]],[[-134.1159,56.4817],[-134.2529,56.5584],[-134.4007,56.7227],[-134.4172,56.8487],[-134.2967,56.9089],[-134.1707,56.8487],[-134.1433,56.9528],[-133.749,56.772],[-133.7106,56.5968],[-133.8476,56.5748],[-133.9352,56.3777],[-133.8366,56.3229],[-133.9571,56.0929],[-134.1105,56.1422],[-134.1324,55.9998],[-134.231,56.071],[-134.2912,56.3503]],[[-134.6362,56.2846],[-134.6691,56.1696],[-134.806,56.2353],[-135.1785,56.6789],[-135.414,56.8104],[-135.3318,56.9144],[-135.4249,57.1664],[-135.6878,57.369],[-135.4194,57.5662],[-135.299,57.484],[-135.0634,57.4183],[-134.8498,57.4073],[-134.8444,57.2485],[-134.6362,56.7282]],[[-134.7129,58.2234],[-134.3734,58.1467],[-134.1762,58.1577],[-134.1871,58.081],[-133.9023,57.8072],[-134.0995,57.851],[-134.1488,57.7579],[-133.9352,57.6155],[-133.8695,57.3635],[-134.0831,57.2978],[-134.1543,57.2102],[-134.4993,57.0294],[-134.6034,57.0349],[-134.6472,57.2266],[-134.576,57.3416],[-134.6089,57.5114],[-134.7294,57.7195],[-134.7074,57.8291],[-134.7841,58.0974],[-134.9156,58.2125],[-134.9539,58.4096]],[[-135.8576,57.3307],[-135.7152,57.3307],[-135.5673,57.1499],[-135.633,57.024],[-135.8576,56.9966],[-135.8247,57.1937]],[[-136.2793,58.207],[-135.9781,58.2015],[-135.7809,58.2891],[-135.4961,58.1686],[-135.6495,58.0372],[-135.5947,57.9879],[-135.4523,58.1358],[-135.1073,58.0865],[-134.9156,57.9769],[-135.0251,57.7798],[-134.9375,57.7633],[-134.8225,57.5005],[-135.0854,57.4621],[-135.5728,57.6757],[-135.5564,57.4566],[-135.7097,57.369],[-135.8905,57.4073],[-136.0,57.5443],[-136.2081,57.6374],[-136.367,57.8291],[-136.5696,57.9167],[-136.5587,58.0755],[-136.4217,58.1303],[-136.3779,58.2672]],[[-147.0799,60.2006],[-147.5016,59.9486],[-147.5344,59.8501],[-147.874,59.7843],[-147.8028,59.9377],[-147.4359,60.0965],[-147.2058,60.2718]],[[-147.5618,60.5785],[-147.6166,60.3704],[-147.759,60.1568],[-147.9562,60.228],[-147.7919,60.4744]],[[-147.7864,70.2453],[-147.6823,70.2015],[-147.162,70.1577],[-146.8882,70.185],[-146.5103,70.185],[-146.0995,70.1467],[-145.8585,70.1686],[-145.623,70.0865],[-145.1958,69.9934],[-144.6207,69.9714],[-144.4619,70.0262],[-144.0785,70.0591],[-143.9142,70.1303],[-143.4979,70.1412],[-143.5034,70.0919],[-143.2569,70.1193],[-142.7476,70.0426],[-142.4025,69.9167],[-142.0794,69.8564],[-142.0082,69.8017],[-141.7125,69.7907],[-141.4331,69.6976],[-141.3784,69.6373],[-141.2086,69.6866],[-141.0005,69.6483],[-141.0005,60.3046],[-140.5349,60.2225],[-140.4747,60.3101],[-139.9872,60.1842],[-139.6969,60.343],[-139.089,60.3594],[-139.1985,60.091],[-139.0452,59.9979],[-138.7001,59.9103],[-138.6235,59.7679],[-137.6047,59.2421],[-137.4459,58.908],[-137.2652,59.0011],[-136.827,59.16],[-136.5806,59.165Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"waz","regions":[{"id":1,"code":"1","name":"1","label_lon":-140,"label_lat":75,"rings":[[[-109.0,85.0],[-109.0,77.5],[-103.2588,75.2824],[-103.4495,72.3827],[-99.2896,70.6113],[-102.0,68.0],[-102.0,60.0],[-139.0569,60.0001],[-138.7074,59.9061],[-138.6686,59.8091],[-138.626,59.7686],[-137.6062,59.2436],[-137.5007,58.9835],[-137.5266,58.9059],[-137.4512,58.9081],[-137.281,59.001],[-136.8272,59.1604],[-136.5843,59.166],[-136.4897,59.2595],[-136.4956,59.2754],[-136.4695,59.284],[-136.4771,59.4656],[-136.3681,59.449],[-136.3055,59.4644],[-136.2387,59.5242],[-136.2393,59.5592],[-136.3538,59.5998],[-136.1933,59.6401],[-135.9529,59.6622],[-135.4793,59.798],[-135.3601,59.7376],[-135.2336,59.6962],[-135.2198,59.6627],[-135.1575,59.6257],[-135.1179,59.6231],[-135.0289,59.5637],[-135.0275,59.4748],[-135.101,59.4276],[-134.9897,59.3871],[-135.0305,59.3466],[-134.9596,59.2808],[-134.7007,59.2489],[-134.6793,59.1925],[-134.5659,59.1312],[-134.4831,59.1316],[-134.3821,59.0386],[-134.4072,58.9796],[-134.3138,58.962],[-134.3365,58.9235],[-134.2579,58.8612],[-133.8404,58.7293],[-133.7068,58.6117],[-133.3772,58.4303],[-133.462,58.3879],[-133.346,58.2769],[-133.1722,58.1536],[-133.0701,58.0002],[-132.8689,57.8393],[-132.7512,57.6962],[-132.5542,57.4971],[-132.37,57.35],[-132.2487,57.2116],[-132.3688,57.0917],[-132.0449,57.045],[-132.1237,56.8739],[-131.8735,56.806],[-131.9009,56.7537],[-131.8604,56.703],[-131.8353,56.5994],[-131.5815,56.6123],[-131.4716,56.5523],[-131.1726,56.4494],[-131.0884,56.4062],[-130.7825,56.3671],[-130.6242,56.2672],[-130.4683,56.2431],[-130.4264,56.1415],[-130.2468,56.0966],[-130.104,56.1229],[-130.0037,56.0081],[-130.0178,55.9119],[-130.0041,55.9074],[-130.1524,55.7664],[-130.1481,55.7152],[-130.1118,55.6829],[-130.1281,55.5809],[-130.09,55.498],[-130.0413,55.4517],[-130.0211,55.3379],[-129.9742,55.282],[-130.1029,55.1926],[-130.1876,55.0629],[-130.346,54.9177],[-130.5682,54.7922],[-130.6591,54.7631],[-130.6257,54.7381],[-130.6156,54.7074],[-134.5376,54.2944],[-140.0,50.0],[-140.0,40.0],[-190.0,40.0],[-190.0,53.0],[-167.6871,66.6671],[-180.0,75.0],[-180.0,85.0],[-109.0,85.0]]]},{"id":2,"code":"2","name":"2","label_lon":-82.5,"label_lat":70,"rings":[[[-109.0,85.0],[-109.0,77.5],[-103.2588,75.2824],[-103.4495,72.3827],[-99.2896,70.6113],[-102.0,68.0],[-102.0,60.0],[-85.0,60.0],[-80.8725,54.5159],[-79.7772,51.8514],[-79.6522,51.6179],[-79.5157,51.4606],[-79.5176,50.0],[-60.0,50.0],[-55.9546,51.7455],[-55.4041,51.765],[-49.0,53.0],[-52.2437,59.5838],[-57.8076,67.1394],[-67.854,74.2269],[-74.0332,75.4972],[-74.8618,77.4191],[-73.5809,78.5637],[-69.8831,79.5013],[-68.2947,80.439],[-60.0073,82.2668],[-60.0,85.0],[-109.0,85.0]]]},{"id":3,"code":"3","name":"3","label_lon":-125,"label_lat":45,"rings":[[[-120.0,60.0],[-139.0569,60.0001],[-138.7074,59.9061],[-138.6686,59.8091],[-138.626,59.7686],[-137.6062,59.2436],[-137.5007,58.9835],[-137.5266,58.9059],[-137.4512,58.9081],[-137.281,59.001],[-136.8272,59.1604],[-136.5843,59.166],[-136.4897,59.2595],[-136.4956,59.2754],[-136.4695,59.284],[-136.4771,59.4656],[-136.3681,59.449],[-136.3055,59.4644],[-136.2387,59.5242],[-136.2393,59.5592],[-136.3538,59.5998],[-136.1933,59.6401],[-135.9529,59.6622],[-135.4793,59.798],[-135.3601,59.7376],[-135.2336,59.6962],[-135.2198,59.6627],[-135.1575,59.6257],[-135.1179,59.6231],[-135.0289,59.5637],[-135.0275,59.4748],[-135.101,59.4276],[-134.9897,59.3871],[-135.0305,59.3466],[-134.9596,59.2808],[-134.7007,59.2489],[-134.6793,59.1925],[-134.5659,59.1312],[-134.4831,59.1316],[-134.3821,59.0386],[-134.4072,58.9796],[-134.3138,58.962],[-134.3365,58.9235],[-134.2579,58.8612],[-133.8404,58.7293],[-133.7068,58.6117],[-133.3772,58.4303],[-133.462,58.3879],[-133.346,58.2769],[-133.1722,58.1536],[-133.0701,58.0002],[-132.8689,57.8393],[-132.7512,57.6962],[-132.5542,57.4971],[-132.37,57.35],[-132.2487,57.2116],[-132.3688,57.0917],[-132.0449,57.045],[-132.1237,56.8739],[-131.8735,56.806],[-131.9009,56.7537],[-131.8604,56.703],[-131.8353,56.5994],[-131.5815,56.6123],[-131.4716,56.5523],[-131.1726,56.4494],[-131.0884,56.4062],[-130.7825,56.3671],[-130.6242,56.2672],[-130.4683,56.2431],[-130.4264,56.1415],[-130.2468,56.0966],[-130.104,56.1229],[-130.0037,56.0081],[-130.0178,55.9119],[-130.0041,55.9074],[-130.1524,55.7664],[-130.1481,55.7152],[-130.1118,55.6829],[-130.1281,55.5809],[-130.09,55.498],[-130.0413,55.4517],[-130.0211,55.3379],[-129.9742,55.282],[-130.1029,55.1926],[-130.1876,55.0629],[-130.346,54.9177],[-130.5682,54.7922],[-130.6591,54.7631],[-130.6257,54.7381],[-130.6156,54.7074],[-134.5376,54.2944],[-140.0,50.0],[-140.0,40.0],[-130.0,40.0],[-130.0,32.5],[-120.0,32.5],[-117.1249,32.534],[-114.7197,32.7187],[-114.7643,32.6427],[-114.8087,32.6191],[-114.7997,32.5924],[-114.8137,32.5613],[-114.7932,32.5696],[-114.7916,32.5558],[-114.8057,32.5467],[-114.8135,32.4943],[-111.0748,31.3322],[-109.05,31.3325],[-109.0501,41.0007],[-111.0467,40.9979],[-111.049,44.474],[-111.1227,44.4937],[-111.1394,44.5171],[-111.1317,44.5326],[-111.17Line truncated
|
||||
@@ -0,0 +1,416 @@
|
||||
/*
|
||||
* 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.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import org.json.JSONObject
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.Projection
|
||||
import org.osmdroid.views.overlay.Overlay
|
||||
import java.io.InputStream
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* One administrative/boundary region loaded from the award GeoJSON assets.
|
||||
*
|
||||
* @param code match key against award statistics (e.g. "GD", "34", "24", "CA", "318")
|
||||
* @param name display label in the source language (province/prefecture/state/country name)
|
||||
* @param nameEn optional English display label (null when the source already is English)
|
||||
* @param labelLon/labelLat preferred label anchor (usually the region centroid)
|
||||
* @param rings polygon exterior rings as lon/lat pairs; the overlay projects
|
||||
* them per frame with the same projection helpers as the grid overlay
|
||||
*/
|
||||
data class AwardRegion(
|
||||
val code: String,
|
||||
val name: String,
|
||||
val nameEn: String? = null,
|
||||
val labelLon: Double,
|
||||
val labelLat: Double,
|
||||
val rings: List<List<DoubleArray>>,
|
||||
val forceLabel: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* Loads and caches the award boundary assets (assets/awards/<type>.json).
|
||||
* Format (generated by the awarddata/convert2.py pipeline):
|
||||
* {"type": "wapc", "regions": [{"id":..,"code":"GD","name":"广东省",
|
||||
* "label_lon":..,"label_lat":..,"rings":[[[lon,lat],...],...]}, ...]}
|
||||
*/
|
||||
object AwardBoundaryData {
|
||||
|
||||
private val cache = mutableMapOf<AwardAsset, List<AwardRegion>>()
|
||||
|
||||
enum class AwardAsset(val fileName: String) {
|
||||
WAPC("wapc.json"),
|
||||
WAJA("waja.json"),
|
||||
WAZ("waz.json"),
|
||||
WAS("was.json"),
|
||||
DXCC("dxcc.json")
|
||||
}
|
||||
|
||||
fun load(context: Context, asset: AwardAsset): List<AwardRegion> {
|
||||
cache[asset]?.let { return it }
|
||||
val regions = try {
|
||||
val stream: InputStream = context.assets.open("awards/${asset.fileName}")
|
||||
val text = stream.bufferedReader().use { it.readText() }
|
||||
val root = JSONObject(text)
|
||||
val array = root.optJSONArray("regions") ?: return emptyList()
|
||||
val list = mutableListOf<AwardRegion>()
|
||||
for (i in 0 until array.length()) {
|
||||
val o = array.optJSONObject(i) ?: continue
|
||||
val ringsArray = o.optJSONArray("rings") ?: continue
|
||||
val rings = mutableListOf<List<DoubleArray>>()
|
||||
for (r in 0 until ringsArray.length()) {
|
||||
val ring = ringsArray.optJSONArray(r) ?: continue
|
||||
val points = mutableListOf<DoubleArray>()
|
||||
for (p in 0 until ring.length()) {
|
||||
val pt = ring.optJSONArray(p) ?: continue
|
||||
if (pt.length() >= 2) points.add(doubleArrayOf(pt.getDouble(0), pt.getDouble(1)))
|
||||
}
|
||||
if (points.size >= 3) rings.add(points)
|
||||
}
|
||||
if (rings.isEmpty()) continue
|
||||
list.add(
|
||||
AwardRegion(
|
||||
code = o.optString("code"),
|
||||
name = o.optString("name"),
|
||||
nameEn = o.optString("name_en").takeIf { it.isNotEmpty() },
|
||||
labelLon = o.optDouble("label_lon", 0.0),
|
||||
labelLat = o.optDouble("label_lat", 0.0),
|
||||
rings = rings,
|
||||
forceLabel = o.optBoolean("force_label", false)
|
||||
)
|
||||
)
|
||||
}
|
||||
list
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
cache[asset] = regions
|
||||
return regions
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders award boundary regions (provinces/prefectures/CQ zones/states/
|
||||
* countries) with the same visual language as the VUCC grid overlay:
|
||||
* yellow boundary lines, green fill for worked regions, yellow labels.
|
||||
*
|
||||
* Worked regions are matched by [workedCodes] against each region's [AwardRegion.code]
|
||||
* (e.g. {"GD", "ZJ", "HK", ...} for WAPC). All region boundaries are always drawn
|
||||
* so missing regions stay visible; only worked ones get the green fill.
|
||||
*/
|
||||
class AwardBoundaryOverlay : 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 = 22f
|
||||
style = 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 = Paint.Style.FILL
|
||||
color = Color.argb(90, 76, 217, 100)
|
||||
}
|
||||
|
||||
/** Boundary regions to render; assigning recomputes culling bounds. */
|
||||
var regions: List<AwardRegion> = emptyList()
|
||||
set(value) {
|
||||
field = value
|
||||
regionBounds.clear()
|
||||
for (r in value) {
|
||||
var minLon = Double.MAX_VALUE; var minLat = Double.MAX_VALUE
|
||||
var maxLon = -Double.MAX_VALUE; var maxLat = -Double.MAX_VALUE
|
||||
for (ring in r.rings) for (pt in ring) {
|
||||
if (pt[0] < minLon) minLon = pt[0]
|
||||
if (pt[0] > maxLon) maxLon = pt[0]
|
||||
if (pt[1] < minLat) minLat = pt[1]
|
||||
if (pt[1] > maxLat) maxLat = pt[1]
|
||||
}
|
||||
regionBounds.add(doubleArrayOf(minLon, minLat, maxLon, maxLat))
|
||||
}
|
||||
}
|
||||
|
||||
/** Codes of worked regions -> green fill. */
|
||||
var workedCodes: Set<String> = emptySet()
|
||||
|
||||
/** Bounding boxes (computed on region assignment) for cheap culling. */
|
||||
private val regionBounds = mutableListOf<DoubleArray>() // [minLon,minLat,maxLon,maxLat]
|
||||
|
||||
override fun setEnabled(on: Boolean) {
|
||||
super.setEnabled(on)
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
|
||||
if (shadow || !isEnabled || regions.isEmpty()) return
|
||||
val projection = mapView.projection
|
||||
val zoom = mapView.zoomLevelDouble
|
||||
val worldWidthPx = 256.0 * Math.pow(2.0, zoom)
|
||||
|
||||
// Visible bounding box (same anchor-on-center approach as the grid overlay).
|
||||
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 viewCenter = projection.fromPixels(canvas.width / 2, canvas.height / 2)
|
||||
val centerLon = viewCenter.longitude
|
||||
val halfSpanDeg = (canvas.width / 2.0) / worldWidthPx * 360.0
|
||||
val leftLon = centerLon - halfSpanDeg
|
||||
val rightLon = centerLon + halfSpanDeg
|
||||
|
||||
labelPaint.textAlign = Paint.Align.CENTER
|
||||
val fontMetrics = labelPaint.fontMetrics
|
||||
val textHalfHeight = (fontMetrics.descent + fontMetrics.ascent) / 2f
|
||||
// Label language follows the system: Chinese UI keeps the source
|
||||
// (Chinese/Japanese) names; any other UI language uses the English
|
||||
// name when the asset provides one (falls back to the source name).
|
||||
val useEnglishLabels = mapView.context.resources.configuration.locales[0].language != "zh"
|
||||
|
||||
for (i in regions.indices) {
|
||||
val region = regions[i]
|
||||
val b = regionBounds.getOrNull(i) ?: continue
|
||||
// Cull: region bbox outside the visible span (with wrap slack).
|
||||
if (b[1] > topLat || b[3] < bottomLat) continue
|
||||
if (b[0] > rightLon || b[2] < leftLon) continue
|
||||
val worked = region.code in workedCodes
|
||||
|
||||
val path = Path()
|
||||
var pathHasPoints = false
|
||||
for (ring in region.rings) {
|
||||
// Split each ring into consecutive segments at the antimeridian
|
||||
// (±180°) and draw each as its own sub-path. Naively normalizing
|
||||
// every vertex to [-180,180) folds vertices that sit on the
|
||||
// "far" side of ±180 onto the opposite screen edge, drawing a
|
||||
// spurious line that sweeps across the whole map (Russia/Fiji/
|
||||
// Antarctica for DXCC, the Pacific CQ zones for WAZ). Splitting
|
||||
// keeps each segment contiguous. Each segment then gets ONE
|
||||
// per-segment longitude offset (in whole 360° turns) chosen to
|
||||
// sit closest to the view center, so vertices far from the
|
||||
// center are not folded to ±180 the wrong way at low zoom.
|
||||
// A ring that was NOT split keeps implicit closure (close());
|
||||
// a SPLIT ring must NOT be closed per segment — each close()
|
||||
// would draw a straight chord across the map between the two
|
||||
// cut ends (visible as a bogus Russia border line), so the
|
||||
// cut edges are left open.
|
||||
val segments = splitRingAtAntimeridian(ring)
|
||||
val closeRing = segments.size == 1
|
||||
for (segment in segments) {
|
||||
if (segment.isEmpty()) continue
|
||||
pathHasPoints = traceSegment(path, segment, projection, centerLon, worldWidthPx, closeRing) || pathHasPoints
|
||||
}
|
||||
}
|
||||
if (!pathHasPoints) continue
|
||||
|
||||
if (worked) canvas.drawPath(path, workedPaint)
|
||||
canvas.drawPath(path, linePaint)
|
||||
|
||||
// Label: draw when its anchor is on screen and the region is big
|
||||
// enough on screen to hold the text (avoid clutter at low zoom).
|
||||
val labelLonNorm = normalizeLon(region.labelLon)
|
||||
if (labelLonNorm < leftLon - 1.0 || labelLonNorm > rightLon + 1.0) continue
|
||||
if (region.labelLat < bottomLat - 1.0 || region.labelLat > topLat + 1.0) continue
|
||||
val lx = projectionToX(projection, labelLonNorm, centerLon, worldWidthPx) ?: continue
|
||||
val ly = projectionToY(projection, region.labelLat) ?: continue
|
||||
// Screen-size gate: the label only when the region spans enough px.
|
||||
// force_label regions (HK / Macau) always draw — their tiny land
|
||||
// bbox would otherwise stay below the gate forever, and their label
|
||||
// anchor sits out on the sea where there is room for the text.
|
||||
val regionH = (projectionToY(projection, b[3]) ?: 0f) - (projectionToY(projection, b[1]) ?: 0f)
|
||||
if (abs(regionH) < MIN_LABEL_REGION_PX && !region.forceLabel) continue
|
||||
val label = if (useEnglishLabels) region.nameEn ?: region.name else region.name
|
||||
canvas.drawText(label, lx, ly - textHalfHeight, labelPaint)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a ring's vertices into consecutive segments that do NOT cross the
|
||||
* antimeridian. Vertices are first normalized to [-180,180); whenever two
|
||||
* consecutive vertices differ by more than 180° in longitude (the ring
|
||||
* wraps over ±180), the segment is closed there and a new segment starts.
|
||||
*
|
||||
* A ring that crosses the antimeridian (e.g. Russia's eastern tip, or the
|
||||
* Pacific CQ zones whose raw lon runs past ±180) becomes two segments, each
|
||||
* kept geometrically contiguous so no spurious long edge is drawn.
|
||||
*/
|
||||
private fun splitRingAtAntimeridian(ring: List<DoubleArray>): List<List<DoubleArray>> {
|
||||
val n = ring.size
|
||||
if (n == 0) return emptyList()
|
||||
val pts = Array(n) { doubleArrayOf(normalizeLon(ring[it][0]), ring[it][1]) }
|
||||
val segments = mutableListOf<MutableList<DoubleArray>>()
|
||||
var cur = mutableListOf<DoubleArray>()
|
||||
cur.add(pts[0])
|
||||
for (i in 1 until n) {
|
||||
val prevLon = pts[i - 1][0]
|
||||
val lon = pts[i][0]
|
||||
if (abs(lon - prevLon) > 180.0) {
|
||||
// Crossed the antimeridian. Do NOT drop this edge — that would
|
||||
// leave a visible gap in the outline (WAZ Pacific zones 1/19/
|
||||
// 31/32, e.g. zone 31's 40°N boundary from 130°W to 160°E).
|
||||
// Split the edge IN TWO at ±180 instead: each half stays in its
|
||||
// own segment, so the outline closes across the map's left and
|
||||
// right edges. The crossing latitude is interpolated along the
|
||||
// edge (constant for latitude-aligned edges).
|
||||
val a = pts[i - 1]
|
||||
val b = pts[i]
|
||||
val span = 360.0 - abs(lon - prevLon) // shortest angular distance
|
||||
val edgeLon = if (prevLon < lon) -180.0 else 180.0 // side being crossed
|
||||
val t = abs(edgeLon - prevLon) / span
|
||||
val latAt = a[1] + (b[1] - a[1]) * t
|
||||
cur.add(doubleArrayOf(edgeLon, latAt))
|
||||
if (cur.size >= 2) segments.add(cur)
|
||||
cur = mutableListOf()
|
||||
cur.add(doubleArrayOf(-edgeLon, latAt))
|
||||
}
|
||||
cur.add(pts[i])
|
||||
}
|
||||
if (cur.size >= 2) segments.add(cur)
|
||||
// A fully-winding ring may have collapsed into a single segment after
|
||||
// normalization (no actual crossing between consecutive vertices); in
|
||||
// that case fall back to the whole ring as one segment.
|
||||
if (segments.isEmpty()) segments.add(mutableListOf<DoubleArray>().also { it.addAll(pts) })
|
||||
return segments
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects [segment] into [path], keeping the segment's geometry
|
||||
* contiguous on screen.
|
||||
*
|
||||
* Longitudes are normalized once when the ring is split, and every vertex
|
||||
* of a segment lies within 180° of its neighbours (a segment never crosses
|
||||
* the antimeridian). We anchor on the segment's FIRST vertex: its screen x
|
||||
* comes from [projectionToX] (correctly placed relative to the view
|
||||
* center), and every other vertex is offset by the *continuous* longitude
|
||||
* difference from that anchor, scaled by worldWidthPx. Because the whole
|
||||
* segment stays on the "near" side of ±180 relative to its anchor, no
|
||||
* vertex is folded to the opposite screen edge — that fold is what drew
|
||||
* the spurious long lines across the map (Russia/Fiji/Antarctica for
|
||||
* DXCC, the Pacific CQ zones for WAZ).
|
||||
*
|
||||
* @return true if at least one point was placed in the path
|
||||
*/
|
||||
private fun traceSegment(
|
||||
path: Path,
|
||||
segment: List<DoubleArray>,
|
||||
projection: Projection,
|
||||
centerLon: Double,
|
||||
worldWidthPx: Double,
|
||||
closeRing: Boolean
|
||||
): Boolean {
|
||||
if (segment.isEmpty()) return false
|
||||
val anchorLon = segment[0][0]
|
||||
val anchorX = projectionToX(projection, anchorLon, centerLon, worldWidthPx) ?: return false
|
||||
var penDown = false
|
||||
var any = false
|
||||
var firstX = 0f
|
||||
var firstY = 0f
|
||||
var lastX = 0f
|
||||
var lastY = 0f
|
||||
var prevDelta = 0.0
|
||||
for (i in segment.indices) {
|
||||
val pt = segment[i]
|
||||
// Continuous longitude difference from the anchor — no per-vertex
|
||||
// fold. For ordinary segments (span < 180° after the antimeridian
|
||||
// split) the raw delta is already correct. For full-circle rings
|
||||
// (e.g. Antarctica, which runs from -180° eastward to +178° and
|
||||
// relies on close() to seal a ~1.7° gap at the antimeridian) the
|
||||
// raw delta approaches ±360°. Unwrapping keeps each vertex within
|
||||
// 180° of its neighbours (a spurious fold would throw the path a
|
||||
// full world turn and draw a line across the map).
|
||||
val delta = if (i == 0) 0.0 else {
|
||||
var d = pt[0] - anchorLon
|
||||
while (d - prevDelta > 180.0) d -= 360.0
|
||||
while (prevDelta - d > 180.0) d += 360.0
|
||||
d
|
||||
}
|
||||
val x = anchorX + (delta / 360.0 * worldWidthPx).toFloat()
|
||||
val y = projectionToY(projection, pt[1]) ?: continue
|
||||
if (!penDown) {
|
||||
path.moveTo(x, y)
|
||||
firstX = x
|
||||
firstY = y
|
||||
penDown = true
|
||||
} else {
|
||||
path.lineTo(x, y)
|
||||
}
|
||||
lastX = x
|
||||
lastY = y
|
||||
prevDelta = delta
|
||||
any = true
|
||||
}
|
||||
if (penDown && closeRing) {
|
||||
// Closing chord: take the SHORT way around. A full-circle ring
|
||||
// (Antarctica) unwraps to ~360° so a plain close() would draw a
|
||||
// chord across the whole map; folding the first point onto the
|
||||
// adjacent world copy keeps the seal at the antimeridian tiny.
|
||||
var fx = firstX
|
||||
while (fx - lastX > worldWidthPx / 2f) fx -= worldWidthPx.toFloat()
|
||||
while (lastX - fx > worldWidthPx / 2f) fx += worldWidthPx.toFloat()
|
||||
path.lineTo(fx, firstY)
|
||||
}
|
||||
return any
|
||||
}
|
||||
|
||||
/** X pixel for a longitude (Mercator X is linear in longitude). */
|
||||
private fun projectionToX(projection: Projection, lon: Double, centerLon: Double, worldWidthPx: Double): Float? {
|
||||
var delta = lon - centerLon
|
||||
while (delta > 180.0) delta -= 360.0
|
||||
while (delta < -180.0) delta += 360.0
|
||||
val geo = org.osmdroid.util.GeoPoint(0.0, centerLon)
|
||||
val p = projection.toPixels(geo, null)
|
||||
return (p.x + delta / 360.0 * worldWidthPx).toFloat()
|
||||
}
|
||||
|
||||
/** Y pixel for a latitude. Mercator blows up at the poles (lat ±90 → ±∞),
|
||||
* so clamp to the Web-Mercator maximum (~±85.05113°) — the rendered map
|
||||
* edge. Raw data points AT the poles (e.g. WAZ zones and Antarctica rings
|
||||
* with [-90, ...] vertices) otherwise project far outside the canvas and
|
||||
* draw long spurious lines from the pole to the map edge. */
|
||||
private fun projectionToY(projection: Projection, lat: Double): Float? {
|
||||
val clamped = lat.coerceIn(-MAX_MERCATOR_LAT, MAX_MERCATOR_LAT)
|
||||
val geo = org.osmdroid.util.GeoPoint(clamped, 0.0)
|
||||
val p = projection.toPixels(geo, null)
|
||||
return p.y.toFloat()
|
||||
}
|
||||
|
||||
private fun normalizeLon(lon: Double): Double {
|
||||
var l = lon % 360.0
|
||||
// Normalize into (-180, 180] — +180, not -180. An edge whose endpoint
|
||||
// sits ON the antimeridian (e.g. a ring starting at -180°) must not be
|
||||
// seen as a >180° jump away from a nearby 165° vertex; keeping the
|
||||
// antimeridian value as +180 makes such edges short and un-split.
|
||||
if (l <= -180.0) l += 360.0
|
||||
if (l > 180.0) l -= 360.0
|
||||
return l
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MIN_LABEL_REGION_PX = 42f
|
||||
const val MAX_MERCATOR_LAT = 85.05113 // Web Mercator latitude limit
|
||||
}
|
||||
}
|
||||
+420
-84
@@ -46,28 +46,79 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
style = Paint.Style.STROKE
|
||||
color = Color.argb(160, 255, 224, 130)
|
||||
}
|
||||
// 网格标签与首通呼号标签同字号 (用户要求 2026-09-21: 字体一样大)
|
||||
private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textSize = 26f
|
||||
textSize = LABEL_TEXT_SIZE
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
color = Color.argb(220, 255, 224, 130)
|
||||
setShadowLayer(3f, 2f, 2f, Color.BLACK)
|
||||
}
|
||||
// 首通呼号标签: 与网格标签同字号.
|
||||
private val firstCallPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textSize = LABEL_TEXT_SIZE
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
color = Color.argb(230, 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)
|
||||
}
|
||||
// 标记(想通联)网格填充: 红色, 透明度与绿色 workedPaint 一致(alpha 90).
|
||||
private val markedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
color = Color.argb(90, 255, 59, 48)
|
||||
}
|
||||
private val roamStripePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 14f
|
||||
// Same alpha as the green worked fill (90) — user req: stripe opacity
|
||||
// must match the green grid. Wide 14f stripes at 44f spacing read as a
|
||||
// sparse GridMaster-style zebra (user picked spacing 44 / width 14).
|
||||
color = Color.argb(90, 66, 133, 244)
|
||||
}
|
||||
private val ownLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
strokeWidth = 6f
|
||||
style = Paint.Style.STROKE
|
||||
// 与普通网格线同色(淡黄), 仅加粗 — 用户要求当前网格框线不换色
|
||||
color = Color.argb(160, 255, 224, 130)
|
||||
}
|
||||
private val selectedLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
strokeWidth = 4.5f
|
||||
style = Paint.Style.STROKE
|
||||
color = Color.argb(255, 90, 200, 255)
|
||||
color = Color.argb(255, 76, 217, 100)
|
||||
}
|
||||
|
||||
/** Worked gridsquares (4-char, uppercase) to highlight, e.g. {"OL62", "PM95"}. */
|
||||
var workedGrids: Set<String> = emptySet()
|
||||
|
||||
/** Marked gridsquares (4-char, uppercase) — unworked grids the user has
|
||||
* marked stations they want to contact in; drawn red, same alpha as the
|
||||
* green worked fill. Value is the ordered mark list (first = pinned);
|
||||
* label mode shows the FIRST marked callsign. */
|
||||
var markedGrids: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
|
||||
|
||||
/** Grid-mode first-call labels: label worked (green) cells with the first
|
||||
* callsign worked in that grid instead of the Maidenhead code; non-worked
|
||||
* cells get no label at all. Only meaningful at sub-square zoom. */
|
||||
var showFirstCallLabels: Boolean = false
|
||||
|
||||
/** Worked grid -> first callsign worked in it (earliest QSO by time).
|
||||
* Only read when [showFirstCallLabels] is on. */
|
||||
var firstCallsByGrid: Map<String, String> = emptyMap()
|
||||
|
||||
/**
|
||||
* Gridsquares the station operated from (4-char, uppercase) — drawn with
|
||||
* blue 45° stripes (GridMaster-style zebra), e.g. {"OL62", "PM95"}.
|
||||
*/
|
||||
var roamedGrids: Set<String> = emptySet()
|
||||
|
||||
/** The station's own 4-char gridsquare, drawn with a distinct outline. */
|
||||
var ownGrid: String? = null
|
||||
|
||||
/** The currently tapped worked grid (4-char) highlighted with a distinct outline, or null. */
|
||||
var selectedGrid: String? = null
|
||||
|
||||
/** Viewport width, refreshed each draw; used by projectionToX bounds. */
|
||||
private var canvasWidthPx = 1080f
|
||||
|
||||
@@ -79,91 +130,278 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
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
|
||||
// Visible bounding box. Do NOT derive longitude bounds from the left/
|
||||
// right edge pixels: osmdroid normalizes them to [-180,180), which both
|
||||
// garbles antimeridian-crossing views AND makes any global view (span
|
||||
// > 180°) look like an antimeridian crossing — unwrapping then produced
|
||||
// an inverted range and ALL meridians vanished at low zoom. Instead
|
||||
// anchor on the view-center longitude and expand by the half-width in
|
||||
// degrees; the range is continuous (may exceed ±180) and cell indices
|
||||
// beyond 18 wrap correctly via normalizeLon().
|
||||
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 worldWidthPx = 256.0 * Math.pow(2.0, zoom)
|
||||
val viewCenter = projection.fromPixels(canvas.width / 2, canvas.height / 2)
|
||||
val centerLon = viewCenter.longitude
|
||||
val halfSpanDeg = (canvas.width / 2.0) / worldWidthPx * 360.0
|
||||
val leftLon = centerLon - halfSpanDeg
|
||||
val rightLon = centerLon + halfSpanDeg
|
||||
|
||||
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)
|
||||
// Longitude cell indices are in the continuous unwrapped space and may
|
||||
// exceed the [-180, 180) range when the view crosses the antimeridian
|
||||
// or spans world repeats; labels normalize each cell back.
|
||||
val firstCol = floor(leftLon / cellLon).toInt()
|
||||
val lastCol = ceil(rightLon / cellLon).toInt()
|
||||
val centerLon = (leftLon + rightLon) / 2.0
|
||||
// At low zoom the world is narrower than the viewport and osmdroid shows
|
||||
// repeating copies on both sides. The visible bounding box spans more
|
||||
// than 360° of longitude there; extend the column range by whole world
|
||||
// turns so meridians, fills and labels tile across the repeats too.
|
||||
val worldTurns = ceil(((rightLon - leftLon) / 360.0) - 1e-9).toInt().coerceAtLeast(0)
|
||||
val colRepeats = if (worldTurns > 0) worldTurns else 0
|
||||
|
||||
// The station's own grid square (4-char, only meaningful at sub-square zoom)
|
||||
val ownCell = ownGrid?.takeIf { zoom >= GRID_ZOOM_SUB }
|
||||
// The station's own grid: always keep a bold outline of the 4-char
|
||||
// square. At sub-square zoom it matches the visible cell grid; at field
|
||||
// zoom (2-char labels) the own 2°x1° square is drawn on top of the
|
||||
// field grid so the operator still sees exactly where they are (user
|
||||
// req: "field zoom must also draw the own 4-char grid, bolded").
|
||||
val ownCell = ownGrid
|
||||
// The tapped worked grid gets a distinct outline (sub-square zoom only).
|
||||
val selectedCell = selectedGrid?.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) {
|
||||
// Worked-grid highlight fills.
|
||||
// - Sub-square zoom: fill each worked 4-char cell directly.
|
||||
// - Field zoom (two-char labels): do NOT fill whole fields; instead fill
|
||||
// the individual 2°x1° squares that were worked, without drawing the
|
||||
// square grid lines — so you see green patches inside the field.
|
||||
if (workedGrids.isNotEmpty()) {
|
||||
if (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 (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
|
||||
val lon = col * cellLon
|
||||
// World-repeat copies: keep the turn offset in pixels
|
||||
// (colRepeats is 0 at this zoom today, but the same
|
||||
// alpha-stacking fix applies if it ever becomes > 0).
|
||||
val xLeftBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
|
||||
val xRightBase = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
|
||||
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
|
||||
val xRight = xRightBase + turn * worldWidthPx.toFloat()
|
||||
if (xRight < 0f || xLeft > canvas.width) continue
|
||||
if (cellLabel(lat, lon, zoom) in workedGrids) {
|
||||
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
|
||||
}
|
||||
if (cellLabel(lat, lon, zoom) in markedGrids) {
|
||||
canvas.drawRect(xLeft, yTop, xRight, yBottom, markedPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (grid in workedGrids) {
|
||||
val cell = gridCellBounds(grid) ?: continue
|
||||
// World-repeat copies: a 360° turn shifts the cell by a
|
||||
// full world width in pixels. projectionToX() normalizes
|
||||
// longitude back into [-180,180), so WITHOUT adding the
|
||||
// turn*worldWidthPx offset every copy lands on the SAME
|
||||
// screen x and the cell gets painted 2*colRepeats+1 times
|
||||
// in place — alpha stacks and the green turns brighter and
|
||||
// more opaque. Keep the world-width offset on the x.
|
||||
for (turn in -colRepeats..colRepeats) {
|
||||
val dLon = turn * 360.0
|
||||
if (cell.lonRight + dLon <= leftLon || cell.lonLeft + dLon >= rightLon) continue
|
||||
val yTop = projectionToY(projection, cell.latTop) ?: continue
|
||||
val yBottom = projectionToY(projection, cell.latBottom) ?: continue
|
||||
val xLeftBase = projectionToX(projection, cell.lonLeft, centerLon, worldWidthPx) ?: continue
|
||||
val xRightBase = projectionToX(projection, cell.lonRight, centerLon, worldWidthPx) ?: continue
|
||||
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
|
||||
val xRight = xRightBase + turn * worldWidthPx.toFloat()
|
||||
if (xRight < 0f || xLeft > canvas.width) continue
|
||||
if (yBottom < 0f || yTop > canvas.height) continue
|
||||
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
|
||||
}
|
||||
}
|
||||
for (grid in markedGrids.keys) {
|
||||
val cell = gridCellBounds(grid) ?: continue
|
||||
for (turn in -colRepeats..colRepeats) {
|
||||
val dLon = turn * 360.0
|
||||
if (cell.lonRight + dLon <= leftLon || cell.lonLeft + dLon >= rightLon) continue
|
||||
val yTop = projectionToY(projection, cell.latTop) ?: continue
|
||||
val yBottom = projectionToY(projection, cell.latBottom) ?: continue
|
||||
val xLeftBase = projectionToX(projection, cell.lonLeft, centerLon, worldWidthPx) ?: continue
|
||||
val xRightBase = projectionToX(projection, cell.lonRight, centerLon, worldWidthPx) ?: continue
|
||||
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
|
||||
val xRight = xRightBase + turn * worldWidthPx.toFloat()
|
||||
if (xRight < 0f || xLeft > canvas.width) continue
|
||||
if (yBottom < 0f || yTop > canvas.height) continue
|
||||
canvas.drawRect(xLeft, yTop, xRight, yBottom, markedPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Roamed/activated grid stripes: blue 45° zebra (GridMaster style) over
|
||||
// every cell the station operated from. Drawn AFTER the worked fills so
|
||||
// a worked+roamed cell shows blue stripes with green between them — the
|
||||
// stripe paint is fully opaque, so the two colors never alpha-blend
|
||||
// into a teal/green mix. Same geometry as the worked fills (per-4-char
|
||||
// cell at both zoom levels). The station's OWN grid is excluded: it
|
||||
// already carries the bold outline and must not be striped (user req).
|
||||
if (roamedGrids.isNotEmpty()) {
|
||||
if (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) ?: continue
|
||||
val yBottom = projectionToY(projection, lat) ?: continue
|
||||
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
|
||||
val lon = col * cellLon
|
||||
val xLeftBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
|
||||
val xRightBase = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
|
||||
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
|
||||
val xRight = xRightBase + turn * worldWidthPx.toFloat()
|
||||
if (xRight < 0f || xLeft > canvas.width) continue
|
||||
val label = cellLabel(lat, lon, zoom)
|
||||
if (label in roamedGrids && label != ownGrid) {
|
||||
drawStripes(canvas, xLeft, yTop, xRight, yBottom, zoom)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (grid in roamedGrids) {
|
||||
// The own grid keeps only its bold outline — no stripes.
|
||||
if (grid == ownGrid) continue
|
||||
val cell = gridCellBounds(grid) ?: continue
|
||||
for (turn in -colRepeats..colRepeats) {
|
||||
val dLon = turn * 360.0
|
||||
if (cell.lonRight + dLon <= leftLon || cell.lonLeft + dLon >= rightLon) continue
|
||||
val yTop = projectionToY(projection, cell.latTop) ?: continue
|
||||
val yBottom = projectionToY(projection, cell.latBottom) ?: continue
|
||||
val xLeftBase = projectionToX(projection, cell.lonLeft, centerLon, worldWidthPx) ?: continue
|
||||
val xRightBase = projectionToX(projection, cell.lonRight, centerLon, worldWidthPx) ?: continue
|
||||
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
|
||||
val xRight = xRightBase + turn * worldWidthPx.toFloat()
|
||||
if (xRight < 0f || xLeft > canvas.width) continue
|
||||
if (yBottom < 0f || yTop > canvas.height) continue
|
||||
drawStripes(canvas, xLeft, yTop, xRight, yBottom, zoom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Horizontal lines (parallels)
|
||||
|
||||
// Vertical lines (meridians). Worked-grid cells are NOT excluded:
|
||||
// the boundary lines between adjacent worked squares must stay visible
|
||||
// so the grid structure remains readable (reverted 2026-09-09 — the
|
||||
// earlier skip logic removed those shared edges entirely).
|
||||
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
|
||||
val lon = col * cellLon
|
||||
// World-repeat copies: keep the turn offset in pixels, otherwise
|
||||
// every turn normalizes to the same x and the line paints over
|
||||
// itself 2*colRepeats+1 times (alpha stacking).
|
||||
val xBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
|
||||
val x = xBase + turn * worldWidthPx.toFloat()
|
||||
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
|
||||
canvas.drawLine(x, yTop, x, yBottom, 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)
|
||||
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
|
||||
val lon = col * cellLon
|
||||
// World-repeat copies: keep the turn offset in pixels (same
|
||||
// alpha-stacking fix as the vertical lines above).
|
||||
val xLeftBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
|
||||
val xRightBase = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
|
||||
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
|
||||
val xRight = xRightBase + turn * worldWidthPx.toFloat()
|
||||
if (xRight < 0f || xLeft > canvas.width) continue
|
||||
canvas.drawLine(xLeft, y, xRight, 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)
|
||||
// The tapped worked grid gets the same treatment in a different color.
|
||||
if (ownCell != null || selectedCell != null) {
|
||||
if (zoom >= GRID_ZOOM_SUB) {
|
||||
for (row in firstRow..lastRow) {
|
||||
val lat = row * cellLat
|
||||
if (lat < -90.0 || lat >= 90.0) continue
|
||||
for (col in firstCol..lastCol) {
|
||||
val lon = col * cellLon
|
||||
val label = cellLabel(lat, lon, zoom)
|
||||
if (label != ownCell && label != selectedCell) continue
|
||||
val yTop = projectionToY(projection, lat + cellLat) ?: continue
|
||||
val yBottom = projectionToY(projection, lat) ?: continue
|
||||
val xLeft = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
|
||||
val xRight = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
|
||||
val paint = if (label == selectedCell) selectedLinePaint else ownLinePaint
|
||||
canvas.drawLine(xLeft, yTop, xRight, yTop, paint)
|
||||
canvas.drawLine(xLeft, yBottom, xRight, yBottom, paint)
|
||||
canvas.drawLine(xLeft, yTop, xLeft, yBottom, paint)
|
||||
canvas.drawLine(xRight, yTop, xRight, yBottom, paint)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Field zoom: the visible grid shows 2-char fields only, but the
|
||||
// station's own 2°x1° square still gets its bold outline drawn
|
||||
// on top of the field grid (user req). selectedCell is null here.
|
||||
val grid = ownCell
|
||||
if (grid != null) {
|
||||
val cell = gridCellBounds(grid)
|
||||
if (cell != null) {
|
||||
for (turn in -colRepeats..colRepeats) {
|
||||
val dLon = turn * 360.0
|
||||
if (cell.lonRight + dLon <= leftLon || cell.lonLeft + dLon >= rightLon) continue
|
||||
val yTop = projectionToY(projection, cell.latTop) ?: continue
|
||||
val yBottom = projectionToY(projection, cell.latBottom) ?: continue
|
||||
val xLeftBase = projectionToX(projection, cell.lonLeft, centerLon, worldWidthPx) ?: continue
|
||||
val xRightBase = projectionToX(projection, cell.lonRight, centerLon, worldWidthPx) ?: continue
|
||||
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
|
||||
val xRight = xRightBase + turn * worldWidthPx.toFloat()
|
||||
if (xRight < 0f || xLeft > canvas.width) continue
|
||||
if (yBottom < 0f || yTop > canvas.height) 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
|
||||
}
|
||||
// screen to hold a label (avoid clutter at low zoom).
|
||||
// Field (2-char) labels show at every zoom, subject only to the pixel-
|
||||
// size check below; sub-square (4-char) labels appear together with the
|
||||
// grid LINES at GRID_ZOOM_SUB (user req 2026-09-21: 与首通呼号同一显示缩放).
|
||||
// First-call labels replace the 4-char grid codes and only exist at
|
||||
// sub-square zoom; at field zoom NO labels are drawn at all (the user
|
||||
// requirement is that non-worked cells carry no grid characters).
|
||||
if (showFirstCallLabels && zoom < GRID_ZOOM_SUB) return
|
||||
// 网格标签与首通呼号同一显示缩放 (用户要求 2026-09-21): 子方块(4字符)
|
||||
// 标签与首通呼号都在网格线出现的 GRID_ZOOM_SUB 同时显示, 不再晚一档.
|
||||
val showLabels = zoom >= GRID_ZOOM_SUB || cellLat == FIELD_LAT
|
||||
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.
|
||||
@@ -171,10 +409,14 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
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
|
||||
// 两种模式同一像素门限: GRID_ZOOM_SUB 处 1° 格约 45px, 低于 48px
|
||||
// 常规阈值, 统一用放宽的 0.6x 门限, 保证网格标签与首通呼号同时出现.
|
||||
val minCellPx = MIN_LABEL_CELL_PX * 0.6f
|
||||
if (pixelsPerDegree * cellLat < minCellPx) return
|
||||
|
||||
labelPaint.textAlign = Paint.Align.CENTER
|
||||
val fontMetrics = labelPaint.fontMetrics
|
||||
val activePaint = if (showFirstCallLabels) firstCallPaint else labelPaint
|
||||
activePaint.textAlign = Paint.Align.CENTER
|
||||
val fontMetrics = activePaint.fontMetrics
|
||||
val textHalfHeight = (fontMetrics.descent + fontMetrics.ascent) / 2f
|
||||
for (row in firstRow..lastRow) {
|
||||
val lat = row * cellLat
|
||||
@@ -183,41 +425,92 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
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) {
|
||||
// Polar bands (80..90 / -90..-80) extend beyond the map's latitude
|
||||
// limit, so their geometric center falls off-screen and the label
|
||||
// would never be visible. Center the label in the VISIBLE part of
|
||||
// the cell instead; fully off-screen cells are still skipped.
|
||||
val visTop = maxOf(yTop, 0f)
|
||||
val visBottom = minOf(yBottom, canvas.height.toFloat())
|
||||
if (visBottom < 0f || visTop > canvas.height) continue
|
||||
val yCenter = (visTop + visBottom) / 2f - textHalfHeight
|
||||
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
|
||||
val lon = col * cellLon
|
||||
val xLeft = projectionToX(projection, lon, centerLon) ?: continue
|
||||
val xRight = projectionToX(projection, lon + cellLon, centerLon) ?: continue
|
||||
// World-repeat copies: keep the turn offset in pixels (same
|
||||
// alpha-stacking fix as the lines above).
|
||||
val xLeftBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
|
||||
val xRightBase = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
|
||||
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
|
||||
val xRight = xRightBase + turn * worldWidthPx.toFloat()
|
||||
if (xRight < 0f || xLeft > canvas.width) continue
|
||||
val label = cellLabel(lat, lon, zoom)
|
||||
canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint)
|
||||
if (showFirstCallLabels) {
|
||||
// 首通呼号模式: 绿格(worked)标注该格第一个通联的呼号, 红格(marked)
|
||||
// 标注用户标记想通联的呼号, 均淡黄文字; 其它格空着不写网格字符.
|
||||
when {
|
||||
label in workedGrids -> firstCallsByGrid[label]?.let { call ->
|
||||
canvas.drawText(call, (xLeft + xRight) / 2f, yCenter, firstCallPaint)
|
||||
}
|
||||
label in markedGrids -> markedGrids[label]?.firstOrNull()?.let { station ->
|
||||
canvas.drawText(station.call, (xLeft + xRight) / 2f, yCenter, firstCallPaint)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws 45° diagonal stripes (GridMaster-style zebra) clipped to the cell
|
||||
* rectangle, from top-left to bottom-right. The paint is nearly opaque so
|
||||
* stripes stay blue even over a green worked fill underneath.
|
||||
*/
|
||||
private fun drawStripes(canvas: Canvas, xLeft: Float, yTop: Float, xRight: Float, yBottom: Float, zoom: Double) {
|
||||
if (xRight <= xLeft || yBottom <= yTop) return
|
||||
canvas.save()
|
||||
canvas.clipRect(xLeft, yTop, xRight, yBottom)
|
||||
val height = yBottom - yTop
|
||||
// Geographic density is kept constant: spacing is the reference pixel
|
||||
// spacing (STRIPE_SPACING_PX at the map's max zoom) scaled by 2^(zoom-max),
|
||||
// so stripes shrink/grow with the map instead of staying fixed on screen.
|
||||
val spacing = STRIPE_SPACING_PX * Math.pow(2.0, zoom - MAX_GRID_ZOOM).toFloat()
|
||||
// Start one stripe-width left of the cell so the top-left corner is
|
||||
// always covered; each stripe runs from (x, top) to (x+height, bottom).
|
||||
var x = xLeft - height
|
||||
while (x < xRight) {
|
||||
canvas.drawLine(x, yTop, x + height, yBottom, roamStripePaint)
|
||||
x += spacing
|
||||
}
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
/** 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)
|
||||
private fun projectionToX(projection: Projection, lon: Double, centerLon: Double, worldWidthPx: Double): Float? {
|
||||
// Do NOT use osmdroid's toPixels() here: at low zoom its wrap-around
|
||||
// logic (getCloserPixel) mis-projects longitudes far from the view
|
||||
// center, which makes meridians vanish while panning. Mercator X is
|
||||
// linear in longitude, so compute it directly:
|
||||
// x = screenCenterX + (lon - centerLon) / 360 * worldWidthPx
|
||||
var delta = lon - centerLon
|
||||
while (delta > 180.0) delta -= 360.0
|
||||
while (delta < -180.0) delta += 360.0
|
||||
val geo = org.osmdroid.util.GeoPoint(0.0, centerLon)
|
||||
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
|
||||
return (p.x + delta / 360.0 * worldWidthPx).toFloat()
|
||||
}
|
||||
|
||||
/** 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)
|
||||
// Web Mercator is undefined beyond ±85.0511° (osmdroid clamps the world
|
||||
// to TileSystemWebMercator limits). Projecting the polar field rows
|
||||
// (lat=±90: Maidenhead row R = 80..90°N, row A = -90..-80°S) yields
|
||||
// ±9.2e18 pixel coordinates that the canvas cannot rasterize, so the
|
||||
// polar grid lines silently vanish. Clamp to the Mercator limit: the
|
||||
// polar rows then project onto the screen edge and their lines are
|
||||
// drawn (the off-limit sliver beyond 85.05° is invisible anyway).
|
||||
val clamped = lat.coerceIn(-MAX_MERCATOR_LAT, MAX_MERCATOR_LAT)
|
||||
val geo = org.osmdroid.util.GeoPoint(clamped, 0.0)
|
||||
val p = projection.toPixels(geo, null)
|
||||
return p.y.toFloat()
|
||||
}
|
||||
@@ -237,6 +530,30 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
return "$field$squareLon$squareLat"
|
||||
}
|
||||
|
||||
/**
|
||||
* Geographic bounds of a 4-char Maidenhead square (e.g. "OL62"):
|
||||
* 2° wide in longitude, 1° tall in latitude.
|
||||
*/
|
||||
private fun gridCellBounds(grid: String): CellBounds? {
|
||||
val g = grid.trim().uppercase()
|
||||
if (g.length < 4) return null
|
||||
val fieldLon = g[0] - 'A'
|
||||
val fieldLat = g[1] - 'A'
|
||||
val sqLon = g[2] - '0'
|
||||
val sqLat = g[3] - '0'
|
||||
if (fieldLon !in 0..17 || fieldLat !in 0..17 || sqLon !in 0..9 || sqLat !in 0..9) return null
|
||||
val lonLeft = -180.0 + fieldLon * FIELD_LON + sqLon * SUB_SQUARE_LON
|
||||
val latBottom = -90.0 + fieldLat * FIELD_LAT + sqLat * SUB_SQUARE_LAT
|
||||
return CellBounds(lonLeft, lonLeft + SUB_SQUARE_LON, latBottom, latBottom + SUB_SQUARE_LAT)
|
||||
}
|
||||
|
||||
private data class CellBounds(
|
||||
val lonLeft: Double,
|
||||
val lonRight: Double,
|
||||
val latBottom: Double,
|
||||
val latTop: Double
|
||||
)
|
||||
|
||||
private fun normalizeLon(lon: Double): Double {
|
||||
var l = lon % 360.0
|
||||
if (l >= 180.0) l -= 360.0
|
||||
@@ -244,14 +561,33 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
return l
|
||||
}
|
||||
|
||||
private companion object {
|
||||
internal 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
|
||||
// Web Mercator latitude limit (osmdroid TileSystemWebMercator): projecting
|
||||
// beyond it produces ±9.2e18 pixel coordinates the canvas cannot draw.
|
||||
const val MAX_MERCATOR_LAT = 85.05112877980658
|
||||
// Zoom at which the 2°x1° sub-square grid lines/fills appear.
|
||||
// 5.0 → 6.0: at zoom 5 a full field spans too little screen width and
|
||||
// the sub-square grid is too dense to read; 6 roughly doubles the
|
||||
// on-screen size of each square.
|
||||
const val GRID_ZOOM_SUB = 6.0
|
||||
// 网格标签与首通呼号标签统一字号 (用户要求 2026-09-21: 字体一样大).
|
||||
const val LABEL_TEXT_SIZE = 20f
|
||||
const val MIN_LABEL_CELL_PX = 48f
|
||||
const val MAX_OVERSHOOT_PX = 64
|
||||
/** Center-to-center spacing of the roamed-grid zebra stripes, in px. */
|
||||
// 44f spacing (was 16f — too dense per user) with 14f-wide stripes;
|
||||
// sparse GridMaster-style zebra.
|
||||
const val STRIPE_SPACING_PX = 44f
|
||||
/**
|
||||
* Reference zoom for stripe spacing: the map's max zoom (MapScreen sets
|
||||
* maxZoomLevel = 7.0). At this zoom stripes are STRIPE_SPACING_PX apart;
|
||||
* at lower zooms spacing scales by 2^(zoom-max) so the geographic
|
||||
* density matches the max-zoom look at every level.
|
||||
*/
|
||||
const val MAX_GRID_ZOOM = 7.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.rtbishop.look4sat.feature.map
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.model.AwardType
|
||||
|
||||
/**
|
||||
* Session-scoped holder for the map's award filter selection.
|
||||
*
|
||||
* Scoped to the Activity (not the navigation entry) so the selection survives
|
||||
* switching to another page and back; it resets to VUCC only when the process
|
||||
* starts fresh (cold start), which is what the user expects as the default.
|
||||
*/
|
||||
class MapFilterViewModel : ViewModel() {
|
||||
|
||||
/** Last chosen award chip; null means "All". Initialized to VUCC once per process. */
|
||||
val selectedAward: MutableState<AwardType?> = mutableStateOf(AwardType.VUCC)
|
||||
|
||||
/** Last map viewport (center + zoom), saved when leaving the page. Null until first visit. */
|
||||
var mapCenterLat: Double? = null
|
||||
var mapCenterLon: Double? = null
|
||||
var mapZoom: Double? = null
|
||||
|
||||
/** Persists the current viewport so a later re-entry restores exactly where the user left off. */
|
||||
fun saveMapViewState(centerLat: Double, centerLon: Double, zoom: Double) {
|
||||
mapCenterLat = centerLat
|
||||
mapCenterLon = centerLon
|
||||
mapZoom = zoom
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory() = viewModelFactory {
|
||||
initializer { MapFilterViewModel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Overlay
|
||||
import kotlin.math.cos
|
||||
@@ -39,9 +40,11 @@ import kotlin.math.sin
|
||||
* exceeds 90°, i.e. the dot product of the two unit vectors is negative:
|
||||
* dot = sin(lat)*sin(sunLat) + cos(lat)*cos(sunLat)*cos(lon - sunLon) < 0
|
||||
*
|
||||
* Performance: we sample one column per [stepPx] pixels (default 4) and draw
|
||||
* Performance: we sample one column per stepPx pixels (default 4) and draw
|
||||
* filled vertical rectangles. On a 1080-wide screen this means ~270 trig
|
||||
* evaluations per row, which is imperceptible.
|
||||
* evaluations per row, which is imperceptible. draw() is called on every
|
||||
* frame, so it must stay allocation-free — all fromPixels() calls reuse a
|
||||
* single GeoPoint instance.
|
||||
*/
|
||||
class MapNightOverlay : Overlay() {
|
||||
|
||||
@@ -58,6 +61,9 @@ class MapNightOverlay : Overlay() {
|
||||
|
||||
private val rect = RectF()
|
||||
|
||||
/** Reused across every fromPixels() call — draw() runs on every frame, so it must not allocate */
|
||||
private val reusableGeoPoint = GeoPoint(0.0, 0.0)
|
||||
|
||||
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
|
||||
if (shadow) return
|
||||
|
||||
@@ -71,6 +77,15 @@ class MapNightOverlay : Overlay() {
|
||||
val h = mapView.height
|
||||
val stepPx = 4 // sample every N pixels — balance quality vs CPU
|
||||
|
||||
// The map is never rotated, so latitude depends only on y and longitude only on x.
|
||||
// Resolve the top/bottom latitudes once instead of once per column.
|
||||
val latTopRad = Math.toRadians((proj.fromPixels(0, 0, reusableGeoPoint) ?: return).latitude)
|
||||
val latBotRad = Math.toRadians((proj.fromPixels(0, h - 1, reusableGeoPoint) ?: return).latitude)
|
||||
val sinLatTop = sin(latTopRad)
|
||||
val cosLatTop = cos(latTopRad)
|
||||
val sinLatBot = sin(latBotRad)
|
||||
val cosLatBot = cos(latBotRad)
|
||||
|
||||
// We scan column by column. For each column we determine the longitude,
|
||||
// then find the latitude range that is in night and shade it.
|
||||
// Since longitude is constant along a vertical strip and the day/night
|
||||
@@ -79,22 +94,14 @@ class MapNightOverlay : Overlay() {
|
||||
|
||||
var x = 0
|
||||
while (x < w) {
|
||||
// Get the geographic coordinate at the top and bottom of this column.
|
||||
val geoTop = proj.fromPixels(x, 0) ?: run { x += stepPx; continue }
|
||||
val geoBot = proj.fromPixels(x, h - 1) ?: run { x += stepPx; continue }
|
||||
|
||||
val geoTop = proj.fromPixels(x, 0, reusableGeoPoint) ?: run { x += stepPx; continue }
|
||||
val lonRad = Math.toRadians(geoTop.longitude)
|
||||
val cosLonDiff = cos(lonRad - sunLonRad)
|
||||
|
||||
// Top pixel geographic lat
|
||||
val latTopRad = Math.toRadians(geoTop.latitude)
|
||||
// Bottom pixel geographic lat (osmdroid: y=0 is top of screen, higher y = lower lat)
|
||||
val latBotRad = Math.toRadians(geoBot.latitude)
|
||||
|
||||
// dot(sunVec, pointVec) < 0 → night
|
||||
// dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff
|
||||
val dotTop = sin(latTopRad) * sinSunLat + cos(latTopRad) * cosSunLat * cosLonDiff
|
||||
val dotBot = sin(latBotRad) * sinSunLat + cos(latBotRad) * cosSunLat * cosLonDiff
|
||||
val dotTop = sinLatTop * sinSunLat + cosLatTop * cosSunLat * cosLonDiff
|
||||
val dotBot = sinLatBot * sinSunLat + cosLatBot * cosSunLat * cosLonDiff
|
||||
|
||||
when {
|
||||
dotTop < 0 && dotBot < 0 -> {
|
||||
@@ -142,7 +149,7 @@ class MapNightOverlay : Overlay() {
|
||||
var hi = yBot
|
||||
while (hi - lo > 1) {
|
||||
val mid = (lo + hi) / 2
|
||||
val geo = proj.fromPixels(x, mid) ?: return mid
|
||||
val geo = proj.fromPixels(x, mid, reusableGeoPoint) ?: return mid
|
||||
val latRad = Math.toRadians(geo.latitude)
|
||||
val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
|
||||
if (dot < 0) hi = mid else lo = mid
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -21,13 +21,25 @@ 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.model.MapSource
|
||||
|
||||
data class MapState(
|
||||
val mapData: MapData? = null,
|
||||
val isLightUi: Boolean = false,
|
||||
val isUtc: Boolean = false,
|
||||
val mapSource: String = MapSource.OSM,
|
||||
val tiandituKey: String = "",
|
||||
val isGridMode: Boolean = false,
|
||||
/** Grid mode: label worked (green) cells with the first callsign worked in
|
||||
* that grid instead of the grid code; non-worked cells get no label. */
|
||||
val showFirstCallLabels: Boolean = false,
|
||||
val workedGrids: Set<String> = emptySet(),
|
||||
val workedGridQsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap(),
|
||||
/** 4-char gridsquares the station operated from (LoTW MY_GRIDSQUARE) — blue stripes. */
|
||||
val roamedGrids: Set<String> = emptySet(),
|
||||
/** Stations the user marked in unworked gridsquares (grid -> ordered list;
|
||||
* first = pinned/top) — red fill. */
|
||||
val markedGrids: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap(),
|
||||
val stationPosition: GeoPos? = null,
|
||||
val orbitalPass: OrbitalPass,
|
||||
val track: List<List<GeoPos>>? = null,
|
||||
@@ -44,6 +56,12 @@ sealed interface MapAction {
|
||||
data object SelectNext : MapAction
|
||||
data class SelectItem(val item: OrbitalObject) : MapAction
|
||||
data class SelectDefaultItem(val catnum: Int) : MapAction
|
||||
data class ToggleGridMode(val value: Boolean) : MapAction
|
||||
data class ToggleFirstCallLabels(val value: Boolean) : MapAction
|
||||
data class SetMarkedStation(val grid: String, val call: String) : MapAction
|
||||
data class RemoveMarkedStation(val grid: String, val call: String) : MapAction
|
||||
data class PinMarkedStation(val grid: String, val call: String) : MapAction
|
||||
data class SetVisible(val isVisible: Boolean) : MapAction
|
||||
}
|
||||
|
||||
data class MapData(
|
||||
|
||||
@@ -37,6 +37,7 @@ import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
@@ -45,6 +46,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -61,6 +63,8 @@ class MapViewModel(
|
||||
MapState(
|
||||
isLightUi = settingsRepo.otherSettings.value.stateOfLightTheme,
|
||||
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
||||
mapSource = settingsRepo.otherSettings.value.mapSource,
|
||||
tiandituKey = settingsRepo.otherSettings.value.tiandituKey,
|
||||
orbitalPass = defaultPass
|
||||
)
|
||||
)
|
||||
@@ -69,17 +73,42 @@ class MapViewModel(
|
||||
private var dataUpdateJob: Job? = null
|
||||
private var dataUpdateRate = 1000L
|
||||
private var selectedOrbitalObject: OrbitalObject? = null
|
||||
|
||||
/** Gates the prediction loop so it doesn't burn CPU while the map isn't on screen */
|
||||
private val isScreenVisible = MutableStateFlow(true)
|
||||
val uiState: StateFlow<MapState> = _uiState
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
settingsRepo.otherSettings.collectLatest { settings ->
|
||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc, isGridMode = settings.stateOfMapGrid) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isUtc = settings.stateOfUtc,
|
||||
mapSource = settings.mapSource,
|
||||
tiandituKey = settings.tiandituKey,
|
||||
isGridMode = settings.stateOfMapGrid,
|
||||
showFirstCallLabels = settings.stateOfMapFirstCall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.wavelogSettings.collectLatest { _ ->
|
||||
_uiState.update { it.copy(workedGrids = settingsRepo.getWorkedGrids()) }
|
||||
val workedGrids = settingsRepo.getWorkedGrids()
|
||||
val markedGrids = settingsRepo.getMarkedGridStations()
|
||||
// Marked stations auto-clear once their grid becomes worked:
|
||||
// the reminder has served its purpose (user req: 网格变绿后标记自动清除).
|
||||
val stale = markedGrids.keys.filter { it in workedGrids }
|
||||
val activeMarks = if (stale.isEmpty()) markedGrids else markedGrids - stale.toSet()
|
||||
if (stale.isNotEmpty()) settingsRepo.setMarkedGridStations(activeMarks)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
workedGrids = workedGrids,
|
||||
workedGridQsos = settingsRepo.getWorkedGridQsos(),
|
||||
roamedGrids = settingsRepo.getRoamedGrids(),
|
||||
markedGrids = activeMarks
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
|
||||
@@ -93,6 +122,41 @@ class MapViewModel(
|
||||
MapAction.SelectNext -> scrollSelection(false)
|
||||
is MapAction.SelectItem -> selectSatellite(action.item)
|
||||
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
|
||||
is MapAction.ToggleGridMode -> settingsRepo.updateOtherSettings { it.copy(stateOfMapGrid = action.value) }
|
||||
is MapAction.ToggleFirstCallLabels -> settingsRepo.updateOtherSettings { it.copy(stateOfMapFirstCall = action.value) }
|
||||
is MapAction.SetMarkedStation -> {
|
||||
val call = action.call.trim().uppercase()
|
||||
val existing = settingsRepo.getMarkedGridStations()
|
||||
val current = existing[action.grid].orEmpty()
|
||||
// 重复呼号忽略: 同一网格已标记过的呼号不再重复添加.
|
||||
val updated = if (current.any { it.call == call }) existing
|
||||
else existing + (action.grid to current + com.rtbishop.look4sat.core.domain.model.MarkedStation(
|
||||
call = call,
|
||||
epochMs = System.currentTimeMillis()
|
||||
))
|
||||
settingsRepo.setMarkedGridStations(updated)
|
||||
_uiState.update { it.copy(markedGrids = updated) }
|
||||
}
|
||||
is MapAction.RemoveMarkedStation -> {
|
||||
val existing = settingsRepo.getMarkedGridStations()
|
||||
val current = existing[action.grid].orEmpty()
|
||||
val remaining = current.filterNot { it.call == action.call }
|
||||
val updated = if (remaining.isEmpty()) existing - action.grid
|
||||
else existing + (action.grid to remaining)
|
||||
settingsRepo.setMarkedGridStations(updated)
|
||||
_uiState.update { it.copy(markedGrids = updated) }
|
||||
}
|
||||
is MapAction.PinMarkedStation -> {
|
||||
val existing = settingsRepo.getMarkedGridStations()
|
||||
val current = existing[action.grid].orEmpty()
|
||||
val target = current.firstOrNull { it.call == action.call }
|
||||
if (target != null) {
|
||||
val updated = existing + (action.grid to listOf(target) + current.filterNot { it.call == action.call })
|
||||
settingsRepo.setMarkedGridStations(updated)
|
||||
_uiState.update { it.copy(markedGrids = updated) }
|
||||
}
|
||||
}
|
||||
is MapAction.SetVisible -> isScreenVisible.value = action.isVisible
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +194,11 @@ class MapViewModel(
|
||||
selectedOrbitalObject = orbitalObject
|
||||
viewModelScope.launch {
|
||||
dataUpdateJob?.cancelAndJoin()
|
||||
dataUpdateJob = launch {
|
||||
// Default dispatcher is mandatory: viewModelScope is Main.immediate, and every
|
||||
// satelliteRepo call internally hops to Default and resumes back on the caller's
|
||||
// dispatcher. On Main that posts one continuation per satellite per tick, which
|
||||
// floods the looper and ANRs for users tracking thousands of objects.
|
||||
dataUpdateJob = launch(Dispatchers.Default) {
|
||||
val dateNow = Date()
|
||||
getStationPosition()
|
||||
getSatTrack(orbitalObject, stationPos, dateNow)
|
||||
@@ -141,6 +209,8 @@ class MapViewModel(
|
||||
else -> updateFreq
|
||||
}
|
||||
while (isActive) {
|
||||
// Suspends while the map is off-screen instead of predicting into the void
|
||||
isScreenVisible.first { it }
|
||||
dateNow.time = System.currentTimeMillis()
|
||||
updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
|
||||
delay(effectiveRate)
|
||||
|
||||
@@ -4,4 +4,20 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.rtbishop.look4sat.feature.mutual"
|
||||
}
|
||||
testOptions {
|
||||
unitTests {
|
||||
// Robolectric needs real Android resources for Compose UI tests.
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation(libs.test.junit4)
|
||||
testImplementation(libs.test.coroutines)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.androidx.test.core)
|
||||
testImplementation(libs.compose.ui.test.junit4)
|
||||
testImplementation(libs.compose.navigation3)
|
||||
debugImplementation(libs.compose.debug.manifest)
|
||||
}
|
||||
+7
-1
@@ -45,6 +45,7 @@ import com.rtbishop.look4sat.core.presentation.R
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -58,6 +59,7 @@ fun ElevationCurveChart(
|
||||
startTime: Long,
|
||||
endTime: Long,
|
||||
maxElev: Double,
|
||||
isUtc: Boolean = false,
|
||||
progress: Float = 0.5f,
|
||||
onProgressChange: (Float) -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
@@ -74,7 +76,11 @@ fun ElevationCurveChart(
|
||||
val textColorArgb = textColor.toArgb()
|
||||
val onSurfaceArgb = onSurfaceColor.toArgb()
|
||||
val gridColorArgb = gridColor.toArgb()
|
||||
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||
val timeFormat = remember(isUtc) {
|
||||
SimpleDateFormat("HH:mm", Locale.getDefault()).apply {
|
||||
if (isUtc) timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Canvas(
|
||||
|
||||
+203
-46
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.mutual
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
@@ -29,8 +30,10 @@ 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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
@@ -40,6 +43,7 @@ import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -47,13 +51,17 @@ import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -71,6 +79,9 @@ import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@Composable
|
||||
fun MutualScreen(
|
||||
@@ -89,7 +100,8 @@ fun MutualScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.mutual_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
},
|
||||
bottomInfo = {
|
||||
@@ -99,7 +111,9 @@ fun MutualScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 12.dp)
|
||||
)
|
||||
},
|
||||
endAction = {
|
||||
@@ -144,9 +158,10 @@ fun MutualScreen(
|
||||
}
|
||||
) { isVertical ->
|
||||
MutualContent(
|
||||
viewModel = viewModel,
|
||||
state = state,
|
||||
isVertical = isVertical,
|
||||
onQuery = viewModel::queryMutualPasses,
|
||||
onQuery = { viewModel.queryMutualPasses() },
|
||||
onSelectPass = viewModel::onSelectPass,
|
||||
onNavigateToRadar = navigateToRadar,
|
||||
onStationALat = viewModel::onStationALat,
|
||||
@@ -159,6 +174,8 @@ fun MutualScreen(
|
||||
onStationBMinElev = viewModel::onStationBMinElev,
|
||||
onUseCurrentPosition = viewModel::onUseCurrentPosition,
|
||||
onHoursAhead = viewModel::onHoursAhead,
|
||||
onFilterFM = viewModel::onFilterFM,
|
||||
onFilterLinear = viewModel::onFilterLinear,
|
||||
onClearError = viewModel::clearError
|
||||
)
|
||||
}
|
||||
@@ -166,6 +183,7 @@ fun MutualScreen(
|
||||
|
||||
@Composable
|
||||
private fun MutualContent(
|
||||
viewModel: MutualViewModel,
|
||||
state: MutualUiState,
|
||||
isVertical: Boolean,
|
||||
onQuery: () -> Unit,
|
||||
@@ -181,12 +199,54 @@ private fun MutualContent(
|
||||
onStationBMinElev: (Double) -> Unit,
|
||||
onUseCurrentPosition: () -> Unit,
|
||||
onHoursAhead: (Int) -> Unit,
|
||||
onFilterFM: (Boolean) -> Unit,
|
||||
onFilterLinear: (Boolean) -> Unit,
|
||||
onClearError: () -> Unit
|
||||
) {
|
||||
val timeFormat = remember { SimpleDateFormat("MM/dd HH:mm", Locale.getDefault()) }
|
||||
val timeFormat = remember(state.isUtc) {
|
||||
SimpleDateFormat("MM/dd HH:mm", Locale.getDefault()).apply {
|
||||
if (state.isUtc) timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
}
|
||||
|
||||
// List state survives page switches via the Activity-scoped ViewModel: the
|
||||
// initial position is restored from the VM, and scrolling writes back to it,
|
||||
// so returning to this page keeps exactly where the user left off. A new
|
||||
// query (queryGeneration bump) rebuilds the state at the top.
|
||||
val listState = remember(viewModel.queryGeneration) {
|
||||
LazyListState(
|
||||
firstVisibleItemIndex = viewModel.listScrollIndex,
|
||||
firstVisibleItemScrollOffset = viewModel.listScrollOffset
|
||||
)
|
||||
}
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||
.distinctUntilChanged()
|
||||
.collect { (index, offset) ->
|
||||
viewModel.listScrollIndex = index
|
||||
viewModel.listScrollOffset = offset
|
||||
}
|
||||
}
|
||||
|
||||
// Set once the list has actually been laid out. The prefill scroll only
|
||||
// fires after this, because on a real device the Mutual entry appears
|
||||
// through the NavDisplay transition and LaunchedEffect alone can run
|
||||
// before the list is measurable (the scroll then never lands and the
|
||||
// page stays at the top). onGloballyPositioned fires on the first real
|
||||
// layout, so the subsequent scrollToItem always has a measured list.
|
||||
var listReady by remember { mutableStateOf(false) }
|
||||
Log.d(TAG, "composed: scrollToTimeRange=${state.scrollToTimeRange} matchIndex=${if (state.errorMessage != null) 2 else 1} listReady=$listReady")
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.onGloballyPositioned {
|
||||
if (!listReady) {
|
||||
Log.d(TAG, "lazy list laid out, opening prefill scroll gate")
|
||||
listReady = true
|
||||
}
|
||||
},
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
// Error message
|
||||
@@ -210,7 +270,7 @@ private fun MutualContent(
|
||||
}
|
||||
|
||||
// Input form
|
||||
item {
|
||||
item(key = "stationInputs") {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
@@ -287,16 +347,25 @@ private fun MutualContent(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MatchSearchCard(
|
||||
hoursAhead = state.hoursAhead,
|
||||
isCalculating = state.isCalculating,
|
||||
onHoursAhead = onHoursAhead,
|
||||
onQuery = onQuery
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Time range + query card. Its own LazyColumn item so a prefill from
|
||||
// the map (grid-QSO dialog "Match" button) can scroll the page exactly
|
||||
// to the time-range selector.
|
||||
item(key = "matchSearch") {
|
||||
MatchSearchCard(
|
||||
hoursAhead = state.hoursAhead,
|
||||
isCalculating = state.isCalculating,
|
||||
filterFM = state.filterFM,
|
||||
filterLinear = state.filterLinear,
|
||||
onFilterFM = onFilterFM,
|
||||
onFilterLinear = onFilterLinear,
|
||||
onHoursAhead = onHoursAhead,
|
||||
onQuery = onQuery
|
||||
)
|
||||
}
|
||||
|
||||
// Results
|
||||
itemsIndexed(state.mutualPasses) { index, pass ->
|
||||
val mutualData = MutualPassData(
|
||||
@@ -320,6 +389,7 @@ private fun MutualContent(
|
||||
pass = pass,
|
||||
isExpanded = state.selectedPassIndex == index,
|
||||
timeFormat = timeFormat,
|
||||
isUtc = state.isUtc,
|
||||
minElevA = state.stationAMinElev,
|
||||
minElevB = state.stationBMinElev,
|
||||
onClick = { onSelectPass(if (state.selectedPassIndex == index) -1 else index) },
|
||||
@@ -327,8 +397,44 @@ private fun MutualContent(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Prefill from the map's grid-QSO dialog "Match" button: once the page is
|
||||
// laid out, jump straight to the time-range card. Index accounts for the
|
||||
// optional error card at the top (error = 0, station inputs = 1, time
|
||||
// range = 2; else 1). scrollToItem is used instead of initializing the
|
||||
// LazyListState at index 1: the constructor parameter is ignored by this
|
||||
// Compose version (verified in tests), while scrollToItem lands correctly.
|
||||
val matchSearchIndex = if (state.errorMessage != null) 2 else 1
|
||||
LaunchedEffect(state.scrollToTimeRange, matchSearchIndex, listReady) {
|
||||
Log.d(TAG, "scroll effect: scrollToTimeRange=${state.scrollToTimeRange} matchIndex=$matchSearchIndex listReady=$listReady")
|
||||
if (state.scrollToTimeRange && listReady) {
|
||||
// Keep trying until the scroll really lands: on a device the first
|
||||
// frame only contains the station cards + time-range card, which
|
||||
// can be shorter than the viewport (no scroll range), so a single
|
||||
// scrollToItem does nothing. When the async query results arrive
|
||||
// the list grows past one screen and the scroll becomes possible.
|
||||
for (attempt in 0 until 20) {
|
||||
Log.d(TAG, "attempting scrollToItem($matchSearchIndex) #$attempt")
|
||||
try {
|
||||
listState.scrollToItem(matchSearchIndex)
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "scrollToItem($matchSearchIndex) threw", t)
|
||||
break
|
||||
}
|
||||
if (listState.firstVisibleItemIndex == matchSearchIndex) {
|
||||
Log.d(TAG, "scroll landed at $matchSearchIndex on attempt #$attempt")
|
||||
break
|
||||
}
|
||||
delay(100)
|
||||
}
|
||||
viewModel.consumeScrollToTimeRange()
|
||||
Log.d(TAG, "scrollToTimeRange consumed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "Look4SatMutual"
|
||||
|
||||
@Composable
|
||||
private fun MutualStatusChip(state: MutualUiState) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
@@ -472,6 +578,10 @@ private fun StationInputCard(
|
||||
private fun MatchSearchCard(
|
||||
hoursAhead: Int,
|
||||
isCalculating: Boolean,
|
||||
filterFM: Boolean,
|
||||
filterLinear: Boolean,
|
||||
onFilterFM: (Boolean) -> Unit,
|
||||
onFilterLinear: (Boolean) -> Unit,
|
||||
onHoursAhead: (Int) -> Unit,
|
||||
onQuery: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
@@ -498,6 +608,28 @@ private fun MatchSearchCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
// 转发器类型筛选: 只匹配 AMSAT Live 清单里的 FM 话音 / 线性转发器卫星,
|
||||
// 防止选入大量无转发器卫星占用匹配页.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.mutual_filter_transponder),
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
FilterChip(
|
||||
selected = filterFM,
|
||||
onClick = { onFilterFM(!filterFM) },
|
||||
label = { Text(stringResource(R.string.mutual_filter_fm)) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = filterLinear,
|
||||
onClick = { onFilterLinear(!filterLinear) },
|
||||
label = { Text(stringResource(R.string.mutual_filter_linear)) }
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onQuery,
|
||||
enabled = !isCalculating,
|
||||
@@ -524,6 +656,7 @@ private fun MutualPassCard(
|
||||
pass: MutualPass,
|
||||
isExpanded: Boolean,
|
||||
timeFormat: SimpleDateFormat,
|
||||
isUtc: Boolean,
|
||||
minElevA: Double,
|
||||
minElevB: Double,
|
||||
onClick: () -> Unit,
|
||||
@@ -555,39 +688,62 @@ private fun MutualPassCard(
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = pass.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "${timeFormat.format(Date(pass.startTime))} - ${timeFormat.format(Date(pass.endTime))}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.mutual_elevation_you, pass.maxElevationA.toInt()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.mutual_elevation_opposite, pass.maxElevationB.toInt()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
// Header: pass info (name/time + elevations) on the left, and a
|
||||
// larger radar shortcut on the right that spans both rows vertically.
|
||||
// The pass info column narrows, so the "Opposite" elevation shifts
|
||||
// left to make room for the taller button.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = pass.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "${timeFormat.format(Date(pass.startTime))} - ${timeFormat.format(Date(pass.endTime))}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.mutual_elevation_you, pass.maxElevationA.toInt()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.mutual_elevation_opposite, pass.maxElevationB.toInt()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
// Radar shortcut, visible even on the collapsed card: a taller
|
||||
// button spanning both header rows. The card's own click
|
||||
// (expand) is not triggered by the IconButton's tap.
|
||||
IconButton(
|
||||
onClick = onNavigateToRadar,
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_radar),
|
||||
contentDescription = stringResource(R.string.mutual_open_radar),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
@@ -619,6 +775,7 @@ private fun MutualPassCard(
|
||||
startTime = visibleStart,
|
||||
endTime = visibleEnd,
|
||||
maxElev = adjustedMaxElev,
|
||||
isUtc = isUtc,
|
||||
progress = dragProgress,
|
||||
onProgressChange = { dragProgress = it }
|
||||
)
|
||||
|
||||
+143
-10
@@ -26,6 +26,7 @@ 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.source.Sources
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -33,12 +34,15 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val TAG = "Look4SatMutual"
|
||||
|
||||
data class MutualUiState(
|
||||
val stationALat: String = "",
|
||||
val stationALon: String = "",
|
||||
@@ -49,11 +53,28 @@ data class MutualUiState(
|
||||
val stationBGrid: String = "",
|
||||
val stationBMinElev: Double = 10.0,
|
||||
val hoursAhead: Int = 24,
|
||||
/** 历史过境回看窗口(小时), 跟随主页面 Passes 设置; 匹配查询从
|
||||
* now-hoursBefore 开始, 使设置过历史回看时匹配页也包含历史过境. */
|
||||
val hoursBefore: Int = 0,
|
||||
/** 转发器类型筛选: 只匹配搭载 FM 话音转发器的卫星. 默认开启. */
|
||||
val filterFM: Boolean = true,
|
||||
/** 转发器类型筛选: 只匹配搭载线性(USB/LSB/CW/SSB)转发器的卫星. 默认开启. */
|
||||
val filterLinear: Boolean = true,
|
||||
val mutualPasses: List<MutualPass> = emptyList(),
|
||||
val isCalculating: Boolean = false,
|
||||
val hasSearched: Boolean = false,
|
||||
val selectedPassIndex: Int = -1,
|
||||
val errorMessage: String? = null
|
||||
val isUtc: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
// One-shot flag set by prefillMatchFromGrid() (map grid → Match button):
|
||||
// the page scrolls to the time-range card after the first layout, then
|
||||
// consumeScrollToTimeRange() clears it.
|
||||
val scrollToTimeRange: Boolean = false,
|
||||
// One-shot flag set by prefillMatchFromGrid(): the map page waits for the
|
||||
// pre-filled query to finish, then navigates to the match page so the very
|
||||
// first frame already contains the results (time-range card + pass curves,
|
||||
// no scroll flicker). consumePendingNavigation() clears it.
|
||||
val pendingNavigation: Boolean = false
|
||||
)
|
||||
|
||||
class MutualViewModel(
|
||||
@@ -67,6 +88,15 @@ class MutualViewModel(
|
||||
private val _uiState = MutableStateFlow(MutualUiState())
|
||||
val uiState: StateFlow<MutualUiState> = _uiState.asStateFlow()
|
||||
|
||||
// Session-scoped scroll position of the pass list (Activity-scoped VM, so it
|
||||
// survives switching to another page and back). Reset to the top whenever a
|
||||
// new query replaces the results.
|
||||
var listScrollIndex: Int = 0
|
||||
var listScrollOffset: Int = 0
|
||||
|
||||
/** Bumped on every new query so the list scrolls back to the top. */
|
||||
var queryGeneration: Int = 0
|
||||
|
||||
init {
|
||||
// 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
|
||||
@@ -77,8 +107,21 @@ class MutualViewModel(
|
||||
stationALat = "%.4f".format(pos.latitude),
|
||||
stationALon = "%.4f".format(pos.longitude),
|
||||
stationAMinElev = 0.0,
|
||||
stationBMinElev = 0.0
|
||||
stationBMinElev = 0.0,
|
||||
isUtc = settingsRepo.otherSettings.value.stateOfUtc
|
||||
) }
|
||||
// Keep the time display in sync with the UTC toggle in Settings
|
||||
viewModelScope.launch {
|
||||
settingsRepo.otherSettings.collectLatest { settings ->
|
||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
|
||||
}
|
||||
}
|
||||
// 历史过境回看窗口跟随主页面 Passes 设置 (设置里改小时数, 匹配页同步生效)
|
||||
viewModelScope.launch {
|
||||
settingsRepo.passesSettings.collectLatest { settings ->
|
||||
_uiState.update { it.copy(hoursBefore = settings.hoursBefore) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onStationALat(value: String) {
|
||||
@@ -163,9 +206,54 @@ class MutualViewModel(
|
||||
) }
|
||||
}
|
||||
fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) }
|
||||
fun onFilterFM(value: Boolean) = _uiState.update { it.copy(filterFM = value) }
|
||||
fun onFilterLinear(value: Boolean) = _uiState.update { it.copy(filterLinear = value) }
|
||||
fun onSelectPass(index: Int) = _uiState.update { it.copy(selectedPassIndex = index) }
|
||||
|
||||
fun queryMutualPasses() {
|
||||
/**
|
||||
* Pre-fill the match page for a target grid picked from the map's grid-QSO
|
||||
* dialog ("Match" button): set the opposite-station grid (with its
|
||||
* coordinates), reset the time range to 24h, start the query immediately
|
||||
* so results are ready when the page opens, and flag the page to scroll
|
||||
* straight to the time-range card once it is laid out.
|
||||
*/
|
||||
fun prefillMatchFromGrid(grid: String) {
|
||||
val g = grid.trim().uppercase()
|
||||
val pos = qthToPosition(g)
|
||||
android.util.Log.d(TAG, "prefillMatchFromGrid('$g')")
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
stationBGrid = g,
|
||||
stationBLat = pos?.let { p -> "%.4f".format(p.latitude) } ?: it.stationBLat,
|
||||
stationBLon = pos?.let { p -> "%.4f".format(p.longitude) } ?: it.stationBLon,
|
||||
hoursAhead = 24,
|
||||
scrollToTimeRange = true,
|
||||
pendingNavigation = true
|
||||
)
|
||||
}
|
||||
queryMutualPasses()
|
||||
}
|
||||
|
||||
fun consumePendingNavigation() {
|
||||
android.util.Log.d(TAG, "consumePendingNavigation")
|
||||
_uiState.update { it.copy(pendingNavigation = false) }
|
||||
}
|
||||
|
||||
fun consumeScrollToTimeRange() {
|
||||
android.util.Log.d(TAG, "consumeScrollToTimeRange")
|
||||
_uiState.update { it.copy(scrollToTimeRange = false) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a mutual-pass query. `initialScrollIndex` non-null forces the list
|
||||
* to rebuild at that position (legacy callers); null keeps the current
|
||||
* scroll position, so re-running a query from the match page does not
|
||||
* jump back to the top. The previous results stay on screen while the
|
||||
* query runs (the list keeps its height, so the scroll position is not
|
||||
* clamped away); they are replaced — possibly with empty — when the new
|
||||
* results arrive.
|
||||
*/
|
||||
fun queryMutualPasses(initialScrollIndex: Int? = null) {
|
||||
val state = _uiState.value
|
||||
|
||||
// Resolve positions from lat/lon or grid
|
||||
@@ -183,24 +271,64 @@ class MutualViewModel(
|
||||
return
|
||||
}
|
||||
|
||||
// 转发器类型筛选: 至少勾选一种(默认 FM + Linear 都开), 否则提示不查询.
|
||||
// 防止用户选入大量无转发器卫星占用匹配页.
|
||||
if (!state.filterFM && !state.filterLinear) {
|
||||
_uiState.update { it.copy(errorMessage = "Select at least one transponder type (FM / Linear) to run the match.") }
|
||||
return
|
||||
}
|
||||
// AMSAT Live 清单(同步自 AMSAT "Live FM/Linear Satellites" 页面)是本过滤的
|
||||
// 权威来源: 只匹配当前在轨且话音/线性转发器实际工作的卫星. 清单为空
|
||||
// (从未成功同步过)时回退到数据库 downlinkMode 过滤, 避免首启匹配页空结果.
|
||||
// 过滤本身在下面的协程里执行 (getSatelliteIdsWithModes 是 suspend).
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isCalculating = true,
|
||||
hasSearched = true,
|
||||
errorMessage = null,
|
||||
mutualPasses = emptyList(),
|
||||
// Deliberately keep mutualPasses: clearing it would shrink the
|
||||
// list below one screen and clamp the scroll position away.
|
||||
// The stale results are replaced when the new ones arrive.
|
||||
selectedPassIndex = -1
|
||||
)
|
||||
}
|
||||
// Scroll position: an explicit initialScrollIndex overrides (legacy
|
||||
// callers start at a fixed spot); null keeps the current position so
|
||||
// re-querying does not jump back to the top.
|
||||
if (initialScrollIndex != null) {
|
||||
listScrollIndex = initialScrollIndex
|
||||
listScrollOffset = 0
|
||||
}
|
||||
queryGeneration += 1
|
||||
|
||||
viewModelScope.launch {
|
||||
val time = System.currentTimeMillis()
|
||||
val now = System.currentTimeMillis()
|
||||
// 转发器类型筛选: 使用硬编码 AMSAT Live 清单 (见 Sources):
|
||||
// FM = {SO-50, ISS ZARYA, AO-123}, Linear = {RS-44, FO-29,
|
||||
// AO-7, AO-73, JO-97}. 不再依赖 AMSAT 网页同步: 任何网络下都
|
||||
// 稳定, 没有旧脏列表/超时/同步失败清空等问题.
|
||||
val allowed = buildSet {
|
||||
if (state.filterFM) addAll(Sources.amSatFmCatnums)
|
||||
if (state.filterLinear) addAll(Sources.amSatLinearCatnums)
|
||||
}
|
||||
val filteredSatellites = satellites.filter { it.data.catnum in allowed }
|
||||
// 历史回看: 查询窗口起点 = now - hoursBefore (跟随主页面 Passes 设置),
|
||||
// 终点 = now + hoursAhead 不变.
|
||||
val time = now - state.hoursBefore * 60L * 60L * 1000L
|
||||
val minElevA = state.stationAMinElev
|
||||
val minElevB = state.stationBMinElev
|
||||
val hours = state.hoursAhead
|
||||
|
||||
val results = withContext(computeDispatcher) {
|
||||
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours)
|
||||
val results = try {
|
||||
withContext(computeDispatcher) {
|
||||
findMutualPasses(filteredSatellites, posA, posB, minElevA, minElevB, time, hours, state.hoursBefore)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
// Never leave the query stuck in "calculating" (which would also
|
||||
// block the pending map→match navigation forever).
|
||||
android.util.Log.e(TAG, "findMutualPasses threw", t)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val errorMsg = if (results.isEmpty()) {
|
||||
@@ -239,15 +367,20 @@ class MutualViewModel(
|
||||
satellites: List<OrbitalObject>,
|
||||
posA: GeoPos, posB: GeoPos,
|
||||
minElevADeg: Double, minElevBDeg: Double,
|
||||
time: Long, hours: Int
|
||||
time: Long, hours: Int, hoursBefore: Int
|
||||
): List<MutualPass> {
|
||||
val endTime = time + hours * 60L * 60L * 1000L
|
||||
// 终点始终是 now + hoursAhead; 起点已由调用方下移 hoursBefore 小时,
|
||||
// 因此这里的 endTime 要在起点之上补回 hoursBefore, 保证窗口 = [now-hb, now+h].
|
||||
val endTime = time + (hoursBefore + hours) * 60L * 60L * 1000L
|
||||
val sampleInterval = 5_000L
|
||||
|
||||
// Use the main page's pass list for AOS/LOS times, then sample the curves
|
||||
// using the actual positions. The passes list is already computed by getLeoPass
|
||||
// and its AOS/LOS times match the Passes page exactly.
|
||||
val existingPasses = satelliteRepo.passes.value
|
||||
// The satellites argument may be a subset (transponder-type filter), so
|
||||
// only passes belonging to the filtered satellites are reused.
|
||||
val allowedCatnums = satellites.map { it.data.catnum }.toSet()
|
||||
val existingPasses = satelliteRepo.passes.value.filter { it.catNum in allowedCatnums }
|
||||
val results = findMutualPassesFromList(existingPasses, satellites, posA, posB,
|
||||
minElevADeg, minElevBDeg, time, endTime, sampleInterval)
|
||||
if (results.isNotEmpty()) return results
|
||||
|
||||
@@ -2,11 +2,14 @@ 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.GridQso
|
||||
import com.rtbishop.look4sat.core.domain.model.LoTWSettings
|
||||
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.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
@@ -41,13 +44,18 @@ class FakeSatelliteRepo(
|
||||
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()
|
||||
override suspend fun getSatelliteIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||
override suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>): List<Int> = emptyList()
|
||||
override suspend fun getSatelliteIdsWithModesAndAmateur(modes: List<String>): List<Int> = emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
class FakeSettingsRepo(
|
||||
initialPosition: GeoPos = GeoPos(23.13, 113.26)
|
||||
) : ISettingsRepo {
|
||||
|
||||
override val stationPosition = MutableStateFlow(initialPosition)
|
||||
|
||||
@@ -62,7 +70,17 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti
|
||||
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||
)
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
OtherSettings(false, false, false, false, false, false, false, false)
|
||||
OtherSettings(
|
||||
stateOfAutoUpdate = false,
|
||||
stateOfSensors = false,
|
||||
stateOfSweep = false,
|
||||
stateOfUtc = false,
|
||||
stateOfLightTheme = false,
|
||||
stateOfNightMode = false,
|
||||
stateOfMapGrid = false,
|
||||
shouldSeeWarning = false,
|
||||
shouldSeeWhatsNew = false
|
||||
)
|
||||
)
|
||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
|
||||
DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList())
|
||||
@@ -70,6 +88,8 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
|
||||
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = TODO()
|
||||
override fun setSelectedTypes(types: List<String>) = TODO()
|
||||
@@ -93,4 +113,18 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
override fun setAmSatCallsign(callsign: String) = TODO()
|
||||
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
|
||||
override fun getWorkedGrids(): Set<String> = TODO()
|
||||
override fun setWorkedGrids(grids: Set<String>) = TODO()
|
||||
override fun getWorkedGridQsos(): Map<String, List<GridQso>> = TODO()
|
||||
override fun setWorkedGridQsos(qsos: Map<String, List<GridQso>>) = TODO()
|
||||
override fun getRoamedGrids(): Set<String> = TODO()
|
||||
override fun setRoamedGrids(grids: Set<String>) = TODO()
|
||||
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
|
||||
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) = Unit
|
||||
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
|
||||
override fun getLastLotwSyncDate(): String = ""
|
||||
override fun setLastLotwSyncDate(date: String) = Unit
|
||||
override fun getLastLotwSyncCallsign(): String = ""
|
||||
override fun setLastLotwSyncCallsign(callsign: String) = Unit
|
||||
}
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
package com.rtbishop.look4sat.feature.mutual
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import com.rtbishop.look4sat.core.presentation.Screen
|
||||
import kotlinx.coroutines.delay
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* Verifies the map grid-QSO dialog "Match" prefill: after
|
||||
* [MutualViewModel.prefillMatchFromGrid] the page opens with the time-range
|
||||
* card at the top and the 24h query auto-started.
|
||||
*
|
||||
* Note: `LazyListState(firstVisibleItemIndex = 1)` is deliberately NOT used to
|
||||
* position the page. In this Compose version (BOM 2026.06.01 / ui 1.11.x) the
|
||||
* constructor parameter is ignored — the list still starts at item 0 — while
|
||||
* `listState.scrollToItem(1)` from a LaunchedEffect lands correctly. See
|
||||
* [scrollToItemLandsAtTop] below, which guards the scrollToItem mechanism.
|
||||
*
|
||||
* Robolectric quirk: `assertIsNotDisplayed()` is unreliable here (a node
|
||||
* scrolled fully out of the viewport can still report "displayed"), so the
|
||||
* prefill test asserts the time-range card IS displayed (positive assertion)
|
||||
* rather than that the station card is NOT.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w411dp-h891dp-port")
|
||||
class MutualMatchPrefillScrollTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun sanity_textRenders() {
|
||||
composeRule.setContent {
|
||||
MaterialTheme {
|
||||
Text("sanity-check-text")
|
||||
}
|
||||
}
|
||||
composeRule.onNodeWithText("sanity-check-text").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scrollToItemLandsAtTop() {
|
||||
// Guards the mechanism the prefill relies on: scrollToItem(1) from a
|
||||
// LaunchedEffect moves item 1 to the top of the viewport.
|
||||
composeRule.setContent {
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
val state = remember { LazyListState(firstVisibleItemIndex = 0) }
|
||||
LaunchedEffect(Unit) { state.scrollToItem(1) }
|
||||
LazyColumn(state = state) {
|
||||
item { Text("min-item-0") }
|
||||
item { Text("min-item-1") }
|
||||
item { Text("min-item-2") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText("min-item-1").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultOpen_showsStationInputsAtTop() {
|
||||
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
|
||||
composeRule.setContent {
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
MutualScreen(viewModel = vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Without a prefill the list starts at item 0 (station inputs).
|
||||
composeRule.onNodeWithText("Your Station").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prefillFromMap_opensAtTimeRangeCard() {
|
||||
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
|
||||
// Same call the map grid-QSO "Match" button makes.
|
||||
vm.prefillMatchFromGrid("OL62")
|
||||
|
||||
composeRule.setContent {
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
MutualScreen(viewModel = vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
// The time-range card must be visible at the top of the page after the
|
||||
// prefill scroll.
|
||||
composeRule.onNodeWithText("Time range").assertIsDisplayed()
|
||||
// The snapshotFlow write-back stores the scrolled position in the VM.
|
||||
// It equals 1 only if scrollToItem(1) REALLY scrolled the station card
|
||||
// out. If the list content is shorter than the viewport there is no
|
||||
// scroll range, scrollToItem cannot move, and this stays 0 — that is
|
||||
// exactly the device symptom ("page stays at the top").
|
||||
org.junit.Assert.assertEquals(
|
||||
"list must have actually scrolled to item 1 (content shorter than viewport?)",
|
||||
1, vm.listScrollIndex
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prefillAutoStartsQueryAndFillsState() {
|
||||
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
|
||||
vm.prefillMatchFromGrid("OL62")
|
||||
val s = vm.uiState.value
|
||||
// Query auto-started: with the empty-satellite fake the query reaches
|
||||
// the "no satellite data" guard (rather than never being triggered),
|
||||
// proving prefillMatchFromGrid kicks off queryMutualPasses.
|
||||
assertTrue(
|
||||
"auto-query should reach the satellite guard: gridA=${s.stationAGrid} " +
|
||||
"gridB=${s.stationBGrid} err=${s.errorMessage}",
|
||||
s.errorMessage?.contains("satellite", ignoreCase = true) == true
|
||||
)
|
||||
// ...and the target grid + 24h range pre-filled.
|
||||
org.junit.Assert.assertEquals("OL62", s.stationBGrid)
|
||||
org.junit.Assert.assertEquals(24, s.hoursAhead)
|
||||
// ...and navigation to the match page is pending until the query done.
|
||||
assertTrue("navigation should be pending", s.pendingNavigation)
|
||||
// The early-return guard means the query finished immediately (no
|
||||
// satellites): the map page must now be free to navigate.
|
||||
org.junit.Assert.assertFalse("query finished -> not calculating", s.isCalculating)
|
||||
vm.consumePendingNavigation()
|
||||
org.junit.Assert.assertFalse("consume clears pending navigation", vm.uiState.value.pendingNavigation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun navDisplayEntryAfterPrefill_scrollsToTimeRange() {
|
||||
// The Mutual screen composed inside a NavDisplay entry (back stack
|
||||
// [Mutual]) right after prefillMatchFromGrid.
|
||||
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
|
||||
vm.prefillMatchFromGrid("OL62")
|
||||
composeRule.setContent {
|
||||
val backStack = rememberNavBackStack(Screen.Mutual)
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Mutual> { MutualScreen(viewModel = vm) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText("Time range").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun navDisplay_switchToMutualEntry_scrollsToTimeRange() {
|
||||
// Closest device path: start on the Map entry, then push the Mutual
|
||||
// entry (what the map "Match" button does). NavDisplay runs a real
|
||||
// fade transition while the Mutual screen composes.
|
||||
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
|
||||
vm.prefillMatchFromGrid("OL62")
|
||||
val backStackRef = mutableStateOf<NavBackStack<NavKey>?>(null)
|
||||
composeRule.setContent {
|
||||
val backStack = rememberNavBackStack(Screen.Map)
|
||||
backStackRef.value = backStack
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Map> { Text("MAP PAGE") }
|
||||
entry<Screen.Mutual> { MutualScreen(viewModel = vm) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
// Push Mutual like onMatchGrid does, so the transition composes
|
||||
// MutualScreen with scrollToTimeRange already set.
|
||||
composeRule.runOnIdle { backStackRef.value?.add(Screen.Mutual) }
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText("Time range").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scrollRetriesUntilContentGrows() {
|
||||
// Reproduces the real-device mechanism: the first frame's content
|
||||
// (item 0 short) is shorter than the viewport, so scrollToItem(1) has
|
||||
// no range and cannot move. Then the content grows (async results)
|
||||
// past one screen; the retry loop must land on item 1.
|
||||
composeRule.setContent {
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
val tall = mutableStateOf(false)
|
||||
LaunchedEffect(Unit) { delay(300); tall.value = true }
|
||||
val state = remember { LazyListState() }
|
||||
LaunchedEffect(state) {
|
||||
// Same retry loop MutualScreen uses for the prefill.
|
||||
for (attempt in 0 until 20) {
|
||||
state.scrollToItem(1)
|
||||
if (state.firstVisibleItemIndex == 1) return@LaunchedEffect
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
LazyColumn(state = state) {
|
||||
item {
|
||||
if (tall.value) Spacer(modifier = Modifier.height(900.dp))
|
||||
else Spacer(modifier = Modifier.height(40.dp))
|
||||
}
|
||||
item { Text("target-item") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText("target-item").assertIsDisplayed()
|
||||
// Confirm the list really scrolled (station card equivalent is gone).
|
||||
// We can't read listState from here, so use the VM-style probe: none —
|
||||
// the target visible + viewport tall enough implies item 1 on top.
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepPositionAcrossTabs_thenMapMatchPrefill_scrollsToTimeRange() {
|
||||
// User hypothesis: the "keep scroll position across tab switches"
|
||||
// machinery (listState remember(queryGeneration) + snapshotFlow
|
||||
// write-back) interferes with the prefill auto-scroll. Reproduce the
|
||||
// full journey: first visit -> scroll a bit -> leave -> return
|
||||
// (position restored) -> leave -> enter via map Match button.
|
||||
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
|
||||
val showMutual = mutableStateOf(true)
|
||||
|
||||
// First visit (e.g. bottom nav), user scrolls a little; the
|
||||
// snapshotFlow write-back stored index/offset in the VM.
|
||||
composeRule.setContent {
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
if (showMutual.value) MutualScreen(viewModel = vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
vm.listScrollIndex = 0
|
||||
vm.listScrollOffset = 40
|
||||
|
||||
// Leave the page (tab switch destroys the composition).
|
||||
showMutual.value = false
|
||||
composeRule.waitForIdle()
|
||||
|
||||
// Re-enter: keep-position restores the scroll offset.
|
||||
showMutual.value = true
|
||||
composeRule.waitForIdle()
|
||||
|
||||
// Leave again, then enter via the map grid-QSO Match button.
|
||||
showMutual.value = false
|
||||
composeRule.waitForIdle()
|
||||
vm.prefillMatchFromGrid("OL62")
|
||||
showMutual.value = true
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithText("Time range").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
+97
@@ -285,4 +285,101 @@ class MutualViewModelTest {
|
||||
|
||||
assertNull(vm.uiState.value.errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requery keeps scroll position and stale results while running`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val windows = TestOrbits.findPassWindows()
|
||||
assertTrue("fixture must produce ISS windows", windows.isNotEmpty())
|
||||
val vm = createVm(
|
||||
satellites = listOf(TestOrbits.ISS),
|
||||
passes = windows
|
||||
)
|
||||
vm.onStationBGrid("OL62")
|
||||
queryAndSettle(vm)
|
||||
assertTrue("fixture must yield passes", vm.uiState.value.mutualPasses.isNotEmpty())
|
||||
|
||||
// The user scrolled into the results; the snapshotFlow write-back has
|
||||
// stored this position in the VM.
|
||||
vm.listScrollIndex = 4
|
||||
vm.listScrollOffset = 137
|
||||
|
||||
// Re-run the query from the match page ("query" button).
|
||||
vm.queryMutualPasses()
|
||||
|
||||
// While the query runs, the old results stay on screen (the list keeps
|
||||
// its height, so the scroll position is not clamped away)...
|
||||
assertTrue("stale results kept while calculating", vm.uiState.value.mutualPasses.isNotEmpty())
|
||||
// ...and the scroll position must not be reset to the top.
|
||||
assertEquals(4, vm.listScrollIndex)
|
||||
assertEquals(137, vm.listScrollOffset)
|
||||
|
||||
advanceUntilIdle()
|
||||
assertFalse(vm.uiState.value.isCalculating)
|
||||
assertTrue("fresh results replace the stale ones", vm.uiState.value.mutualPasses.isNotEmpty())
|
||||
// Position intact after the query settled.
|
||||
assertEquals(4, vm.listScrollIndex)
|
||||
assertEquals(137, vm.listScrollOffset)
|
||||
}
|
||||
|
||||
// --------------------------------------------- transponder filters (AMSAT)
|
||||
|
||||
@Test
|
||||
fun `both filters off sets an error and does not search`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val windows = TestOrbits.findPassWindows()
|
||||
val vm = createVm(satellites = listOf(TestOrbits.ISS), passes = windows)
|
||||
vm.onStationBGrid("OL62")
|
||||
vm.onFilterFM(false)
|
||||
vm.onFilterLinear(false)
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
assertEquals(
|
||||
"Select at least one transponder type (FM / Linear) to run the match.",
|
||||
vm.uiState.value.errorMessage
|
||||
)
|
||||
assertFalse(vm.uiState.value.hasSearched)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hardcoded AMSAT filter restricts results to listed catnums`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val windows = TestOrbits.findPassWindows(hoursAhead = 24) +
|
||||
TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24)
|
||||
assertTrue("fixture must produce windows", windows.isNotEmpty())
|
||||
// ISS (25544) is on the hardcoded FM list (Sources.amSatFmCatnums);
|
||||
// ISS_VARIANT (25545) is not.
|
||||
val vm = createVm(
|
||||
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
|
||||
passes = windows
|
||||
)
|
||||
vm.onStationBGrid("OL62")
|
||||
vm.onHoursAhead(24)
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertNull(state.errorMessage)
|
||||
assertTrue(state.mutualPasses.isNotEmpty())
|
||||
assertTrue("only hardcoded FM-listed satellites should appear", state.mutualPasses.all { it.catNum == 25544 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `turning FM off excludes FM-listed satellites under Linear filter`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val windows = TestOrbits.findPassWindows(hoursAhead = 24) +
|
||||
TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24)
|
||||
// ISS (25544) is on the hardcoded FM list but NOT on the hardcoded
|
||||
// Linear list; with FM off and Linear on, no satellite passes.
|
||||
val vm = createVm(
|
||||
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
|
||||
passes = windows
|
||||
)
|
||||
vm.onStationBGrid("OL62")
|
||||
vm.onHoursAhead(24)
|
||||
vm.onFilterFM(false)
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
val state = vm.uiState.value
|
||||
// 空结果会设置 "No mutual passes found" 提示, 这是正常行为而非筛选错误.
|
||||
assertTrue(state.mutualPasses.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,7 @@ private val allModes = listOf(
|
||||
)
|
||||
|
||||
private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 192, 216, 240)
|
||||
private val historyHourSteps = listOf(0) + hourSteps
|
||||
private const val dayMinutes = 24 * 60
|
||||
private const val minuteStep = 15
|
||||
private const val endOfDayMinute = dayMinutes - 1
|
||||
@@ -80,6 +81,7 @@ private const val quarterHourSlots = dayMinutes / minuteStep
|
||||
|
||||
data class PassFilterParams(
|
||||
val hours: Int,
|
||||
val hoursBefore: Int,
|
||||
val elevation: Double,
|
||||
val lowElevation: Double,
|
||||
val highElevation: Double,
|
||||
@@ -95,6 +97,7 @@ private fun PassesDialogPreview() {
|
||||
MainTheme {
|
||||
PassesFilterDialog(
|
||||
hours = 24,
|
||||
hoursBefore = 0,
|
||||
elevation = 16.0,
|
||||
lowElevation = 16.0,
|
||||
highElevation = 65.0,
|
||||
@@ -111,6 +114,7 @@ private fun PassesDialogPreview() {
|
||||
@Composable
|
||||
internal fun PassesFilterDialog(
|
||||
hours: Int,
|
||||
hoursBefore: Int,
|
||||
elevation: Double,
|
||||
lowElevation: Double,
|
||||
highElevation: Double,
|
||||
@@ -122,6 +126,9 @@ internal fun PassesFilterDialog(
|
||||
accept: (PassFilterParams) -> Unit
|
||||
) {
|
||||
val hoursIndex = remember { mutableIntStateOf(hourSteps.indexOfFirst { it >= hours }.coerceAtLeast(0)) }
|
||||
val hoursBeforeIndex = remember {
|
||||
mutableIntStateOf(historyHourSteps.indexOfFirst { it >= hoursBefore }.coerceAtLeast(0))
|
||||
}
|
||||
val elevationValueNew = remember { mutableDoubleStateOf(elevation) }
|
||||
val highlightBounds = 0f..90f
|
||||
var highlightRange by remember(lowElevation, highElevation) {
|
||||
@@ -136,6 +143,7 @@ internal fun PassesFilterDialog(
|
||||
accept(
|
||||
PassFilterParams(
|
||||
hours = hourSteps[hoursIndex.intValue],
|
||||
hoursBefore = historyHourSteps[hoursBeforeIndex.intValue],
|
||||
elevation = elevationValueNew.doubleValue,
|
||||
lowElevation = highlightRange.start.roundToInt().toDouble(),
|
||||
highElevation = highlightRange.endInclusive.roundToInt().toDouble(),
|
||||
@@ -164,6 +172,14 @@ internal fun PassesFilterDialog(
|
||||
valueRange = 0f..(hourSteps.size - 1).toFloat(),
|
||||
steps = hourSteps.size - 2
|
||||
) { hoursIndex.intValue = it.toInt().coerceIn(0, hourSteps.size - 1) }
|
||||
SliderRow(
|
||||
title = stringResource(R.string.pass_filter_history_hours),
|
||||
value = hoursBeforeIndex.intValue.toDouble(),
|
||||
displayValue = formatHoursLabel(historyHourSteps[hoursBeforeIndex.intValue]),
|
||||
valueResId = R.drawable.ic_clock,
|
||||
valueRange = 0f..(historyHourSteps.size - 1).toFloat(),
|
||||
steps = historyHourSteps.size - 2
|
||||
) { hoursBeforeIndex.intValue = it.toInt().coerceIn(0, historyHourSteps.size - 1) }
|
||||
ToggleRow(
|
||||
title = stringResource(R.string.pass_filter_deep_space),
|
||||
checked = deepSpaceEnabled,
|
||||
|
||||
@@ -30,12 +30,15 @@ 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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -85,26 +88,25 @@ import java.util.TimeZone
|
||||
|
||||
@Composable
|
||||
fun PassesDestination(
|
||||
navigateToRadar: (Int, Long) -> Unit,
|
||||
navigateToMap: () -> Unit
|
||||
navigateToRadar: (Int, Long) -> 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, navigateToMap)
|
||||
PassesScreen(uiState, viewModel::onAction, navigateToRadar)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PassesScreen(
|
||||
uiState: PassesState,
|
||||
onAction: (PassesAction) -> Unit,
|
||||
navigateToRadar: (Int, Long) -> Unit,
|
||||
navigateToMap: () -> Unit
|
||||
navigateToRadar: (Int, Long) -> Unit
|
||||
) {
|
||||
if (uiState.isPassesDialogShown) {
|
||||
PassesFilterDialog(
|
||||
hours = uiState.hours,
|
||||
hoursBefore = uiState.hoursBefore,
|
||||
elevation = uiState.elevation,
|
||||
lowElevation = uiState.lowElevation,
|
||||
highElevation = uiState.highElevation,
|
||||
@@ -117,6 +119,7 @@ private fun PassesScreen(
|
||||
onAction(
|
||||
PassesAction.FilterPasses(
|
||||
hoursAhead = params.hours,
|
||||
hoursBefore = params.hoursBefore,
|
||||
minElevation = params.elevation,
|
||||
lowElevation = params.lowElevation,
|
||||
highElevation = params.highElevation,
|
||||
@@ -145,11 +148,16 @@ private fun PassesScreen(
|
||||
onAccept = dismiss,
|
||||
titleFontSize = 18
|
||||
) { padding ->
|
||||
// The changelog is long; keep the dialog frame fixed and let the
|
||||
// text scroll inside it (matches the update-checker page pattern).
|
||||
Text(
|
||||
text = stringResource(R.string.pass_whatsnew_message),
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = padding)
|
||||
modifier = Modifier
|
||||
.padding(horizontal = padding)
|
||||
.heightIn(max = 360.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -158,7 +166,7 @@ private fun PassesScreen(
|
||||
TopBar(
|
||||
isVerticalLayout = isVerticalLayout,
|
||||
startAction = {
|
||||
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
|
||||
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
|
||||
},
|
||||
topInfo = {
|
||||
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
|
||||
@@ -167,7 +175,7 @@ private fun PassesScreen(
|
||||
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
||||
},
|
||||
endAction = {
|
||||
IconCard(action = navigateToMap, resId = R.drawable.ic_map)
|
||||
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ data class PassesState(
|
||||
val nextTime: String = "00:00:00",
|
||||
val isNextTimeAos: Boolean = true,
|
||||
val hours: Int = 24,
|
||||
/** 历史过境回看窗口(小时), 0 = 只看未来. */
|
||||
val hoursBefore: Int = 0,
|
||||
val elevation: Double = 16.0,
|
||||
val lowElevation: Double = 16.0,
|
||||
val highElevation: Double = 65.0,
|
||||
@@ -47,6 +49,7 @@ sealed interface PassesAction {
|
||||
data object DismissWhatsNew : PassesAction
|
||||
data class FilterPasses(
|
||||
val hoursAhead: Int,
|
||||
val hoursBefore: Int,
|
||||
val minElevation: Double,
|
||||
val lowElevation: Double,
|
||||
val highElevation: Double,
|
||||
|
||||
+27
-12
@@ -55,6 +55,7 @@ class PassesViewModel(
|
||||
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
||||
nextPass = defaultPass,
|
||||
hours = settingsRepo.passesSettings.value.hoursAhead,
|
||||
hoursBefore = settingsRepo.passesSettings.value.hoursBefore,
|
||||
elevation = settingsRepo.passesSettings.value.minElevation,
|
||||
lowElevation = settingsRepo.otherSettings.value.lowElevation,
|
||||
highElevation = settingsRepo.otherSettings.value.highElevation,
|
||||
@@ -95,8 +96,12 @@ class PassesViewModel(
|
||||
val timeNow = System.currentTimeMillis()
|
||||
val isUtc = _uiState.value.isUtc
|
||||
val showDeepSpace = _uiState.value.showDeepSpace
|
||||
// 历史过境窗口: 已结束的过境保留到超过 hoursBefore 小时后消失;
|
||||
// DeepSpace 保持原行为(始终显示). hoursBefore=0 时过境一结束即消失.
|
||||
val historyCutoff = timeNow - _uiState.value.hoursBefore * 3600_000L
|
||||
val filtered = allPasses
|
||||
.let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
|
||||
.filter { pass -> pass.isDeepSpace || pass.losTime >= historyCutoff }
|
||||
val processed = computePassProgress(filtered, timeNow)
|
||||
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
||||
val sunTimes = computeSunTimes(processed, isUtc)
|
||||
@@ -123,6 +128,7 @@ class PassesViewModel(
|
||||
is PassesAction.FilterPasses ->
|
||||
applyFilter(
|
||||
hoursAhead = action.hoursAhead,
|
||||
hoursBefore = action.hoursBefore,
|
||||
minElevation = action.minElevation,
|
||||
lowElevation = action.lowElevation,
|
||||
highElevation = action.highElevation,
|
||||
@@ -135,6 +141,7 @@ class PassesViewModel(
|
||||
is PassesAction.FilterRadios ->
|
||||
applyFilter(
|
||||
hoursAhead = _uiState.value.hours,
|
||||
hoursBefore = _uiState.value.hoursBefore,
|
||||
minElevation = _uiState.value.elevation,
|
||||
lowElevation = _uiState.value.lowElevation,
|
||||
highElevation = _uiState.value.highElevation,
|
||||
@@ -175,9 +182,12 @@ class PassesViewModel(
|
||||
val sdfDate = dateFormat(tz)
|
||||
val sdfTime = SimpleDateFormat("HH:mm", Locale.getDefault()).also { it.timeZone = tz }
|
||||
val result = LinkedHashMap<String, Pair<String, String>>()
|
||||
// DeepSpace group always shows today's sun times
|
||||
// DeepSpace group always shows today's sun times.
|
||||
// The natural-day anchor is the DEVICE timezone, not the display timezone:
|
||||
// UTC midnight is 08:00 local for UTC+8 stations, when the sun is already
|
||||
// up — that would trip the white-night guard and render "--:--".
|
||||
if (passes.any { it.isDeepSpace }) {
|
||||
val riseSet = findTodaySunRiseSet(stationPos, startOfDayMillis(System.currentTimeMillis(), tz))
|
||||
val riseSet = findTodaySunRiseSet(stationPos, startOfDayMillis(System.currentTimeMillis(), TimeZone.getDefault()))
|
||||
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
|
||||
@@ -186,7 +196,7 @@ class PassesViewModel(
|
||||
if (pass.isDeepSpace) continue
|
||||
val label = sdfDate.format(Date(pass.aosTime))
|
||||
if (label in result) continue
|
||||
val riseSet = findTodaySunRiseSet(stationPos, startOfDayMillis(pass.aosTime, tz))
|
||||
val riseSet = findTodaySunRiseSet(stationPos, startOfDayMillis(pass.aosTime, TimeZone.getDefault()))
|
||||
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
|
||||
@@ -243,7 +253,7 @@ class PassesViewModel(
|
||||
return ordered
|
||||
}
|
||||
|
||||
/** Computes live progress for each pass, filtering out expired ones. */
|
||||
/** Computes live progress while retaining completed passes in the requested history window. */
|
||||
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
|
||||
val result = ArrayList<OrbitalPass>(passList.size)
|
||||
for (pass in passList) {
|
||||
@@ -251,9 +261,9 @@ class PassesViewModel(
|
||||
val deltaNow = time.minus(pass.aosTime).toFloat()
|
||||
val deltaTotal = pass.losTime.minus(pass.aosTime).toFloat()
|
||||
val newProgress = (deltaNow / deltaTotal).round(2)
|
||||
if (newProgress >= 1.0f) continue
|
||||
if (newProgress != pass.progress) {
|
||||
result.add(pass.copy(progress = newProgress))
|
||||
val boundedProgress = newProgress.coerceIn(0f, 1f)
|
||||
if (boundedProgress != pass.progress) {
|
||||
result.add(pass.copy(progress = boundedProgress))
|
||||
} else {
|
||||
result.add(pass)
|
||||
}
|
||||
@@ -282,6 +292,7 @@ class PassesViewModel(
|
||||
|
||||
private fun applyFilter(
|
||||
hoursAhead: Int,
|
||||
hoursBefore: Int,
|
||||
minElevation: Double,
|
||||
lowElevation: Double,
|
||||
highElevation: Double,
|
||||
@@ -299,13 +310,15 @@ class PassesViewModel(
|
||||
aosStartMinute,
|
||||
aosEndMinute,
|
||||
invertAosTimeWindow,
|
||||
modes
|
||||
modes,
|
||||
hoursBefore
|
||||
)
|
||||
)
|
||||
settingsRepo.updateOtherSettings { it.copy(lowElevation = lowElevation, highElevation = highElevation) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
hours = hoursAhead,
|
||||
hoursBefore = hoursBefore,
|
||||
elevation = minElevation,
|
||||
lowElevation = lowElevation,
|
||||
highElevation = highElevation,
|
||||
@@ -316,9 +329,10 @@ class PassesViewModel(
|
||||
modes = modes
|
||||
)
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
satelliteRepo.calculatePasses(
|
||||
time = System.currentTimeMillis(),
|
||||
hoursAhead = hoursAhead,
|
||||
time = now - hoursBefore * 60L * 60L * 1000L,
|
||||
hoursAhead = hoursBefore + hoursAhead,
|
||||
minElevation = minElevation,
|
||||
aosStartMinute = aosStartMinute,
|
||||
aosEndMinute = aosEndMinute,
|
||||
@@ -329,9 +343,10 @@ class PassesViewModel(
|
||||
|
||||
private fun refreshPasses() = viewModelScope.launch {
|
||||
val settings = settingsRepo.passesSettings.value
|
||||
// 下拉刷新也保留历史过境窗口, 否则刷新即丢 history (与筛选/启动计算口径一致)
|
||||
satelliteRepo.calculatePasses(
|
||||
time = System.currentTimeMillis(),
|
||||
hoursAhead = settings.hoursAhead,
|
||||
time = System.currentTimeMillis() - settings.hoursBefore * 60L * 60L * 1000L,
|
||||
hoursAhead = settings.hoursBefore + settings.hoursAhead,
|
||||
minElevation = settings.minElevation,
|
||||
aosStartMinute = settings.aosStartMinute,
|
||||
aosEndMinute = settings.aosEndMinute,
|
||||
|
||||
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user