mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
137
Commits
v4.4.7-ba7opf.0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d13af978af | ||
|
|
b48e23804f | ||
|
|
a2b3ce5d69 | ||
|
|
6ef994e4cd | ||
|
|
4c3fdeaa9c | ||
|
|
33a7f23d7a | ||
|
|
fb4f14f75d | ||
|
|
473c3bf228 | ||
|
|
2110bf0cd6 | ||
|
|
95d2d792cc | ||
|
|
dd29c6681c | ||
|
|
45cd72faf7 | ||
|
|
5acf71b2a6 | ||
|
|
1871c05002 | ||
|
|
e9f58f76f6 | ||
|
|
800494b978 | ||
|
|
f1091acbbd | ||
|
|
99bf3d34a9 | ||
|
|
1dd185971f | ||
|
|
fe85703e56 | ||
|
|
caddba5b81 | ||
|
|
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 |
No files matched your search
@@ -4,15 +4,16 @@
|
|||||||
|
|
||||||
**BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的业余无线电卫星追踪器,增加了线性卫星频率计算器等功能。
|
**BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的业余无线电卫星追踪器,增加了线性卫星频率计算器等功能。
|
||||||
|
|
||||||
|
非官方修改版(fork),独立维护;首次发布 2026-09,持续修改中。
|
||||||
|
|
||||||
## 本仓库特色功能
|
## 本仓库特色功能
|
||||||
|
|
||||||
- **网格地图模式(Grid Mode)** — 地图页内置 VUCC 网格覆盖图:已通联网格绿色填充、漫游网格蓝色 45° 斜纹(疏朗半透明样式)、当前所在方格淡黄加粗框(field 与 sub-square 缩放均显示);极区(南北纬 80°–90°)网格线与二位标签完整可见;点击已通联网格弹出该网格内通联明细弹窗;进入网格模式默认 VUCC 视图
|
- **网格地图模式(Grid Mode)** — 地图页内置 VUCC 网格覆盖图:已通联网格绿色填充、漫游网格蓝色 45° 斜纹(疏朗半透明样式)、当前所在方格淡黄加粗框;点击已通联网格弹出该网格内通联明细弹窗;进入网格模式默认 VUCC 视图
|
||||||
- **奖状边界模式(Award Boundaries)** — 在网格地图上叠加 DXCC / WAPC / WAJA / WAZ / WAS 国界·省界·分区边界,已确认区域绿色填充;香港/澳门作为独立实体显示(DXCC 321/152),港澳标签强制显示
|
- **奖状边界模式(Award Boundaries)** — 在网格地图上叠加 DXCC / WAPC / WAJA / WAZ / WAS 国界·省界·分区边界,已确认区域绿色填充
|
||||||
- **LoTW 网格同步** — 直连 ARRL Logbook(账号密码验证)下载确认报告,worked / roamed 网格自动更新(兼容任意字段顺序的 ADIF 解析);同步失败按凭据错误/限流/超时分类提示
|
- **LoTW 网格同步** — 直连 ARRL Logbook(账号密码验证)下载确认报告,worked / roamed 网格自动更新(兼容任意字段顺序的 ADIF 解析);同步失败按凭据错误/限流/超时分类提示
|
||||||
- **双站过境匹配(Mutual Pass)** — 输入友台经纬度/网格,筛选双方同时可见的卫星过境,在地图上同时显示双方仰角曲线和地面轨迹,支持一键跳转雷达页查看详情
|
- **双站过境匹配(Mutual Pass)** — 输入友台经纬度/网格,筛选双方同时可见的卫星过境,在地图上同时显示双方仰角曲线和地面轨迹,支持一键跳转雷达页查看详情
|
||||||
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
|
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
|
||||||
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本(arm64 + armeabi-v7a 双架构)
|
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本(arm64 + armeabi-v7a 双架构)
|
||||||
- **Passband 模式** — 支持通过位置滑块(Passband)自动计算 TX/RX 频率,避免切换时跳变
|
|
||||||
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放,启动时预取缓存
|
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放,启动时预取缓存
|
||||||
- **应用内更新检查** — 设置页一键检查 GitHub 最新 Release,展示版本说明并直接下载安装
|
- **应用内更新检查** — 设置页一键检查 GitHub 最新 Release,展示版本说明并直接下载安装
|
||||||
- **自定义 TLE 数据源** — 可添加自定义 TLE/Celestrak CSV 数据源,长按拖拽排序,数据源启停开关与 HTTP 状态码显示
|
- **自定义 TLE 数据源** — 可添加自定义 TLE/Celestrak CSV 数据源,长按拖拽排序,数据源启停开关与 HTTP 状态码显示
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Third-Party Notices
|
||||||
|
|
||||||
|
Look4Sat-BA7OPF (this fork) bundles or adapts the following third-party
|
||||||
|
components:
|
||||||
|
|
||||||
|
## OrbitDeckiOS
|
||||||
|
|
||||||
|
- Source: https://github.com/prstoetzer/OrbitDeckiOS
|
||||||
|
- Copyright (c) 2025 Paul Stoetzer, N8HM
|
||||||
|
- License: MIT
|
||||||
|
- Used for: the Grid Finder feature — the VUCC grid line / grid point
|
||||||
|
geometry in `core/domain/.../utility/GridGeometry.kt` is ported from
|
||||||
|
OrbitDeckiOS' GridGeometry.
|
||||||
|
|
||||||
|
### MIT License
|
||||||
|
|
||||||
|
```
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Paul Stoetzer, N8HM
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Material Design Icons
|
||||||
|
|
||||||
|
- Source: https://materialdesignicons.com (Pictogrammers)
|
||||||
|
- License: Apache-2.0
|
||||||
|
- Used for: `core/presentation/src/main/res/drawable/ic_grid_finder.xml`
|
||||||
|
(crosshairs-gps icon). Attribution is also noted in the icon file header.
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||||
android:value="com.rtbishop.look4sat" />
|
android:value="cn.ba7opf.look4sat" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ import android.view.View
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
@@ -47,9 +49,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
val settingsRepo = (applicationContext as IContainerProvider).getMainContainer().settingsRepo
|
||||||
observeNightFilterState()
|
observeNightFilterState()
|
||||||
setContent {
|
setContent {
|
||||||
MainTheme(isDarkTheme = true) { NavRoot() }
|
val otherSettings by settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||||
|
// Light theme is opt-in: the app stays dark until the user turns it on.
|
||||||
|
MainTheme(isDarkTheme = !otherSettings.stateOfLightTheme) { NavRoot() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ import android.os.Bundle
|
|||||||
import com.rtbishop.look4sat.core.data.injection.MainContainer
|
import com.rtbishop.look4sat.core.data.injection.MainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
|
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 kotlinx.coroutines.launch
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@@ -41,6 +49,11 @@ class MainApplication : Application(), IContainerProvider {
|
|||||||
clearAmSatCacheOnAppBackground()
|
clearAmSatCacheOnAppBackground()
|
||||||
// trigger automatic update every 48 hours
|
// trigger automatic update every 48 hours
|
||||||
container.appScope.launch { checkAutoUpdate() }
|
container.appScope.launch { checkAutoUpdate() }
|
||||||
|
// automatic LoTW grid sync on every app start (gated, see checkLoTWAutoSync)
|
||||||
|
container.appScope.launch { checkLoTWAutoSync() }
|
||||||
|
// repair logbooks split by the old name/band identity mismatch, independent of
|
||||||
|
// whether a sync is due (cheap read + in-memory match on every start)
|
||||||
|
container.appScope.launch { runCatching { container.qsoRepository.consolidateConfirmations() } }
|
||||||
// load satellite data on every app start
|
// load satellite data on every app start
|
||||||
container.appScope.launch { container.satelliteRepo.initRepository() }
|
container.appScope.launch { container.satelliteRepo.initRepository() }
|
||||||
}
|
}
|
||||||
@@ -79,4 +92,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})")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -75,18 +75,23 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||||
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
||||||
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
||||||
|
import com.rtbishop.look4sat.core.presentation.GridFinderDestination
|
||||||
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
||||||
|
import com.rtbishop.look4sat.core.presentation.LoTWUploadDestination
|
||||||
import com.rtbishop.look4sat.core.presentation.RadarDestination
|
import com.rtbishop.look4sat.core.presentation.RadarDestination
|
||||||
import com.rtbishop.look4sat.core.presentation.Screen
|
import com.rtbishop.look4sat.core.presentation.Screen
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
||||||
|
import com.rtbishop.look4sat.feature.gridfinder.GridFinderDestination
|
||||||
import com.rtbishop.look4sat.feature.map.MapDestination
|
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.MutualScreen
|
||||||
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
||||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||||
|
import com.rtbishop.look4sat.feature.settings.LoTWUploadDestination
|
||||||
import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -98,44 +103,81 @@ fun NavRoot(deeplink: String? = null) {
|
|||||||
}
|
}
|
||||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||||
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
||||||
|
val navigateToGridFinder: () -> Unit = { rootBackStack.add(GridFinderDestination) }
|
||||||
|
val navigateToLoTWStation: () -> Unit = { rootBackStack.add(LoTWUploadDestination) }
|
||||||
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
|
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
|
||||||
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
||||||
slideOutHorizontally(tween(300)) { -it / 3 }
|
slideOutHorizontally(tween(300)) { -it / 3 }
|
||||||
// Reverse: outgoing slides out to the right, incoming drifts in from the left
|
// Reverse: outgoing slides out to the right, incoming drifts in from the left
|
||||||
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
|
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
|
||||||
slideOutHorizontally(tween(300)) { it }
|
slideOutHorizontally(tween(300)) { it }
|
||||||
NavDisplay(
|
// Elevation color thresholds must be provided at the root level so that BOTH
|
||||||
modifier = Modifier.fillMaxSize(),
|
// the tab content (MainScreen) and the root-level RadarDestination see the
|
||||||
backStack = rootBackStack,
|
// user's custom thresholds. RadarDestination is a sibling entry of MainScreen
|
||||||
onBack = navigateBack,
|
// in this NavDisplay, so a provider inside MainScreen never reaches it.
|
||||||
transitionSpec = { pushTransition },
|
val context = LocalContext.current
|
||||||
popTransitionSpec = { popTransition },
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
predictivePopTransitionSpec = { popTransition },
|
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||||
entryDecorators = listOf(
|
CompositionLocalProvider(
|
||||||
rememberSaveableStateHolderNavEntryDecorator(),
|
LocalElevationThresholds provides ElevationThresholds(
|
||||||
rememberViewModelStoreNavEntryDecorator()
|
low = otherSettings.lowElevation,
|
||||||
),
|
high = otherSettings.highElevation
|
||||||
entryProvider = entryProvider {
|
)
|
||||||
entry<Screen.Passes> {
|
) {
|
||||||
MainScreen(
|
NavDisplay(
|
||||||
navigateToRadar = navigateToRadar
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
backStack = rootBackStack,
|
||||||
}
|
onBack = navigateBack,
|
||||||
entry<RadarDestination> {
|
transitionSpec = { pushTransition },
|
||||||
Surface(
|
popTransitionSpec = { popTransition },
|
||||||
modifier = Modifier.fillMaxSize(),
|
predictivePopTransitionSpec = { popTransition },
|
||||||
color = MaterialTheme.colorScheme.background
|
entryDecorators = listOf(
|
||||||
) {
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
RadarDestination(navigateUp = navigateBack)
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
|
),
|
||||||
|
entryProvider = entryProvider {
|
||||||
|
entry<Screen.Passes> {
|
||||||
|
MainScreen(
|
||||||
|
navigateToRadar = navigateToRadar,
|
||||||
|
navigateToGridFinder = navigateToGridFinder
|
||||||
|
)
|
||||||
|
}
|
||||||
|
entry<RadarDestination> {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
RadarDestination(navigateUp = navigateBack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry<GridFinderDestination> {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
GridFinderDestination(
|
||||||
|
navigateUp = navigateBack,
|
||||||
|
onOpenLoTWStation = navigateToLoTWStation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry<LoTWUploadDestination> {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
LoTWUploadDestination(navigateUp = navigateBack)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(
|
fun MainScreen(
|
||||||
navigateToRadar: () -> Unit = {}
|
navigateToRadar: () -> Unit = {},
|
||||||
|
navigateToGridFinder: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val backStack = rememberNavBackStack(Screen.Passes)
|
val backStack = rememberNavBackStack(Screen.Passes)
|
||||||
val currentKey = backStack.lastOrNull()
|
val currentKey = backStack.lastOrNull()
|
||||||
@@ -155,6 +197,34 @@ fun MainScreen(
|
|||||||
viewModelStoreOwner = context as ViewModelStoreOwner,
|
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||||
factory = MutualViewModel.factory(container)
|
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(
|
CompositionLocalProvider(
|
||||||
LocalElevationThresholds provides ElevationThresholds(
|
LocalElevationThresholds provides ElevationThresholds(
|
||||||
@@ -223,7 +293,18 @@ fun MainScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
entry<Screen.Map> {
|
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> {
|
entry<Screen.Mutual> {
|
||||||
MutualScreen(
|
MutualScreen(
|
||||||
@@ -237,7 +318,7 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
entry<Screen.AMSAT> { SatStatusDestination() }
|
entry<Screen.AMSAT> { SatStatusDestination() }
|
||||||
entry<Screen.Settings> {
|
entry<Screen.Settings> {
|
||||||
SettingsDestination()
|
SettingsDestination(onOpenGridFinder = navigateToGridFinder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
+1
@@ -32,6 +32,7 @@ internal class ApplicationPlugin : Plugin<Project> {
|
|||||||
implementation(project(":core:domain"))
|
implementation(project(":core:domain"))
|
||||||
implementation(project(":core:presentation"))
|
implementation(project(":core:presentation"))
|
||||||
implementation(project(":feature:map"))
|
implementation(project(":feature:map"))
|
||||||
|
implementation(project(":feature:gridfinder"))
|
||||||
implementation(project(":feature:mutual"))
|
implementation(project(":feature:mutual"))
|
||||||
implementation(project(":feature:passes"))
|
implementation(project(":feature:passes"))
|
||||||
implementation(project(":feature:radar"))
|
implementation(project(":feature:radar"))
|
||||||
|
|||||||
+3
@@ -35,6 +35,9 @@ internal class CoreDataPlugin : Plugin<Project> {
|
|||||||
ksp(libs.androidx.room.compiler)
|
ksp(libs.androidx.room.compiler)
|
||||||
implementation(libs.kotlin.coroutines)
|
implementation(libs.kotlin.coroutines)
|
||||||
implementation(libs.other.okhttp)
|
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")
|
@Query("DELETE FROM entries")
|
||||||
suspend fun deleteEntries()
|
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>
|
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")
|
@Query("SELECT COUNT(*) FROM radios")
|
||||||
suspend fun getRadiosTotal(): Int
|
suspend fun getRadiosTotal(): Int
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
|
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
|
||||||
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
|
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
|
||||||
|
|
||||||
@Database(entities = [SatEntry::class, SatRadio::class], version = 2, exportSchema = false)
|
@Database(entities = [SatEntry::class, SatRadio::class], version = 3, exportSchema = false)
|
||||||
abstract class Look4SatDb : RoomDatabase() {
|
abstract class Look4SatDb : RoomDatabase() {
|
||||||
abstract fun look4SatDao(): Look4SatDao
|
abstract fun look4SatDao(): Look4SatDao
|
||||||
}
|
}
|
||||||
@@ -37,3 +37,11 @@ val MIGRATION_1_2 = object : Migration(1, 2) {
|
|||||||
db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 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 = ""
|
||||||
|
)
|
||||||
@@ -33,6 +33,10 @@ data class SatRadio(
|
|||||||
val uplinkMode: String?,
|
val uplinkMode: String?,
|
||||||
val isInverted: Boolean,
|
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. */
|
/** Set for manually imported transceivers, which remote updates must not replace. */
|
||||||
val isCustom: Boolean = false
|
val isCustom: Boolean = false
|
||||||
)
|
)
|
||||||
+4
-3
@@ -330,12 +330,13 @@ object IcomCivProtocol {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse frequency + mode from a CMD_READ_FREQ reply payload.
|
* Parse frequency + mode from a CMD_READ_FREQ reply payload.
|
||||||
* Payload layout after stripping command byte: [5 freq bytes] [mode byte] [filter byte]
|
* Payload layout after stripping command byte: [5 freq bytes]
|
||||||
|
* The IC-705 does not include a mode byte; if one is present it is parsed, otherwise mode is empty.
|
||||||
*/
|
*/
|
||||||
fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? {
|
fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? {
|
||||||
if (payload.size < 6) return null
|
if (payload.size < 5) return null
|
||||||
val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5))
|
val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5))
|
||||||
val mode = BYTE_TO_MODE[payload[5]] ?: return null
|
val mode = if (payload.size > 5) BYTE_TO_MODE[payload[5]] ?: return null else ""
|
||||||
return freqHz to mode
|
return freqHz to mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,27 +17,36 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.data.injection
|
package com.rtbishop.look4sat.core.data.injection
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
import android.hardware.display.DisplayManager
|
import android.hardware.display.DisplayManager
|
||||||
import android.location.LocationManager
|
import android.location.LocationManager
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
|
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.BluetoothReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||||
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
||||||
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
||||||
|
import com.rtbishop.look4sat.core.data.lotw.LoTWUploadRepository
|
||||||
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
|
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
|
||||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
||||||
|
import com.rtbishop.look4sat.core.data.repository.QsoRepository
|
||||||
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
|
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.UpdateRepository
|
import com.rtbishop.look4sat.core.data.repository.UpdateRepository
|
||||||
import com.rtbishop.look4sat.core.data.repository.LoTWRepository
|
import com.rtbishop.look4sat.core.data.repository.LoTWRepository
|
||||||
|
import com.rtbishop.look4sat.core.data.repository.LocationRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.WavelogRepository
|
import com.rtbishop.look4sat.core.data.repository.WavelogRepository
|
||||||
import com.rtbishop.look4sat.core.data.source.LocalSource
|
import com.rtbishop.look4sat.core.data.source.LocalSource
|
||||||
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
||||||
@@ -45,13 +54,16 @@ import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
|||||||
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
||||||
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
||||||
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ILocationRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
@@ -73,6 +85,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class MainContainer(private val context: Context) : IMainContainer {
|
class MainContainer(private val context: Context) : IMainContainer {
|
||||||
|
|
||||||
@@ -88,17 +101,36 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
override val updateRepo by lazy { UpdateRepository(remoteSource) }
|
override val updateRepo by lazy { UpdateRepository(remoteSource) }
|
||||||
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
|
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
|
||||||
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
|
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 {
|
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||||
}
|
}
|
||||||
|
override val locationRepo: ILocationRepo by lazy {
|
||||||
|
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||||
|
LocationRepo(manager) {
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val _mutualPassData = MutableStateFlow(MutualPassData())
|
private val _mutualPassData = MutableStateFlow(MutualPassData())
|
||||||
override val mutualPassData: StateFlow<MutualPassData> = _mutualPassData.asStateFlow()
|
override val mutualPassData: StateFlow<MutualPassData> = _mutualPassData.asStateFlow()
|
||||||
|
private val _pendingLoTWStationGrid = MutableStateFlow<String?>(null)
|
||||||
|
override val pendingLoTWStationGrid: StateFlow<String?> = _pendingLoTWStationGrid.asStateFlow()
|
||||||
|
|
||||||
override fun setMutualPassData(data: MutualPassData) {
|
override fun setMutualPassData(data: MutualPassData) {
|
||||||
_mutualPassData.value = data
|
_mutualPassData.value = data
|
||||||
}
|
}
|
||||||
|
override fun setPendingLoTWStationGrid(grid: String?) {
|
||||||
|
_pendingLoTWStationGrid.value = grid
|
||||||
|
}
|
||||||
|
|
||||||
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
||||||
|
|
||||||
@@ -169,14 +201,23 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
private fun provideLocalSource(): ILocalSource {
|
private fun provideLocalSource(): ILocalSource {
|
||||||
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
|
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
|
||||||
val database = builder
|
val database = builder
|
||||||
.addMigrations(MIGRATION_1_2)
|
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
||||||
.fallbackToDestructiveMigration(false)
|
.fallbackToDestructiveMigration(false)
|
||||||
.build()
|
.build()
|
||||||
return LocalSource(database.look4SatDao())
|
return LocalSource(database.look4SatDao())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideRemoteSource(): IRemoteSource {
|
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 {
|
private fun provideSatelliteRepo(): ISatelliteRepo {
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by 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 com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.LoTWSatelliteAliases
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every satellite name ARRL's config.tq6 knows, sorted: the only names LoTW accepts. */
|
||||||
|
fun satelliteNames(): List<String> = satellites.keys.sorted()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a tracker name to the ARRL name, or null when it is not an ARRL satellite.
|
||||||
|
* Exact official name first, then the alias table ("SAUDISAT 1C" -> "SO-50",
|
||||||
|
* "ISS (ZARYA)" -> "ARISS"), then an official name contained in the tracker name.
|
||||||
|
*/
|
||||||
|
fun resolveSatellite(name: String): String? = LoTWSatelliteAliases.resolve(name, satellites.keys)
|
||||||
|
|
||||||
|
fun satellite(name: String, date: String): String {
|
||||||
|
val normalized = name.trim().uppercase(Locale.US)
|
||||||
|
// Tracker names differ from ARRL's: SatNOGS stores "SAUDISAT 1C" for "SO-50" and
|
||||||
|
// "ISS (ZARYA)" for "ARISS". resolveSatellite() checks the exact name, the alias
|
||||||
|
// table and finally substring containment; anything else is not uploadable.
|
||||||
|
val official = resolveSatellite(name) ?: fail(LoTWProblem.SATELLITE, normalized)
|
||||||
|
val sat = satellites.getValue(official)
|
||||||
|
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,175 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by 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 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,98 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by 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 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)
|
||||||
|
}
|
||||||
|
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("")
|
||||||
|
// Fingerprint identifies the CONTACT only (call, date/time, band, mode,
|
||||||
|
// satellite, frequency). Station fields (grid, zones, county, IOTA) are
|
||||||
|
// deliberately excluded: changing the station location must not change
|
||||||
|
// the fingerprint, otherwise previously-uploaded contacts would lose
|
||||||
|
// their ledger entry and be re-uploaded (and rejected as duplicates).
|
||||||
|
val identity = field("CALL", key.info.callsign) + field("DXCC", key.info.dxcc.toString()) +
|
||||||
|
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,79 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by 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 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,459 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by 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 android.content.Context
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ARRL satellite catalogue is static for the lifetime of the bundled config.tq6. */
|
||||||
|
private val satelliteNames: List<String> by lazy { config().satelliteNames() }
|
||||||
|
|
||||||
|
override suspend fun satelliteCatalog(): List<String> = withContext(Dispatchers.IO) { satelliteNames }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What makes two records the same contact for batch de-duplication: the opposite station,
|
||||||
|
* the satellite (resolved to its ARRL identity, so the tracker spelling and the official
|
||||||
|
* name are one) and the minute the QSO started. Frequency and mode are deliberately not
|
||||||
|
* part of the key — the same contact logged twice with slightly different tuning (or with
|
||||||
|
* the frequency fields empty) must still be caught before LoTW rejects the duplicate.
|
||||||
|
*/
|
||||||
|
private fun duplicateKey(record: QsoRecord): String = listOf(
|
||||||
|
record.theirCallsign.trim().uppercase(Locale.US),
|
||||||
|
satelliteIdentity(record.satelliteName),
|
||||||
|
(record.startUtcMillis / 60_000L).toString()
|
||||||
|
).joinToString("|")
|
||||||
|
|
||||||
|
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
|
||||||
|
var duplicates = 0
|
||||||
|
var incomplete = 0
|
||||||
|
val reasons = mutableMapOf<LoTWProblem, Int>()
|
||||||
|
val details = mutableMapOf<LoTWProblem, String>()
|
||||||
|
records.sortedBy { it.startUtcMillis }.forEach { record ->
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
when {
|
||||||
|
record.status != QsoStatus.COMPLETE -> {
|
||||||
|
unavailable++
|
||||||
|
incomplete++
|
||||||
|
}
|
||||||
|
|
||||||
|
record.lotwReceived || record.lotwUploaded -> uploaded++
|
||||||
|
else -> {
|
||||||
|
val contact = try {
|
||||||
|
signing.signer.contact(record, signing.key, signing.location, now())
|
||||||
|
} catch (e: LoTWOperationException) {
|
||||||
|
unavailable++
|
||||||
|
reasons[e.reason] = (reasons[e.reason] ?: 0) + 1
|
||||||
|
if (e.detail.isNotBlank()) details.putIfAbsent(e.reason, e.detail)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (contact != null) when {
|
||||||
|
!unique.add(duplicateKey(contact.record)) -> {
|
||||||
|
unavailable++
|
||||||
|
duplicates++
|
||||||
|
}
|
||||||
|
|
||||||
|
ledger[contact.fingerprint] == "accepted" -> uploaded++
|
||||||
|
ledger[contact.fingerprint] == "unknown" -> unknown++
|
||||||
|
else -> pendingCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LoTWUploadAudit(
|
||||||
|
records.size, pendingCount, uploaded, unknown, unavailable,
|
||||||
|
reasons, details, duplicates, incomplete
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
var unavailable = 0
|
||||||
|
var duplicates = 0
|
||||||
|
val unique = hashSetOf<String>()
|
||||||
|
val reasons = mutableMapOf<LoTWProblem, Int>()
|
||||||
|
val contacts = records.sortedBy { it.startUtcMillis }.mapNotNull { record ->
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
if (record.status != QsoStatus.COMPLETE || ((record.lotwReceived || record.lotwUploaded) && !resubmit)) { skipped++; return@mapNotNull null }
|
||||||
|
val contact = try {
|
||||||
|
signing.signer.contact(record, signing.key, signing.location, now())
|
||||||
|
} catch (e: LoTWOperationException) {
|
||||||
|
// One un-signable record must not abort the whole batch:
|
||||||
|
// skip it, count the real reason, and let the rest upload.
|
||||||
|
unavailable++
|
||||||
|
reasons[e.reason] = (reasons[e.reason] ?: 0) + 1
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
val previous = ledger[contact.fingerprint]
|
||||||
|
when {
|
||||||
|
(previous == "accepted" && !resubmit) -> { skipped++; null }
|
||||||
|
!unique.add(duplicateKey(contact.record)) -> {
|
||||||
|
skipped++
|
||||||
|
duplicates++
|
||||||
|
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,
|
||||||
|
unavailable,
|
||||||
|
reasons.toMap(),
|
||||||
|
duplicates,
|
||||||
|
contacts.map { it.record.id }
|
||||||
|
)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-1
@@ -1,3 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
package com.rtbishop.look4sat.core.data.repository
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||||
@@ -221,7 +239,22 @@ class AmSatRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val days = (0 until 3).map { d ->
|
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)
|
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.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
|||||||
+193
-27
@@ -18,9 +18,13 @@
|
|||||||
package com.rtbishop.look4sat.core.data.repository
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
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 com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.SocketTimeoutException
|
import java.net.SocketTimeoutException
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
@@ -46,7 +50,7 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
|
|
||||||
override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult =
|
override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
fetchReportBody(callsign, password).fold(
|
fetchReportBody(callsign, password, since = "", onProgress = {}).fold(
|
||||||
onSuccess = { body ->
|
onSuccess = { body ->
|
||||||
parseBoth(body)?.let { (grids, _, roamed) ->
|
parseBoth(body)?.let { (grids, _, roamed) ->
|
||||||
LoTWResult.Success(grids, emptyMap(), roamed)
|
LoTWResult.Success(grids, emptyMap(), roamed)
|
||||||
@@ -58,9 +62,11 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
|
|
||||||
override suspend fun fetchConfirmedGridQsos(
|
override suspend fun fetchConfirmedGridQsos(
|
||||||
callsign: String,
|
callsign: String,
|
||||||
password: String
|
password: String,
|
||||||
|
since: String,
|
||||||
|
onProgress: (LoTWProgress) -> Unit
|
||||||
): LoTWResult = withContext(Dispatchers.IO) {
|
): LoTWResult = withContext(Dispatchers.IO) {
|
||||||
fetchReportBody(callsign, password).fold(
|
fetchReportBody(callsign, password, since, onProgress).fold(
|
||||||
onSuccess = { body ->
|
onSuccess = { body ->
|
||||||
parseBoth(body)?.let { (grids, qsos, roamed) ->
|
parseBoth(body)?.let { (grids, qsos, roamed) ->
|
||||||
LoTWResult.Success(grids, qsos, roamed)
|
LoTWResult.Success(grids, qsos, roamed)
|
||||||
@@ -87,33 +93,46 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
return Triple(grids, qsos, roamed)
|
return Triple(grids, qsos, roamed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchReportBody(callsign: String, password: String): Result<String> {
|
private suspend fun fetchReportBody(
|
||||||
|
callsign: String,
|
||||||
|
password: String,
|
||||||
|
since: String,
|
||||||
|
onProgress: (LoTWProgress) -> Unit
|
||||||
|
): Result<String> {
|
||||||
val call = callsign.trim().uppercase()
|
val call = callsign.trim().uppercase()
|
||||||
val pwd = password.trim()
|
val pwd = password.trim()
|
||||||
if (call.isBlank() || pwd.isBlank()) return Result.failure(IOException("empty credentials"))
|
if (call.isBlank() || pwd.isBlank()) return Result.failure(IOException("empty credentials"))
|
||||||
// qso_qslsince with an early date forces a FULL confirmed-QSL report.
|
// Empty since -> full report. qso_qslsince with an early date forces a
|
||||||
// Without it, LoTW applies a "system supplied default" since-date and
|
// FULL confirmed-QSL report; without it LoTW applies a "system supplied
|
||||||
// only returns confirmations newer than the account's last query —
|
// default" since-date and only returns confirmations newer than the
|
||||||
// subsequent syncs would return an empty/incremental report.
|
// account's last query — subsequent syncs would be incremental/empty.
|
||||||
val since = "2000-01-01"
|
val effectiveSince = since.ifBlank { "2000-01-01" }
|
||||||
val query = buildString {
|
val query = buildString {
|
||||||
append("login=").append(URLEncoder.encode(call, "UTF-8"))
|
append("login=").append(URLEncoder.encode(call, "UTF-8"))
|
||||||
append("&password=").append(URLEncoder.encode(pwd, "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_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes")
|
||||||
append("&qso_qslsince=").append(URLEncoder.encode(since, "UTF-8"))
|
append("&qso_qslsince=").append(URLEncoder.encode(effectiveSince, "UTF-8"))
|
||||||
}
|
}
|
||||||
return try {
|
return try {
|
||||||
val connection = URL("$BASE_URL?$query").openConnection() as HttpURLConnection
|
val connection = URL("$BASE_URL?$query").openConnection() as HttpURLConnection
|
||||||
// ARRL can be slow to accept connections from mobile networks
|
// ARRL can be slow to accept connections from mobile networks
|
||||||
// (long TLS handshakes across the Pacific, occasional server-side
|
// (long TLS handshakes across the Pacific, occasional server-side
|
||||||
// queueing). 30s connect + 120s read gives the request enough
|
// queueing — a busy server took 36.5 s to accept a connection in
|
||||||
// headroom; the sync button stays disabled meanwhile so users
|
// testing, so 30 s connect would have killed it).
|
||||||
// see progress rather than a hung dialog.
|
//
|
||||||
connection.connectTimeout = 30_000
|
// NOTE: readTimeout is NOT a total-download cap. It is the longest
|
||||||
connection.readTimeout = 120_000
|
// 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.requestMethod = "GET"
|
||||||
connection.setRequestProperty("Accept-Encoding", "gzip")
|
connection.setRequestProperty("Accept-Encoding", "gzip")
|
||||||
connection.instanceFollowRedirects = true
|
connection.instanceFollowRedirects = true
|
||||||
|
onProgress(LoTWProgress(LoTWPhase.Connecting))
|
||||||
val code = connection.responseCode
|
val code = connection.responseCode
|
||||||
if (code !in 200..299) {
|
if (code !in 200..299) {
|
||||||
connection.disconnect()
|
connection.disconnect()
|
||||||
@@ -123,9 +142,8 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
else Result.failure(IOException("HTTP $code"))
|
else Result.failure(IOException("HTTP $code"))
|
||||||
}
|
}
|
||||||
val stream = connection.inputStream
|
val stream = connection.inputStream
|
||||||
val body = ("gzip".equals(connection.contentEncoding, ignoreCase = true))
|
val isGzip = "gzip".equals(connection.contentEncoding, ignoreCase = true)
|
||||||
.let { gz -> if (gz) java.util.zip.GZIPInputStream(stream) else stream }
|
val body = readBodyWithProgress(stream, isGzip, onProgress)
|
||||||
.bufferedReader().use { it.readText() }
|
|
||||||
connection.disconnect()
|
connection.disconnect()
|
||||||
when {
|
when {
|
||||||
// Login failure: HTTP 200 + HTML login page with the error text.
|
// Login failure: HTTP 200 + HTML login page with the error text.
|
||||||
@@ -135,6 +153,9 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
!body.contains("<eoh>", ignoreCase = true) -> Result.failure(RateLimitException())
|
!body.contains("<eoh>", ignoreCase = true) -> Result.failure(RateLimitException())
|
||||||
else -> Result.success(body)
|
else -> Result.success(body)
|
||||||
}
|
}
|
||||||
|
} catch (e: java.util.concurrent.CancellationException) {
|
||||||
|
// Cancellation must propagate — it is not a network failure.
|
||||||
|
throw e
|
||||||
} catch (e: SocketTimeoutException) {
|
} catch (e: SocketTimeoutException) {
|
||||||
Result.failure(TimeoutException(e.message ?: "timed out"))
|
Result.failure(TimeoutException(e.message ?: "timed out"))
|
||||||
} catch (e: SSLException) {
|
} catch (e: SSLException) {
|
||||||
@@ -145,6 +166,68 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 CredentialsException : Exception("bad callsign/password")
|
||||||
internal class RateLimitException : Exception("report refused (rate limit / server error)")
|
internal class RateLimitException : Exception("report refused (rate limit / server error)")
|
||||||
internal class TimeoutException(message: String) : Exception(message)
|
internal class TimeoutException(message: String) : Exception(message)
|
||||||
@@ -169,15 +252,40 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
var country: String? = null
|
var country: String? = null
|
||||||
var cqz: Int? = null
|
var cqz: Int? = null
|
||||||
var state: String? = 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>()
|
val gridsInRecord = mutableListOf<String>()
|
||||||
|
|
||||||
fun emitRecord() {
|
fun emitRecord() {
|
||||||
if (propMode != "SAT" || gridsInRecord.isEmpty()) return
|
if (propMode != "SAT" || gridsInRecord.isEmpty()) return
|
||||||
val epochMs = adifTimestampToEpoch(qsoDate, timeOn)
|
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(
|
val qso = com.rtbishop.look4sat.core.domain.model.GridQso(
|
||||||
call = call, epochMs = epochMs, satName = satName,
|
call = call, epochMs = epochMs, satName = satName,
|
||||||
mode = mode, bandUp = bandUp, bandDown = bandDown,
|
mode = mode, bandUp = bandUp, bandDown = bandDown,
|
||||||
dxcc = dxcc, country = country, cqz = cqz, state = state
|
dxcc = dxcc, country = country, cqz = cqz, state = state,
|
||||||
|
myGrid = myGrids.firstOrNull(),
|
||||||
|
myGrids = myGrids,
|
||||||
|
myCallsign = myCallsign,
|
||||||
|
stationKey = stationKey
|
||||||
)
|
)
|
||||||
for (grid in gridsInRecord) {
|
for (grid in gridsInRecord) {
|
||||||
result.getOrPut(grid) { mutableListOf() }.add(qso)
|
result.getOrPut(grid) { mutableListOf() }.add(qso)
|
||||||
@@ -188,6 +296,9 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
propMode = null; call = ""; qsoDate = ""; timeOn = ""
|
propMode = null; call = ""; qsoDate = ""; timeOn = ""
|
||||||
satName = ""; mode = ""; bandUp = ""; bandDown = ""
|
satName = ""; mode = ""; bandUp = ""; bandDown = ""
|
||||||
dxcc = null; country = null; cqz = null; state = null
|
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()
|
gridsInRecord.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,9 +322,9 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
line.startsWith("<MODE:") ->
|
line.startsWith("<MODE:") ->
|
||||||
mode = adifValue(line)
|
mode = adifValue(line)
|
||||||
line.startsWith("<BAND_RX:") ->
|
line.startsWith("<BAND_RX:") ->
|
||||||
bandUp = adifValue(line).uppercase()
|
|
||||||
line.startsWith("<BAND:") && !line.startsWith("<BAND_RX:") ->
|
|
||||||
bandDown = adifValue(line).uppercase()
|
bandDown = adifValue(line).uppercase()
|
||||||
|
line.startsWith("<BAND:") && !line.startsWith("<BAND_RX:") ->
|
||||||
|
bandUp = adifValue(line).uppercase()
|
||||||
line.startsWith("<DXCC:") ->
|
line.startsWith("<DXCC:") ->
|
||||||
dxcc = adifValue(line).toIntOrNull()
|
dxcc = adifValue(line).toIntOrNull()
|
||||||
line.startsWith("<COUNTRY:") ->
|
line.startsWith("<COUNTRY:") ->
|
||||||
@@ -222,6 +333,43 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
cqz = adifValue(line).toIntOrNull()
|
cqz = adifValue(line).toIntOrNull()
|
||||||
line.startsWith("<STATE:") ->
|
line.startsWith("<STATE:") ->
|
||||||
state = adifValue(line).trim().ifBlank { null }?.let { normalizeState(it) }
|
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:") -> {
|
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
|
||||||
// VUCC_GRIDS holds a comma-separated list of grids
|
// VUCC_GRIDS holds a comma-separated list of grids
|
||||||
// ("EN52en,EN53fa"), up to four for contacts spanning
|
// ("EN52en,EN53fa"), up to four for contacts spanning
|
||||||
@@ -327,23 +475,30 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
// would silently drop every own grid on real reports; buffer the record
|
// would silently drop every own grid on real reports; buffer the record
|
||||||
// and decide at <EOR> instead.
|
// and decide at <EOR> instead.
|
||||||
var propMode: String? = null
|
var propMode: String? = null
|
||||||
var myGrid: String? = null
|
var myGrids = mutableSetOf<String>()
|
||||||
for (raw in body.lineSequence()) {
|
for (raw in body.lineSequence()) {
|
||||||
val line = raw.trim()
|
val line = raw.trim()
|
||||||
when {
|
when {
|
||||||
line.equals("<EOR>", ignoreCase = true) -> {
|
line.equals("<EOR>", ignoreCase = true) -> {
|
||||||
if (propMode == "SAT" && myGrid != null) grids.add(myGrid)
|
if (propMode == "SAT" && myGrids.isNotEmpty()) grids.addAll(myGrids)
|
||||||
propMode = null
|
propMode = null
|
||||||
myGrid = null
|
myGrids = mutableSetOf()
|
||||||
}
|
}
|
||||||
line.startsWith("<PROP_MODE:") -> {
|
line.startsWith("<PROP_MODE:") -> {
|
||||||
propMode = adifValue(line).uppercase()
|
propMode = adifValue(line).uppercase()
|
||||||
}
|
}
|
||||||
line.startsWith("<MY_GRIDSQUARE:") -> {
|
line.startsWith("<MY_GRIDSQUARE:") -> {
|
||||||
// MY_GRIDSQUARE must not be mistaken for GRIDSQUARE (the
|
|
||||||
// opposite station's grid) — only own-station grids count.
|
|
||||||
val value = adifValue(line)
|
val value = adifValue(line)
|
||||||
if (value.length >= 4) myGrid = value.take(4)
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,5 +507,16 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val BASE_URL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
|
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,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.repository
|
||||||
|
|
||||||
|
import android.location.Location
|
||||||
|
import android.location.LocationListener
|
||||||
|
import android.location.LocationManager
|
||||||
|
import androidx.core.location.LocationManagerCompat
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ILocationRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LocationFix
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LocationState
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
private const val UPDATE_INTERVAL_MS = 1_000L
|
||||||
|
private const val UPDATE_MIN_DISTANCE_M = 0f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-location adapter for the field tools. Deliberately independent of
|
||||||
|
* [SettingsRepo]'s station position: a walk around a grid corner must not
|
||||||
|
* rewrite the station that every pass prediction is built on.
|
||||||
|
*
|
||||||
|
* Unlike [SettingsRepo.setStationPosition], this class always calls
|
||||||
|
* [LocationManager.removeUpdates] in [stopUpdates], so the listener is only
|
||||||
|
* alive while a screen is actually collecting fixes.
|
||||||
|
*/
|
||||||
|
class LocationRepo(
|
||||||
|
private val locationManager: LocationManager,
|
||||||
|
private val permissionGranted: () -> Boolean
|
||||||
|
) : ILocationRepo, LocationListener {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(initialState())
|
||||||
|
override val state: StateFlow<LocationState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var isRegistered = false
|
||||||
|
|
||||||
|
override fun hasPermission(): Boolean = permissionGranted()
|
||||||
|
|
||||||
|
override fun startUpdates() {
|
||||||
|
if (isRegistered) return
|
||||||
|
val request = describeProvider() ?: return
|
||||||
|
if (!request.permissionGranted || !request.providerEnabled) return
|
||||||
|
try {
|
||||||
|
// A last-known fix is better than an empty screen while GPS warms up.
|
||||||
|
locationManager.getLastKnownLocation(request.provider)?.let(::publish)
|
||||||
|
locationManager.requestLocationUpdates(
|
||||||
|
request.provider, UPDATE_INTERVAL_MS, UPDATE_MIN_DISTANCE_M, this
|
||||||
|
)
|
||||||
|
isRegistered = true
|
||||||
|
} catch (exception: SecurityException) {
|
||||||
|
println("LocationRepo: no permission - $exception")
|
||||||
|
} catch (exception: IllegalArgumentException) {
|
||||||
|
println("LocationRepo: provider unavailable - $exception")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stopUpdates() {
|
||||||
|
if (!isRegistered) return
|
||||||
|
isRegistered = false
|
||||||
|
try {
|
||||||
|
locationManager.removeUpdates(this)
|
||||||
|
} catch (exception: SecurityException) {
|
||||||
|
println("LocationRepo: removing updates without permission - $exception")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLocationChanged(location: Location) = publish(location)
|
||||||
|
|
||||||
|
override fun onProviderDisabled(provider: String) = refreshWaitingState()
|
||||||
|
|
||||||
|
override fun onProviderEnabled(provider: String) = refreshWaitingState()
|
||||||
|
|
||||||
|
private fun publish(location: Location) {
|
||||||
|
_state.value = LocationState.Fix(
|
||||||
|
LocationFix(
|
||||||
|
latitude = location.latitude,
|
||||||
|
longitude = location.longitude,
|
||||||
|
altitudeMeters = location.altitude.takeIf { location.hasAltitude() } ?: 0.0,
|
||||||
|
// Providers that cannot report accuracy use -1; treat that as
|
||||||
|
// useless rather than perfect.
|
||||||
|
accuracyMeters = location.accuracy.takeIf { location.hasAccuracy() } ?: Float.MAX_VALUE,
|
||||||
|
epochMs = location.time.takeIf { it > 0L } ?: System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initialState(): LocationState {
|
||||||
|
val request = describeProvider()
|
||||||
|
return LocationState.Waiting(
|
||||||
|
permissionGranted = request?.permissionGranted ?: false,
|
||||||
|
providerEnabled = request?.providerEnabled ?: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshWaitingState() {
|
||||||
|
if (_state.value is LocationState.Fix) return
|
||||||
|
_state.value = initialState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun describeProvider(): ProviderRequest? {
|
||||||
|
if (!permissionGranted()) {
|
||||||
|
return ProviderRequest(LocationManager.GPS_PROVIDER, false, false)
|
||||||
|
}
|
||||||
|
val enabled = LocationManagerCompat.isLocationEnabled(locationManager)
|
||||||
|
val provider = when {
|
||||||
|
locationManager.allProviders.contains(LocationManager.GPS_PROVIDER) -> LocationManager.GPS_PROVIDER
|
||||||
|
locationManager.allProviders.contains(LocationManager.NETWORK_PROVIDER) -> LocationManager.NETWORK_PROVIDER
|
||||||
|
else -> null
|
||||||
|
} ?: return null
|
||||||
|
return ProviderRequest(provider, true, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ProviderRequest(
|
||||||
|
val provider: String,
|
||||||
|
val permissionGranted: Boolean,
|
||||||
|
val providerEnabled: Boolean
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
/*
|
||||||
|
* 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.displayMode
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.sameConfirmedContact
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.withConfirmation
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.confirmationLookupKey
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.officialSatelliteName
|
||||||
|
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
|
||||||
|
// Editing a record that was already uploaded or confirmed must not rewrite it in
|
||||||
|
// place: that contact already exists on LoTW under the old values. The original row
|
||||||
|
// keeps its upload/confirmation state, and the edited content is saved as a NEW
|
||||||
|
// record (without any LoTW state, so it can be uploaded again).
|
||||||
|
if (previous != null && (previous.lotwUploaded || previous.lotwConfirmed) &&
|
||||||
|
!record.sameEditableContentAs(previous)
|
||||||
|
) {
|
||||||
|
dao.save(record.copy(
|
||||||
|
id = 0L,
|
||||||
|
lotwUploaded = false, lotwConfirmed = false, lotwReceived = false,
|
||||||
|
lotwQslDate = "", vuccGrids = emptyList(),
|
||||||
|
dxcc = null, country = "", cqZone = null, region = ""
|
||||||
|
).toEntity())
|
||||||
|
} else {
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun consolidateConfirmations(): Int = withContext(dispatcher) {
|
||||||
|
importMutex.withLock {
|
||||||
|
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
|
||||||
|
val consolidation = consolidateSplitRows(working)
|
||||||
|
val renames = rewriteOfficialNames(working)
|
||||||
|
if (consolidation.merged.isEmpty() && consolidation.redundant.isEmpty() && renames.isEmpty()) {
|
||||||
|
return@withLock 0
|
||||||
|
}
|
||||||
|
val toSave = LinkedHashMap(consolidation.merged).apply { putAll(renames) }
|
||||||
|
dao.saveBatch(toSave.values.map(QsoRecord::toEntity))
|
||||||
|
consolidation.redundant.forEach { dao.delete(it.id) }
|
||||||
|
consolidation.redundant.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun mergeRecords(records: List<QsoRecord>, fromLoTW: Boolean = false): AdifImportResult {
|
||||||
|
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
|
||||||
|
// Consolidate first: confirmations synced before the identity fix sit as their own
|
||||||
|
// rows (the tracker name and a mirrored band direction both failed the comparison),
|
||||||
|
// and left in place they would make the lookup below ambiguous between the operator's
|
||||||
|
// own row and the stale duplicate.
|
||||||
|
val consolidation = consolidateSplitRows(working)
|
||||||
|
// Rows imported under a tracking-source name join the ARRL name here, so what is stored
|
||||||
|
// is the identity both sides of a match resolve to (the lookup below is built on it).
|
||||||
|
val renames = rewriteOfficialNames(working)
|
||||||
|
val lookup = working.indices.groupBy { working[it].confirmationLookupKey() }
|
||||||
|
.mapValues { it.value.toMutableList() }.toMutableMap()
|
||||||
|
val keyIndex = working.indices.groupBy { stableQsoKey(working[it]) }
|
||||||
|
.mapValues { it.value.toMutableList() }.toMutableMap()
|
||||||
|
val knownKeys = keyIndex.keys.toMutableSet()
|
||||||
|
val changes = linkedMapOf<Int, QsoRecord>().apply {
|
||||||
|
putAll(consolidation.merged)
|
||||||
|
putAll(renames)
|
||||||
|
}
|
||||||
|
var imported = 0
|
||||||
|
var updated = consolidation.merged.size
|
||||||
|
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) {
|
||||||
|
// A confirmation downloaded by the pre-fix parser sits as its own row with the
|
||||||
|
// band direction read mirrored (the downlink was stored as the uplink). It has no
|
||||||
|
// local partner (so nothing folds it) and its dedupe key equals the fresh report,
|
||||||
|
// so it would otherwise be skipped forever, keeping the wrong band on display.
|
||||||
|
// When the bands are exactly mirrored, adopt the report's direction instead.
|
||||||
|
val stale = if (fromLoTW) keyIndex[stableQsoKey(remote)]?.firstOrNull()
|
||||||
|
?.takeIf { index -> working[index].lotwConfirmed &&
|
||||||
|
working[index].band.equals(remote.rxBand, true) &&
|
||||||
|
working[index].rxBand.equals(remote.band, true) } else null
|
||||||
|
if (stale != null) {
|
||||||
|
val fixed = working[stale].copy(band = remote.band, rxBand = remote.rxBand)
|
||||||
|
working[stale] = fixed
|
||||||
|
changes[stale] = fixed
|
||||||
|
updated++
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
if (stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
|
||||||
|
val added = remote.copy(id = 0L, satelliteName = officialSatelliteName(remote.satelliteName))
|
||||||
|
changes[working.size] = added
|
||||||
|
lookup.getOrPut(added.confirmationLookupKey()) { mutableListOf() }.add(working.size)
|
||||||
|
keyIndex.getOrPut(stableQsoKey(added)) { 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))
|
||||||
|
consolidation.redundant.forEach { dao.delete(it.id) }
|
||||||
|
return AdifImportResult(imported, skipped, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of folding already-split rows back together. */
|
||||||
|
private data class Consolidation(
|
||||||
|
/** index in the stored list -> the record to save (local row + its confirmation). */
|
||||||
|
val merged: Map<Int, QsoRecord>,
|
||||||
|
/** imported confirmation rows that are now part of a local record. */
|
||||||
|
val redundant: List<QsoRecord>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrites the names records were imported under into their ARRL names ("SAUDISAT 1C" ->
|
||||||
|
* "SO-50"), so the logbook, the ADIF export and the signed record all carry one name.
|
||||||
|
* Names the alias table does not know (recycled placeholders, satellites ARRL does not
|
||||||
|
* list) are kept exactly as they are. [working] is updated in place; the rewritten records
|
||||||
|
* are returned for the caller to save.
|
||||||
|
*/
|
||||||
|
private fun rewriteOfficialNames(working: MutableList<QsoRecord>): Map<Int, QsoRecord> {
|
||||||
|
val renamed = mutableMapOf<Int, QsoRecord>()
|
||||||
|
working.indices.forEach { index ->
|
||||||
|
val record = working[index]
|
||||||
|
val official = officialSatelliteName(record.satelliteName)
|
||||||
|
if (official != record.satelliteName) {
|
||||||
|
val updated = record.copy(satelliteName = official)
|
||||||
|
working[index] = updated
|
||||||
|
renamed[index] = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return renamed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folds confirmation rows that were imported as separate QSOs back into the local
|
||||||
|
* record they belong to (see [splitConfirmationPairs]). [working] is updated in
|
||||||
|
* place; the redundant rows are returned for the caller to delete.
|
||||||
|
*/
|
||||||
|
private fun consolidateSplitRows(working: MutableList<QsoRecord>): Consolidation {
|
||||||
|
val merged = mutableMapOf<Int, QsoRecord>()
|
||||||
|
val redundant = mutableListOf<QsoRecord>()
|
||||||
|
// Snapshot the pairs first: the list is rewritten as pairs are applied.
|
||||||
|
splitConfirmationPairs(working.toList()).forEach { pair ->
|
||||||
|
val local = working[pair.localIndex]
|
||||||
|
val confirmation = working[pair.confirmationIndex]
|
||||||
|
val folded = local.withConfirmation(confirmation)
|
||||||
|
if (folded != local) {
|
||||||
|
working[pair.localIndex] = folded
|
||||||
|
merged[pair.localIndex] = folded
|
||||||
|
}
|
||||||
|
redundant += confirmation
|
||||||
|
}
|
||||||
|
return Consolidation(merged, redundant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
// The identity, not the spelling: a row stored as "SO-50" is the same QSO as one imported
|
||||||
|
// as "SAUDISAT 1C", so re-importing an older export does not duplicate it.
|
||||||
|
satelliteIdentity(record.satelliteName)
|
||||||
|
).joinToString("|")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether two rows carry the same operator-editable content.
|
||||||
|
*
|
||||||
|
* Minutes are the granularity for time: the edit dialog edits whole minutes (and rounds the
|
||||||
|
* stored seconds away when saving), so a seconds-only difference must not count as an edit.
|
||||||
|
* The satellite is compared by its ARRL identity, the mode by its display label — both sides
|
||||||
|
* of the same contact written differently (tracker vs official name, MFSK vs FT4) are the same
|
||||||
|
* content. Used to decide whether saving an already-uploaded/confirmed row should create a new
|
||||||
|
* record or leave the row alone.
|
||||||
|
*/
|
||||||
|
private fun QsoRecord.sameEditableContentAs(other: QsoRecord): Boolean =
|
||||||
|
theirCallsign.trim().equals(other.theirCallsign.trim(), true) &&
|
||||||
|
startUtcMillis / 60_000L == other.startUtcMillis / 60_000L &&
|
||||||
|
txFrequencyHz == other.txFrequencyHz &&
|
||||||
|
rxFrequencyHz == other.rxFrequencyHz &&
|
||||||
|
displayMode == other.displayMode &&
|
||||||
|
satelliteIdentity(satelliteName) == satelliteIdentity(other.satelliteName) &&
|
||||||
|
sentReport.trim() == other.sentReport.trim() &&
|
||||||
|
receivedReport.trim() == other.receivedReport.trim() &&
|
||||||
|
theirGrid.trim().uppercase(Locale.US) == other.theirGrid.trim().uppercase(Locale.US) &&
|
||||||
|
comment == other.comment
|
||||||
+17
-4
@@ -35,6 +35,8 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
@@ -63,17 +65,28 @@ class SatelliteRepo(
|
|||||||
|
|
||||||
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
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) {
|
override suspend fun initRepository() = withContext(dispatcher) {
|
||||||
combine(
|
combine(
|
||||||
settingsRepo.selectedIds,
|
settingsRepo.selectedIds,
|
||||||
settingsRepo.stationPosition
|
settingsRepo.stationPosition,
|
||||||
) { selectedIds, _ -> selectedIds }
|
// 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 ->
|
.collect { selectedIds ->
|
||||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||||
val settings = settingsRepo.passesSettings.value
|
val settings = settingsRepo.passesSettings.value
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
// 历史过境: 回看窗口 hoursBefore 小时, 过境列表从更早时间开始计算.
|
||||||
calculatePasses(
|
calculatePasses(
|
||||||
time = System.currentTimeMillis(),
|
time = now - settings.hoursBefore * 60L * 60L * 1000L,
|
||||||
hoursAhead = settings.hoursAhead,
|
hoursAhead = settings.hoursBefore + settings.hoursAhead,
|
||||||
minElevation = settings.minElevation,
|
minElevation = settings.minElevation,
|
||||||
aosStartMinute = settings.aosStartMinute,
|
aosStartMinute = settings.aosStartMinute,
|
||||||
aosEndMinute = settings.aosEndMinute,
|
aosEndMinute = settings.aosEndMinute,
|
||||||
|
|||||||
+64
-6
@@ -43,12 +43,15 @@ class SelectionRepo(
|
|||||||
|
|
||||||
// Resolve type IDs once when types change, then filter items reactively.
|
// 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 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> ->
|
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
||||||
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
||||||
null // null = no filtering
|
null // null = no filtering
|
||||||
} else {
|
} else {
|
||||||
val ids = settingsRepo.getSatelliteTypesIds(types)
|
val ids = resolveTypeIds(types)
|
||||||
if (ids.isEmpty()) null else ids.toHashSet()
|
if (ids.isEmpty()) emptySet() else ids.toHashSet()
|
||||||
}
|
}
|
||||||
currentItems.map { items ->
|
currentItems.map { items ->
|
||||||
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
|
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
|
||||||
@@ -61,8 +64,46 @@ class SelectionRepo(
|
|||||||
|
|
||||||
override fun getCurrentTypes() = currentTypes.value
|
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) {
|
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
||||||
@@ -111,12 +152,29 @@ class SelectionRepo(
|
|||||||
/**
|
/**
|
||||||
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
|
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
|
||||||
* and lowercases the query once up front instead of per-item.
|
* 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> {
|
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
|
||||||
if (query.isBlank()) return items
|
if (query.isBlank()) return items
|
||||||
val catnum = query.toIntOrNull()
|
val catnum = query.toIntOrNull()
|
||||||
if (catnum != null) return items.filter { it.catnum == catnum }
|
if (catnum != null) return items.filter { it.catnum == catnum }
|
||||||
val lowerQuery = query.lowercase()
|
val tokens = query.split(' ')
|
||||||
return items.filter { it.name.lowercase().contains(lowerQuery) }
|
.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 android.view.Surface
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
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 com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -41,7 +42,12 @@ class SensorsRepo(
|
|||||||
) : ISensorsRepo, SensorEventListener {
|
) : ISensorsRepo, SensorEventListener {
|
||||||
|
|
||||||
private val _sensorData = MutableStateFlow(Pair(0f, 0f))
|
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 rotationMatrix = FloatArray(9)
|
||||||
private val tempMatrix = FloatArray(9)
|
private val tempMatrix = FloatArray(9)
|
||||||
private val orientationValues = FloatArray(3)
|
private val orientationValues = FloatArray(3)
|
||||||
@@ -50,6 +56,7 @@ class SensorsRepo(
|
|||||||
private var hasInitialReading = false
|
private var hasInitialReading = false
|
||||||
|
|
||||||
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
|
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
|
||||||
|
override val compassAccuracy: StateFlow<CompassAccuracy> = _compassAccuracy
|
||||||
|
|
||||||
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
||||||
return GeomagneticField(
|
return GeomagneticField(
|
||||||
@@ -62,15 +69,41 @@ class SensorsRepo(
|
|||||||
|
|
||||||
override fun enableSensor() {
|
override fun enableSensor() {
|
||||||
hasInitialReading = false
|
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 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) {
|
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 {
|
private fun getDisplayRotation(): Int {
|
||||||
|
|||||||
+172
-6
@@ -25,6 +25,7 @@ import androidx.core.location.LocationListenerCompat
|
|||||||
import androidx.core.location.LocationManagerCompat
|
import androidx.core.location.LocationManagerCompat
|
||||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.MapSource
|
||||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
@@ -58,6 +59,7 @@ class SettingsRepo(
|
|||||||
private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat"
|
private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat"
|
||||||
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
|
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
|
||||||
private val keyFilterHoursAhead = "filterHoursAhead"
|
private val keyFilterHoursAhead = "filterHoursAhead"
|
||||||
|
private val keyFilterHoursBefore = "filterHoursBefore"
|
||||||
private val keyFilterMinElevation = "filterMinElevation"
|
private val keyFilterMinElevation = "filterMinElevation"
|
||||||
private val keyFilterAosStartMinute = "filterAosStartMinute"
|
private val keyFilterAosStartMinute = "filterAosStartMinute"
|
||||||
private val keyFilterAosEndMinute = "filterAosEndMinute"
|
private val keyFilterAosEndMinute = "filterAosEndMinute"
|
||||||
@@ -77,12 +79,15 @@ class SettingsRepo(
|
|||||||
private val keySelectedTypes = "selectedTypes"
|
private val keySelectedTypes = "selectedTypes"
|
||||||
private val keySelectedModes = "selectedModes"
|
private val keySelectedModes = "selectedModes"
|
||||||
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
||||||
|
private val keyStateOfAutoLotwSync = "stateOfAutoLotwSync"
|
||||||
private val keyStateOfSensors = "stateOfSensors"
|
private val keyStateOfSensors = "stateOfSensors"
|
||||||
|
private val keyCompassOffsetDegrees = "compassOffsetDegrees"
|
||||||
private val keyStateOfSweep = "stateOfSweep"
|
private val keyStateOfSweep = "stateOfSweep"
|
||||||
private val keyStateOfUtc = "stateOfUtc"
|
private val keyStateOfUtc = "stateOfUtc"
|
||||||
private val keyStateOfLightTheme = "stateOfLightTheme"
|
private val keyStateOfLightTheme = "stateOfLightTheme"
|
||||||
private val keyStateOfNightMode = "stateOfNightMode"
|
private val keyStateOfNightMode = "stateOfNightMode"
|
||||||
private val keyStateOfMapGrid = "stateOfMapGrid"
|
private val keyStateOfMapGrid = "stateOfMapGrid"
|
||||||
|
private val keyStateOfMapFirstCall = "stateOfMapFirstCall"
|
||||||
private val keyStationAltitude = "stationAltitude"
|
private val keyStationAltitude = "stationAltitude"
|
||||||
private val keyStationLatitude = "stationLatitude"
|
private val keyStationLatitude = "stationLatitude"
|
||||||
private val keyStationLongitude = "stationLongitude"
|
private val keyStationLongitude = "stationLongitude"
|
||||||
@@ -94,6 +99,8 @@ class SettingsRepo(
|
|||||||
private val keySstvMode = "sstvMode"
|
private val keySstvMode = "sstvMode"
|
||||||
private val keyLowElevation = "lowElevation"
|
private val keyLowElevation = "lowElevation"
|
||||||
private val keyHighElevation = "highElevation"
|
private val keyHighElevation = "highElevation"
|
||||||
|
private val keyMapSource = "mapSource"
|
||||||
|
private val keyTiandituKey = "tiandituKey"
|
||||||
private val keyUseCustomTle = "useCustomTle"
|
private val keyUseCustomTle = "useCustomTle"
|
||||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||||
private val keyTleUrl = "tleUrl"
|
private val keyTleUrl = "tleUrl"
|
||||||
@@ -103,6 +110,7 @@ class SettingsRepo(
|
|||||||
private val keySatelliteEnabled = "satelliteEnabled"
|
private val keySatelliteEnabled = "satelliteEnabled"
|
||||||
private val keyTransceiversEnabled = "transceiversEnabled"
|
private val keyTransceiversEnabled = "transceiversEnabled"
|
||||||
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
|
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
|
||||||
|
private val keyAutoTleSourceMigration = "autoTleSourceMigration"
|
||||||
private val separatorComma = ","
|
private val separatorComma = ","
|
||||||
private val separatorUrl = "\n"
|
private val separatorUrl = "\n"
|
||||||
private val legacyCelestrakSatnogsUrl =
|
private val legacyCelestrakSatnogsUrl =
|
||||||
@@ -204,7 +212,22 @@ class SettingsRepo(
|
|||||||
dxcc = o.optInt("dx", 0).takeIf { it > 0 },
|
dxcc = o.optInt("dx", 0).takeIf { it > 0 },
|
||||||
country = o.optString("cty").ifBlank { null },
|
country = o.optString("cty").ifBlank { null },
|
||||||
cqz = o.optInt("cq", 0).takeIf { it > 0 },
|
cqz = o.optInt("cq", 0).takeIf { it > 0 },
|
||||||
state = o.optString("st").ifBlank { null }
|
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
|
if (list.isNotEmpty()) result[grid] = list
|
||||||
@@ -232,6 +255,10 @@ class SettingsRepo(
|
|||||||
.put("cty", q.country ?: "")
|
.put("cty", q.country ?: "")
|
||||||
.put("cq", q.cqz ?: 0)
|
.put("cq", q.cqz ?: 0)
|
||||||
.put("st", q.state ?: "")
|
.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)
|
root.put(grid, array)
|
||||||
@@ -262,6 +289,59 @@ class SettingsRepo(
|
|||||||
preferences.edit { putString(keyRoamedGrids, array.toString()) }
|
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)
|
// LoTW credentials (stored locally on the device only)
|
||||||
private val keyLoTWCall = "lotwCallsign"
|
private val keyLoTWCall = "lotwCallsign"
|
||||||
private val keyLoTWPass = "lotwPassword"
|
private val keyLoTWPass = "lotwPassword"
|
||||||
@@ -286,6 +366,24 @@ class SettingsRepo(
|
|||||||
callsign = preferences.getString(keyLoTWCall, null).orEmpty(),
|
callsign = preferences.getString(keyLoTWCall, null).orEmpty(),
|
||||||
password = preferences.getString(keyLoTWPass, 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
|
//endregion
|
||||||
|
|
||||||
//region # Transceivers settings
|
//region # Transceivers settings
|
||||||
@@ -295,6 +393,7 @@ class SettingsRepo(
|
|||||||
override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
|
override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
|
||||||
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
|
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
|
||||||
putInt(keyFilterHoursAhead, settings.hoursAhead)
|
putInt(keyFilterHoursAhead, settings.hoursAhead)
|
||||||
|
putInt(keyFilterHoursBefore, settings.hoursBefore)
|
||||||
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
|
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
|
||||||
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
||||||
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
||||||
@@ -306,6 +405,7 @@ class SettingsRepo(
|
|||||||
private fun getPassesSettings(): PassesSettings {
|
private fun getPassesSettings(): PassesSettings {
|
||||||
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
|
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
|
||||||
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
|
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
|
||||||
|
val hoursBefore = preferences.getInt(keyFilterHoursBefore, 0).coerceIn(0, 240)
|
||||||
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
|
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
|
||||||
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
||||||
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
||||||
@@ -319,7 +419,8 @@ class SettingsRepo(
|
|||||||
aosStartMinute,
|
aosStartMinute,
|
||||||
aosEndMinute,
|
aosEndMinute,
|
||||||
invertAosTimeWindow,
|
invertAosTimeWindow,
|
||||||
selectedModes
|
selectedModes,
|
||||||
|
hoursBefore
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
@@ -414,7 +515,9 @@ class SettingsRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
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)
|
val typesString = ids.joinToString(separatorComma)
|
||||||
preferences.edit { putString("type$type", typesString) }
|
preferences.edit { putString("type$type", typesString) }
|
||||||
}
|
}
|
||||||
@@ -514,20 +617,30 @@ class SettingsRepo(
|
|||||||
|
|
||||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) {
|
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) {
|
||||||
_otherSettings.update { current ->
|
_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 {
|
preferences.edit {
|
||||||
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
|
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
|
||||||
|
putBoolean(keyStateOfAutoLotwSync, new.stateOfAutoLotwSync)
|
||||||
putBoolean(keyStateOfSensors, new.stateOfSensors)
|
putBoolean(keyStateOfSensors, new.stateOfSensors)
|
||||||
|
putFloat(keyCompassOffsetDegrees, new.compassOffsetDegrees.coerceIn(-180f, 180f))
|
||||||
putBoolean(keyStateOfSweep, new.stateOfSweep)
|
putBoolean(keyStateOfSweep, new.stateOfSweep)
|
||||||
putBoolean(keyStateOfUtc, new.stateOfUtc)
|
putBoolean(keyStateOfUtc, new.stateOfUtc)
|
||||||
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
|
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
|
||||||
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
|
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
|
||||||
putBoolean(keyStateOfMapGrid, new.stateOfMapGrid)
|
putBoolean(keyStateOfMapGrid, new.stateOfMapGrid)
|
||||||
|
putBoolean(keyStateOfMapFirstCall, new.stateOfMapFirstCall)
|
||||||
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
||||||
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
||||||
putString(keySstvMode, new.sstvMode)
|
putString(keySstvMode, new.sstvMode)
|
||||||
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
||||||
putLong(keyHighElevation, new.highElevation.toRawBits())
|
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||||
|
putString(keyMapSource, new.mapSource)
|
||||||
|
putString(keyTiandituKey, new.tiandituKey)
|
||||||
}
|
}
|
||||||
new
|
new
|
||||||
}
|
}
|
||||||
@@ -535,17 +648,22 @@ class SettingsRepo(
|
|||||||
|
|
||||||
private fun getOtherSettings(): OtherSettings = OtherSettings(
|
private fun getOtherSettings(): OtherSettings = OtherSettings(
|
||||||
stateOfAutoUpdate = preferences.getBoolean(keyStateOfAutoUpdate, true),
|
stateOfAutoUpdate = preferences.getBoolean(keyStateOfAutoUpdate, true),
|
||||||
|
stateOfAutoLotwSync = preferences.getBoolean(keyStateOfAutoLotwSync, true),
|
||||||
stateOfSensors = preferences.getBoolean(keyStateOfSensors, true),
|
stateOfSensors = preferences.getBoolean(keyStateOfSensors, true),
|
||||||
|
compassOffsetDegrees = preferences.getFloat(keyCompassOffsetDegrees, 0f).coerceIn(-180f, 180f),
|
||||||
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
|
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
|
||||||
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
|
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
|
||||||
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
|
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
|
||||||
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
||||||
stateOfMapGrid = preferences.getBoolean(keyStateOfMapGrid, false),
|
stateOfMapGrid = preferences.getBoolean(keyStateOfMapGrid, false),
|
||||||
|
stateOfMapFirstCall = preferences.getBoolean(keyStateOfMapFirstCall, false),
|
||||||
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
||||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||||
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
||||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
|
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
|
||||||
|
mapSource = MapSource.normalize(preferences.getString(keyMapSource, null).orEmpty()),
|
||||||
|
tiandituKey = preferences.getString(keyTiandituKey, null).orEmpty().trim()
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -567,6 +685,7 @@ class SettingsRepo(
|
|||||||
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
|
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
|
||||||
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
|
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
|
||||||
putBoolean(keySatnogsTleSourceMigration, true)
|
putBoolean(keySatnogsTleSourceMigration, true)
|
||||||
|
putBoolean(keyAutoTleSourceMigration, true)
|
||||||
}
|
}
|
||||||
_dataSourcesSettings.value = normalized
|
_dataSourcesSettings.value = normalized
|
||||||
}
|
}
|
||||||
@@ -577,7 +696,7 @@ class SettingsRepo(
|
|||||||
defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
|
defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
|
||||||
legacyEnabledKey = keyUseCustomTle,
|
legacyEnabledKey = keyUseCustomTle,
|
||||||
legacyUrlKey = keyTleUrl
|
legacyUrlKey = keyTleUrl
|
||||||
).migrateSatnogsTleSource(),
|
).migrateSatnogsTleSource().migrateAutoTleSources(),
|
||||||
transceiversUrls = getDataSourceUrls(
|
transceiversUrls = getDataSourceUrls(
|
||||||
key = keyTransceiversUrls,
|
key = keyTransceiversUrls,
|
||||||
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
|
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
|
||||||
@@ -638,6 +757,28 @@ class SettingsRepo(
|
|||||||
return migrated
|
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 List<String>.containsSourceUrl(url: String): Boolean = any { isSameSourceUrl(it, url) }
|
||||||
|
|
||||||
private fun isSameSourceUrl(first: String, second: String): Boolean =
|
private fun isSameSourceUrl(first: String, second: String): Boolean =
|
||||||
@@ -736,6 +877,31 @@ class SettingsRepo(
|
|||||||
}
|
}
|
||||||
//endregion
|
//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
|
//region # AMSAT status report settings
|
||||||
private val keyAmSatCallsign = "amSatCallsign"
|
private val keyAmSatCallsign = "amSatCallsign"
|
||||||
|
|
||||||
|
|||||||
+18
@@ -1,3 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
package com.rtbishop.look4sat.core.data.repository
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.LatestRelease
|
import com.rtbishop.look4sat.core.domain.model.LatestRelease
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
|
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
|
||||||
|
|
||||||
override suspend fun getIdsWithModes(modes: List<String>) = look4SatDao.getIdsWithModes(modes)
|
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(
|
private fun FrameworkEntry.toDomain() = OrbitalData(
|
||||||
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
||||||
@@ -83,12 +85,14 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
|
|
||||||
private fun DomainRadio.toFramework() = FrameworkRadio(
|
private fun DomainRadio.toFramework() = FrameworkRadio(
|
||||||
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
||||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, this.isCustom
|
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum,
|
||||||
|
this.service, this.isCustom
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun FrameworkRadio.toDomain() = DomainRadio(
|
private fun FrameworkRadio.toDomain() = DomainRadio(
|
||||||
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
||||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, this.isCustom
|
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum,
|
||||||
|
this.service, this.isCustom
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() }
|
private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() }
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class AddToCalendar(private val context: Context) : IAddToCalendar {
|
|||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
setData(CalendarContract.Events.CONTENT_URI)
|
setData(CalendarContract.Events.CONTENT_URI)
|
||||||
putExtra(CalendarContract.Events.TITLE, name)
|
putExtra(CalendarContract.Events.TITLE, name)
|
||||||
putExtra(CalendarContract.Events.DESCRIPTION, "Look4Sat Pass")
|
putExtra(CalendarContract.Events.DESCRIPTION, "Look4Sat-BA7OPF Pass")
|
||||||
putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, aosTime)
|
putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, aosTime)
|
||||||
putExtra(CalendarContract.EXTRA_EVENT_END_TIME, losTime)
|
putExtra(CalendarContract.EXTRA_EVENT_END_TIME, losTime)
|
||||||
}
|
}
|
||||||
|
|||||||
+18
@@ -1,3 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
package com.rtbishop.look4sat.core.data.framework
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
import org.junit.Assert.assertArrayEquals
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
|||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class IcomCivProtocolTest {
|
||||||
|
|
||||||
|
private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseFreqModePayload_ic705ReadFreqReply() {
|
||||||
|
// Reply captured from an IC-705 to CMD 0x03: frequency only, no mode byte
|
||||||
|
val reply = bytes(0xFE, 0xFE, 0xE0, 0xA4, 0x03, 0x60, 0x74, 0x95, 0x45, 0x01, 0xFD)
|
||||||
|
val response = IcomCivProtocol.parseResponse(reply, IcomCivProtocol.CMD_READ_FREQ)
|
||||||
|
assertNotNull(response)
|
||||||
|
assertEquals(145957460L to "", IcomCivProtocol.parseFreqModePayload(response!!.payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseFreqModePayload_withModeByte() {
|
||||||
|
assertEquals(
|
||||||
|
435611000L to "USB",
|
||||||
|
IcomCivProtocol.parseFreqModePayload(bytes(0x00, 0x10, 0x61, 0x35, 0x04, 0x01, 0x01))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseFreqModePayload_tooShort() {
|
||||||
|
assertNull(IcomCivProtocol.parseFreqModePayload(bytes(0x60, 0x74, 0x95, 0x45)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/*
|
||||||
|
* 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.logbook.LoTWSatelliteAliases
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `tracker catalogue names resolve to the ARRL satellite`() {
|
||||||
|
// The names the TLE sources store vs the single name ARRL knows for that satellite.
|
||||||
|
assertEquals("SO-50", config.resolveSatellite("SAUDISAT 1C"))
|
||||||
|
assertEquals("ARISS", config.resolveSatellite("ISS (ZARYA)"))
|
||||||
|
assertEquals("PO-101", config.resolveSatellite("DIWATA 2B"))
|
||||||
|
assertEquals("AO-91", config.resolveSatellite("FOX-1B"))
|
||||||
|
assertEquals("IO-86", config.resolveSatellite("LAPAN-A2"))
|
||||||
|
assertEquals("RS-44", config.resolveSatellite("DOSAAF-85"))
|
||||||
|
assertEquals("BO-102", config.resolveSatellite("CAS-7B"))
|
||||||
|
assertEquals("SO-50", config.resolveSatellite("SO-50"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `satellite signing accepts tracker names and keeps ARRL spelling`() {
|
||||||
|
assertEquals("SO-50", config.satellite("SAUDISAT 1C", "2026-09-27"))
|
||||||
|
assertEquals("ARISS", config.satellite("ISS (ZARYA)", "2026-09-27"))
|
||||||
|
assertEquals("SO-50", config.satellite("SO-50 (SaudiOSCAR 50)", "2026-09-27"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `satellite signing rejects names ARRL does not know`() {
|
||||||
|
// Placeholder designations are deliberately unmapped: signing them would name the
|
||||||
|
// wrong object, so the record must stay un-uploadable.
|
||||||
|
listOf("OBJECT AY", "MARINA", "NOT A SATELLITE").forEach { name ->
|
||||||
|
val error = runCatching { config.satellite(name, "2026-09-27") }.exceptionOrNull()
|
||||||
|
assertTrue("$name should be rejected", error is LoTWOperationException)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `satellite signing enforces the ARRL service dates`() {
|
||||||
|
// SO-50 is listed from 2002-12-20: a QSO before that cannot be signed.
|
||||||
|
val error = runCatching { config.satellite("SAUDISAT 1C", "2002-01-01") }.exceptionOrNull()
|
||||||
|
assertTrue(error is LoTWOperationException)
|
||||||
|
assertEquals("SO-50", config.satellite("SAUDISAT 1C", "2003-01-01"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every alias target is a real ARRL satellite name`() {
|
||||||
|
val catalogue = config.satelliteNames().toSet()
|
||||||
|
assertTrue("ARRL catalogue is unexpectedly small", catalogue.size > 100)
|
||||||
|
val unknown = LoTWSatelliteAliases.table.filterValues { it !in catalogue }
|
||||||
|
assertEquals("alias targets missing from config.tq6: $unknown", emptyMap<String, String>(), unknown)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
-2
@@ -1,3 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
package com.rtbishop.look4sat.core.data.repository
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||||
@@ -12,6 +30,10 @@ import org.junit.Assert.assertNull
|
|||||||
import org.junit.Assert.assertSame
|
import org.junit.Assert.assertSame
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
class AmSatRepositoryTest {
|
class AmSatRepositoryTest {
|
||||||
|
|
||||||
@@ -75,6 +97,52 @@ class AmSatRepositoryTest {
|
|||||||
assertEquals(1, remoteSource.catalogRequests)
|
assertEquals(1, remoteSource.catalogRequests)
|
||||||
assertEquals(1, remoteSource.reportRequests)
|
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) {
|
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
|
||||||
@@ -83,7 +151,10 @@ private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
|
|||||||
cacheField.set(this, page)
|
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 catalogRequests = 0
|
||||||
var reportRequests = 0
|
var reportRequests = 0
|
||||||
|
|
||||||
@@ -100,8 +171,19 @@ private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L)
|
|||||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? {
|
override suspend fun getAmSatReports(hours: Int, limit: Int): String? {
|
||||||
if (responseDelayMillis > 0L) delay(responseDelayMillis)
|
if (responseDelayMillis > 0L) delay(responseDelayMillis)
|
||||||
reportRequests += 1
|
reportRequests += 1
|
||||||
return """{"data":[]}"""
|
return reportsJson
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
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))
|
||||||
+37
-1
@@ -127,6 +127,29 @@ class DatabaseRepoTest {
|
|||||||
assertEquals(listOf(98248), settingsRepo.satelliteTypeIdsByType["SatNOGS"])
|
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 = """
|
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
|
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
|
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
|
||||||
@@ -167,7 +190,8 @@ private class FakeLocalSource : ILocalSource {
|
|||||||
|
|
||||||
override suspend fun getEntriesTotal(): Int = insertedEntries.size
|
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()
|
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||||
|
|
||||||
@@ -180,6 +204,8 @@ private class FakeLocalSource : ILocalSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
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 getRadiosTotal(): Int = insertedRadios.size
|
||||||
|
|
||||||
@@ -272,6 +298,8 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||||
|
|
||||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
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 setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||||
|
|
||||||
@@ -296,6 +324,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
MutableStateFlow(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 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 getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
|
||||||
|
|
||||||
@@ -308,6 +340,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
override fun setRoamedGrids(grids: Set<String>) {
|
override fun setRoamedGrids(grids: Set<String>) {
|
||||||
roamedGrids.value = grids
|
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 {
|
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||||
|
|||||||
+114
-3
@@ -1,8 +1,27 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
package com.rtbishop.look4sat.core.data.repository
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class LoTWRepositoryTest {
|
class LoTWRepositoryTest {
|
||||||
@@ -111,9 +130,12 @@ class LoTWRepositoryTest {
|
|||||||
assertEquals("A50QO", qso.call)
|
assertEquals("A50QO", qso.call)
|
||||||
assertEquals("FO-29", qso.satName)
|
assertEquals("FO-29", qso.satName)
|
||||||
assertEquals("CW", qso.mode)
|
assertEquals("CW", qso.mode)
|
||||||
assertEquals("2M", qso.bandUp)
|
// ADIF BAND is the transmitted (uplink) band, BAND_RX the received (downlink) one.
|
||||||
assertEquals("70CM", qso.bandDown)
|
// Confirmed against the live report of the own uploads: SO-50 QSOs arrive as
|
||||||
assertEquals("V/U", qso.bandLabel)
|
// BAND=2M/FREQ=145.85000 + BAND_RX=70CM/FREQ_RX=436.80500, i.e. BAND=uplink.
|
||||||
|
assertEquals("70CM", qso.bandUp)
|
||||||
|
assertEquals("2M", qso.bandDown)
|
||||||
|
assertEquals("U/V", qso.bandLabel)
|
||||||
assertEquals(2026, java.time.Instant.ofEpochMilli(qso.epochMs).atZone(java.time.ZoneOffset.UTC).year)
|
assertEquals(2026, java.time.Instant.ofEpochMilli(qso.epochMs).atZone(java.time.ZoneOffset.UTC).year)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +172,71 @@ class LoTWRepositoryTest {
|
|||||||
assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>"))
|
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
|
// endregion
|
||||||
|
|
||||||
// region parseRoamedGrids (MY_GRIDSQUARE = grids the account operated from)
|
// region parseRoamedGrids (MY_GRIDSQUARE = grids the account operated from)
|
||||||
@@ -205,6 +292,30 @@ class LoTWRepositoryTest {
|
|||||||
assertNull(repo.parseRoamedGrids("<HTML>Username/password incorrect</HTML>"))
|
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
|
// endregion
|
||||||
|
|
||||||
// region failure classification (fetchReportBody -> LoTWResult mapping)
|
// region failure classification (fetchReportBody -> LoTWResult mapping)
|
||||||
|
|||||||
+292
@@ -0,0 +1,292 @@
|
|||||||
|
/*
|
||||||
|
* 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.QsoRecord
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The logbook split reported 2026-09-28: a QSO uploaded from the app and the confirmation LoTW
|
||||||
|
* reported for it ended up as two rows, because the record keeps the tracking-source name
|
||||||
|
* ("SAUDISAT 1C") and the uplink band while the report carries the ARRL name ("SO-50") — and,
|
||||||
|
* before the direction fix, the mirrored band pair.
|
||||||
|
*/
|
||||||
|
class QsoRepositoryTest {
|
||||||
|
|
||||||
|
private val dao = FakeQsoDao()
|
||||||
|
private val repository = QsoRepository(dao, Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
/** 2026-09-16 07:45Z, an SO-50 contact of the user's own report. */
|
||||||
|
private val qsoStart = 1_789_544_700_000L
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mergeLoTW_confirmsTheUploadedRecordInsteadOfAddingASecondRow() = runBlocking {
|
||||||
|
val uploadedId = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity()) // 145.850 -> 2M
|
||||||
|
|
||||||
|
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
|
||||||
|
|
||||||
|
val rows = dao.getAll()
|
||||||
|
assertEquals(1, rows.size)
|
||||||
|
assertEquals(uploadedId, rows.first().id)
|
||||||
|
assertTrue(rows.first().lotwConfirmed)
|
||||||
|
// The row is also renamed to the ARRL spelling while it is merged.
|
||||||
|
assertEquals("SO-50", rows.first().satelliteName)
|
||||||
|
assertEquals(145_850_000L, rows.first().txFrequencyHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mergeLoTW_foldsRowsSplitByTheOldNameAndBandMismatch() = runBlocking {
|
||||||
|
val uploadedId = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity())
|
||||||
|
// Row imported before the fix: ARRL name and the mirrored band pair (BAND_RX read as
|
||||||
|
// the uplink), so it never folded into the local record.
|
||||||
|
dao.save(
|
||||||
|
reportConfirmation(satName = "SO-50").copy(band = "70CM", rxBand = "2M").toEntity()
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
|
||||||
|
|
||||||
|
val rows = dao.getAll()
|
||||||
|
assertEquals(1, rows.size)
|
||||||
|
assertEquals(uploadedId, rows.first().id)
|
||||||
|
assertTrue(rows.first().lotwConfirmed)
|
||||||
|
// The operator's own frequencies survive the consolidation.
|
||||||
|
assertEquals(145_850_000L, rows.first().txFrequencyHz)
|
||||||
|
assertEquals(436_795_000L, rows.first().rxFrequencyHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mergeLoTW_keepsAnotherContactOfTheSamePassApart() = runBlocking {
|
||||||
|
dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity())
|
||||||
|
val otherOperator = reportConfirmation(satName = "SO-50").copy(theirCallsign = "BG5JVM")
|
||||||
|
|
||||||
|
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50"), otherOperator))
|
||||||
|
|
||||||
|
val rows = dao.getAll()
|
||||||
|
assertEquals(2, rows.size)
|
||||||
|
// The local row is confirmed by its own report entry; the second operator's contact
|
||||||
|
// is a separate QSO and stays a separate, separately confirmed row.
|
||||||
|
assertEquals(setOf("BG5JSB", "BG5JVM"), rows.map { it.theirCallsign }.toSet())
|
||||||
|
assertTrue(rows.all { it.lotwConfirmed })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun consolidateConfirmations_repairsAnAlreadySplitLogbook() = runBlocking {
|
||||||
|
val uploadedId = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity())
|
||||||
|
// Row imported before the fix: ARRL name + mirrored band pair.
|
||||||
|
dao.save(reportConfirmation(satName = "SO-50").copy(band = "70CM", rxBand = "2M").toEntity())
|
||||||
|
|
||||||
|
assertEquals(1, repository.consolidateConfirmations())
|
||||||
|
|
||||||
|
val rows = dao.getAll()
|
||||||
|
assertEquals(1, rows.size)
|
||||||
|
assertEquals(uploadedId, rows.first().id)
|
||||||
|
assertTrue(rows.first().lotwConfirmed)
|
||||||
|
// Nothing left to fold on a second pass.
|
||||||
|
assertEquals(0, repository.consolidateConfirmations())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun consolidateConfirmations_renamesRowsImportedUnderATrackerName() = runBlocking {
|
||||||
|
// A record logged before names were normalised at write time.
|
||||||
|
val id = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity())
|
||||||
|
|
||||||
|
repository.consolidateConfirmations()
|
||||||
|
|
||||||
|
val row = dao.getAll().single()
|
||||||
|
assertEquals(id, row.id)
|
||||||
|
assertEquals("SO-50", row.satelliteName)
|
||||||
|
// Upload state and the operator's own frequencies survive the rename.
|
||||||
|
assertTrue(row.lotwUploaded)
|
||||||
|
assertEquals(145_850_000L, row.txFrequencyHz)
|
||||||
|
// A satellite ARRL does not list keeps whatever the tracker published.
|
||||||
|
dao.save(loggedInApp("FOO-1", lotwUploaded = false).toEntity())
|
||||||
|
repository.consolidateConfirmations()
|
||||||
|
assertEquals(setOf("SO-50", "FOO-1"), dao.getAll().map { it.satelliteName }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mergeLoTW_fixesBandDirectionOfAStaleConfirmationRow() = runBlocking {
|
||||||
|
// A confirmation downloaded by the pre-fix parser: ARRL name but the band direction
|
||||||
|
// mirrored (downlink stored as the uplink), and no local row to fold it into, so it
|
||||||
|
// sits as its own QSL row.
|
||||||
|
val staleId = dao.save(reportConfirmation(satName = "SO-50").copy(band = "70CM", rxBand = "2M").toEntity())
|
||||||
|
|
||||||
|
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
|
||||||
|
|
||||||
|
val rows = dao.getAll()
|
||||||
|
assertEquals(1, rows.size)
|
||||||
|
assertEquals(staleId, rows.first().id)
|
||||||
|
assertTrue(rows.first().lotwConfirmed)
|
||||||
|
// The stale row adopts the report's (uplink-first) direction instead of being skipped
|
||||||
|
// forever by the dedupe key.
|
||||||
|
assertEquals("2M", rows.first().band)
|
||||||
|
assertEquals("70CM", rows.first().rxBand)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun save_editingAnUploadedRecordCreatesANewRecord() = runBlocking {
|
||||||
|
val originalId = repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true))
|
||||||
|
val edited = loggedInApp("SAUDISAT 1C", lotwUploaded = true).copy(id = originalId, theirCallsign = "BG5JVM")
|
||||||
|
|
||||||
|
val newId = repository.save(edited)
|
||||||
|
|
||||||
|
assertNotEquals(originalId, newId)
|
||||||
|
val rows = dao.getAll()
|
||||||
|
assertEquals(2, rows.size)
|
||||||
|
// The original keeps its identity and upload state, untouched.
|
||||||
|
val original = rows.first { it.id == originalId }
|
||||||
|
assertEquals("BG5JSB", original.theirCallsign)
|
||||||
|
assertTrue(original.lotwUploaded)
|
||||||
|
// The edited content is a fresh, uploadable record.
|
||||||
|
val created = rows.first { it.id == newId }
|
||||||
|
assertEquals("BG5JVM", created.theirCallsign)
|
||||||
|
assertFalse(created.lotwUploaded)
|
||||||
|
assertFalse(created.lotwConfirmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun save_editingAnUnuploadedRecordUpdatesInPlace() = runBlocking {
|
||||||
|
val id = repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = false))
|
||||||
|
repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = false).copy(id = id, theirCallsign = "BG5JVM"))
|
||||||
|
|
||||||
|
val rows = dao.getAll()
|
||||||
|
assertEquals(1, rows.size)
|
||||||
|
assertEquals("BG5JVM", rows.first().theirCallsign)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun save_editingAnUploadedRecordWithoutChangesKeepsOneRow() = runBlocking {
|
||||||
|
val id = repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true))
|
||||||
|
repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).copy(id = id))
|
||||||
|
|
||||||
|
assertEquals(1, dao.getAll().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun save_editingAnUploadedRecordWithOnlySecondsChangedKeepsOneRow() = runBlocking {
|
||||||
|
val id = repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true))
|
||||||
|
// The edit dialog edits whole minutes and rounds the seconds away on save, so a
|
||||||
|
// seconds-only difference must not be treated as an edit (no new record).
|
||||||
|
repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).copy(id = id, startUtcMillis = qsoStart + 10_000L))
|
||||||
|
|
||||||
|
assertEquals(1, dao.getAll().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A record as the log page creates it: tracker name, repeater pair, uplink band. */
|
||||||
|
private fun loggedInApp(satelliteName: String, lotwUploaded: Boolean) = QsoRecord(
|
||||||
|
startUtcMillis = qsoStart,
|
||||||
|
theirCallsign = "BG5JSB",
|
||||||
|
myCallsign = "BA7OPF",
|
||||||
|
txFrequencyHz = 145_850_000L,
|
||||||
|
rxFrequencyHz = 436_795_000L,
|
||||||
|
band = "2M",
|
||||||
|
rxBand = "70CM",
|
||||||
|
mode = "FM",
|
||||||
|
satelliteName = satelliteName,
|
||||||
|
propagationMode = "SAT",
|
||||||
|
status = QsoStatus.COMPLETE,
|
||||||
|
lotwUploaded = lotwUploaded
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A confirmation as the report parser + [toConfirmedRecord] build it (BAND carries the uplink). */
|
||||||
|
private fun reportConfirmation(satName: String) = GridQso(
|
||||||
|
call = "BG5JSB",
|
||||||
|
epochMs = qsoStart,
|
||||||
|
satName = satName,
|
||||||
|
mode = "FM",
|
||||||
|
bandUp = "2M",
|
||||||
|
bandDown = "70CM",
|
||||||
|
dxcc = 318,
|
||||||
|
country = "China",
|
||||||
|
cqz = 24,
|
||||||
|
state = "GD",
|
||||||
|
myGrid = "OL62",
|
||||||
|
myGrids = setOf("OL62", "OL63")
|
||||||
|
).toConfirmedRecord("BA7OPF")
|
||||||
|
|
||||||
|
private fun QsoRecord.toEntity() = QsoEntity(
|
||||||
|
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 = status.name,
|
||||||
|
dedupeKey = listOf(startUtcMillis, theirCallsign, myCallsign, txFrequencyHz, mode, submode, satelliteName)
|
||||||
|
.joinToString("|"),
|
||||||
|
propagationMode = propagationMode,
|
||||||
|
lotwConfirmed = lotwConfirmed,
|
||||||
|
lotwUploaded = lotwUploaded,
|
||||||
|
lotwReceived = lotwReceived,
|
||||||
|
lotwQslDate = lotwQslDate,
|
||||||
|
vuccGrids = vuccGrids.joinToString(","),
|
||||||
|
dxcc = dxcc,
|
||||||
|
country = country,
|
||||||
|
cqZone = cqZone,
|
||||||
|
region = region,
|
||||||
|
comment = comment
|
||||||
|
)
|
||||||
|
|
||||||
|
private class FakeQsoDao : QsoDao {
|
||||||
|
private val rows = linkedMapOf<Long, QsoEntity>()
|
||||||
|
private var nextId = 1L
|
||||||
|
|
||||||
|
override fun observeAll(): Flow<List<QsoEntity>> = flowOf(rows.values.toList())
|
||||||
|
|
||||||
|
override suspend fun find(id: Long): QsoEntity? = rows[id]
|
||||||
|
|
||||||
|
override suspend fun getAll(): List<QsoEntity> = rows.values.toList()
|
||||||
|
|
||||||
|
override suspend fun getDedupeKeys(): List<String> = rows.values.map { it.dedupeKey }
|
||||||
|
|
||||||
|
override suspend fun save(record: QsoEntity): Long {
|
||||||
|
val id = if (record.id == 0L) nextId++ else record.id
|
||||||
|
rows[id] = record.copy(id = id)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun importRecords(records: List<QsoEntity>): List<Long> = records.map { save(it) }
|
||||||
|
|
||||||
|
override suspend fun delete(id: Long) {
|
||||||
|
rows.remove(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun markUploaded(ids: List<Long>) {
|
||||||
|
ids.forEach { id -> rows[id]?.let { rows[id] = it.copy(lotwUploaded = true) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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,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.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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folds LoTW confirmations that were imported as their own rows back into the local
|
||||||
|
* record they belong to (see [com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs]).
|
||||||
|
* Confirmations merged before the tracker-name/band identity fix split every contact in
|
||||||
|
* two; this repairs the existing logbook. Returns the number of merged rows.
|
||||||
|
*/
|
||||||
|
suspend fun consolidateConfirmations(): Int
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by 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.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()
|
||||||
|
)
|
||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
/*
|
||||||
|
* 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.logbook
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Satellite names of the ARRL LoTW catalogue and the tracker names that map onto them.
|
||||||
|
*
|
||||||
|
* ARRL's config.tq6 knows every satellite by exactly ONE name ("SO-50", "ARISS", "PO-101",
|
||||||
|
* "BO-102"), while the TLE sources the tracker uses carry their own catalogue names: SatNOGS
|
||||||
|
* lists SO-50 as "SAUDISAT 1C", the ISS as "ISS (ZARYA)", PO-101 as "DIWATA 2B" and AO-123 as
|
||||||
|
* "OBJECT AY". Without this mapping a signed QSO on those satellites is rejected as an unknown
|
||||||
|
* satellite.
|
||||||
|
*
|
||||||
|
* Keys are tracker names normalised by [normalize] (upper case, A-Z0-9 only); values are the
|
||||||
|
* exact config.tq6 satellite names. Generated 2026-09-27 from:
|
||||||
|
* - SatNOGS DB satellites (`name` + `names` alias list) joined to config.tq6 by exact alias,
|
||||||
|
* - live SatNOGS 3LE names and Celestrak amateur names joined to the same NORAD id,
|
||||||
|
* - designator tokens of the config.tq6 satellite descriptions (e.g. CAS-7B -> BO-102),
|
||||||
|
* - the ISS family, which SatNOGS does not list under its ARRL name "ARISS".
|
||||||
|
* Placeholder names ("OBJECT AY" and friends) are deliberately NOT mapped: they are recycled
|
||||||
|
* between objects and would sign the wrong satellite. LoTWConfigTest asserts that every target
|
||||||
|
* here is a real config.tq6 name.
|
||||||
|
*/
|
||||||
|
object LoTWSatelliteAliases {
|
||||||
|
|
||||||
|
val table: Map<String, String> = mapOf(
|
||||||
|
"AMSATOSCAR7" to "AO-7", "AO123" to "AO-123", "AO16" to "AO-16",
|
||||||
|
"AO27" to "AO-27", "AO7" to "AO-7", "AO73" to "AO-73",
|
||||||
|
"AO85" to "AO-85", "AO91" to "AO-91", "AO92" to "AO-92",
|
||||||
|
"ARISSAT1" to "KEDR", "ASRTU1" to "AO-123", "ASRTUFRIENDSHIP" to "AO-123",
|
||||||
|
"BJ2CR" to "AO-123", "BREEZEKMRB" to "RS-44", "BRICSAT2" to "NO-103",
|
||||||
|
"BRICSATP" to "NO-83", "BY701" to "BY70-1", "CAMSAT" to "FO-118",
|
||||||
|
"CAS10" to "HO-119", "CAS2F" to "XW-2F", "CAS2T" to "CAS-2T",
|
||||||
|
"CAS3A" to "XW-2A", "CAS3B" to "XW-2B", "CAS3C" to "XW-2C",
|
||||||
|
"CAS3CBJ1SD" to "XW-2C", "CAS3D" to "XW-2D", "CAS3E" to "XW-2E",
|
||||||
|
"CAS3F" to "XW-2F", "CAS4A" to "CAS-4A", "CAS4B" to "CAS-4B",
|
||||||
|
"CAS5A" to "FO-118", "CAS6" to "TO-108", "CAS7B" to "BO-102",
|
||||||
|
"CAS9" to "HO-113", "CZ11RB" to "CAS-2T", "DELFIC3" to "DO-64",
|
||||||
|
"DIWATA2" to "PO-101", "DIWATA2B" to "PO-101", "DO64" to "DO-64",
|
||||||
|
"DOSAAF85" to "RS-44", "DRUZHBAATURK" to "AO-123", "EO79" to "EO-79",
|
||||||
|
"EO88" to "EO-88", "ESHAIL2" to "QO-100", "EUROPEANOSCAR79" to "EO-79",
|
||||||
|
"EYESAT1" to "AO-27", "EYESATA" to "AO-27", "FENGTAIOSCAR118" to "FO-118",
|
||||||
|
"FO118" to "FO-118", "FO29" to "FO-29", "FOX1" to "AO-85",
|
||||||
|
"FOX1A" to "AO-85", "FOX1B" to "AO-91", "FOX1D" to "AO-92",
|
||||||
|
"FRESCO" to "LO-87", "FUNCUBE1" to "AO-73", "FUNCUBE2" to "UKUBE1",
|
||||||
|
"FUNCUBE3" to "EO-79", "FUNCUBE5" to "EO-88", "GREENCUBE" to "IO-117",
|
||||||
|
"HADESD" to "SO-121", "HADESICM" to "SO-125", "HADESR" to "SO-124",
|
||||||
|
"HAIL2" to "QO-100", "HAMSAT" to "VO-52", "HO113" to "HO-113",
|
||||||
|
"HO119" to "HO-119", "HOPE4" to "HO-119", "HYDRA1" to "SO-121",
|
||||||
|
"INSPIRESAT7" to "INSPR7", "IO117" to "IO-117", "IO86" to "IO-86",
|
||||||
|
"ISS" to "ARISS", "ISSZARYA" to "ARISS", "JAS2" to "FO-29",
|
||||||
|
"JINNIUZUO1" to "TAURUS", "JO97" to "JO-97", "JORDANOSCAR97" to "JO-97",
|
||||||
|
"JY1SAT" to "JO-97", "LAPANA2" to "IO-86", "LILACSAT1" to "LO-90",
|
||||||
|
"LILACSAT2" to "CAS-3H", "LITUANICASAT1" to "LO-78", "LO19" to "LO-19",
|
||||||
|
"LO87" to "LO-87", "LUSAT" to "LO-19", "LUSEX" to "LO-87",
|
||||||
|
"MAYA3" to "MAYA-3", "MAYA4" to "MAYA-4", "MESAT1" to "MO-122",
|
||||||
|
"MESAT1OSCAR" to "MO-122", "MESAT1OSCAR122" to "MO-122", "MIRSAT1" to "MO-112",
|
||||||
|
"MO122" to "MO-122", "MTCUBE2" to "IO-117", "NA1SS" to "ARISS",
|
||||||
|
"NAYIF1" to "EO-88", "NEWSAT1" to "LO-87", "NO103" to "NO-103",
|
||||||
|
"NO104" to "NO-104", "NO44" to "NO-44", "NO83" to "NO-83",
|
||||||
|
"NO84" to "NO-84", "NUSAT1" to "LO-87", "ORARI" to "IO-86",
|
||||||
|
"OSCAR16PACSAT" to "AO-16", "OSCAR19LUSAT" to "LO-19", "OSCAR64" to "DO-64",
|
||||||
|
"OSCAR7" to "AO-7", "PACSAT" to "AO-16", "PARKINSONSAT" to "NO-84",
|
||||||
|
"PCSAT" to "NO-44", "PHILLIPINESOSCAR101" to "PO-101", "PO101" to "PO-101",
|
||||||
|
"PSAT" to "NO-84", "PSAT2" to "NO-104", "QB50P1" to "EO-79",
|
||||||
|
"QO100" to "QO-100", "RADFXSAT" to "AO-91", "RADIOROSTO" to "RS-15",
|
||||||
|
"RS0ISS" to "ARISS", "RS14" to "AO-21", "RS15" to "RS-15",
|
||||||
|
"RS44" to "RS-44", "RS64S" to "AO-123", "SAUDISAT1C" to "SO-50",
|
||||||
|
"SO121" to "SO-121", "SO124" to "SO-124", "SO125" to "SO-125",
|
||||||
|
"SO50" to "SO-50", "SOLUTUSNANOSATTELITE" to "SONATE", "SONATE2" to "SONATE",
|
||||||
|
"TAURUS1" to "TAURUS", "TEVEL21" to "TEV2-1", "TEVEL22" to "TEV2-2",
|
||||||
|
"TEVEL23" to "TEV2-3", "TEVEL24" to "TEV2-4", "TEVEL25" to "TEV2-5",
|
||||||
|
"TEVEL26" to "TEV2-6", "TEVEL27" to "TEV2-7", "TEVEL28" to "TEV2-8",
|
||||||
|
"TEVEL29" to "TEV2-9", "USAT1" to "LO-87", "USNAP1" to "NO-103",
|
||||||
|
"VO52" to "VO-52", "W3ADO" to "NO-44", "XW2A" to "XW-2A",
|
||||||
|
"XW2B" to "XW-2B", "XW2C" to "XW-2C", "XW2D" to "XW-2D",
|
||||||
|
"XW2E" to "XW-2E", "XW2F" to "XW-2F", "XW3" to "HO-113",
|
||||||
|
"XW3CAS9" to "HO-113", "XW4" to "HO-119", "YB0X" to "IO-86",
|
||||||
|
"ZARYA" to "ARISS",
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracker name -> ARRL name. An exact table hit wins; otherwise the longest alias key
|
||||||
|
* contained in the name matches, so a name carrying an extra suffix ("SAUDISAT 1C
|
||||||
|
* (SO-50)") still resolves. Keys shorter than 5 characters never match loosely.
|
||||||
|
*/
|
||||||
|
fun lookup(name: String): String? {
|
||||||
|
val normalized = normalize(name)
|
||||||
|
table[normalized]?.let { return it }
|
||||||
|
return table.entries
|
||||||
|
.filter { it.key.length >= 5 && normalized.contains(it.key) }
|
||||||
|
.maxByOrNull { it.key.length }
|
||||||
|
?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upper case, everything that is not A-Z0-9 removed ("ISS (ZARYA)" -> "ISSZARYA"). */
|
||||||
|
fun normalize(name: String): String = name.uppercase(Locale.US).filter { it.isLetterOrDigit() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve any tracker name against the ARRL catalogue: exact official name, then an alias
|
||||||
|
* from [table], then an official name contained in the tracker name. Returns the official
|
||||||
|
* name, or null when the tracker name is not an ARRL satellite at all.
|
||||||
|
*/
|
||||||
|
fun resolve(name: String, officialNames: Collection<String>): String? {
|
||||||
|
val trimmed = name.trim()
|
||||||
|
if (trimmed.isEmpty()) return null
|
||||||
|
val normalized = trimmed.uppercase(Locale.US)
|
||||||
|
officialNames.firstOrNull { it.equals(trimmed, true) }?.let { return it }
|
||||||
|
lookup(trimmed)?.let { alias -> officialNames.firstOrNull { it.equals(alias, true) }?.let { return it } }
|
||||||
|
return officialNames.firstOrNull { it.length > 1 && normalized.contains(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catalogue candidates for a partly typed name. Alias keys are matched too, so the
|
||||||
|
* operator's own spelling ("diwata", "saudisat 1c", "ao9") reaches the ARRL name even
|
||||||
|
* when the two share no characters.
|
||||||
|
*/
|
||||||
|
fun suggestions(query: String, officialNames: Collection<String>, limit: Int = 6): List<String> {
|
||||||
|
val normalized = normalize(query)
|
||||||
|
if (normalized.isEmpty()) return emptyList()
|
||||||
|
val fromAliases = table.entries
|
||||||
|
.filter { it.key.contains(normalized) || normalized.contains(it.key) }
|
||||||
|
.map { it.value }
|
||||||
|
val fromNames = officialNames.filter { normalize(it).contains(normalized) }
|
||||||
|
return (fromAliases + fromNames + listOfNotNull(lookup(query)))
|
||||||
|
.distinct()
|
||||||
|
.filter { candidate -> officialNames.any { it.equals(candidate, true) } }
|
||||||
|
.take(limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
/*
|
||||||
|
* 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.logbook
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wording for the reasons a QSO cannot be signed for LoTW.
|
||||||
|
*
|
||||||
|
* The logbook used to report every one of these as "invalid call/date", which sent the operator
|
||||||
|
* looking at the callsign and date even when the real cause was a missing frequency (no BAND) or
|
||||||
|
* a satellite name ARRL does not know.
|
||||||
|
*/
|
||||||
|
fun LoTWProblem.label(): String = when (this) {
|
||||||
|
LoTWProblem.BAND -> "missing frequency/band"
|
||||||
|
LoTWProblem.SATELLITE -> "satellite name not in ARRL's list"
|
||||||
|
LoTWProblem.MODE -> "mode not accepted by LoTW"
|
||||||
|
LoTWProblem.QSO_DATE -> "date outside the certificate"
|
||||||
|
LoTWProblem.CALLSIGN_MISMATCH -> "MY callsign does not match the certificate"
|
||||||
|
LoTWProblem.INVALID_CONTACT -> "invalid callsign/record"
|
||||||
|
LoTWProblem.STATION_GRID -> "station grid missing"
|
||||||
|
LoTWProblem.STATION_REGION -> "station region missing"
|
||||||
|
LoTWProblem.STATION_ZONE -> "station zones missing"
|
||||||
|
LoTWProblem.STATION_IOTA -> "station IOTA missing"
|
||||||
|
LoTWProblem.TOO_MANY_CONTACTS -> "too many contacts in one batch"
|
||||||
|
LoTWProblem.EMPTY_SELECTION -> "nothing selected"
|
||||||
|
else -> name.lowercase().replace('_', ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-line explanation of why records were left out of an upload, e.g.
|
||||||
|
* "3 QSO(s) can't be uploaded — 2× missing frequency/band, 1× satellite name not in ARRL's list (SAUDISAT 1C)".
|
||||||
|
* Returns an empty string when nothing was skipped.
|
||||||
|
*/
|
||||||
|
fun unavailableUploadSummary(
|
||||||
|
total: Int,
|
||||||
|
reasons: Map<LoTWProblem, Int> = emptyMap(),
|
||||||
|
details: Map<LoTWProblem, String> = emptyMap(),
|
||||||
|
duplicates: Int = 0,
|
||||||
|
incomplete: Int = 0
|
||||||
|
): String {
|
||||||
|
if (total <= 0) return ""
|
||||||
|
val parts = reasons.entries
|
||||||
|
.sortedByDescending { it.value }
|
||||||
|
.map { (problem, count) ->
|
||||||
|
val detail = details[problem]?.takeIf { it.isNotBlank() }?.let { " ($it)" }.orEmpty()
|
||||||
|
"$count× ${problem.label()}$detail"
|
||||||
|
}
|
||||||
|
.toMutableList()
|
||||||
|
if (duplicates > 0) parts += "$duplicates× duplicate of another record in the batch"
|
||||||
|
if (incomplete > 0) parts += "$incomplete× record not completed"
|
||||||
|
val subject = if (total == 1) "1 QSO can't be uploaded" else "$total QSO(s) can't be uploaded"
|
||||||
|
val detail = parts.joinToString(", ").ifBlank { "check the logbook" }
|
||||||
|
return "$subject — $detail"
|
||||||
|
}
|
||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by 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.logbook
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/** LoTW official name vs tracking-source name ("SO-50 (SaudiOSCAR 50)"): compare the
|
||||||
|
* parenthetical-free prefix. Kept as the last-resort identity for names the alias
|
||||||
|
* table does not know (see [satelliteIdentity]). */
|
||||||
|
fun satMatchKey(name: String): String = name.substringBefore('(').trim().uppercase(Locale.US)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The satellite identity both sides of a match resolve to.
|
||||||
|
*
|
||||||
|
* A local record keeps whatever the tracking source published ("SAUDISAT 1C",
|
||||||
|
* "ISS (ZARYA)", "DIWATA 2B", "JAS 2") while the LoTW report always carries the ARRL
|
||||||
|
* name ("SO-50", "ARISS", "PO-101", "FO-29"). Comparing the raw names made the two
|
||||||
|
* sides of the SAME contact unequal, so a downloaded confirmation was stored as a
|
||||||
|
* second, separate QSO instead of confirming the uploaded one.
|
||||||
|
*
|
||||||
|
* Both sides are therefore resolved through [LoTWSatelliteAliases], which maps the
|
||||||
|
* tracker names onto the ARRL names and every ARRL name onto itself. Names the table
|
||||||
|
* does not know (e.g. the recycled "OBJECT xx" placeholders) keep falling back to the
|
||||||
|
* parenthetical-free prefix, i.e. they simply never match anything else.
|
||||||
|
*/
|
||||||
|
fun satelliteIdentity(name: String): String {
|
||||||
|
val trimmed = name.trim()
|
||||||
|
if (trimmed.isEmpty()) return ""
|
||||||
|
val prefix = satMatchKey(trimmed)
|
||||||
|
return LoTWSatelliteAliases.lookup(trimmed)
|
||||||
|
?: LoTWSatelliteAliases.lookup(prefix)
|
||||||
|
?: prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name a QSO is stored under: the ARRL name whenever the tracking source's name maps onto
|
||||||
|
* one, otherwise the name as published.
|
||||||
|
*
|
||||||
|
* Records used to keep the tracker's spelling ("SAUDISAT 1C", "ISS (ZARYA)", "OBJECT AY") while
|
||||||
|
* every signed ADIF carries the ARRL name, so the logbook showed a different satellite than the
|
||||||
|
* one LoTW knows and only the upload path resolved the two. Storing the ARRL name keeps one name
|
||||||
|
* for the logbook, the ADIF export and the signature. Names the alias table does not know are
|
||||||
|
* left exactly as they are: satellites ARRL does not list have no official name to store, and the
|
||||||
|
* recycled "OBJECT xx" placeholders must never be mapped onto a real satellite.
|
||||||
|
*/
|
||||||
|
fun officialSatelliteName(name: String, officialNames: Collection<String> = emptyList()): String {
|
||||||
|
val trimmed = name.trim()
|
||||||
|
if (trimmed.isEmpty()) return ""
|
||||||
|
LoTWSatelliteAliases.resolve(trimmed, officialNames)?.let { return it }
|
||||||
|
val prefix = satMatchKey(trimmed)
|
||||||
|
return LoTWSatelliteAliases.lookup(trimmed) ?: LoTWSatelliteAliases.lookup(prefix) ?: trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything about a contact except its bands: the same opposite station, the same
|
||||||
|
* satellite, the same mode and less than a minute apart. LoTW reports the minute, so
|
||||||
|
* the time window is what makes a match unambiguous.
|
||||||
|
*/
|
||||||
|
fun sameContactIdentity(local: QsoRecord, remote: QsoRecord): Boolean {
|
||||||
|
val localSat = satelliteIdentity(local.satelliteName)
|
||||||
|
return localSat.isNotBlank() &&
|
||||||
|
localSat == satelliteIdentity(remote.satelliteName) &&
|
||||||
|
local.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) &&
|
||||||
|
(local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) &&
|
||||||
|
local.isSatellite == remote.isSatellite &&
|
||||||
|
local.displayMode == remote.displayMode &&
|
||||||
|
abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bands agree when both sides name one and they are the same; a missing band is not a mismatch. */
|
||||||
|
private fun sameBandOrBlank(one: String, other: String): Boolean =
|
||||||
|
one.isBlank() || other.isBlank() || one.equals(other, true)
|
||||||
|
|
||||||
|
/** LoTW omits frequency and may round time to a minute. Only merge an unambiguous contact. */
|
||||||
|
fun sameConfirmedContact(local: QsoRecord, remote: QsoRecord): Boolean =
|
||||||
|
sameContactIdentity(local, remote) && sameBandOrBlank(local.band, remote.band)
|
||||||
|
|
||||||
|
fun QsoRecord.confirmationLookupKey(): String = listOf(
|
||||||
|
theirCallsign.trim().uppercase(Locale.US), satelliteIdentity(satelliteName), displayMode
|
||||||
|
).joinToString("|")
|
||||||
|
|
||||||
|
/** A local row and the confirmation row that belongs to it, as indices into one list. */
|
||||||
|
data class SplitConfirmationPair(val localIndex: Int, val confirmationIndex: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rows that a confirmation should have been folded into but was not, so they can be
|
||||||
|
* consolidated back into one.
|
||||||
|
*
|
||||||
|
* Confirmations merged before the identity fix landed as their own rows: the tracker
|
||||||
|
* name vs ARRL name comparison failed, and the band direction of the report was read
|
||||||
|
* mirrored (BAND_RX was taken as the uplink), so even satellites with matching names
|
||||||
|
* were stored twice. The pair is only accepted when the contact identity matches and
|
||||||
|
* the bands are either identical or exactly mirrored — the signature of that second
|
||||||
|
* mismatch — which keeps unrelated QSOs of the same operator and minute apart.
|
||||||
|
*/
|
||||||
|
fun splitConfirmationPairs(records: List<QsoRecord>): List<SplitConfirmationPair> {
|
||||||
|
val pendingIndices = records.indices.filter { !records[it].lotwConfirmed }
|
||||||
|
val taken = mutableSetOf<Int>()
|
||||||
|
return records.indices.filter { records[it].lotwConfirmed }.mapNotNull { confirmationIndex ->
|
||||||
|
val confirmation = records[confirmationIndex]
|
||||||
|
val localIndex = pendingIndices.firstOrNull { index ->
|
||||||
|
index !in taken && sameContactIdentity(records[index], confirmation) &&
|
||||||
|
bandsMatchOrMirror(records[index], confirmation)
|
||||||
|
} ?: return@mapNotNull null
|
||||||
|
taken += localIndex
|
||||||
|
SplitConfirmationPair(localIndex, confirmationIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same band on both sides, or the mirrored pair the pre-fix parser produced. */
|
||||||
|
private fun bandsMatchOrMirror(local: QsoRecord, confirmation: QsoRecord): Boolean =
|
||||||
|
(sameBandOrBlank(local.band, confirmation.band) && sameBandOrBlank(local.rxBand, confirmation.rxBand)) ||
|
||||||
|
(sameBandOrBlank(local.band, confirmation.rxBand) && sameBandOrBlank(local.rxBand, confirmation.band))
|
||||||
|
|
||||||
|
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)
|
||||||
+18
@@ -1,3 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by 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
|
package com.rtbishop.look4sat.core.domain.model
|
||||||
|
|
||||||
/** One public AMSAT satellite status report submission. */
|
/** One public AMSAT satellite status report submission. */
|
||||||
|
|||||||
@@ -26,9 +26,12 @@ package com.rtbishop.look4sat.core.domain.model
|
|||||||
* @param epochMs QSO date+time in UTC milliseconds
|
* @param epochMs QSO date+time in UTC milliseconds
|
||||||
* @param satName satellite name (e.g. "FO-29")
|
* @param satName satellite name (e.g. "FO-29")
|
||||||
* @param mode ADIF mode (FM / CW / SSB ...)
|
* @param mode ADIF mode (FM / CW / SSB ...)
|
||||||
* @param bandUp uplink band as reported by LoTW (BAND_RX: "70CM", "2M", "10M"...)
|
* @param bandUp uplink band, the band of the transmitted frequency (ADIF BAND:
|
||||||
* @param bandDown downlink band (BAND: "2M", "70CM"...) — may be empty when
|
* "2M", "70CM", "10M"...). Measured against the live report of the
|
||||||
* LoTW did not include it
|
* user's own uploads: SO-50 arrives as BAND=2M/FREQ=145.85 with
|
||||||
|
* BAND_RX=70CM/FREQ_RX=436.8, i.e. BAND carries the uplink.
|
||||||
|
* @param bandDown downlink band (ADIF BAND_RX) — may be empty when LoTW did not
|
||||||
|
* include it
|
||||||
* @param dxcc ARRL DXCC entity code of the opposite station (from ADIF
|
* @param dxcc ARRL DXCC entity code of the opposite station (from ADIF
|
||||||
* <DXCC>), or null when LoTW omitted it
|
* <DXCC>), or null when LoTW omitted it
|
||||||
* @param country DXCC entity name (ADIF <COUNTRY>), or null
|
* @param country DXCC entity name (ADIF <COUNTRY>), or null
|
||||||
@@ -48,7 +51,28 @@ data class GridQso(
|
|||||||
val dxcc: Int? = null,
|
val dxcc: Int? = null,
|
||||||
val country: String? = null,
|
val country: String? = null,
|
||||||
val cqz: Int? = null,
|
val cqz: Int? = null,
|
||||||
val state: String? = 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. */
|
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
|
||||||
val bandLabel: String
|
val bandLabel: String
|
||||||
|
|||||||
@@ -1,3 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by 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
|
package com.rtbishop.look4sat.core.domain.model
|
||||||
|
|
||||||
/** Latest release info fetched from the fork's GitHub releases API. */
|
/** Latest release info fetched from the fork's GitHub releases API. */
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -33,6 +33,10 @@ data class SatRadio(
|
|||||||
@SerialName("uplink_mode") val uplinkMode: String?,
|
@SerialName("uplink_mode") val uplinkMode: String?,
|
||||||
@SerialName("invert") val isInverted: Boolean,
|
@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. */
|
/** Set for manually imported transceivers, which remote updates must not replace. */
|
||||||
val isCustom: Boolean = false
|
val isCustom: Boolean = false
|
||||||
)
|
)
|
||||||
@@ -1,3 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by 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
|
package com.rtbishop.look4sat.core.domain.model
|
||||||
|
|
||||||
/** One satellite status report (AMSAT site tooltip data) */
|
/** One satellite status report (AMSAT site tooltip data) */
|
||||||
@@ -20,7 +38,8 @@ data class SatSlot(
|
|||||||
/** One satellite day (12 two-hour slots) */
|
/** One satellite day (12 two-hour slots) */
|
||||||
data class SatDay(
|
data class SatDay(
|
||||||
val dateLabel: String, // "Aug 4"
|
val dateLabel: String, // "Aug 4"
|
||||||
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
val slots: List<SatSlot>, // 12 槽(00-02 ... 22-24)
|
||||||
|
val streakCount: Int = 0 // 当天最近连续相同状态报告数(0 = 当天无报告)
|
||||||
)
|
)
|
||||||
|
|
||||||
/** One satellite, 3 days of state */
|
/** One satellite, 3 days of state */
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ data class PassesSettings(
|
|||||||
val aosStartMinute: Int = 0,
|
val aosStartMinute: Int = 0,
|
||||||
val aosEndMinute: Int = 23 * 60 + 59,
|
val aosEndMinute: Int = 23 * 60 + 59,
|
||||||
val invertAosTimeWindow: Boolean = false,
|
val invertAosTimeWindow: Boolean = false,
|
||||||
val selectedModes: List<String>
|
val selectedModes: List<String>,
|
||||||
|
/** 历史过境回看窗口(小时): 过境列表从 now-hoursBefore 开始计算. */
|
||||||
|
val hoursBefore: Int = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RCSettings(
|
data class RCSettings(
|
||||||
@@ -60,13 +62,36 @@ data class OtherSettings(
|
|||||||
val stateOfLightTheme: Boolean,
|
val stateOfLightTheme: Boolean,
|
||||||
val stateOfNightMode: Boolean = false,
|
val stateOfNightMode: Boolean = false,
|
||||||
val stateOfMapGrid: 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 shouldSeeWarning: Boolean,
|
||||||
val shouldSeeWhatsNew: Boolean,
|
val shouldSeeWhatsNew: Boolean,
|
||||||
val sstvMode: String = "Auto",
|
val sstvMode: String = "Auto",
|
||||||
val lowElevation: Double = 15.0,
|
val lowElevation: Double = 15.0,
|
||||||
val highElevation: Double = 45.0
|
val highElevation: Double = 45.0,
|
||||||
|
/** 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(
|
data class DataSourcesSettings(
|
||||||
val satelliteUrls: List<String>,
|
val satelliteUrls: List<String>,
|
||||||
val transceiversUrls: List<String>,
|
val transceiversUrls: List<String>,
|
||||||
|
|||||||
+18
@@ -1,3 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by 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
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||||
|
|||||||
+35
-1
@@ -30,9 +30,43 @@ interface ILoTWRepository {
|
|||||||
* Same report, but keeps the per-QSO detail of every confirmed satellite
|
* Same report, but keeps the per-QSO detail of every confirmed satellite
|
||||||
* QSO (call / time / satellite / mode / bands), grouped by worked 4-char
|
* QSO (call / time / satellite / mode / bands), grouped by worked 4-char
|
||||||
* gridsquare. Failure cause is kept explicit.
|
* 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(
|
suspend fun fetchConfirmedGridQsos(
|
||||||
callsign: String,
|
callsign: String,
|
||||||
password: String
|
password: String,
|
||||||
|
since: String = "",
|
||||||
|
onProgress: (LoTWProgress) -> Unit = {}
|
||||||
): LoTWResult
|
): 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 }
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by 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.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
|
||||||
|
/** Every satellite name ARRL accepts (config.tq6), sorted. Only these names may be
|
||||||
|
* signed, so the logbook UI validates/picks satellite names from this list. */
|
||||||
|
suspend fun satelliteCatalog(): List<String>
|
||||||
|
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,
|
||||||
|
/** Un-signable records (invalid call/date/…) skipped instead of aborting the batch. */
|
||||||
|
val unavailableSkipped: Int = 0,
|
||||||
|
/** Why those records were un-signable, so the operator sees the real cause. */
|
||||||
|
val unavailableReasons: Map<LoTWProblem, Int> = emptyMap(),
|
||||||
|
/** Records dropped because the same contact already appears earlier in the batch. */
|
||||||
|
val duplicateSkipped: Int = 0,
|
||||||
|
/** Ids of the records that actually made it into this TQ8 batch. Only these
|
||||||
|
* may be marked "uploaded" after an accepted POST — never the full candidate list. */
|
||||||
|
val submittedIds: List<Long> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Why the un-uploadable records of a selection cannot be signed, and what was left out. */
|
||||||
|
data class LoTWUploadAudit(
|
||||||
|
val total: Int,
|
||||||
|
val pending: Int,
|
||||||
|
val uploaded: Int,
|
||||||
|
val unknown: Int,
|
||||||
|
val unavailable: Int,
|
||||||
|
/** Signing failures counted per reason (see [com.rtbishop.look4sat.core.domain.logbook.label]). */
|
||||||
|
val reasons: Map<LoTWProblem, Int> = emptyMap(),
|
||||||
|
/** First offending value per reason, e.g. SATELLITE -> "SAUDISAT 1C". */
|
||||||
|
val details: Map<LoTWProblem, String> = emptyMap(),
|
||||||
|
/** Records dropped as duplicates of an earlier record in the same selection. */
|
||||||
|
val duplicates: Int = 0,
|
||||||
|
/** Records that are not marked complete, so they are not uploadable at all. */
|
||||||
|
val incomplete: Int = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
/*
|
||||||
|
* 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 kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single position fix from the device's location provider, with the
|
||||||
|
* horizontal accuracy reported by the platform.
|
||||||
|
*/
|
||||||
|
data class LocationFix(
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val altitudeMeters: Double = 0.0,
|
||||||
|
val accuracyMeters: Float,
|
||||||
|
val epochMs: Long
|
||||||
|
) {
|
||||||
|
/** ARRL VUCC work needs a fix good to the 20 ft (6.1 m) rule. */
|
||||||
|
fun isPreciseEnough(limitMeters: Float): Boolean = accuracyMeters in 0f..limitMeters
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the live-location source is currently able to give. */
|
||||||
|
sealed interface LocationState {
|
||||||
|
/** No fix yet - either permission is missing or the provider is warming up. */
|
||||||
|
data class Waiting(val permissionGranted: Boolean, val providerEnabled: Boolean) : LocationState
|
||||||
|
|
||||||
|
data class Fix(val location: LocationFix) : LocationState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live device position for field tools (currently the Grid Finder). Kept
|
||||||
|
* separate from [ISettingsRepo.stationPosition] on purpose: collecting fixes
|
||||||
|
* here must never move the user's station, which is what drives every pass
|
||||||
|
* prediction in the app.
|
||||||
|
*
|
||||||
|
* Callers register with [startUpdates] while their screen is alive and MUST
|
||||||
|
* call [stopUpdates] when it goes away.
|
||||||
|
*/
|
||||||
|
interface ILocationRepo {
|
||||||
|
val state: StateFlow<LocationState>
|
||||||
|
|
||||||
|
/** Whether the app currently holds the fine-location permission. */
|
||||||
|
fun hasPermission(): Boolean
|
||||||
|
|
||||||
|
/** Begins delivering fixes; no-op when permission is missing. */
|
||||||
|
fun startUpdates()
|
||||||
|
|
||||||
|
/** Stops delivering fixes and releases the platform listener. */
|
||||||
|
fun stopUpdates()
|
||||||
|
}
|
||||||
+7
@@ -16,6 +16,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.domain.repository
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||||
@@ -34,9 +35,15 @@ interface IMainContainer {
|
|||||||
val updateRepo: IUpdateRepository
|
val updateRepo: IUpdateRepository
|
||||||
val wavelogRepo: IWavelogRepository
|
val wavelogRepo: IWavelogRepository
|
||||||
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||||
|
val qsoRepository: IQsoRepository
|
||||||
|
val lotwUploadRepository: ILoTWUploadRepository
|
||||||
val radioTrackingService: IRadioTrackingService
|
val radioTrackingService: IRadioTrackingService
|
||||||
|
val locationRepo: ILocationRepo
|
||||||
val mutualPassData: StateFlow<MutualPassData>
|
val mutualPassData: StateFlow<MutualPassData>
|
||||||
|
/** Grid Finder → LoTW station page prefill, consumed once by that page. */
|
||||||
|
val pendingLoTWStationGrid: StateFlow<String?>
|
||||||
fun setMutualPassData(data: MutualPassData)
|
fun setMutualPassData(data: MutualPassData)
|
||||||
|
fun setPendingLoTWStationGrid(grid: String?)
|
||||||
fun provideAddToCalendar(): IAddToCalendar
|
fun provideAddToCalendar(): IAddToCalendar
|
||||||
fun provideShowToast(): IShowToast
|
fun provideShowToast(): IShowToast
|
||||||
fun provideBluetoothReporter(): IReporter
|
fun provideBluetoothReporter(): IReporter
|
||||||
|
|||||||
+15
@@ -65,4 +65,19 @@ interface ISatelliteRepo {
|
|||||||
|
|
||||||
/** Fetch radio transceivers for a satellite by its catalog number. */
|
/** Fetch radio transceivers for a satellite by its catalog number. */
|
||||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||||
|
|
||||||
|
/** 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 com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/** 指南针校准精度等级, 由磁场传感器 accuracy 事件映射而来. */
|
||||||
|
enum class CompassAccuracy {
|
||||||
|
UNAVAILABLE,
|
||||||
|
UNRELIABLE,
|
||||||
|
LOW,
|
||||||
|
MEDIUM,
|
||||||
|
HIGH
|
||||||
|
}
|
||||||
|
|
||||||
interface ISensorsRepo {
|
interface ISensorsRepo {
|
||||||
val sensorData: StateFlow<Pair<Float, Float>>
|
val sensorData: StateFlow<Pair<Float, Float>>
|
||||||
|
/** 指南针当前校准精度 (磁场传感器 accuracy), 用于校准对话框进度. */
|
||||||
|
val compassAccuracy: StateFlow<CompassAccuracy>
|
||||||
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
||||||
fun enableSensor()
|
fun enableSensor()
|
||||||
fun disableSensor()
|
fun disableSensor()
|
||||||
|
|||||||
+17
@@ -89,6 +89,12 @@ interface ISettingsRepo {
|
|||||||
fun setSatelliteOffset(catnum: Int, offset: String)
|
fun setSatelliteOffset(catnum: Int, offset: String)
|
||||||
//endregion
|
//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
|
//region # AMSAT status report settings
|
||||||
fun getAmSatCallsign(): String
|
fun getAmSatCallsign(): String
|
||||||
fun setAmSatCallsign(callsign: String)
|
fun setAmSatCallsign(callsign: String)
|
||||||
@@ -105,10 +111,21 @@ interface ISettingsRepo {
|
|||||||
/** Distinct 4-char gridsquares the account operated from (LoTW <MY_GRIDSQUARE>). */
|
/** Distinct 4-char gridsquares the account operated from (LoTW <MY_GRIDSQUARE>). */
|
||||||
fun getRoamedGrids(): Set<String>
|
fun getRoamedGrids(): Set<String>
|
||||||
fun setRoamedGrids(grids: 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
|
//endregion
|
||||||
|
|
||||||
//region # LoTW confirmed-grids settings
|
//region # LoTW confirmed-grids settings
|
||||||
val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
|
val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
|
||||||
fun updateLoTWSettings(settings: 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
|
//endregion
|
||||||
}
|
}
|
||||||
+18
@@ -1,3 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by 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
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.LatestRelease
|
import com.rtbishop.look4sat.core.domain.model.LatestRelease
|
||||||
|
|||||||
+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
|
||||||
|
}
|
||||||
@@ -46,3 +46,6 @@ sealed class LoTWResult {
|
|||||||
/** Any other network-level failure (DNS, TLS, connection reset, HTTP 5xx). */
|
/** Any other network-level failure (DNS, TLS, connection reset, HTTP 5xx). */
|
||||||
data class NetworkError(val detail: String) : LoTWResult()
|
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,6 +29,8 @@ interface ILocalSource {
|
|||||||
suspend fun insertEntries(entries: List<OrbitalData>)
|
suspend fun insertEntries(entries: List<OrbitalData>)
|
||||||
suspend fun deleteEntries()
|
suspend fun deleteEntries()
|
||||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
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 getRadiosTotal(): Int
|
||||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||||
suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean = false)
|
suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean = false)
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ object Sources {
|
|||||||
val satelliteDataUrls = mapOf(
|
val satelliteDataUrls = mapOf(
|
||||||
"LAPAN-A2" to "https://www.kaggle.com/api/v1/datasets/download/muazamnugroho/lapan-a2-satellite-two-line-element-tle-dataset/LAPAN-A2_TLE_latest.txt",
|
"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",
|
"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",
|
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||||
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
|
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
|
||||||
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
||||||
@@ -55,4 +57,25 @@ object Sources {
|
|||||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
|
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
|
||||||
"R4UAB" to "https://r4uab.ru/transmitters.json"
|
"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,193 @@
|
|||||||
|
/*
|
||||||
|
* 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/>.
|
||||||
|
*
|
||||||
|
* Contains code ported from OrbitDeckiOS (https://github.com/prstoetzer/OrbitDeckiOS),
|
||||||
|
* Copyright (c) 2025 Paul Stoetzer, N8HM, licensed under the MIT License.
|
||||||
|
* See THIRD_PARTY_NOTICES.md for the full MIT license text.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.utility
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.hypot
|
||||||
|
import kotlin.math.round
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grid-line / grid-corner geometry for the 4-character Maidenhead squares that
|
||||||
|
* VUCC credits, used by the Grid Finder field tool.
|
||||||
|
*
|
||||||
|
* A 4-character square is 1 degree tall (boundaries at integer latitudes) and
|
||||||
|
* 2 degrees wide (boundaries at even offsets from the -180 degree antimeridian),
|
||||||
|
* so a station's distance to the nearest VUCC line and corner is a matter of
|
||||||
|
* simple planar geometry at the station's latitude.
|
||||||
|
*
|
||||||
|
* Geometry ported from OrbitDeck for iOS (MIT License, (c) 2026 Paul Stoetzer,
|
||||||
|
* N8HM) - `GridGeometry` in `Views/HomeView.swift` and `vuccGrids` in
|
||||||
|
* `Engine/FeatureEngine.swift`. Golden values are pinned by GridGeometryTest.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** ARRL VUCC "20 ft rule": a fix within this distance of a boundary counts as on it. */
|
||||||
|
const val VUCC_BOUNDARY_TOLERANCE_METERS = 20.0 * 0.3048
|
||||||
|
|
||||||
|
/** One degree of latitude in metres; 4-character squares are 1 degree tall. */
|
||||||
|
private const val METERS_PER_DEGREE_LATITUDE = 111_320.0
|
||||||
|
|
||||||
|
/** Nudge used to name the square on the far side of a boundary (display only). */
|
||||||
|
private const val NEIGHBOUR_NUDGE_DEGREES = 0.0005
|
||||||
|
|
||||||
|
/** Nudge used by [vuccClaimableGrids] so the neighbour lands squarely across. */
|
||||||
|
private const val CLAIM_NEIGHBOUR_NUDGE_DEGREES = 0.001
|
||||||
|
|
||||||
|
private const val DEG_TO_RAD = 0.017453292519943295
|
||||||
|
private const val RAD_TO_DEG = 57.29577951308232
|
||||||
|
|
||||||
|
/** Where the nearest 4-character boundary lies relative to the fix. */
|
||||||
|
enum class GridBoundaryDirection { NORTH, SOUTH, EAST, WEST }
|
||||||
|
|
||||||
|
/** ARRL VUCC standing of a fix, derived from how many squares it may claim. */
|
||||||
|
enum class GridFixStatus { INSIDE_GRID, ON_GRID_LINE, ON_GRID_CORNER }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distance and bearing to the nearest 4-character grid corner and grid line,
|
||||||
|
* plus the locators of the squares that meet there. All distances are metres
|
||||||
|
* computed on a flat approximation local to the fix (the same approximation
|
||||||
|
* the source implementation uses; over the <=100 km distances involved the
|
||||||
|
* error is far below a GPS fix's own accuracy).
|
||||||
|
*/
|
||||||
|
data class GridGeometry(
|
||||||
|
val grid4: String,
|
||||||
|
val grid6: String,
|
||||||
|
val grid8: String,
|
||||||
|
val latLineMeters: Double,
|
||||||
|
val latLineDirection: GridBoundaryDirection,
|
||||||
|
val lonLineMeters: Double,
|
||||||
|
val lonLineDirection: GridBoundaryDirection,
|
||||||
|
val cornerMeters: Double,
|
||||||
|
val cornerBearingDegrees: Double,
|
||||||
|
val cornerGrids: List<String>,
|
||||||
|
val nearestLineMeters: Double,
|
||||||
|
val nearestLineBearingDegrees: Double,
|
||||||
|
val nearestLineIsLatitude: Boolean,
|
||||||
|
val nearestLineGrids: List<String>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The geometry of the VUCC (4-character) grid around a position, or null when
|
||||||
|
* the position is out of range.
|
||||||
|
*/
|
||||||
|
fun gridGeometry(latitude: Double, longitude: Double): GridGeometry? {
|
||||||
|
val grid4 = positionToGrid4(latitude, longitude) ?: return null
|
||||||
|
val grid6 = positionToQth(latitude, longitude) ?: return null
|
||||||
|
val grid8 = positionToGrid8(latitude, longitude) ?: return null
|
||||||
|
|
||||||
|
// 4-character boundaries: integer latitudes, even-degree longitudes.
|
||||||
|
val latBoundary = round(latitude)
|
||||||
|
val lonBoundary = round((longitude + 180.0) / 2.0) * 2.0 - 180.0
|
||||||
|
|
||||||
|
val metersPerDegreeLon = METERS_PER_DEGREE_LATITUDE * cos(latitude * DEG_TO_RAD)
|
||||||
|
val dNorth = (latBoundary - latitude) * METERS_PER_DEGREE_LATITUDE
|
||||||
|
val dEast = (lonBoundary - longitude) * metersPerDegreeLon
|
||||||
|
|
||||||
|
val otherLat = latBoundary - if (latitude >= latBoundary) NEIGHBOUR_NUDGE_DEGREES else -NEIGHBOUR_NUDGE_DEGREES
|
||||||
|
val otherLon = lonBoundary - if (longitude >= lonBoundary) NEIGHBOUR_NUDGE_DEGREES else -NEIGHBOUR_NUDGE_DEGREES
|
||||||
|
|
||||||
|
val cornerGrids = setOfNotNull(
|
||||||
|
positionToGrid4(latitude, longitude),
|
||||||
|
positionToGrid4(otherLat, longitude),
|
||||||
|
positionToGrid4(latitude, otherLon),
|
||||||
|
positionToGrid4(otherLat, otherLon)
|
||||||
|
).sorted()
|
||||||
|
|
||||||
|
val onLatitude = abs(dNorth) <= abs(dEast)
|
||||||
|
val nearestLineGrids = if (onLatitude) {
|
||||||
|
setOfNotNull(grid4, positionToGrid4(otherLat, longitude)).sorted()
|
||||||
|
} else {
|
||||||
|
setOfNotNull(grid4, positionToGrid4(latitude, otherLon)).sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
return GridGeometry(
|
||||||
|
grid4 = grid4,
|
||||||
|
grid6 = grid6,
|
||||||
|
grid8 = grid8,
|
||||||
|
latLineMeters = abs(dNorth),
|
||||||
|
latLineDirection = if (dNorth >= 0) GridBoundaryDirection.NORTH else GridBoundaryDirection.SOUTH,
|
||||||
|
lonLineMeters = abs(dEast),
|
||||||
|
lonLineDirection = if (dEast >= 0) GridBoundaryDirection.EAST else GridBoundaryDirection.WEST,
|
||||||
|
cornerMeters = hypot(dNorth, dEast),
|
||||||
|
cornerBearingDegrees = bearingDegrees(dNorth, dEast),
|
||||||
|
cornerGrids = cornerGrids,
|
||||||
|
nearestLineMeters = if (onLatitude) abs(dNorth) else abs(dEast),
|
||||||
|
nearestLineBearingDegrees = if (onLatitude) {
|
||||||
|
if (dNorth >= 0) 0.0 else 180.0
|
||||||
|
} else {
|
||||||
|
if (dEast >= 0) 90.0 else 270.0
|
||||||
|
},
|
||||||
|
nearestLineIsLatitude = onLatitude,
|
||||||
|
nearestLineGrids = nearestLineGrids
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 4-character squares a station at this position may claim under ARRL VUCC
|
||||||
|
* rules: normally one, but two when the fix is on the line between two squares
|
||||||
|
* and four when it is on the corner where four squares meet. A fix within
|
||||||
|
* [toleranceMeters] of a boundary is considered to be on it (the 20 ft rule).
|
||||||
|
*/
|
||||||
|
fun vuccClaimableGrids(
|
||||||
|
latitude: Double,
|
||||||
|
longitude: Double,
|
||||||
|
toleranceMeters: Double = VUCC_BOUNDARY_TOLERANCE_METERS
|
||||||
|
): List<String> {
|
||||||
|
val own = positionToGrid4(latitude, longitude) ?: return emptyList()
|
||||||
|
val grids = mutableSetOf(own)
|
||||||
|
|
||||||
|
val latBoundary = round(latitude)
|
||||||
|
val lonBoundary = round((longitude + 180.0) / 2.0) * 2.0 - 180.0
|
||||||
|
val metersPerDegreeLon = METERS_PER_DEGREE_LATITUDE * cos(latitude * DEG_TO_RAD)
|
||||||
|
|
||||||
|
val onLatitude = abs(latitude - latBoundary) * METERS_PER_DEGREE_LATITUDE <= toleranceMeters
|
||||||
|
val onLongitude = abs(longitude - lonBoundary) * metersPerDegreeLon <= toleranceMeters
|
||||||
|
if (!onLatitude && !onLongitude) return grids.sorted()
|
||||||
|
|
||||||
|
// Step just past the boundary so the neighbour's locator is unambiguous.
|
||||||
|
val otherLat = latBoundary - if (latitude >= latBoundary) CLAIM_NEIGHBOUR_NUDGE_DEGREES else -CLAIM_NEIGHBOUR_NUDGE_DEGREES
|
||||||
|
val otherLon = lonBoundary - if (longitude >= lonBoundary) CLAIM_NEIGHBOUR_NUDGE_DEGREES else -CLAIM_NEIGHBOUR_NUDGE_DEGREES
|
||||||
|
|
||||||
|
if (onLatitude) {
|
||||||
|
positionToGrid4(otherLat, longitude)?.let(grids::add)
|
||||||
|
}
|
||||||
|
if (onLongitude) {
|
||||||
|
positionToGrid4(latitude, otherLon)?.let(grids::add)
|
||||||
|
}
|
||||||
|
if (onLatitude && onLongitude) {
|
||||||
|
positionToGrid4(otherLat, otherLon)?.let(grids::add)
|
||||||
|
}
|
||||||
|
return grids.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** VUCC standing implied by the number of squares a fix may claim. */
|
||||||
|
fun gridFixStatus(claimableGrids: List<String>): GridFixStatus = when {
|
||||||
|
claimableGrids.size >= 4 -> GridFixStatus.ON_GRID_CORNER
|
||||||
|
claimableGrids.size >= 2 -> GridFixStatus.ON_GRID_LINE
|
||||||
|
else -> GridFixStatus.INSIDE_GRID
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bearingDegrees(dNorth: Double, dEast: Double): Double {
|
||||||
|
val bearing = atan2(dEast, dNorth) * RAD_TO_DEG
|
||||||
|
return if (bearing < 0.0) bearing + 360.0 else bearing
|
||||||
|
}
|
||||||
@@ -69,6 +69,45 @@ fun positionToQth(latitude: Double, longitude: Double): String? {
|
|||||||
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
|
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a position to its 4-char Maidenhead locator (field + square), the
|
||||||
|
* square VUCC credits, upper-cased. Returns null outside the Maidenhead grid
|
||||||
|
* (lat >= 90 is out of range: the top row is 80..90 N). The longitude is
|
||||||
|
* wrapped into [-180, 180) first, so fixes either side of the antimeridian
|
||||||
|
* resolve to the square they are actually in.
|
||||||
|
*/
|
||||||
|
fun positionToGrid4(latitude: Double, longitude: Double): String? {
|
||||||
|
if (latitude < -90.0 || latitude >= 90.0) return null
|
||||||
|
val normalizedLon = normalizeLongitude(longitude)
|
||||||
|
val fieldLat = ((latitude + 90.0) / 10.0).toInt().coerceIn(0, 17)
|
||||||
|
val fieldLon = ((normalizedLon + 180.0) / 20.0).toInt().coerceIn(0, 17)
|
||||||
|
val subLat = ((latitude + 90.0) % 10.0).toInt()
|
||||||
|
val subLon = ((normalizedLon + 180.0) % 20.0 / 2.0).toInt()
|
||||||
|
return "${'A' + fieldLon}${'A' + fieldLat}$subLon$subLat"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a position to its 8-char Maidenhead locator: the 6-char locator plus
|
||||||
|
* the numeric extended-square pair (the Maidenhead maximum). Returns null for
|
||||||
|
* out-of-range positions.
|
||||||
|
*/
|
||||||
|
fun positionToGrid8(latitude: Double, longitude: Double): String? {
|
||||||
|
val normalizedLon = normalizeLongitude(longitude)
|
||||||
|
val base = positionToQth(latitude, normalizedLon) ?: return null
|
||||||
|
val latitudeOffset = latitude.coerceIn(-90.0, 89.999999) + 90.0
|
||||||
|
val longitudeOffset = normalizedLon.coerceIn(-180.0, 179.999999) + 180.0
|
||||||
|
val subLon = 2.0 / 24.0
|
||||||
|
val subLat = 1.0 / 24.0
|
||||||
|
val lonDigit = minOf(9, ((longitudeOffset % subLon) / subLon * 10).toInt())
|
||||||
|
val latDigit = minOf(9, ((latitudeOffset % subLat) / subLat * 10).toInt())
|
||||||
|
return "$base$lonDigit$latDigit"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap any longitude into [-180, 180). */
|
||||||
|
private fun normalizeLongitude(longitude: Double): Double {
|
||||||
|
return ((longitude + 180.0) % 360.0 + 360.0) % 360.0 - 180.0
|
||||||
|
}
|
||||||
|
|
||||||
private fun isValidPosition(lat: Double, lon: Double): Boolean {
|
private fun isValidPosition(lat: Double, lon: Double): Boolean {
|
||||||
return (lat >= -90.0 && lat <= 90.0) && (lon >= -180.0 && lon <= 360.0)
|
return (lat >= -90.0 && lat <= 90.0) && (lon >= -180.0 && lon <= 360.0)
|
||||||
}
|
}
|
||||||
|
|||||||
+18
@@ -1,3 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by 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.utility
|
package com.rtbishop.look4sat.core.domain.utility
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.utility
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/** Nominal uplink frequency: the middle of the passband when one is published. */
|
||||||
|
fun SatRadio.uplinkHz(): Long? = uplinkLow?.let { low -> uplinkHigh?.let { high -> (low + high) / 2 } ?: low }
|
||||||
|
|
||||||
|
/** Nominal downlink frequency: the middle of the passband when one is published. */
|
||||||
|
fun SatRadio.downlinkHz(): Long? = downlinkLow?.let { low -> downlinkHigh?.let { high -> (low + high) / 2 } ?: low }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The FM voice repeater of a satellite, if it has one.
|
||||||
|
*
|
||||||
|
* A voice repeater is the single-frequency FM transceiver the operator actually
|
||||||
|
* talks through (SatNOGS type "Transceiver", e.g. SO-50 "Mode V/U FM Voice
|
||||||
|
* CTCSS 67.0 Hz"). Beacons and telemetry carry no uplink, so requiring BOTH an
|
||||||
|
* uplink and a downlink already filters them out; the FM mode check removes
|
||||||
|
* AFSK/APRS and linear entries.
|
||||||
|
*
|
||||||
|
* Ranking when several FM duplex entries exist (ISS is the awkward case, it also
|
||||||
|
* lists crew V/V channels):
|
||||||
|
* 1. entries described as a repeater / voice channel,
|
||||||
|
* 2. amateur service entries,
|
||||||
|
* 3. cross-band pairs (a V/U repeater over a same-band V/V downlink).
|
||||||
|
*/
|
||||||
|
fun List<SatRadio>.voiceRepeater(): SatRadio? = asSequence()
|
||||||
|
.filter { it.isAlive }
|
||||||
|
.filter { it.uplinkHz() != null && it.downlinkHz() != null }
|
||||||
|
.filter { it.uplinkMode.equals("FM", true) || it.downlinkMode.equals("FM", true) }
|
||||||
|
.sortedWith(
|
||||||
|
compareByDescending<SatRadio> { it.info.contains("repeater", true) || it.info.contains("voice", true) }
|
||||||
|
.thenByDescending { it.service.equals("Amateur", true) }
|
||||||
|
.thenByDescending { abs((it.downlinkHz() ?: 0L) - (it.uplinkHz() ?: 0L)) }
|
||||||
|
)
|
||||||
|
.firstOrNull()
|
||||||
+18
@@ -1,3 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
package com.rtbishop.look4sat.core.domain
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
|||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.LoTWSatelliteAliases
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** Alias table that maps tracker catalogue names onto the names ARRL's config.tq6 uses. */
|
||||||
|
class LoTWSatelliteAliasesTest {
|
||||||
|
|
||||||
|
private val catalogue = listOf("SO-50", "ARISS", "PO-101", "BO-102", "AO-91", "RS-44", "IO-117")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `lookup maps SatNOGS catalogue names to ARRL names`() {
|
||||||
|
assertEquals("SO-50", LoTWSatelliteAliases.lookup("SAUDISAT 1C"))
|
||||||
|
assertEquals("ARISS", LoTWSatelliteAliases.lookup("ISS (ZARYA)"))
|
||||||
|
assertEquals("PO-101", LoTWSatelliteAliases.lookup("DIWATA 2B"))
|
||||||
|
assertEquals("AO-91", LoTWSatelliteAliases.lookup("FOX-1B"))
|
||||||
|
assertEquals("RS-44", LoTWSatelliteAliases.lookup("DOSAAF-85"))
|
||||||
|
assertEquals("IO-117", LoTWSatelliteAliases.lookup("GREENCUBE"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `normalize strips separators and case`() {
|
||||||
|
assertEquals("ISSZARYA", LoTWSatelliteAliases.normalize("iss (zarya)"))
|
||||||
|
assertEquals("SAUDISAT1C", LoTWSatelliteAliases.normalize("SaudiSat-1C"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `lookup ignores unknown and placeholder names`() {
|
||||||
|
assertNull(LoTWSatelliteAliases.lookup("NOT A SATELLITE"))
|
||||||
|
// Recycled temporary designations must never resolve: they would sign the wrong object.
|
||||||
|
assertNull(LoTWSatelliteAliases.lookup("OBJECT AY"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolve accepts ARRL names, aliases and suffixed tracker names`() {
|
||||||
|
assertEquals("SO-50", LoTWSatelliteAliases.resolve("SO-50", catalogue))
|
||||||
|
assertEquals("SO-50", LoTWSatelliteAliases.resolve("saudisat 1c", catalogue))
|
||||||
|
assertEquals("SO-50", LoTWSatelliteAliases.resolve("SAUDISAT 1C (SO-50)", catalogue))
|
||||||
|
assertEquals("ARISS", LoTWSatelliteAliases.resolve("ISS (ZARYA)", catalogue))
|
||||||
|
assertEquals("AO-91", LoTWSatelliteAliases.resolve("AO-91 (RadFxSat)", catalogue))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolve rejects names that are not ARRL satellites`() {
|
||||||
|
assertNull(LoTWSatelliteAliases.resolve("", catalogue))
|
||||||
|
assertNull(LoTWSatelliteAliases.resolve("MARINA", catalogue))
|
||||||
|
assertNull(LoTWSatelliteAliases.resolve("OBJECT AY", catalogue))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `suggestions find the ARRL name from a tracker spelling`() {
|
||||||
|
assertEquals(listOf("SO-50"), LoTWSatelliteAliases.suggestions("saudisat 1c", catalogue))
|
||||||
|
assertTrue(LoTWSatelliteAliases.suggestions("ao9", catalogue).contains("AO-91"))
|
||||||
|
assertEquals(listOf("PO-101"), LoTWSatelliteAliases.suggestions("diwata", catalogue))
|
||||||
|
assertTrue(LoTWSatelliteAliases.suggestions("", catalogue).isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
package com.rtbishop.look4sat.core.domain
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||||
|
|||||||
@@ -1,3 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
package com.rtbishop.look4sat.core.domain
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
|||||||
@@ -1,3 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
package com.rtbishop.look4sat.core.domain
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.downlinkHz
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.uplinkHz
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.voiceRepeater
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** Fixtures are the real SatNOGS transceiver rows of the three hardcoded AMSAT Live FM
|
||||||
|
* satellites (fetched 2026-09-27), so the selection rules are tested against live data. */
|
||||||
|
class VoiceRepeaterTest {
|
||||||
|
|
||||||
|
private fun radio(
|
||||||
|
uuid: String,
|
||||||
|
info: String,
|
||||||
|
up: Long? = null,
|
||||||
|
upHigh: Long? = null,
|
||||||
|
down: Long? = null,
|
||||||
|
downHigh: Long? = null,
|
||||||
|
upMode: String? = null,
|
||||||
|
downMode: String? = null,
|
||||||
|
alive: Boolean = true,
|
||||||
|
service: String? = null,
|
||||||
|
catnum: Int = 12345
|
||||||
|
) = SatRadio(
|
||||||
|
uuid = uuid, info = info, isAlive = alive,
|
||||||
|
downlinkLow = down, downlinkHigh = downHigh, downlinkMode = downMode,
|
||||||
|
uplinkLow = up, uplinkHigh = upHigh, uplinkMode = upMode,
|
||||||
|
isInverted = false, catnum = catnum, service = service
|
||||||
|
)
|
||||||
|
|
||||||
|
// SO-50 (27607)
|
||||||
|
private val so50Tlm = radio("so50-tlm", "Mode V TLM", down = 149_025_000L, downMode = "FM")
|
||||||
|
private val so50Voice = radio(
|
||||||
|
"so50-voice", "Mode V/U FM Voice CTCSS 67.0 Hz",
|
||||||
|
up = 145_850_000L, down = 436_795_000L, upMode = "FM", downMode = "FM", catnum = 27607
|
||||||
|
)
|
||||||
|
|
||||||
|
// ISS (25544): a voice repeater plus two crew V/V channels and an APRS transceiver
|
||||||
|
private val issCrew2 = radio(
|
||||||
|
"iss-crew2", "Mode V/V FM (crew R2+3)",
|
||||||
|
up = 144_490_000L, down = 145_800_000L, upMode = "FM", downMode = "FM", catnum = 25544
|
||||||
|
)
|
||||||
|
private val issCrew1 = radio(
|
||||||
|
"iss-crew1", "Mode V/V FM (crew R1)",
|
||||||
|
up = 145_200_000L, down = 145_800_000L, upMode = "FM", downMode = "FM", catnum = 25544
|
||||||
|
)
|
||||||
|
private val issVoice = radio(
|
||||||
|
"iss-voice", "Mode V/U FM - Voice Repeater CTCSS 67.0 Hz",
|
||||||
|
up = 145_990_000L, down = 437_800_000L, upMode = "FM", downMode = "FM",
|
||||||
|
service = "Amateur", catnum = 25544
|
||||||
|
)
|
||||||
|
private val issAprs = radio(
|
||||||
|
"iss-aprs", "Mode V APRS",
|
||||||
|
up = 145_825_000L, down = 145_825_000L, upMode = "AFSK", downMode = "AFSK", catnum = 25544
|
||||||
|
)
|
||||||
|
|
||||||
|
// AO-123 / ASRTU-1 (61781)
|
||||||
|
private val ao123Voice = radio(
|
||||||
|
"ao123-voice", "Mode V/U - FM Transceiver",
|
||||||
|
up = 145_850_000L, down = 435_400_000L, upMode = "FM", downMode = "FM",
|
||||||
|
service = "Amateur", catnum = 61781
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun voiceRepeater_picksSo50VoicePair() {
|
||||||
|
val repeater = listOf(so50Tlm, so50Voice).voiceRepeater()
|
||||||
|
assertEquals("so50-voice", repeater?.uuid)
|
||||||
|
assertEquals(145_850_000L, repeater?.uplinkHz())
|
||||||
|
assertEquals(436_795_000L, repeater?.downlinkHz())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun voiceRepeater_prefersVoiceChannelOverCrewChannels() {
|
||||||
|
val transmitterList = listOf(issCrew2, issAprs, issVoice, issCrew1)
|
||||||
|
// Order must not matter - ranking decides.
|
||||||
|
assertEquals("iss-voice", transmitterList.voiceRepeater()?.uuid)
|
||||||
|
assertEquals("iss-voice", transmitterList.reversed().voiceRepeater()?.uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun voiceRepeater_picksAo123Transceiver() {
|
||||||
|
val repeater = listOf(ao123Voice).voiceRepeater()
|
||||||
|
assertEquals(145_850_000L, repeater?.uplinkHz())
|
||||||
|
assertEquals(435_400_000L, repeater?.downlinkHz())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun voiceRepeater_ignoresBeaconsWithoutUplink() {
|
||||||
|
assertNull(listOf(so50Tlm).voiceRepeater())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun voiceRepeater_ignoresDeadEntries() {
|
||||||
|
val dead = so50Voice.copy(uuid = "dead", isAlive = false)
|
||||||
|
assertNull(listOf(dead).voiceRepeater())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun voiceRepeater_ignoresLinearTransponders() {
|
||||||
|
val linear = radio(
|
||||||
|
"lin", "Linear Transponder",
|
||||||
|
up = 145_900_000L, upHigh = 145_950_000L, down = 435_600_000L, downHigh = 435_650_000L,
|
||||||
|
upMode = "LSB", downMode = "USB"
|
||||||
|
)
|
||||||
|
assertNull(listOf(linear).voiceRepeater())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun uplinkHz_usesPassbandMiddleWhenPresent() {
|
||||||
|
val wide = so50Voice.copy(uuid = "wide", uplinkLow = 145_840_000L, uplinkHigh = 145_860_000L)
|
||||||
|
assertEquals(145_850_000L, wide.uplinkHz())
|
||||||
|
assertEquals(145_850_000L, so50Voice.uplinkHz())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun downlinkHz_isNullWhenNoDownlink() {
|
||||||
|
assertNull(so50Voice.copy(downlinkLow = null).downlinkHz())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+273
@@ -0,0 +1,273 @@
|
|||||||
|
/*
|
||||||
|
* 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.assertNotEquals
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// region logbook split (tracker name / band direction, 2026-09-28)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun satelliteIdentity_mapsTrackerNamesOntoArlNames() {
|
||||||
|
// Local records keep the tracking-source name, LoTW reports the ARRL name; both
|
||||||
|
// must land on one identity or every confirmation becomes a second row.
|
||||||
|
assertEquals("SO-50", satelliteIdentity("SAUDISAT 1C"))
|
||||||
|
assertEquals("SO-50", satelliteIdentity("SO-50"))
|
||||||
|
assertEquals("ARISS", satelliteIdentity("ISS (ZARYA)"))
|
||||||
|
assertEquals("ARISS", satelliteIdentity("ARISS"))
|
||||||
|
assertEquals("PO-101", satelliteIdentity("DIWATA 2B"))
|
||||||
|
assertEquals("FO-29", satelliteIdentity("JAS 2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun satelliteIdentity_isSymmetricForEveryAliasEntry() {
|
||||||
|
// Guards the whole table: an entry whose target does not resolve back to the same
|
||||||
|
// identity would split that satellite's contacts again.
|
||||||
|
LoTWSatelliteAliases.table.forEach { (tracker, official) ->
|
||||||
|
assertEquals(official, satelliteIdentity(tracker), satelliteIdentity(official))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun satelliteIdentity_keepsRecycledPlaceholdersUnmatched() {
|
||||||
|
// "OBJECT xx" names are reused between objects and deliberately unmapped, so they
|
||||||
|
// must never resolve onto a real ARRL satellite.
|
||||||
|
assertEquals("OBJECT AY", satelliteIdentity("OBJECT AY"))
|
||||||
|
assertNotEquals(satelliteIdentity("AO-123"), satelliteIdentity("OBJECT AY"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sameConfirmedContact_matchesTrackerNameAgainstArlName() {
|
||||||
|
// The reported bug: SO-50 logged in the app (tracker name, 145.850 uplink) never
|
||||||
|
// picked up the confirmation LoTW reported for it.
|
||||||
|
val uploaded = loggedRecord("SAUDISAT 1C", uplinkHz = 145_850_000L, downlinkHz = 436_795_000L)
|
||||||
|
val reported = reportedRecord("SO-50", band = "2M", rxBand = "70CM")
|
||||||
|
assertTrue(sameConfirmedContact(uploaded, reported))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sameConfirmedContact_stillRejectsOtherContactOrMinute() {
|
||||||
|
val uploaded = loggedRecord("SAUDISAT 1C", uplinkHz = 145_850_000L, downlinkHz = 436_795_000L)
|
||||||
|
val otherOperator = reportedRecord("SO-50", band = "2M", rxBand = "70CM").copy(theirCallsign = "BG5JVM")
|
||||||
|
val anotherMinute = reportedRecord("SO-50", band = "2M", rxBand = "70CM")
|
||||||
|
.copy(startUtcMillis = uploaded.startUtcMillis + 90_000L)
|
||||||
|
assertFalse(sameConfirmedContact(uploaded, otherOperator))
|
||||||
|
assertFalse(sameConfirmedContact(uploaded, anotherMinute))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun splitConfirmationPairs_foldsRowsSplitByTheOldMismatch() {
|
||||||
|
// Both pre-fix symptoms in one pair: the imported row keeps the ARRL name and the
|
||||||
|
// mirrored band direction the old parser produced (band=downlink).
|
||||||
|
val uploaded = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
|
||||||
|
.copy(id = 1L, lotwUploaded = true)
|
||||||
|
val imported = reportedRecord("SO-50", band = "70CM", rxBand = "2M").copy(id = 2L)
|
||||||
|
val pairs = splitConfirmationPairs(listOf(uploaded, imported))
|
||||||
|
assertEquals(1, pairs.size)
|
||||||
|
assertEquals(0, pairs.first().localIndex)
|
||||||
|
assertEquals(1, pairs.first().confirmationIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun splitConfirmationPairs_keepsUnrelatedRowsApart() {
|
||||||
|
val uploaded = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
|
||||||
|
.copy(id = 1L, lotwUploaded = true)
|
||||||
|
// Another operator on another satellite in the same minute must not be folded in.
|
||||||
|
val otherOperator = reportedRecord("SO-50", band = "70CM", rxBand = "2M")
|
||||||
|
.copy(id = 2L, theirCallsign = "BG5JVM")
|
||||||
|
// A contact on a satellite the local record does not name at all stays separate.
|
||||||
|
val otherSatellite = reportedRecord("AO-91", band = "70CM", rxBand = "2M").copy(id = 3L)
|
||||||
|
assertTrue(splitConfirmationPairs(listOf(uploaded, otherOperator, otherSatellite)).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A record as the log page creates it: tracker name, uplink/downlink bands, no flags. */
|
||||||
|
@Test
|
||||||
|
fun officialSatelliteName_storesTheArlName() {
|
||||||
|
assertEquals("SO-50", officialSatelliteName("SAUDISAT 1C"))
|
||||||
|
assertEquals("ARISS", officialSatelliteName("ISS (ZARYA)"))
|
||||||
|
assertEquals("PO-101", officialSatelliteName("DIWATA 2B"))
|
||||||
|
assertEquals("FO-29", officialSatelliteName("JAS 2"))
|
||||||
|
// Already-ARRL names are left alone (the rewrite is idempotent).
|
||||||
|
assertEquals("SO-50", officialSatelliteName("SO-50"))
|
||||||
|
assertEquals("AO-123", officialSatelliteName("AO-123"))
|
||||||
|
// Nothing to map onto: a satellite ARRL does not list, a recycled placeholder, a blank.
|
||||||
|
assertEquals("FOO-1", officialSatelliteName("FOO-1"))
|
||||||
|
assertEquals("OBJECT AY", officialSatelliteName("OBJECT AY"))
|
||||||
|
assertEquals("", officialSatelliteName(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun officialSatelliteName_usesTheCatalogueWhenItKnowsTheNameTheTrackerCarries() {
|
||||||
|
val catalogue = listOf("BO-102", "SO-50")
|
||||||
|
// "CAS-7B" resolves through the alias table, the description-only name through the catalogue.
|
||||||
|
assertEquals("BO-102", officialSatelliteName("CAS-7B", catalogue))
|
||||||
|
assertEquals("BO-102", officialSatelliteName("CAS-7B (BO-102)", catalogue))
|
||||||
|
assertEquals("SO-50", officialSatelliteName("SAUDISAT 1C", catalogue))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loggedRecord(satelliteName: String, uplinkHz: Long, downlinkHz: Long) = QsoRecord(
|
||||||
|
startUtcMillis = REPORTED_START,
|
||||||
|
theirCallsign = "BG5JSB",
|
||||||
|
myCallsign = "BA7OPF",
|
||||||
|
txFrequencyHz = uplinkHz,
|
||||||
|
rxFrequencyHz = downlinkHz,
|
||||||
|
band = frequencyBand(uplinkHz),
|
||||||
|
rxBand = frequencyBand(downlinkHz),
|
||||||
|
mode = "FM",
|
||||||
|
satelliteName = satelliteName,
|
||||||
|
propagationMode = "SAT",
|
||||||
|
status = QsoStatus.COMPLETE,
|
||||||
|
lotwUploaded = true
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A record as a LoTW confirmation carries it: ARRL name, report bands, confirmed. */
|
||||||
|
private fun reportedRecord(satelliteName: String, band: String, rxBand: String) = QsoRecord(
|
||||||
|
startUtcMillis = REPORTED_START,
|
||||||
|
theirCallsign = "BG5JSB",
|
||||||
|
myCallsign = "BA7OPF",
|
||||||
|
band = band,
|
||||||
|
rxBand = rxBand,
|
||||||
|
mode = "FM",
|
||||||
|
satelliteName = satelliteName,
|
||||||
|
propagationMode = "SAT",
|
||||||
|
status = QsoStatus.COMPLETE,
|
||||||
|
lotwConfirmed = true
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** 2026-09-16 07:45Z — an SO-50 pass of the user's real report. */
|
||||||
|
const val REPORTED_START = 1_789_544_700_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
}
|
||||||
+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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
/*
|
||||||
|
* 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.utility
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Golden values were produced by a line-by-line port of the reference
|
||||||
|
* implementation (OrbitDeck for iOS, MIT) covering four hemispheres/quadrant
|
||||||
|
* cases plus the two awkward longitudes: the Greenwich meridian and the
|
||||||
|
* antimeridian.
|
||||||
|
*/
|
||||||
|
class GridGeometryTest {
|
||||||
|
|
||||||
|
private val meterDelta = 0.5
|
||||||
|
private val bearingDelta = 0.05
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `station at home square reports both boundaries and the corner`() {
|
||||||
|
val geometry = requireNotNull(gridGeometry(22.3542, 113.6250))
|
||||||
|
assertEquals("OL62", geometry.grid4)
|
||||||
|
assertEquals("OL62TI", geometry.grid6)
|
||||||
|
assertEquals("OL62TI55", geometry.grid8)
|
||||||
|
assertEquals(39429.5, geometry.latLineMeters, meterDelta)
|
||||||
|
assertEquals(GridBoundaryDirection.SOUTH, geometry.latLineDirection)
|
||||||
|
assertEquals(38607.9, geometry.lonLineMeters, meterDelta)
|
||||||
|
assertEquals(GridBoundaryDirection.EAST, geometry.lonLineDirection)
|
||||||
|
assertEquals(55183.8, geometry.cornerMeters, meterDelta)
|
||||||
|
assertEquals(135.60, geometry.cornerBearingDegrees, bearingDelta)
|
||||||
|
assertEquals(listOf("OL61", "OL62", "OL71", "OL72"), geometry.cornerGrids)
|
||||||
|
assertEquals(38607.9, geometry.nearestLineMeters, meterDelta)
|
||||||
|
assertEquals(90.0, geometry.nearestLineBearingDegrees, bearingDelta)
|
||||||
|
assertEquals(false, geometry.nearestLineIsLatitude)
|
||||||
|
assertEquals(listOf("OL62", "OL72"), geometry.nearestLineGrids)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fix on the 114 east line sits on the boundary itself`() {
|
||||||
|
val geometry = requireNotNull(gridGeometry(22.3542, 114.0000))
|
||||||
|
assertEquals("OL72", geometry.grid4)
|
||||||
|
assertEquals("OL72AI", geometry.grid6)
|
||||||
|
assertEquals("OL72AI05", geometry.grid8)
|
||||||
|
assertEquals(39429.5, geometry.latLineMeters, meterDelta)
|
||||||
|
assertEquals(0.0, geometry.lonLineMeters, meterDelta)
|
||||||
|
assertEquals(39429.5, geometry.cornerMeters, meterDelta)
|
||||||
|
assertEquals(180.00, geometry.cornerBearingDegrees, bearingDelta)
|
||||||
|
assertEquals(listOf("OL62", "OL72"), geometry.nearestLineGrids)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fix on the 22 north 114 east corner names all four squares`() {
|
||||||
|
val geometry = requireNotNull(gridGeometry(22.00002, 114.00003))
|
||||||
|
assertEquals("OL72", geometry.grid4)
|
||||||
|
assertEquals(2.2, geometry.latLineMeters, meterDelta)
|
||||||
|
assertEquals(3.1, geometry.lonLineMeters, meterDelta)
|
||||||
|
assertEquals(3.8, geometry.cornerMeters, meterDelta)
|
||||||
|
assertEquals(234.28, geometry.cornerBearingDegrees, bearingDelta)
|
||||||
|
assertEquals(listOf("OL61", "OL62", "OL71", "OL72"), geometry.cornerGrids)
|
||||||
|
assertEquals("OL71", geometry.nearestLineGrids.first())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `greenwich meridian fix reports a zero distance latitude line to the north`() {
|
||||||
|
val geometry = requireNotNull(gridGeometry(45.0000, -0.0004))
|
||||||
|
assertEquals("IN95", geometry.grid4)
|
||||||
|
assertEquals("IN95XA", geometry.grid6)
|
||||||
|
assertEquals("IN95XA90", geometry.grid8)
|
||||||
|
assertEquals(0.0, geometry.latLineMeters, meterDelta)
|
||||||
|
assertEquals(GridBoundaryDirection.NORTH, geometry.latLineDirection)
|
||||||
|
assertEquals(31.5, geometry.lonLineMeters, meterDelta)
|
||||||
|
assertEquals(listOf("IN94", "IN95", "JN04", "JN05"), geometry.cornerGrids)
|
||||||
|
assertEquals(true, geometry.nearestLineIsLatitude)
|
||||||
|
assertEquals(listOf("IN94", "IN95"), geometry.nearestLineGrids)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `antimeridian fix wraps into the eastern square`() {
|
||||||
|
val geometry = requireNotNull(gridGeometry(0.05, 179.9998))
|
||||||
|
assertEquals("RJ90", geometry.grid4)
|
||||||
|
assertEquals("RJ90XB", geometry.grid6)
|
||||||
|
assertEquals("RJ90XB92", geometry.grid8)
|
||||||
|
assertEquals(22.3, geometry.lonLineMeters, meterDelta)
|
||||||
|
assertEquals(GridBoundaryDirection.EAST, geometry.lonLineDirection)
|
||||||
|
assertEquals(179.77, geometry.cornerBearingDegrees, bearingDelta)
|
||||||
|
// The corner's four squares straddle the antimeridian, so the western
|
||||||
|
// pair rolls over to the A-series fields.
|
||||||
|
assertEquals(listOf("AI09", "AJ00", "RI99", "RJ90"), geometry.cornerGrids)
|
||||||
|
assertEquals(listOf("AJ00", "RJ90"), geometry.nearestLineGrids)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `southern hemisphere fix keeps its bearings right way round`() {
|
||||||
|
val geometry = requireNotNull(gridGeometry(-33.86, 151.21))
|
||||||
|
assertEquals("QF56", geometry.grid4)
|
||||||
|
assertEquals("QF56OD", geometry.grid6)
|
||||||
|
assertEquals(15584.8, geometry.latLineMeters, meterDelta)
|
||||||
|
assertEquals(GridBoundaryDirection.SOUTH, geometry.latLineDirection)
|
||||||
|
assertEquals(73027.8, geometry.lonLineMeters, meterDelta)
|
||||||
|
assertEquals(GridBoundaryDirection.EAST, geometry.lonLineDirection)
|
||||||
|
assertEquals(102.05, geometry.cornerBearingDegrees, bearingDelta)
|
||||||
|
assertEquals(180.0, geometry.nearestLineBearingDegrees, bearingDelta)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `out of range positions yield no geometry`() {
|
||||||
|
assertNull(gridGeometry(90.0, 0.0))
|
||||||
|
assertNull(gridGeometry(-91.0, 0.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `vucc claims one square when the fix is inside`() {
|
||||||
|
assertEquals(listOf("OL62"), vuccClaimableGrids(22.3542, 113.6250))
|
||||||
|
assertEquals(GridFixStatus.INSIDE_GRID, gridFixStatus(vuccClaimableGrids(22.3542, 113.6250)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `vucc claims both squares on a line`() {
|
||||||
|
val claimed = vuccClaimableGrids(22.3542, 114.0000)
|
||||||
|
assertEquals(listOf("OL62", "OL72"), claimed)
|
||||||
|
assertEquals(GridFixStatus.ON_GRID_LINE, gridFixStatus(claimed))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `vucc claims all four squares on a corner`() {
|
||||||
|
val claimed = vuccClaimableGrids(22.00002, 114.00003)
|
||||||
|
assertEquals(listOf("OL61", "OL62", "OL71", "OL72"), claimed)
|
||||||
|
assertEquals(GridFixStatus.ON_GRID_CORNER, gridFixStatus(claimed))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `vucc claims nothing extra just outside the 20 foot tolerance`() {
|
||||||
|
// Same corner, but ~38 m away: outside the 20 ft (6.096 m) rule.
|
||||||
|
val claimed = vuccClaimableGrids(22.0002, 114.0003)
|
||||||
|
assertEquals(listOf("OL72"), claimed)
|
||||||
|
assertEquals(GridFixStatus.INSIDE_GRID, gridFixStatus(claimed))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `vucc tolerance boundary is 20 feet`() {
|
||||||
|
assertEquals(6.096, VUCC_BOUNDARY_TOLERANCE_METERS, 0.0001)
|
||||||
|
// 5 m north of the 22 N line: inside the tolerance for a custom, tighter
|
||||||
|
// call, outside it when the caller demands sub-metre precision.
|
||||||
|
assertTrue(vuccClaimableGrids(22.00005, 113.62).size >= 2)
|
||||||
|
assertEquals(1, vuccClaimableGrids(22.00005, 113.62, toleranceMeters = 1.0).size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `grid4 ignores longitudes beyond the antimeridian`() {
|
||||||
|
assertNull(positionToGrid4(91.0, 0.0))
|
||||||
|
// A longitude of 200 degrees is the same meridian as -160 degrees.
|
||||||
|
assertEquals(positionToGrid4(30.0, -160.0), positionToGrid4(30.0, 200.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
+154
-1
@@ -17,9 +17,15 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.presentation
|
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.MarqueeSpacing
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.basicMarquee
|
import androidx.compose.foundation.basicMarquee
|
||||||
import androidx.compose.foundation.clickable
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
@@ -49,16 +57,28 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
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.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
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.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.hideFromAccessibility
|
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||||
@@ -82,6 +102,8 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
|||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
|
|
||||||
@@ -375,7 +397,10 @@ fun SharedDialog(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.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) {
|
if (onAccept != null) {
|
||||||
CardButton(
|
CardButton(
|
||||||
@@ -444,6 +469,134 @@ fun isVerticalLayout(): Boolean = !hasEnoughWidth()
|
|||||||
fun Modifier.infiniteMarquee(): Modifier =
|
fun Modifier.infiniteMarquee(): Modifier =
|
||||||
basicMarquee(iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(16.dp))
|
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
|
@Composable
|
||||||
fun Modifier.layoutPadding(): Modifier {
|
fun Modifier.layoutPadding(): Modifier {
|
||||||
val spacing = LocalSpacing.current.extraSmall
|
val spacing = LocalSpacing.current.extraSmall
|
||||||
|
|||||||
+18
@@ -1,3 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by 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.presentation
|
package com.rtbishop.look4sat.core.presentation
|
||||||
|
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
|
|||||||
+3
-2
@@ -61,8 +61,9 @@ fun MainTheme(isDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable
|
|||||||
SideEffect {
|
SideEffect {
|
||||||
val window = (view.context as ComponentActivity).window
|
val window = (view.context as ComponentActivity).window
|
||||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||||
insetsController.isAppearanceLightStatusBars = false
|
// Light theme needs dark status/nav bar icons; the dark theme needs light ones.
|
||||||
insetsController.isAppearanceLightNavigationBars = false
|
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||||
|
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
window.isNavigationBarContrastEnforced = false
|
window.isNavigationBarContrastEnforced = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object RadarDestination : NavKey
|
data object RadarDestination : NavKey
|
||||||
|
|
||||||
|
/** Full-screen field tool: guides the operator onto a VUCC grid line or corner. */
|
||||||
|
@Serializable
|
||||||
|
data object GridFinderDestination : NavKey
|
||||||
|
|
||||||
|
/** LoTW upload certificate + station location page, reached from the Grid Finder. */
|
||||||
|
@Serializable
|
||||||
|
data object LoTWUploadDestination : NavKey
|
||||||
|
|
||||||
interface IDeeplinkMatcher {
|
interface IDeeplinkMatcher {
|
||||||
fun match(deeplink: String): NavKey?
|
fun match(deeplink: String): NavKey?
|
||||||
}
|
}
|
||||||
|
|||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
/*
|
||||||
|
* 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.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fork's Project Wiki: a short overview of what Look4Sat-BA7OPF adds over upstream
|
||||||
|
* and how to use those features. Shown in the What's-New card (first launch after an
|
||||||
|
* update) and behind the Settings → Project Wiki entry. Text lives in string resources,
|
||||||
|
* so it always renders even when the GitHub page is unreachable.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ProjectWikiBody(modifier: Modifier = Modifier) {
|
||||||
|
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.project_wiki_intro),
|
||||||
|
fontSize = 13.sp
|
||||||
|
)
|
||||||
|
WikiSection(stringResource(R.string.project_wiki_section_logbook))
|
||||||
|
WikiEntry(stringResource(R.string.project_wiki_logbook_1))
|
||||||
|
WikiEntry(stringResource(R.string.project_wiki_logbook_2))
|
||||||
|
WikiEntry(stringResource(R.string.project_wiki_logbook_3))
|
||||||
|
WikiHow(stringResource(R.string.project_wiki_logbook_how))
|
||||||
|
WikiSection(stringResource(R.string.project_wiki_section_mutual))
|
||||||
|
WikiEntry(stringResource(R.string.project_wiki_mutual_1))
|
||||||
|
WikiHow(stringResource(R.string.project_wiki_mutual_how))
|
||||||
|
WikiSection(stringResource(R.string.project_wiki_section_grids))
|
||||||
|
WikiEntry(stringResource(R.string.project_wiki_grids_1))
|
||||||
|
WikiHow(stringResource(R.string.project_wiki_grids_how))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WikiSection(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 13.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WikiEntry(text: String) {
|
||||||
|
Text(text = text, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WikiHow(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
+386
@@ -0,0 +1,386 @@
|
|||||||
|
/*
|
||||||
|
* 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.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.LoTWSatelliteAliases
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
|
||||||
|
private const val DATE_PATTERN = "yyyy-MM-dd"
|
||||||
|
private const val TIME_PATTERN = "HH:mm:ss"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit every field of a recorded QSO (the record list rows in the radar Log tab and
|
||||||
|
* the logbook dialog both open this). Frequencies are entered in MHz and the band is
|
||||||
|
* re-derived from them, because that derivation is what LoTW validates: a record with
|
||||||
|
* no uplink frequency has no BAND and cannot be signed.
|
||||||
|
*
|
||||||
|
* The satellite name is restricted to ARRL's catalogue ([satelliteCandidates], from
|
||||||
|
* config.tq6): typing shows fuzzy suggestions, the picker lists the whole catalogue, and
|
||||||
|
* a name that resolves to no ARRL satellite cannot be saved. A tracker name such as
|
||||||
|
* "SAUDISAT 1C" is accepted and stored as the ARRL name it resolves to ("SO-50").
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun QsoEditDialog(
|
||||||
|
record: QsoRecord,
|
||||||
|
satelliteCandidates: List<String> = emptyList(),
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (QsoRecord) -> Unit
|
||||||
|
) {
|
||||||
|
val utc = remember { TimeZone.getTimeZone("UTC") }
|
||||||
|
fun formatDate(millis: Long) = SimpleDateFormat(DATE_PATTERN, Locale.US).apply { timeZone = utc }.format(Date(millis))
|
||||||
|
fun formatTime(millis: Long) = SimpleDateFormat(TIME_PATTERN, Locale.US).apply { timeZone = utc }.format(Date(millis))
|
||||||
|
|
||||||
|
var callsign by remember(record.id) { mutableStateOf(record.theirCallsign.trim()) }
|
||||||
|
var dateText by remember(record.id) { mutableStateOf(formatDate(record.startUtcMillis)) }
|
||||||
|
var timeText by remember(record.id) { mutableStateOf(formatTime(record.startUtcMillis)) }
|
||||||
|
var txText by remember(record.id) { mutableStateOf(formatMhz(record.txFrequencyHz)) }
|
||||||
|
var rxText by remember(record.id) { mutableStateOf(formatMhz(record.rxFrequencyHz)) }
|
||||||
|
var modeText by remember(record.id) { mutableStateOf(record.displayMode) }
|
||||||
|
var satelliteText by remember(record.id) { mutableStateOf(record.satelliteName) }
|
||||||
|
var pickerOpen by remember(record.id) { mutableStateOf(false) }
|
||||||
|
var sentText by remember(record.id) { mutableStateOf(record.sentReport) }
|
||||||
|
var receivedText by remember(record.id) { mutableStateOf(record.receivedReport) }
|
||||||
|
var gridText by remember(record.id) { mutableStateOf(record.theirGrid) }
|
||||||
|
var commentText by remember(record.id) { mutableStateOf(record.comment) }
|
||||||
|
var error by remember(record.id) { mutableStateOf("") }
|
||||||
|
|
||||||
|
// ARRL name the typed tracker name resolves to ("SAUDISAT 1C" -> "SO-50"), if any.
|
||||||
|
val officialSatellite = remember(satelliteText, satelliteCandidates) {
|
||||||
|
LoTWSatelliteAliases.resolve(satelliteText, satelliteCandidates)
|
||||||
|
}
|
||||||
|
val satelliteSuggestions = remember(satelliteText, satelliteCandidates) {
|
||||||
|
LoTWSatelliteAliases.suggestions(satelliteText, satelliteCandidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
val txHz = parseMhz(txText)
|
||||||
|
val rxHz = parseMhz(rxText)
|
||||||
|
val bandText = listOf(frequencyBand(txHz), frequencyBand(rxHz)).filter { it.isNotBlank() }.distinct()
|
||||||
|
.joinToString(" / ")
|
||||||
|
|
||||||
|
val errCallsign = stringResource(R.string.qso_edit_err_callsign)
|
||||||
|
val errDateTime = stringResource(R.string.qso_edit_err_datetime)
|
||||||
|
val errFrequency = stringResource(R.string.qso_edit_err_frequency)
|
||||||
|
val errSatellite = stringResource(R.string.qso_edit_err_satellite)
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
shape = sheetDialogShape(),
|
||||||
|
containerColor = sheetDialogContainerColor(),
|
||||||
|
title = { SheetDialogTitle(stringResource(R.string.qso_edit_title)) },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
if (record.lotwUploaded || record.lotwConfirmed) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.qso_edit_uploaded_notice),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
EditField(stringResource(R.string.qso_edit_callsign), callsign, KeyboardCapitalization.Characters) {
|
||||||
|
callsign = it.uppercase(Locale.US)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
EditField(stringResource(R.string.qso_edit_date), dateText, KeyboardCapitalization.None, Modifier.weight(1f)) {
|
||||||
|
dateText = it
|
||||||
|
}
|
||||||
|
EditField(stringResource(R.string.qso_edit_time), timeText, KeyboardCapitalization.None, Modifier.weight(1f)) {
|
||||||
|
timeText = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
EditField(stringResource(R.string.qso_edit_tx), txText, KeyboardCapitalization.None, Modifier.weight(1f)) {
|
||||||
|
txText = it
|
||||||
|
}
|
||||||
|
EditField(stringResource(R.string.qso_edit_rx), rxText, KeyboardCapitalization.None, Modifier.weight(1f)) {
|
||||||
|
rxText = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.qso_edit_band, bandText.ifBlank { "—" }),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = if (txHz == null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
EditField(stringResource(R.string.qso_edit_mode), modeText, KeyboardCapitalization.Characters) {
|
||||||
|
modeText = it.uppercase(Locale.US)
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
EditField(
|
||||||
|
label = stringResource(R.string.qso_edit_satellite),
|
||||||
|
value = satelliteText,
|
||||||
|
capitalization = KeyboardCapitalization.Characters,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) { satelliteText = it.uppercase(Locale.US) }
|
||||||
|
TextButton(
|
||||||
|
onClick = { pickerOpen = true },
|
||||||
|
enabled = satelliteCandidates.isNotEmpty()
|
||||||
|
) { Text(stringResource(R.string.qso_edit_satellite_pick), fontSize = 12.sp) }
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
// Resolves to an ARRL satellite under a different name: say so, the
|
||||||
|
// ARRL name is what gets stored and signed.
|
||||||
|
officialSatellite != null && !officialSatellite.equals(satelliteText.trim(), true) ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.qso_edit_satellite_official, officialSatellite),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
// Not an ARRL satellite at all: offer the closest catalogue names.
|
||||||
|
satelliteText.isNotBlank() && officialSatellite == null -> {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.qso_edit_err_satellite),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
if (satelliteSuggestions.isNotEmpty()) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
satelliteSuggestions.take(4).forEach { candidate ->
|
||||||
|
FilterChip(
|
||||||
|
selected = false,
|
||||||
|
onClick = { satelliteText = candidate },
|
||||||
|
label = { Text(candidate, fontSize = 11.sp) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> Text(
|
||||||
|
text = stringResource(R.string.qso_edit_hint_satellite),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
EditField(stringResource(R.string.qso_edit_sent), sentText, KeyboardCapitalization.Characters, Modifier.weight(1f)) {
|
||||||
|
sentText = it
|
||||||
|
}
|
||||||
|
EditField(stringResource(R.string.qso_edit_received), receivedText, KeyboardCapitalization.Characters, Modifier.weight(1f)) {
|
||||||
|
receivedText = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
EditField(stringResource(R.string.qso_edit_grid), gridText, KeyboardCapitalization.Characters, Modifier.weight(1f)) {
|
||||||
|
gridText = it.uppercase(Locale.US)
|
||||||
|
}
|
||||||
|
EditField(stringResource(R.string.qso_edit_comment), commentText, KeyboardCapitalization.None, Modifier.weight(1f)) {
|
||||||
|
commentText = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (txHz == null) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.qso_edit_warn_no_freq),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (error.isNotBlank()) {
|
||||||
|
Text(text = error, fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
val millis = parseUtc(dateText, timeText, utc)
|
||||||
|
val badFrequency = (txText.isNotBlank() && txHz == null) || (rxText.isNotBlank() && rxHz == null)
|
||||||
|
when {
|
||||||
|
callsign.isBlank() -> error = errCallsign
|
||||||
|
millis == null -> error = errDateTime
|
||||||
|
badFrequency -> error = errFrequency
|
||||||
|
// Only ARRL satellite names may be stored: a record that cannot be
|
||||||
|
// resolved here would be rejected at upload time anyway.
|
||||||
|
satelliteCandidates.isNotEmpty() && officialSatellite == null ->
|
||||||
|
error = errSatellite
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val mode = modeText.trim().uppercase(Locale.US).ifBlank { "FM" }
|
||||||
|
val satellite = officialSatellite ?: satelliteText.trim()
|
||||||
|
onSave(
|
||||||
|
record.copy(
|
||||||
|
theirCallsign = callsign.trim().uppercase(Locale.US),
|
||||||
|
startUtcMillis = millis,
|
||||||
|
endUtcMillis = record.endUtcMillis?.let { millis },
|
||||||
|
theirGrid = gridText.trim().uppercase(Locale.US),
|
||||||
|
sentReport = sentText.trim(),
|
||||||
|
receivedReport = receivedText.trim(),
|
||||||
|
txFrequencyHz = txHz,
|
||||||
|
rxFrequencyHz = rxHz,
|
||||||
|
band = frequencyBand(txHz),
|
||||||
|
rxBand = frequencyBand(rxHz),
|
||||||
|
mode = if (mode == "FT4") "MFSK" else mode,
|
||||||
|
submode = if (mode == "FT4") "FT4" else "",
|
||||||
|
satelliteName = satellite,
|
||||||
|
propagationMode = if (satellite.isBlank()) record.propagationMode else "SAT",
|
||||||
|
comment = commentText.trim()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) { Text(stringResource(R.string.qso_edit_save)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.qso_edit_cancel)) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (pickerOpen) {
|
||||||
|
SatellitePickerDialog(
|
||||||
|
candidates = satelliteCandidates,
|
||||||
|
onDismiss = { pickerOpen = false },
|
||||||
|
onPick = {
|
||||||
|
satelliteText = it
|
||||||
|
pickerOpen = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full ARRL satellite catalogue with a fuzzy filter: typing "saud" or "saudisat 1c" narrows the
|
||||||
|
* list down to "SO-50", so a tracker name can always be turned into the name LoTW accepts.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SatellitePickerDialog(
|
||||||
|
candidates: List<String>,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onPick: (String) -> Unit
|
||||||
|
) {
|
||||||
|
var query by remember { mutableStateOf("") }
|
||||||
|
val matches = remember(query, candidates) { filteredCandidates(query, candidates) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
shape = sheetDialogShape(),
|
||||||
|
containerColor = sheetDialogContainerColor(),
|
||||||
|
title = { SheetDialogTitle(stringResource(R.string.qso_edit_satellite_pick_title)) },
|
||||||
|
text = {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = { query = it.uppercase(Locale.US) },
|
||||||
|
label = { Text(stringResource(R.string.qso_edit_satellite_search), fontSize = 12.sp) },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(max = 320.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
items(matches, key = { it }) { name ->
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onPick(name) }
|
||||||
|
.padding(horizontal = 4.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.qso_edit_cancel)) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Catalogue filtered by the fuzzy query; the whole list when nothing matches or the query is blank. */
|
||||||
|
internal fun filteredCandidates(query: String, candidates: List<String>): List<String> {
|
||||||
|
if (query.isBlank()) return candidates
|
||||||
|
val matches = LoTWSatelliteAliases.suggestions(query, candidates, limit = 500)
|
||||||
|
return matches.ifEmpty { candidates }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EditField(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
capitalization: KeyboardCapitalization,
|
||||||
|
modifier: Modifier = Modifier.fillMaxWidth(),
|
||||||
|
onValueChange: (String) -> Unit
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
label = { Text(label, fontSize = 12.sp) },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = MaterialTheme.typography.bodyMedium,
|
||||||
|
keyboardOptions = KeyboardOptions(capitalization = capitalization),
|
||||||
|
modifier = modifier.padding(vertical = 1.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "436.795" (MHz) -> 436_795_000 Hz. Null when blank or unparsable. */
|
||||||
|
internal fun parseMhz(text: String): Long? = text.trim()
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
?.toDoubleOrNull()
|
||||||
|
?.let { (it * 1_000_000.0).roundToLong() }
|
||||||
|
|
||||||
|
/** Hz -> "436.795" (MHz), trailing zeros trimmed. */
|
||||||
|
internal fun formatMhz(hz: Long?): String = hz
|
||||||
|
?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
internal fun parseUtc(date: String, time: String, zone: TimeZone): Long? = runCatching {
|
||||||
|
SimpleDateFormat("$DATE_PATTERN $TIME_PATTERN", Locale.US).apply {
|
||||||
|
timeZone = zone
|
||||||
|
isLenient = false
|
||||||
|
}.parse("${date.trim()} ${time.trim()}")?.time
|
||||||
|
}.getOrNull()
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
* 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.presentation
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Styling of the app's sheet dialogs ([DialogShell] / [SharedDialog] / [InfoDialog], all built on
|
||||||
|
* ModalBottomSheet). AlertDialog-based dialogs (logbook, QSO editor, upload preview) use these so
|
||||||
|
* their container colour, corner radius and title look identical to the bottom sheets.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container colour of the app's bottom sheets. Equal to `BottomSheetDefaults.ContainerColor`
|
||||||
|
* (Material3 resolves that token to `ColorScheme.surfaceContainerLow`) — spelled out here so the
|
||||||
|
* dialogs don't have to opt into the experimental bottom-sheet API.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun sheetDialogContainerColor(): Color = MaterialTheme.colorScheme.surfaceContainerLow
|
||||||
|
|
||||||
|
/** Corner radius of the app's bottom sheets (`MaterialTheme.shapes.medium`, 12 dp). */
|
||||||
|
@Composable
|
||||||
|
fun sheetDialogShape(): Shape = MaterialTheme.shapes.medium
|
||||||
|
|
||||||
|
/** Title row of the app's sheet dialogs: 16 sp medium, primary colour, single line. */
|
||||||
|
@Composable
|
||||||
|
fun SheetDialogTitle(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
+18
@@ -1,3 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2026 BA7OPF.
|
||||||
|
* Based on Look4Sat by 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.presentation
|
package com.rtbishop.look4sat.core.presentation
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!--
|
||||||
|
Grid Finder icon: a GPS crosshair.
|
||||||
|
Ported from Material Design Icons (crosshairs-gps), Apache License 2.0.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M3.05,13H1V11H3.05C3.5,6.83 6.83,3.5 11,3.05V1H13V3.05C17.17,3.5 20.5,6.83 20.95,11H23V13H20.95C20.5,17.17 17.17,20.5 13,20.95V23H11V20.95C6.83,20.5 3.5,17.17 3.05,13M12,5A7,7 0 0,0 5,12A7,7 0 0,0 12,19A7,7 0 0,0 19,12A7,7 0 0,0 12,5Z" />
|
||||||
|
</vector>
|
||||||
@@ -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_title">Filtrar pases</string>
|
||||||
<string name="pass_filter_elev">Elevación mínima</string>
|
<string name="pass_filter_elev">Elevación mínima</string>
|
||||||
<string name="pass_filter_hours">Tiempo por delante</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_aos_time">Ventana AOS</string>
|
||||||
<string name="pass_filter_invert_time">Invertir ventana AOS</string>
|
<string name="pass_filter_invert_time">Invertir ventana AOS</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace (período >225min)</string>
|
<string name="pass_filter_deep_space">DeepSpace (período >225min)</string>
|
||||||
@@ -82,7 +83,6 @@
|
|||||||
<string name="map_visible">Visible</string>
|
<string name="map_visible">Visible</string>
|
||||||
|
|
||||||
<!-- Settings screen -->
|
<!-- Settings screen -->
|
||||||
<string name="prefs_donate_title">Soporte</string>
|
|
||||||
<string name="prefs_privacy_title">Política de privacidad</string>
|
<string name="prefs_privacy_title">Política de privacidad</string>
|
||||||
|
|
||||||
<string name="prefs_updated_title">Actualizado: %s</string>
|
<string name="prefs_updated_title">Actualizado: %s</string>
|
||||||
@@ -147,6 +147,7 @@
|
|||||||
<string name="prefs_other_title">Otros ajustes</string>
|
<string name="prefs_other_title">Otros ajustes</string>
|
||||||
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
|
<string name="prefs_other_switch_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_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_sweep">Habilitar animaciones radar</string>
|
||||||
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
|
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
|
||||||
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
|
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
<string name="pass_filter_title">Фильтровать пролеты</string>
|
<string name="pass_filter_title">Фильтровать пролеты</string>
|
||||||
<string name="pass_filter_elev">Минимальная элевация</string>
|
<string name="pass_filter_elev">Минимальная элевация</string>
|
||||||
<string name="pass_filter_hours">Период расчёта</string>
|
<string name="pass_filter_hours">Период расчёта</string>
|
||||||
|
<string name="pass_filter_history_hours">Прошедший период</string>
|
||||||
<string name="pass_filter_aos_time">Окно AOS</string>
|
<string name="pass_filter_aos_time">Окно AOS</string>
|
||||||
<string name="pass_filter_invert_time">Инвертировать окно AOS</string>
|
<string name="pass_filter_invert_time">Инвертировать окно AOS</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace (период >225мин)</string>
|
<string name="pass_filter_deep_space">DeepSpace (период >225мин)</string>
|
||||||
@@ -81,7 +82,6 @@
|
|||||||
<string name="map_visible">Видимый</string>
|
<string name="map_visible">Видимый</string>
|
||||||
|
|
||||||
<!-- Settings screen -->
|
<!-- Settings screen -->
|
||||||
<string name="prefs_donate_title">Поддержать</string>
|
|
||||||
<string name="prefs_privacy_title">Политика конфиденциальности</string>
|
<string name="prefs_privacy_title">Политика конфиденциальности</string>
|
||||||
|
|
||||||
<string name="prefs_updated_title">Обновлено: %s</string>
|
<string name="prefs_updated_title">Обновлено: %s</string>
|
||||||
@@ -146,6 +146,7 @@
|
|||||||
<string name="prefs_other_title">Другие настройки</string>
|
<string name="prefs_other_title">Другие настройки</string>
|
||||||
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
|
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
|
||||||
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
|
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
|
||||||
|
<string name="prefs_other_switch_lotw_sync">Автосинхронизация подтверждённых сеток LoTW</string>
|
||||||
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
|
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
|
||||||
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
|
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
|
||||||
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
|
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
|
||||||
|
|||||||
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user