mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-05 20:17:29 +00:00
Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7d99862d9 | ||
|
|
a24acc2bfd | ||
|
|
ad970eec77 | ||
|
|
5c12d64824 | ||
|
|
881ca442ff | ||
|
|
f35d78186c | ||
|
|
09539ff13a | ||
|
|
1fa7dd2d88 | ||
|
|
f0fc788ebe | ||
|
|
6f83740983 | ||
|
|
e7c0df0954 | ||
|
|
5a8c4281a3 | ||
|
|
a459cfb046 | ||
|
|
7589f3f37d | ||
|
|
6a263d8422 | ||
|
|
1558919482 | ||
|
|
9e5b9b6ac6 | ||
|
|
c050e7bb9b | ||
|
|
73ac41ff85 | ||
|
|
733f660d78 | ||
|
|
cbe3e50f86 | ||
|
|
38b29e3015 | ||
|
|
1a3a05c8b6 | ||
|
|
1e33a10fb4 | ||
|
|
6546e32360 | ||
|
|
c86893f6b1 | ||
|
|
cad085c25b | ||
|
|
cba5eb7efe | ||
|
|
74c245a7d2 | ||
|
|
135ecb861d | ||
|
|
1fd1459ddf | ||
|
|
56a788138b | ||
|
|
2e704ee1d3 | ||
|
|
5b7c7d6df5 | ||
|
|
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 |
No files matched your search
@@ -112,6 +112,17 @@ transition to OMM/CSV automatically.
|
||||
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
|
||||
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
|
||||
|
||||
## Release Notes Must Be Bilingual (BA7OPF fork convention)
|
||||
|
||||
Every GitHub release body carries **two sections — English first, then Chinese** (`## <tag>` each),
|
||||
listing only that release's changes, in the same order with the same claims and the same numbers.
|
||||
Both sections end with the verification line (which unit tests ran, APK signature / ABI check).
|
||||
A single-language body is a defect: fix it before the release counts as done.
|
||||
|
||||
中文规则:每次 release 的说明必须**中英双语,先英文后中文**,两段一一对应(同一顺序、同一结论、
|
||||
同一数字,不做自由意译),只列本版改动;两段末尾都写验证情况(跑了哪些单测、签名/ABI 校验)。
|
||||
只写一种语言视为缺陷,需补齐后再算发布完成。
|
||||
|
||||
## Copilot Working Mode: Code-Only
|
||||
|
||||
- Default to code changes only. Provide explanations in chat only.
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
**BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的业余无线电卫星追踪器,增加了线性卫星频率计算器等功能。
|
||||
|
||||
非官方修改版(fork),独立维护;首次发布 2026-09,持续修改中。
|
||||
|
||||
## 本仓库特色功能
|
||||
|
||||
- **网格地图模式(Grid Mode)** — 地图页内置 VUCC 网格覆盖图:已通联网格绿色填充、漫游网格蓝色 45° 斜纹(疏朗半透明样式)、当前所在方格淡黄加粗框;点击已通联网格弹出该网格内通联明细弹窗;进入网格模式默认 VUCC 视图
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# 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.
|
||||
|
||||
## OpenStreetMap (award boundary geometry)
|
||||
|
||||
- Source: https://www.openstreetmap.org — relations `Ural Mountains` (21368163),
|
||||
`England` (58447), `Scotland` (58446), `Wales` (58437),
|
||||
`Northern Ireland` (156393)
|
||||
- License: Open Database License (ODbL) 1.0 —
|
||||
https://opendatacommons.org/licenses/odbl/1-0/
|
||||
- Used for: the Europe/Asia split line of European/Asiatic Russia and the
|
||||
England/Scotland/Wales/Northern Ireland partitions in
|
||||
`feature/map/src/main/assets/awards/dxcc.json` (derived geometry).
|
||||
Map tiles displayed in the app are © OpenStreetMap contributors and carry
|
||||
their own in-app attribution.
|
||||
|
||||
## Natural Earth
|
||||
|
||||
- Source: https://www.naturalearthdata.com (1:110m countries, 1:10m admin-0
|
||||
map units, 1:10m rivers)
|
||||
- License: Public domain — https://www.naturalearthdata.com/about/terms-of-use/
|
||||
- Used for: the country/region polygons of the award boundary assets under
|
||||
`feature/map/src/main/assets/awards/` (including the island / microstate
|
||||
DXCC entities — Singapore, Malta, Canary Is., ... — and the Balearic/Canary
|
||||
rings split out of Spain's 1:10m multipolygon), and the Ural river centerline
|
||||
used for the European/Asiatic Russia split.
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
<meta-data
|
||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||
android:value="com.rtbishop.look4sat" />
|
||||
android:value="cn.ba7opf.look4sat" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
@@ -27,7 +27,9 @@ import android.view.View
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||
@@ -47,9 +49,12 @@ class MainActivity : ComponentActivity() {
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
val settingsRepo = (applicationContext as IContainerProvider).getMainContainer().settingsRepo
|
||||
observeNightFilterState()
|
||||
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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
|
||||
import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult
|
||||
import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
|
||||
import com.rtbishop.look4sat.core.domain.repository.lotwSyncToday
|
||||
@@ -50,6 +51,9 @@ class MainApplication : Application(), IContainerProvider {
|
||||
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
|
||||
container.appScope.launch { container.satelliteRepo.initRepository() }
|
||||
}
|
||||
@@ -101,6 +105,9 @@ class MainApplication : Application(), IContainerProvider {
|
||||
*/
|
||||
private suspend fun checkLoTWAutoSync(timeNow: Long = System.currentTimeMillis()) {
|
||||
val settingsRepo = container.settingsRepo
|
||||
// One-of-two rule: while Wavelog is configured the app uploads/syncs through
|
||||
// Wavelog only — the automatic LoTW sync stays off.
|
||||
if (settingsRepo.wavelogUploadSettings.value.isReady) return
|
||||
val lotwSettings = settingsRepo.lotwSettings.value
|
||||
if (!shouldAutoSyncLoTW(
|
||||
isConfigured = lotwSettings.isConfigured,
|
||||
@@ -116,6 +123,11 @@ class MainApplication : Application(), IContainerProvider {
|
||||
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,11 +75,14 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
||||
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
||||
import com.rtbishop.look4sat.core.presentation.GridFinderDestination
|
||||
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.Screen
|
||||
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||
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.MapFilterViewModel
|
||||
import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
||||
@@ -88,6 +91,7 @@ import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||
import com.rtbishop.look4sat.feature.settings.LoTWUploadDestination
|
||||
import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
||||
|
||||
@Composable
|
||||
@@ -99,6 +103,8 @@ fun NavRoot(deeplink: String? = null) {
|
||||
}
|
||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||
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)
|
||||
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
||||
slideOutHorizontally(tween(300)) { -it / 3 }
|
||||
@@ -132,7 +138,8 @@ fun NavRoot(deeplink: String? = null) {
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Passes> {
|
||||
MainScreen(
|
||||
navigateToRadar = navigateToRadar
|
||||
navigateToRadar = navigateToRadar,
|
||||
navigateToGridFinder = navigateToGridFinder
|
||||
)
|
||||
}
|
||||
entry<RadarDestination> {
|
||||
@@ -140,7 +147,26 @@ fun NavRoot(deeplink: String? = null) {
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
RadarDestination(navigateUp = navigateBack, onOpenLoTWStation = navigateToLoTWStation)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +176,8 @@ fun NavRoot(deeplink: String? = null) {
|
||||
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
navigateToRadar: () -> Unit = {}
|
||||
navigateToRadar: () -> Unit = {},
|
||||
navigateToGridFinder: () -> Unit = {}
|
||||
) {
|
||||
val backStack = rememberNavBackStack(Screen.Passes)
|
||||
val currentKey = backStack.lastOrNull()
|
||||
@@ -291,7 +318,7 @@ fun MainScreen(
|
||||
}
|
||||
entry<Screen.AMSAT> { SatStatusDestination() }
|
||||
entry<Screen.Settings> {
|
||||
SettingsDestination()
|
||||
SettingsDestination(onOpenGridFinder = navigateToGridFinder)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+1
@@ -32,6 +32,7 @@ internal class ApplicationPlugin : Plugin<Project> {
|
||||
implementation(project(":core:domain"))
|
||||
implementation(project(":core:presentation"))
|
||||
implementation(project(":feature:map"))
|
||||
implementation(project(":feature:gridfinder"))
|
||||
implementation(project(":feature:mutual"))
|
||||
implementation(project(":feature:passes"))
|
||||
implementation(project(":feature:radar"))
|
||||
|
||||
Binary file not shown.
@@ -19,6 +19,7 @@ package com.rtbishop.look4sat.core.data.database
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.MapColumn
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
@@ -39,6 +40,18 @@ interface Look4SatDao {
|
||||
@Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)")
|
||||
suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry>
|
||||
|
||||
@Query("SELECT catnum, epoch FROM entries")
|
||||
suspend fun getEntriesEpochs(): Map<@MapColumn("catnum") Int, @MapColumn("epoch") Double>
|
||||
|
||||
@Query("SELECT catnum, name FROM entries")
|
||||
suspend fun getEntriesNames(): Map<@MapColumn("catnum") Int, @MapColumn("name") String>
|
||||
|
||||
@Query("UPDATE entries SET name = :name WHERE catnum = :catnum")
|
||||
suspend fun renameEntry(catnum: Int, name: String)
|
||||
|
||||
@Query("DELETE FROM entries WHERE catnum IN (:ids)")
|
||||
suspend fun deleteEntriesWithIds(ids: List<Int>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertEntries(entries: List<SatEntry>)
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
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>)
|
||||
|
||||
@Query("UPDATE qso_records SET wavelogUploaded = 1, wavelogStation = CASE WHEN :stationId = '' THEN wavelogStation ELSE :stationId END WHERE id IN (:ids)")
|
||||
suspend fun markWavelogUploaded(ids: List<Long>, stationId: String)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
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 = 5, 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")
|
||||
}
|
||||
}
|
||||
|
||||
/** v2 → v3: keep the OPPOSITE station's grid set (multi-grid confirmations as LoTW reports them). */
|
||||
val MIGRATION_QSO_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE qso_records ADD COLUMN theirVuccGrids TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
|
||||
/** v3 → v4: track whether a QSO was uploaded to Wavelog (independent of the LoTW state). */
|
||||
val MIGRATION_QSO_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE qso_records ADD COLUMN wavelogUploaded INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
/** v4 → v5: remember which Wavelog station profile (台址) a QSO belongs to. */
|
||||
val MIGRATION_QSO_4_5 = object : Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE qso_records ADD COLUMN wavelogStation TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
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 wavelogUploaded: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "''") val wavelogStation: String = "",
|
||||
@ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "''") val lotwQslDate: String = "",
|
||||
@ColumnInfo(defaultValue = "''") val vuccGrids: String = "",
|
||||
@ColumnInfo(defaultValue = "''") val theirVuccGrids: String = "",
|
||||
val dxcc: Int? = null,
|
||||
@ColumnInfo(defaultValue = "''") val country: String = "",
|
||||
val cqZone: Int? = null,
|
||||
@ColumnInfo(defaultValue = "''") val region: String = "",
|
||||
@ColumnInfo(defaultValue = "''") val comment: String = ""
|
||||
)
|
||||
+4
-3
@@ -330,12 +330,13 @@ object IcomCivProtocol {
|
||||
|
||||
/**
|
||||
* 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>? {
|
||||
if (payload.size < 6) return null
|
||||
if (payload.size < 5) return null
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -17,47 +17,65 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.injection
|
||||
|
||||
import android.Manifest
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.SensorManager
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.location.LocationManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.room.Room
|
||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_1_2
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_2_3
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_3_4
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_4_5
|
||||
import com.rtbishop.look4sat.core.data.database.QsoDatabase
|
||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
||||
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
||||
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
||||
import com.rtbishop.look4sat.core.data.lotw.LoTWUploadRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.QsoRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.UpdateRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.LoTWRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.WavelogRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.LocationRepo
|
||||
|
||||
import com.rtbishop.look4sat.core.data.repository.WavelogUploadRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.WavelogSyncRepository
|
||||
import com.rtbishop.look4sat.core.data.source.LocalSource
|
||||
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
||||
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
||||
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
||||
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
||||
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
||||
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||
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.ISelectionRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogSyncRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
@@ -88,19 +106,41 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
override val databaseRepo = provideDatabaseRepo()
|
||||
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
|
||||
override val updateRepo by lazy { UpdateRepository(remoteSource) }
|
||||
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
|
||||
|
||||
override val wavelogUploadRepository: IWavelogUploadRepository by lazy { WavelogUploadRepository() }
|
||||
override val wavelogSyncRepository: IWavelogSyncRepository by lazy { WavelogSyncRepository() }
|
||||
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, MIGRATION_QSO_2_3, MIGRATION_QSO_3_4)
|
||||
.addMigrations(MIGRATION_QSO_4_5)
|
||||
.build()
|
||||
QsoRepository(database.qsoDao(), Dispatchers.IO)
|
||||
}
|
||||
override val lotwUploadRepository: ILoTWUploadRepository by lazy { LoTWUploadRepository(context) }
|
||||
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||
}
|
||||
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())
|
||||
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) {
|
||||
_mutualPassData.value = data
|
||||
}
|
||||
override fun setPendingLoTWStationGrid(grid: String?) {
|
||||
_pendingLoTWStationGrid.value = grid
|
||||
}
|
||||
|
||||
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
||||
|
||||
|
||||
@@ -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,105 @@
|
||||
/*
|
||||
* 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")
|
||||
// A record dated after "now" gets its own message: it happens when the operator
|
||||
// logged against a pass that had not started yet (out-of-window flow), and "fix the
|
||||
// time" is the actionable advice. Reporting it as "outside the certificate" sent one
|
||||
// operator hunting the certificate while the culprit was the record's future time.
|
||||
if (record.startUtcMillis > now) {
|
||||
fail(LoTWProblem.QSO_FUTURE, "$call @ ${utc(record.startUtcMillis, "MM-dd HH:mm'Z'")}")
|
||||
}
|
||||
if (date < key.info.firstQsoDate || (key.info.lastQsoDate.isNotBlank() && date > key.info.lastQsoDate)) {
|
||||
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,481 @@
|
||||
/*
|
||||
* 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 previewCertificate(data: ByteArray, password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
try {
|
||||
if (data.isEmpty() || data.size > MAX_CERTIFICATE_BYTES) fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||
LoTWKeyMaterial.read(data, password, now()).info
|
||||
} finally {
|
||||
// Never keep the operator's password; the p12 bytes stay the caller's
|
||||
// own copy (the dialog re-reads the file for every preview attempt).
|
||||
password.fill('\u0000')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
// The station-location grids this batch is signed with: the primary grid plus the
|
||||
// MY_VUCC_GRIDS set (1 inside a grid, 2 on a line, 4 on a corner — see [LoTWConfig]).
|
||||
val locationGrids = buildList {
|
||||
signing.location["GRIDSQUARE"]?.takeIf(String::isNotBlank)?.let(::add)
|
||||
signing.location["MY_VUCC_GRIDS"]?.split(',')
|
||||
?.map(String::trim)?.filter(String::isNotBlank)?.let(::addAll)
|
||||
}.distinct()
|
||||
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 },
|
||||
locationGrids,
|
||||
resubmit
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
-11
@@ -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
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||
@@ -34,6 +52,10 @@ private data class ApiReport(
|
||||
val reportedTimeUtcSec: Long
|
||||
)
|
||||
|
||||
/** One slot's verdict: ARGB colour, the count the page prints, and whether the slot
|
||||
* has no strict majority (= the page's "Conflicting reports" state). */
|
||||
private data class SlotStatus(val color: Long, val count: Int, val conflicted: Boolean)
|
||||
|
||||
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
|
||||
class AmSatRepository(
|
||||
private val remoteSource: IRemoteSource,
|
||||
@@ -196,7 +218,8 @@ class AmSatRepository(
|
||||
}
|
||||
}
|
||||
|
||||
/** Build one SatStatus (3 days x 12 slots) per catalog satellite, slotting reports by age. */
|
||||
/** Build one SatStatus (3 days x 12 slots) per catalog satellite, slotting reports by age.
|
||||
* Each slot's colour/count follows the AMSAT status page rule — see [slotStatusOf]. */
|
||||
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||
val byName = reports.groupBy { it.name }
|
||||
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
@@ -212,11 +235,12 @@ class AmSatRepository(
|
||||
if (inSlot.isEmpty()) {
|
||||
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
|
||||
} else {
|
||||
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
|
||||
val status = slotStatusOf(inSlot)
|
||||
SatSlot(
|
||||
statusColor = statusColorOf(newest.report),
|
||||
count = inSlot.size,
|
||||
reportIds = inSlot.map { it.id }
|
||||
statusColor = status.color,
|
||||
count = status.count,
|
||||
reportIds = inSlot.map { it.id },
|
||||
isConflicted = status.conflicted
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -269,12 +293,28 @@ class AmSatRepository(
|
||||
)
|
||||
}
|
||||
|
||||
/** Map status text to color value (for UI rendering). */
|
||||
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
|
||||
"heard", "crew active" -> ACTIVE_BLUE
|
||||
"telemetry only" -> TLM_ORANGE
|
||||
"not heard" -> NOT_HEARD_PINK
|
||||
else -> CONFLICT_DEEP_ORANGE
|
||||
/**
|
||||
* The AMSAT status page's per-block rule, mirrored from the official page source
|
||||
* (gitlab.amsat.org/open-source/satellite-status, index.php):
|
||||
* - any Crew Active report wins: purple, count = crew + heard;
|
||||
* - otherwise one status must be a STRICT majority to take its colour, and the
|
||||
* count shown is the majority group's count (not the block total);
|
||||
* - any tie of the remaining statuses is the "Conflicting reports" state;
|
||||
* - a block whose reports all carry an unknown status stays blank.
|
||||
*/
|
||||
private fun slotStatusOf(inSlot: List<ApiReport>): SlotStatus {
|
||||
val heard = inSlot.count { it.report.equals("Heard", ignoreCase = true) }
|
||||
val notHeard = inSlot.count { it.report.equals("Not Heard", ignoreCase = true) }
|
||||
val telemetry = inSlot.count { it.report.equals("Telemetry Only", ignoreCase = true) }
|
||||
val crew = inSlot.count { it.report.equals("Crew Active", ignoreCase = true) }
|
||||
return when {
|
||||
crew > 0 -> SlotStatus(CREW_PURPLE, crew + heard, conflicted = false)
|
||||
heard > notHeard && heard > telemetry -> SlotStatus(ACTIVE_BLUE, heard, conflicted = false)
|
||||
notHeard > heard && notHeard > telemetry -> SlotStatus(NOT_HEARD_PINK, notHeard, conflicted = false)
|
||||
telemetry > notHeard && telemetry > heard -> SlotStatus(TLM_ORANGE, telemetry, conflicted = false)
|
||||
telemetry + notHeard + heard >= 1 -> SlotStatus(CONFLICT_DEEP_ORANGE, inSlot.size, conflicted = true)
|
||||
else -> SlotStatus(NO_REPORT_GRAY, inSlot.size, conflicted = false)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -283,6 +323,7 @@ class AmSatRepository(
|
||||
private const val TLM_ORANGE = 0xFFFFB000
|
||||
private const val NOT_HEARD_PINK = 0xFFDC267F
|
||||
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
|
||||
private const val CREW_PURPLE = 0xFF785EF0
|
||||
private const val NO_REPORT_GRAY = 0xFFC0C0C0
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,8 @@ class DatabaseRepo(
|
||||
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
||||
var importedCount = 0
|
||||
remoteSource.getFileStream(uri)?.let { stream ->
|
||||
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
|
||||
localSource.insertEntries(entries)
|
||||
val entries = mergeEntries(listOf(parseSatelliteStream(uri, unwrapIfZipped(uri, stream))))
|
||||
insertFresherEntries(entries)
|
||||
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
|
||||
importedCount = entries.size
|
||||
}
|
||||
@@ -93,14 +93,15 @@ class DatabaseRepo(
|
||||
settingsRepo.updateDataSourcesStatus(
|
||||
(tleResults + radioResults).associate { (url, result) -> url to result.code }
|
||||
)
|
||||
// parse fetched data concurrently and associate known built-in URLs with existing type filters.
|
||||
val importedEntries = tleResults.flatMap { (rawUrl, result) ->
|
||||
// parse each source on its own: the type filters need every source's catnums, while the
|
||||
// stored elements are merged across sources below by freshness.
|
||||
val parsedPerSource = tleResults.map { (rawUrl, result) ->
|
||||
val normUrl = normalizeUrl(rawUrl)
|
||||
val entries = result.stream?.let { parseSatelliteStream(normUrl, unwrapIfZipped(normUrl, it)) }.orEmpty()
|
||||
val type = builtinTypesByUrl[normUrl] ?: customSourceType
|
||||
importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum })
|
||||
entries
|
||||
}.distinctBy { it.catnum }
|
||||
}
|
||||
importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
|
||||
val importedRadios = radioResults.flatMap { (rawUrl, result) ->
|
||||
val normUrl = normalizeUrl(rawUrl)
|
||||
@@ -115,8 +116,14 @@ class DatabaseRepo(
|
||||
localSource.deleteManagedRadios()
|
||||
localSource.insertRadios(importedRadios)
|
||||
}
|
||||
if (importedEntries.isNotEmpty()) localSource.insertEntries(importedEntries)
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
// Elements always come from the freshest source for every satellite; only
|
||||
// elements newer than the stored ones are written back.
|
||||
insertFresherEntries(mergeEntries(parsedPerSource))
|
||||
// keep the previous timestamp when every source failed, so the next launch retries
|
||||
val hasFetchedData = parsedPerSource.any { entries -> entries.isNotEmpty() } || importedRadios.isNotEmpty()
|
||||
val previousTimestamp = settingsRepo.databaseState.value.updateTimestamp
|
||||
if (hasFetchedData) pruneStaleEntries()
|
||||
setUpdateSuccessful(if (hasFetchedData) System.currentTimeMillis() else previousTimestamp)
|
||||
}
|
||||
|
||||
override suspend fun clearAllData() = withContext(dispatcher) {
|
||||
@@ -155,6 +162,62 @@ class DatabaseRepo(
|
||||
line.count { it == ',' } >= 4
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the data of every source: orbital elements always come from the set with the newest
|
||||
* epoch, while the name comes from the first source that provides it. Source order is thus a
|
||||
* naming preference only, which also keeps names stable when sources leapfrog each other.
|
||||
*/
|
||||
private fun mergeEntries(sourceEntries: List<List<OrbitalData>>): List<OrbitalData> {
|
||||
val preferredNames = mutableMapOf<Int, String>()
|
||||
val freshestEntries = mutableMapOf<Int, OrbitalData>()
|
||||
sourceEntries.forEach { entries ->
|
||||
entries.forEach { entry ->
|
||||
val name = entry.name.trim()
|
||||
if (name.isNotBlank()) preferredNames.getOrPut(entry.catnum) { name }
|
||||
val current = freshestEntries[entry.catnum]
|
||||
if (current == null || entry.epochDaynum > current.epochDaynum) {
|
||||
freshestEntries[entry.catnum] = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
return freshestEntries.values.map { entry ->
|
||||
val name = preferredNames[entry.catnum]
|
||||
if (name == null || name == entry.name) entry else entry.copy(name = name)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stores the entries that are newer than the ones already saved, renaming the rest in place. */
|
||||
private suspend fun insertFresherEntries(entries: List<OrbitalData>) {
|
||||
val storedEpochs = localSource.getEntriesEpochs()
|
||||
val (fresherEntries, staleEntries) = entries.partition { entry ->
|
||||
val storedEpoch = storedEpochs[entry.catnum]
|
||||
storedEpoch == null || entry.epochDaynum > OrbitalData.epochToDaynum(storedEpoch)
|
||||
}
|
||||
localSource.insertEntries(fresherEntries)
|
||||
// a reordered source list has to rename satellites right away, even the ones holding
|
||||
// elements that are newer than the ones just parsed
|
||||
if (staleEntries.isNotEmpty()) {
|
||||
val storedNames = localSource.getEntriesNames()
|
||||
val renamedEntries = staleEntries.filter { entry -> entry.name != storedNames[entry.catnum] }
|
||||
if (renamedEntries.isNotEmpty()) {
|
||||
localSource.renameEntries(renamedEntries.associate { it.catnum to it.name })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops satellites that no enabled source has refreshed for a month: they either decayed or
|
||||
* disappeared from every catalog. Manually imported data ages out the same way, and every
|
||||
* source republishes active satellites well within that window.
|
||||
*/
|
||||
private suspend fun pruneStaleEntries() {
|
||||
val currentDaynum = OrbitalData.timeToDaynum(System.currentTimeMillis())
|
||||
val staleIds = localSource.getEntriesEpochs()
|
||||
.filterValues { epoch -> currentDaynum - OrbitalData.epochToDaynum(epoch) > 30.0 }
|
||||
.keys.toList()
|
||||
if (staleIds.isNotEmpty()) localSource.deleteEntriesWithIds(staleIds)
|
||||
}
|
||||
|
||||
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
||||
settingsRepo.updateDatabaseState(
|
||||
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
|
||||
|
||||
+5
-3
@@ -285,7 +285,9 @@ class LoTWRepository : ILoTWRepository {
|
||||
myGrid = myGrids.firstOrNull(),
|
||||
myGrids = myGrids,
|
||||
myCallsign = myCallsign,
|
||||
stationKey = stationKey
|
||||
stationKey = stationKey,
|
||||
// Copy, never the live buffer — resetRecord() clears it.
|
||||
theirGrids = gridsInRecord.distinct().toList()
|
||||
)
|
||||
for (grid in gridsInRecord) {
|
||||
result.getOrPut(grid) { mutableListOf() }.add(qso)
|
||||
@@ -322,9 +324,9 @@ class LoTWRepository : ILoTWRepository {
|
||||
line.startsWith("<MODE:") ->
|
||||
mode = adifValue(line)
|
||||
line.startsWith("<BAND_RX:") ->
|
||||
bandUp = adifValue(line).uppercase()
|
||||
line.startsWith("<BAND:") && !line.startsWith("<BAND_RX:") ->
|
||||
bandDown = adifValue(line).uppercase()
|
||||
line.startsWith("<BAND:") && !line.startsWith("<BAND_RX:") ->
|
||||
bandUp = adifValue(line).uppercase()
|
||||
line.startsWith("<DXCC:") ->
|
||||
dxcc = adifValue(line).toIntOrNull()
|
||||
line.startsWith("<COUNTRY:") ->
|
||||
|
||||
@@ -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,444 @@
|
||||
/*
|
||||
* 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.bandsMatchOrMirror
|
||||
import com.rtbishop.look4sat.core.domain.logbook.contentScore
|
||||
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||
import com.rtbishop.look4sat.core.domain.logbook.sameConfirmedContact
|
||||
import com.rtbishop.look4sat.core.domain.logbook.sameContactIdentity
|
||||
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 || previous.wavelogUploaded) &&
|
||||
!record.sameEditableContentAs(previous)
|
||||
) {
|
||||
dao.save(record.copy(
|
||||
id = 0L,
|
||||
lotwUploaded = false, lotwConfirmed = false, lotwReceived = false,
|
||||
wavelogUploaded = false,
|
||||
wavelogStation = "",
|
||||
lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = 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(), theirVuccGrids = 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>, grids: List<String>) = withContext(dispatcher) {
|
||||
if (ids.isEmpty()) return@withContext
|
||||
dao.markUploaded(ids)
|
||||
// Stamp the station grid set this batch went out under (a line/corner prefill from
|
||||
// the grid finder carries 2–4 grids). The logbook row shows it after the timestamp.
|
||||
val normalized = grids.map { it.trim().uppercase(Locale.US).take(4) }
|
||||
.filter { it.length >= 4 }.distinct().sorted()
|
||||
if (normalized.isEmpty()) return@withContext
|
||||
val stamped = ids.mapNotNull { id ->
|
||||
dao.find(id)?.toDomain()?.takeIf { it.vuccGrids != normalized }?.copy(vuccGrids = normalized)
|
||||
}
|
||||
if (stamped.isNotEmpty()) dao.saveBatch(stamped.map { it.toEntity() })
|
||||
}
|
||||
|
||||
override suspend fun markWavelogUploaded(ids: List<Long>, stationId: String) = withContext(dispatcher) {
|
||||
if (ids.isEmpty()) return@withContext
|
||||
importMutex.withLock { dao.markWavelogUploaded(ids, stationId) }
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges QSO records pulled from Wavelog (the sync/download direction).
|
||||
*
|
||||
* Matching uses the same [stableQsoKey] identity as every other import, so a record
|
||||
* already recorded locally (typically uploaded from this app) is not duplicated — it
|
||||
* simply learns its Wavelog station profile and, where the local row is missing them,
|
||||
* the opposite-station fields the server kept. Fresh records are inserted already
|
||||
* marked as uploaded, so a later upload never sends them straight back.
|
||||
*/
|
||||
override suspend fun mergeWavelog(records: List<QsoRecord>): AdifImportResult = withContext(dispatcher) {
|
||||
importMutex.withLock { mergeWavelogRecords(records) }
|
||||
}
|
||||
|
||||
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 knownKeys = working.map { stableQsoKey(it) }.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 an early release sits as its own row with the
|
||||
// band direction read mirrored (the downlink was stored as the uplink). The
|
||||
// strict lookup above cannot see it: its bands fail the comparison, and its
|
||||
// dedupe key can differ too (fields written by older releases — timestamp
|
||||
// precision, FT4 sub-mode spelling, missing callsign — so it is not treated
|
||||
// as known either). Fold the report into the richest mirrored row instead of
|
||||
// inserting a second row next to it; left as two rows, neither consolidation
|
||||
// nor a later sync could reliably heal them.
|
||||
val mirroredRows = if (fromLoTW) lookup[remote.confirmationLookupKey()].orEmpty().filter { index ->
|
||||
working[index].band.isNotBlank() && remote.band.isNotBlank() &&
|
||||
!working[index].band.equals(remote.band, true) &&
|
||||
sameContactIdentity(working[index], remote) &&
|
||||
bandsMatchOrMirror(working[index], remote)
|
||||
} else emptyList()
|
||||
if (mirroredRows.isNotEmpty()) {
|
||||
val target = mirroredRows.maxBy { contentScore(working[it]) }
|
||||
mirroredRows.forEach { index ->
|
||||
val healed = working[index].copy(
|
||||
band = remote.band.ifBlank { working[index].band },
|
||||
rxBand = remote.rxBand.ifBlank { working[index].rxBand }
|
||||
)
|
||||
val folded = if (index == target) healed.withConfirmation(remote) else healed
|
||||
working[index] = folded
|
||||
changes[index] = folded
|
||||
}
|
||||
updated += mirroredRows.size
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
private suspend fun mergeWavelogRecords(records: List<QsoRecord>): AdifImportResult {
|
||||
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
|
||||
// Rows imported under a tracking-source name join the ARRL name first, so the
|
||||
// lookup below sees one identity for both sides of a match.
|
||||
val renames = rewriteOfficialNames(working)
|
||||
val indexByKey = HashMap<String, Int>(working.size)
|
||||
working.indices.forEach { index -> indexByKey.putIfAbsent(stableQsoKey(working[index]), index) }
|
||||
val changes = linkedMapOf<Int, QsoRecord>().apply { putAll(renames) }
|
||||
var imported = 0
|
||||
var updated = renames.size
|
||||
var skipped = 0
|
||||
val seenKeys = hashSetOf<String>()
|
||||
records.forEach { remote ->
|
||||
val key = stableQsoKey(remote)
|
||||
if (!seenKeys.add(key)) {
|
||||
skipped++
|
||||
return@forEach
|
||||
}
|
||||
val index = indexByKey[key]
|
||||
if (index == null) {
|
||||
val added = remote.copy(
|
||||
id = 0L,
|
||||
wavelogUploaded = true,
|
||||
satelliteName = officialSatelliteName(remote.satelliteName)
|
||||
)
|
||||
changes[working.size] = added
|
||||
indexByKey[stableQsoKey(added)] = working.size
|
||||
working += added
|
||||
imported++
|
||||
} else {
|
||||
val previous = working[index]
|
||||
val merged = previous.copy(
|
||||
wavelogUploaded = true,
|
||||
wavelogStation = remote.wavelogStation.ifBlank { previous.wavelogStation },
|
||||
theirGrid = previous.theirGrid.ifBlank { remote.theirGrid },
|
||||
theirVuccGrids = previous.theirVuccGrids.ifEmpty { remote.theirVuccGrids }
|
||||
)
|
||||
if (merged != previous) {
|
||||
working[index] = merged
|
||||
changes[index] = merged
|
||||
updated++
|
||||
} else {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
}
|
||||
dao.saveBatch(changes.values.map { it.toEntity() })
|
||||
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,
|
||||
wavelogUploaded = wavelogUploaded,
|
||||
wavelogStation = wavelogStation,
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
|
||||
theirVuccGrids = theirVuccGrids.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,
|
||||
wavelogUploaded = wavelogUploaded,
|
||||
wavelogStation = wavelogStation.trim(),
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.joinToString(","),
|
||||
theirVuccGrids = theirVuccGrids.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
|
||||
@@ -51,7 +51,15 @@ class SelectionRepo(
|
||||
null // null = no filtering
|
||||
} else {
|
||||
val ids = resolveTypeIds(types)
|
||||
if (ids.isEmpty()) emptySet() else ids.toHashSet()
|
||||
when {
|
||||
ids.isNotEmpty() -> ids.toHashSet()
|
||||
// "All" is the CelesTrak active group, i.e. the whole catalogue: an empty
|
||||
// persisted list must mean "no filtering" (show every satellite), not an
|
||||
// empty list. Its list can be empty simply because CelesTrak answered 403
|
||||
// to a repeated download inside the 2-hour update window.
|
||||
Sources.allSourceType in types -> null
|
||||
else -> emptySet()
|
||||
}
|
||||
}
|
||||
currentItems.map { items ->
|
||||
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
|
||||
|
||||
+162
-20
@@ -30,7 +30,8 @@ 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.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
@@ -147,27 +148,9 @@ class SettingsRepo(
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # Wavelog worked-grids settings
|
||||
private val keyWavelogUrl = "wavelogUrl"
|
||||
private val keyWavelogToken = "wavelogToken"
|
||||
//region # Worked-grid data (written by the LoTW / Wavelog syncs)
|
||||
private val keyWorkedGrids = "workedGrids"
|
||||
|
||||
private val _wavelogSettings = MutableStateFlow(getWavelogSettings())
|
||||
override val wavelogSettings: StateFlow<WavelogSettings> = _wavelogSettings
|
||||
|
||||
override fun updateWavelogSettings(settings: WavelogSettings) {
|
||||
preferences.edit {
|
||||
putString(keyWavelogUrl, settings.url.trim())
|
||||
putString(keyWavelogToken, settings.token.trim())
|
||||
}
|
||||
_wavelogSettings.value = settings.copy(url = settings.url.trim(), token = settings.token.trim())
|
||||
}
|
||||
|
||||
private fun getWavelogSettings(): WavelogSettings = WavelogSettings(
|
||||
url = preferences.getString(keyWavelogUrl, null).orEmpty(),
|
||||
token = preferences.getString(keyWavelogToken, null).orEmpty()
|
||||
)
|
||||
|
||||
override fun getWorkedGrids(): Set<String> {
|
||||
val json = preferences.getString(keyWorkedGrids, null).orEmpty()
|
||||
if (json.isBlank()) return emptySet()
|
||||
@@ -386,6 +369,112 @@ class SettingsRepo(
|
||||
preferences.edit { putString(keyLastLotwSyncCallsign, callsign.trim().uppercase()) }
|
||||
//endregion
|
||||
|
||||
//region # Wavelog upload settings
|
||||
private val keyWavelogUploadUrl = "wavelogUploadUrl"
|
||||
private val keyWavelogUploadApiKey = "wavelogUploadApiKey"
|
||||
private val keyWavelogUploadStationId = "wavelogUploadStationId"
|
||||
private val keyWavelogUploadStationName = "wavelogUploadStationName"
|
||||
private val keyWavelogUploadStationCallsign = "wavelogUploadStationCallsign"
|
||||
private val keyWavelogUploadStationGrid = "wavelogUploadStationGrid"
|
||||
|
||||
private val _wavelogUploadSettings = MutableStateFlow(getWavelogUploadSettings())
|
||||
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> = _wavelogUploadSettings
|
||||
|
||||
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) {
|
||||
val trimmed = settings.copy(url = settings.url.trim(), apiKey = settings.apiKey.trim())
|
||||
preferences.edit {
|
||||
putString(keyWavelogUploadUrl, trimmed.url)
|
||||
putString(keyWavelogUploadApiKey, trimmed.apiKey)
|
||||
putString(keyWavelogUploadStationId, trimmed.stationId)
|
||||
putString(keyWavelogUploadStationName, trimmed.stationName)
|
||||
putString(keyWavelogUploadStationCallsign, trimmed.stationCallsign)
|
||||
putString(keyWavelogUploadStationGrid, trimmed.stationGrid)
|
||||
}
|
||||
_wavelogUploadSettings.value = trimmed
|
||||
}
|
||||
|
||||
private fun getWavelogUploadSettings(): WavelogUploadSettings = WavelogUploadSettings(
|
||||
url = preferences.getString(keyWavelogUploadUrl, null).orEmpty(),
|
||||
apiKey = preferences.getString(keyWavelogUploadApiKey, null).orEmpty(),
|
||||
stationId = preferences.getString(keyWavelogUploadStationId, null).orEmpty(),
|
||||
stationName = preferences.getString(keyWavelogUploadStationName, null).orEmpty(),
|
||||
stationCallsign = preferences.getString(keyWavelogUploadStationCallsign, null).orEmpty(),
|
||||
stationGrid = preferences.getString(keyWavelogUploadStationGrid, null).orEmpty()
|
||||
)
|
||||
//endregion
|
||||
|
||||
//region # Wavelog sync bookkeeping
|
||||
private val keyWavelogStations = "wavelogStations"
|
||||
private val keyWavelogSyncCursors = "wavelogSyncCursors"
|
||||
private val keyWavelogSyncUrl = "wavelogSyncUrl"
|
||||
private val keyLastWavelogSyncEpochMs = "wavelogLastSyncEpochMs"
|
||||
|
||||
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> {
|
||||
val json = preferences.getString(keyWavelogStations, null).orEmpty()
|
||||
if (json.isBlank()) return emptyList()
|
||||
return try {
|
||||
val array = org.json.JSONArray(json)
|
||||
(0 until array.length()).mapNotNull { i ->
|
||||
val o = array.optJSONObject(i) ?: return@mapNotNull null
|
||||
val id = o.optString("id").ifBlank { return@mapNotNull null }
|
||||
com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo(
|
||||
id = id,
|
||||
name = o.optString("name"),
|
||||
callsign = o.optString("call"),
|
||||
grid = o.optString("grid"),
|
||||
active = o.optBoolean("active", true)
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) {
|
||||
val array = org.json.JSONArray()
|
||||
stations.forEach { station ->
|
||||
array.put(
|
||||
org.json.JSONObject()
|
||||
.put("id", station.id)
|
||||
.put("name", station.name)
|
||||
.put("call", station.callsign)
|
||||
.put("grid", station.grid)
|
||||
.put("active", station.active)
|
||||
)
|
||||
}
|
||||
preferences.edit { putString(keyWavelogStations, array.toString()) }
|
||||
}
|
||||
|
||||
override fun getWavelogSyncCursors(): Map<String, Long> {
|
||||
val json = preferences.getString(keyWavelogSyncCursors, null).orEmpty()
|
||||
if (json.isBlank()) return emptyMap()
|
||||
return try {
|
||||
val root = org.json.JSONObject(json)
|
||||
root.keys().asSequence().associateWith { key -> root.optLong(key, 0L) }
|
||||
} catch (_: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setWavelogSyncCursors(cursors: Map<String, Long>) {
|
||||
val root = org.json.JSONObject()
|
||||
cursors.forEach { (stationId, cursor) -> root.put(stationId, cursor) }
|
||||
preferences.edit { putString(keyWavelogSyncCursors, root.toString()) }
|
||||
}
|
||||
|
||||
override fun getWavelogSyncUrl(): String =
|
||||
preferences.getString(keyWavelogSyncUrl, null).orEmpty()
|
||||
|
||||
override fun setWavelogSyncUrl(url: String) =
|
||||
preferences.edit { putString(keyWavelogSyncUrl, url) }
|
||||
|
||||
override fun getLastWavelogSyncEpochMs(): Long =
|
||||
preferences.getLong(keyLastWavelogSyncEpochMs, 0L)
|
||||
|
||||
override fun setLastWavelogSyncEpochMs(value: Long) =
|
||||
preferences.edit { putLong(keyLastWavelogSyncEpochMs, value) }
|
||||
//endregion
|
||||
|
||||
//region # Transceivers settings
|
||||
private val _passesSettings = MutableStateFlow(getPassesSettings())
|
||||
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
|
||||
@@ -468,6 +557,34 @@ class SettingsRepo(
|
||||
return true
|
||||
}
|
||||
|
||||
override fun getCurrentGrid(): String? {
|
||||
val station = _stationPosition.value
|
||||
val now = System.currentTimeMillis()
|
||||
// Only trust a location fix that is both recent (≤ 24 h) and newer than the stored
|
||||
// station, so a stale fix can never override a position the operator just set.
|
||||
val fix = lastKnownGridFix()?.takeIf { now - it.second <= 24 * 3_600_000L }
|
||||
val chosen = if (fix != null && fix.second >= station.timestamp) fix.first else station.qthLocator
|
||||
return chosen.takeIf(String::isNotBlank)
|
||||
}
|
||||
|
||||
/** Last known GPS/NETWORK fix converted to a locator; read-only, null without permission. */
|
||||
private fun lastKnownGridFix(): Pair<String, Long>? {
|
||||
return try {
|
||||
val provider = when {
|
||||
LocationManagerCompat.hasProvider(locationManager, providerGps) -> providerGps
|
||||
LocationManagerCompat.hasProvider(locationManager, providerNet) -> providerNet
|
||||
else -> return null
|
||||
}
|
||||
val location = locationManager.getLastKnownLocation(provider) ?: return null
|
||||
val locator = positionToQth(location.latitude, location.longitude) ?: return null
|
||||
locator to location.time
|
||||
} catch (_: SecurityException) {
|
||||
null
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getStationPosition(): GeoPos {
|
||||
val latitude = (preferences.getString(keyStationLatitude, null) ?: "0.0").toDouble()
|
||||
val longitude = (preferences.getString(keyStationLongitude, null) ?: "0.0").toDouble()
|
||||
@@ -877,6 +994,31 @@ class SettingsRepo(
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # Per-satellite logbook mode preset settings
|
||||
private val keySatelliteModes = "satelliteModes"
|
||||
|
||||
override fun getSatelliteMode(catnum: Int): String {
|
||||
val json = preferences.getString(keySatelliteModes, "{}") ?: "{}"
|
||||
return try {
|
||||
JSONObject(json).optString(catnum.toString(), "")
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
override fun setSatelliteMode(catnum: Int, mode: String) {
|
||||
val json = preferences.getString(keySatelliteModes, "{}") ?: "{}"
|
||||
val updated = try {
|
||||
val obj = JSONObject(json)
|
||||
if (mode.isBlank()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), mode.uppercase())
|
||||
obj.toString()
|
||||
} catch (_: Exception) {
|
||||
if (mode.isBlank()) "{}" else """{"$catnum": "${mode.uppercase()}"}"""
|
||||
}
|
||||
preferences.edit { putString(keySatelliteModes, updated) }
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # AMSAT status report settings
|
||||
private val keyAmSatCallsign = "amSatCallsign"
|
||||
|
||||
|
||||
+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
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.LatestRelease
|
||||
|
||||
-97
@@ -1,97 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Fetches the list of worked gridsquares from a self-hosted Wavelog instance.
|
||||
*
|
||||
* Uses the Wavelog v1 API endpoint (verified against Wavelog 3.0.2):
|
||||
* POST {base}/index.php/api/logbook_get_worked_grids
|
||||
* Body: {"key": "<api key>", "logbook_id": "<logbook id>"}
|
||||
* Response 201: JSON array of 4-char gridsquares, e.g. ["JN47","JO30"]
|
||||
* (see Wavelog Api.php logbook_get_worked_grids / Api_model::get_grids_worked_in_logbook)
|
||||
*
|
||||
* The token field carries "<api_key>:<logbook_id>" so one credential line
|
||||
* configures both values (the dialog explains this to the user).
|
||||
*/
|
||||
class WavelogRepository : IWavelogRepository {
|
||||
|
||||
override suspend fun fetchWorkedGrids(url: String, token: String): Set<String>? = withContext(Dispatchers.IO) {
|
||||
val parts = token.trim().split(":")
|
||||
val apiKey = parts.getOrNull(0)?.trim().orEmpty()
|
||||
val logbookId = parts.getOrNull(1)?.trim().orEmpty()
|
||||
if (url.isBlank() || apiKey.isBlank() || logbookId.isBlank()) return@withContext null
|
||||
val base = url.trim().trimEnd('/')
|
||||
val requestUrl = "$base/index.php/api/logbook_get_worked_grids"
|
||||
try {
|
||||
val connection = java.net.URL(requestUrl).openConnection() as java.net.HttpURLConnection
|
||||
connection.connectTimeout = 10_000
|
||||
connection.readTimeout = 15_000
|
||||
connection.requestMethod = "POST"
|
||||
connection.doOutput = true
|
||||
connection.setRequestProperty("Content-Type", "application/json")
|
||||
val body = JSONObject().apply {
|
||||
put("key", apiKey)
|
||||
put("logbook_id", logbookId)
|
||||
}.toString().toByteArray()
|
||||
connection.outputStream.use { it.write(body) }
|
||||
val code = connection.responseCode
|
||||
if (code !in 200..299) {
|
||||
connection.disconnect()
|
||||
return@withContext null
|
||||
}
|
||||
val response = connection.inputStream.bufferedReader().use { it.readText() }
|
||||
connection.disconnect()
|
||||
parseWorkedGrids(response)
|
||||
} catch (e: Exception) {
|
||||
println("WavelogRepository fetch failure: $e")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parseWorkedGrids(body: String): Set<String>? {
|
||||
return try {
|
||||
val array = when {
|
||||
body.trimStart().startsWith("[") -> JSONArray(body)
|
||||
else -> {
|
||||
// Tolerate {"grids": [...]} wrappers too
|
||||
JSONObject(body).optJSONArray("grids") ?: return null
|
||||
}
|
||||
}
|
||||
val result = mutableSetOf<String>()
|
||||
for (i in 0 until array.length()) {
|
||||
val grid = array.optString(i, "").trim().uppercase()
|
||||
// Keep only well-formed 4-character Maidenhead squares (e.g. OL62)
|
||||
if (grid.length == 4 && grid[0] in 'A'..'R' && grid[1] in 'A'..'R'
|
||||
&& grid[2] in '0'..'9' && grid[3] in '0'..'9'
|
||||
) {
|
||||
result.add(grid)
|
||||
}
|
||||
}
|
||||
result
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.AdifCodec
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogSyncRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogSyncFetch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Pulls QSO records from a self-hosted Wavelog via the v1 API.
|
||||
*
|
||||
* Verified against Wavelog 3.0.2 (`Api.php::get_contacts_adif`):
|
||||
* - `POST {base}/index.php/api/get_contacts_adif`
|
||||
* with `{"key", "station_id": [id...], "fetchfromid": <last id>, "limit"}`.
|
||||
* - Responds `{"status":"successful","lastfetchedid":<id>,"exported_qsos":N,"adif":"..."}`;
|
||||
* only rows whose internal id is greater than `fetchfromid` are exported, so the cursor
|
||||
* makes the pull incremental. Each station keeps its own cursor: a station profile added
|
||||
* later starts from 0 and pulls its full history without touching the others'.
|
||||
* - The export is a full ADIF document; decoding reuses [AdifCodec].
|
||||
*/
|
||||
class WavelogSyncRepository internal constructor(
|
||||
client: OkHttpClient = OkHttpClient()
|
||||
) : IWavelogSyncRepository {
|
||||
|
||||
private val client = client.newBuilder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.callTimeout(180, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(false)
|
||||
.build()
|
||||
|
||||
private val jsonType = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
override suspend fun fetchNewRecords(
|
||||
url: String,
|
||||
apiKey: String,
|
||||
stations: List<WavelogStationInfo>,
|
||||
cursors: Map<String, Long>,
|
||||
full: Boolean
|
||||
): WavelogSyncFetch? = withContext(Dispatchers.IO) {
|
||||
val base = url.trim().trimEnd('/')
|
||||
val key = apiKey.trim()
|
||||
if (base.isBlank() || key.isBlank() || stations.isEmpty()) return@withContext null
|
||||
val records = mutableListOf<QsoRecord>()
|
||||
val newCursors = mutableMapOf<String, Long>()
|
||||
val failed = mutableListOf<String>()
|
||||
// Every station profile is fetched (including ones the server marks inactive:
|
||||
// their historical QSOs still belong to the account and the logbook shows them).
|
||||
stations.forEach { station ->
|
||||
var cursor = if (full) 0L else cursors[station.id] ?: 0L
|
||||
var advanced = false
|
||||
var stationFailed = false
|
||||
try {
|
||||
var guard = 0
|
||||
while (guard++ < MAX_CHUNKS_PER_STATION) {
|
||||
val page = fetchPage(base, key, station.id, cursor)
|
||||
if (page == null) {
|
||||
stationFailed = true
|
||||
break
|
||||
}
|
||||
cursor = maxOf(cursor, page.cursor)
|
||||
advanced = true
|
||||
page.records.forEach { records += it.copy(wavelogStation = station.id) }
|
||||
if (page.exported < CHUNK_LIMIT) break
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
stationFailed = true
|
||||
}
|
||||
// A partially fetched station keeps the cursor of its last good page, so the
|
||||
// next sync resumes exactly where this one stopped — nothing is fetched twice
|
||||
// and nothing is skipped.
|
||||
if (advanced) newCursors[station.id] = cursor
|
||||
if (stationFailed) failed += station.id
|
||||
}
|
||||
WavelogSyncFetch(records, newCursors, failed)
|
||||
}
|
||||
|
||||
internal data class Page(val records: List<QsoRecord>, val cursor: Long, val exported: Int)
|
||||
|
||||
private fun fetchPage(base: String, key: String, stationId: String, fromId: Long): Page? {
|
||||
val body = JSONObject().apply {
|
||||
put("key", key)
|
||||
put("station_id", JSONArray().put(stationId))
|
||||
put("fetchfromid", fromId)
|
||||
put("limit", CHUNK_LIMIT)
|
||||
}.toString()
|
||||
val request = Request.Builder()
|
||||
.url("$base/index.php/api/get_contacts_adif")
|
||||
.post(body.toRequestBody(jsonType))
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
parsePage(response.body.string())
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parsePage(text: String): Page? {
|
||||
val json = runCatching { JSONObject(text) }.getOrNull() ?: return null
|
||||
if (json.optString("status") != "successful") return null
|
||||
if (!json.has("lastfetchedid")) return null
|
||||
val cursor = json.optLong("lastfetchedid", 0L)
|
||||
val exported = json.optInt("exported_qsos", 0)
|
||||
val adif = json.optString("adif")
|
||||
if (adif.isBlank()) return Page(emptyList(), cursor, exported)
|
||||
return Page(AdifCodec.decode(adif), cursor, exported)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CHUNK_LIMIT = 5000
|
||||
const val MAX_CHUNKS_PER_STATION = 200
|
||||
}
|
||||
}
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.AdifCodec
|
||||
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.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogProblem
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogProbe
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Uploads local QSO records to a self-hosted Wavelog via the v1 API.
|
||||
*
|
||||
* The server contract (verified against Wavelog 3.0.2, `Api.php` / `Logbook_model.php`):
|
||||
* - `POST {base}/index.php/api/qso` with `{"key","station_profile_id","type":"adif","string"}`.
|
||||
* - A clean import answers HTTP 201; skipped records (duplicates, differing locator/callsign)
|
||||
* answer HTTP 400 with one message line per record while the remaining records ARE inserted.
|
||||
* Duplicates count as "already in Wavelog" and are marked; `SKIPPED` records are not.
|
||||
* - The export omits `STATION_CALLSIGN` so the server fills it from the chosen station profile
|
||||
* (a present-but-different callsign would make the server skip the record).
|
||||
*/
|
||||
class WavelogUploadRepository internal constructor(
|
||||
client: OkHttpClient = OkHttpClient()
|
||||
) : IWavelogUploadRepository {
|
||||
|
||||
private val client = client.newBuilder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.callTimeout(120, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(false)
|
||||
.build()
|
||||
|
||||
private val jsonType = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
private val mutex = Mutex()
|
||||
@Volatile private var pending: Pending? = null
|
||||
|
||||
private data class Pending(
|
||||
val submittedIds: List<Long>,
|
||||
val callsById: Map<Long, String>,
|
||||
val adif: ByteArray,
|
||||
val created: Long
|
||||
)
|
||||
|
||||
override suspend fun probe(url: String, apiKey: String): WavelogProbe? = withContext(Dispatchers.IO) {
|
||||
val base = url.trim().trimEnd('/')
|
||||
if (base.isBlank() || apiKey.isBlank()) return@withContext null
|
||||
try {
|
||||
val authBody = get("$base/index.php/api/check_auth/$apiKey") ?: return@withContext null
|
||||
val rights = JSONObject(authBody).optString("rights")
|
||||
val stationsBody = get("$base/index.php/api/station_info/$apiKey") ?: return@withContext null
|
||||
WavelogProbe(rights, parseStations(stationsBody))
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun prepare(records: List<QsoRecord>, settings: WavelogUploadSettings): WavelogUploadPreview =
|
||||
withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
discardPending()
|
||||
if (settings.url.isBlank() || settings.apiKey.isBlank()) {
|
||||
return@withLock blocked(settings, WavelogProblem.NOT_CONFIGURED)
|
||||
}
|
||||
if (settings.stationId.isBlank()) return@withLock blocked(settings, WavelogProblem.NO_STATION)
|
||||
var skipped = 0
|
||||
val reasons = mutableMapOf<WavelogProblem, Int>()
|
||||
val unique = hashSetOf<String>()
|
||||
val submitted = records.sortedBy { it.startUtcMillis }.filter { record ->
|
||||
when {
|
||||
record.status != QsoStatus.COMPLETE -> {
|
||||
skipped++
|
||||
false
|
||||
}
|
||||
|
||||
!gridCompatible(record.myGrid, settings.stationGrid) -> {
|
||||
skipped++
|
||||
reasons[WavelogProblem.GRID_MISMATCH] = (reasons[WavelogProblem.GRID_MISMATCH] ?: 0) + 1
|
||||
false
|
||||
}
|
||||
|
||||
!unique.add(stableQsoKey(record)) -> {
|
||||
skipped++
|
||||
false
|
||||
}
|
||||
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
val preview = WavelogUploadPreview(
|
||||
count = submitted.size,
|
||||
skipped = skipped,
|
||||
reasons = reasons,
|
||||
stationLabel = stationLabel(settings),
|
||||
firstUtc = submitted.firstOrNull()?.let { utcText(it.startUtcMillis) }.orEmpty(),
|
||||
lastUtc = submitted.lastOrNull()?.let { utcText(it.startUtcMillis) }.orEmpty(),
|
||||
contacts = submitted.map { record ->
|
||||
"${shortText(record.startUtcMillis)} ${record.theirCallsign} ${record.displayMode} " +
|
||||
record.satelliteName.substringBefore('(').trim()
|
||||
},
|
||||
submittedIds = submitted.map { it.id }
|
||||
)
|
||||
if (submitted.isNotEmpty()) {
|
||||
pending = Pending(
|
||||
submittedIds = submitted.map { it.id },
|
||||
callsById = submitted.associate { it.id to it.theirCallsign.trim().uppercase(Locale.US) },
|
||||
adif = AdifCodec.encode(submitted, includeStationCallsign = false).toByteArray(Charsets.UTF_8),
|
||||
created = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
preview
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun upload(preview: WavelogUploadPreview, settings: WavelogUploadSettings): WavelogUploadOutcome =
|
||||
withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
val payload = pending
|
||||
pending = null // a preview is a one-shot authorization, including across failures
|
||||
val now = System.currentTimeMillis()
|
||||
if (payload == null || payload.submittedIds != preview.submittedIds || now - payload.created !in 0..900_000) {
|
||||
return@withLock WavelogUploadOutcome.Expired
|
||||
}
|
||||
try {
|
||||
val base = settings.url.trim().trimEnd('/')
|
||||
val body = JSONObject().apply {
|
||||
put("key", settings.apiKey.trim())
|
||||
put("station_profile_id", settings.stationId)
|
||||
put("type", "adif")
|
||||
put("string", String(payload.adif, Charsets.UTF_8))
|
||||
}.toString()
|
||||
val request = Request.Builder()
|
||||
.url("$base/index.php/api/qso")
|
||||
.post(body.toRequestBody(jsonType))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
val text = response.body.string().take(64 * 1024)
|
||||
classifyUploadResponse(response.code, text, payload.callsById)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
WavelogUploadOutcome.Failed("Upload failed — check server address/network (Tailscale on?)")
|
||||
} finally {
|
||||
payload.adif.fill(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun discardPending() {
|
||||
pending?.adif?.fill(0)
|
||||
pending = null
|
||||
}
|
||||
|
||||
private fun blocked(settings: WavelogUploadSettings, reason: WavelogProblem) = WavelogUploadPreview(
|
||||
count = 0,
|
||||
skipped = 0,
|
||||
reasons = emptyMap(),
|
||||
stationLabel = stationLabel(settings),
|
||||
firstUtc = "",
|
||||
lastUtc = "",
|
||||
contacts = emptyList(),
|
||||
submittedIds = emptyList(),
|
||||
blockedBy = reason
|
||||
)
|
||||
|
||||
private fun stationLabel(settings: WavelogUploadSettings): String = listOf(
|
||||
settings.stationName, settings.stationCallsign, settings.stationGrid
|
||||
).map(String::trim).filter(String::isNotBlank).joinToString(" · ")
|
||||
|
||||
private fun get(url: String): String? {
|
||||
val request = Request.Builder().url(url).get().build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) null else response.body.string().take(256 * 1024)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a record's logged grid is consistent with the Wavelog station profile grid.
|
||||
* Mirror of the server's `adif_grid_check_location` prefix rule; either side blank
|
||||
* disables the check, exactly like the server.
|
||||
*/
|
||||
internal fun gridCompatible(adifGrid: String, stationGrid: String): Boolean {
|
||||
val adif = adifGrid.trim().uppercase(Locale.US)
|
||||
val station = stationGrid.trim().uppercase(Locale.US)
|
||||
if (adif.isEmpty() || station.isEmpty()) return true
|
||||
val adifParts = adif.split(',').map(String::trim).filter(String::isNotEmpty)
|
||||
val stationParts = station.split(',').map(String::trim).filter(String::isNotEmpty)
|
||||
if (adifParts.isEmpty() || stationParts.isEmpty()) return true
|
||||
val adifMargin = adifParts.size > 1
|
||||
val stationMargin = stationParts.size > 1
|
||||
return when {
|
||||
adifMargin && stationMargin -> adifParts.sorted() == stationParts.sorted()
|
||||
adifMargin -> false
|
||||
stationMargin -> stationParts.any { adif.startsWith(it) }
|
||||
else -> adif.startsWith(station) || station.startsWith(adif)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies Wavelog's upload response.
|
||||
*
|
||||
* 2xx = everything imported. 400 = per-record errors: duplicates count as "already in Wavelog"
|
||||
* (they are marked, so a re-run converges), `SKIPPED` records are excluded from marking, and
|
||||
* any other message is surfaced verbatim with nothing marked.
|
||||
*/
|
||||
internal fun classifyUploadResponse(code: Int, body: String, callsById: Map<Long, String>): WavelogUploadOutcome = when {
|
||||
code in 200..299 -> WavelogUploadOutcome.Imported(
|
||||
imported = callsById.size, duplicates = 0, skipped = 0, markIds = callsById.keys.toList()
|
||||
)
|
||||
|
||||
code == 401 -> WavelogUploadOutcome.Failed("Wavelog rejected the API key or station (HTTP 401)")
|
||||
|
||||
code == 403 -> WavelogUploadOutcome.Failed(
|
||||
"API key has no write permission — create a read & write key in Wavelog (Settings → API)"
|
||||
)
|
||||
|
||||
else -> classifyAbortMessages(body, callsById)
|
||||
}
|
||||
|
||||
private val skipCallRegex = Regex("importing QSO with\\s*<b>([^<]+)</b>", RegexOption.IGNORE_CASE)
|
||||
|
||||
private fun classifyAbortMessages(body: String, callsById: Map<Long, String>): WavelogUploadOutcome {
|
||||
val raw = rawMessages(body)
|
||||
val lines = stripTags(raw).split('\n').map(String::trim).filter(String::isNotEmpty)
|
||||
if (lines.isEmpty()) {
|
||||
return WavelogUploadOutcome.Failed("Wavelog did not accept the upload — check the server log")
|
||||
}
|
||||
val duplicates = lines.count { it.contains("Duplicate for", ignoreCase = true) }
|
||||
val skipLines = lines.filter { it.contains("SKIPPED", ignoreCase = true) }
|
||||
val others = lines.filter {
|
||||
!it.contains("Duplicate for", ignoreCase = true) && !it.contains("SKIPPED", ignoreCase = true)
|
||||
}
|
||||
if (others.isNotEmpty()) {
|
||||
return WavelogUploadOutcome.Rejected("Wavelog reported: ${others.first().take(200)}")
|
||||
}
|
||||
val submittedCalls = callsById.values.toSet()
|
||||
val skipCalls = skipCallRegex.findAll(raw)
|
||||
.map { it.groupValues[1].trim().uppercase(Locale.US) }.toSet()
|
||||
if (skipLines.isNotEmpty() && (skipCalls.isEmpty() || skipCalls.any { it !in submittedCalls })) {
|
||||
// A skip we cannot map back to a submitted record: stay conservative, mark nothing.
|
||||
return WavelogUploadOutcome.Rejected("Some records were skipped by Wavelog — nothing was marked; review and retry")
|
||||
}
|
||||
val markIds = callsById.filterValues { it !in skipCalls }.keys.toList()
|
||||
val skipped = callsById.size - markIds.size
|
||||
val imported = (callsById.size - duplicates - skipped).coerceAtLeast(0)
|
||||
return WavelogUploadOutcome.Imported(imported, duplicates, skipped, markIds)
|
||||
}
|
||||
|
||||
private fun rawMessages(body: String): String {
|
||||
val json = runCatching { JSONObject(body) }.getOrNull()
|
||||
return if (json != null) {
|
||||
val messages = json.optJSONArray("messages")
|
||||
if (messages != null) (0 until messages.length()).joinToString("") { messages.optString(it) }
|
||||
else json.optString("reason")
|
||||
} else {
|
||||
body
|
||||
}
|
||||
}
|
||||
|
||||
private fun stripTags(raw: String): String = raw
|
||||
.replace(Regex("(?i)<br\\s*/?>"), "\n")
|
||||
.replace(Regex("<[^>]*>"), " ")
|
||||
.replace(""", "\"").replace("'", "'").replace("&", "&")
|
||||
.replace(Regex("[ \\t]+"), " ")
|
||||
.lines().joinToString("\n") { it.trim() }
|
||||
|
||||
/** Parses the v1 station_info array (only the fields the app uses). */
|
||||
internal fun parseStations(body: String): List<WavelogStationInfo> {
|
||||
val array = JSONArray(body)
|
||||
return (0 until array.length()).mapNotNull { index ->
|
||||
val item = array.optJSONObject(index) ?: return@mapNotNull null
|
||||
val id = item.optString("station_id")
|
||||
if (id.isBlank()) return@mapNotNull null
|
||||
WavelogStationInfo(
|
||||
id = id,
|
||||
name = item.optString("station_profile_name"),
|
||||
callsign = item.optString("station_callsign"),
|
||||
grid = item.optString("station_gridsquare"),
|
||||
active = item.optString("station_active") == "1"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun utcText(millis: Long): String = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}.format(Date(millis))
|
||||
|
||||
private fun shortText(millis: Long): String = SimpleDateFormat("MM-dd HH:mm", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}.format(Date(millis))
|
||||
@@ -43,6 +43,16 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
||||
return selectedOrbitalObjects
|
||||
}
|
||||
|
||||
override suspend fun getEntriesEpochs() = look4SatDao.getEntriesEpochs()
|
||||
|
||||
override suspend fun getEntriesNames() = look4SatDao.getEntriesNames()
|
||||
|
||||
override suspend fun renameEntries(names: Map<Int, String>) =
|
||||
names.forEach { (catnum, name) -> look4SatDao.renameEntry(catnum, name) }
|
||||
|
||||
override suspend fun deleteEntriesWithIds(ids: List<Int>) =
|
||||
ids.chunked(999).forEach { idsPart -> look4SatDao.deleteEntriesWithIds(idsPart) }
|
||||
|
||||
override suspend fun insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity())
|
||||
|
||||
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
|
||||
|
||||
@@ -29,7 +29,7 @@ class AddToCalendar(private val context: Context) : IAddToCalendar {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
setData(CalendarContract.Events.CONTENT_URI)
|
||||
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_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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
+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.data.lotw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* previewCertificate parses a .p12 without touching storage — just enough to
|
||||
* learn the certificate's DXCC entity, so the settings dialog can offer the
|
||||
* entity's Province/State dropdown before the operator confirms the import.
|
||||
*
|
||||
* Fixture test_tqsl_empty.p12 mirrors a modern TQSL export: PBES2/AES-256,
|
||||
* empty password, TQSL subject attribute 1.3.6.1.4.1.12348.1.1 = BA7OPF and
|
||||
* extensions .2/.3/.4 = first/last QSO date, DXCC 318 (China).
|
||||
*/
|
||||
class LoTWUploadRepositoryPreviewTest {
|
||||
|
||||
private class MemStorage : LoTWStorage {
|
||||
val files = mutableMapOf<String, ByteArray>()
|
||||
override fun read(name: String): ByteArray? = files[name]
|
||||
override fun write(name: String, data: ByteArray) { files[name] = data.copyOf() }
|
||||
override fun delete(name: String) { files.remove(name) }
|
||||
}
|
||||
|
||||
private fun fixture(name: String): ByteArray =
|
||||
javaClass.classLoader!!.getResourceAsStream(name)!!.use { it.readBytes() }
|
||||
|
||||
private fun repository(storage: MemStorage) = LoTWUploadRepository(
|
||||
storage, { error("station config not needed") }, now = { System.currentTimeMillis() }
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `preview parses TQSL-style certificate without persisting it`() {
|
||||
runBlocking {
|
||||
val storage = MemStorage()
|
||||
val repo = repository(storage)
|
||||
val info = repo.previewCertificate(fixture("test_tqsl_empty.p12"), charArrayOf())
|
||||
assertEquals("BA7OPF", info.callsign)
|
||||
assertEquals(318, info.dxcc)
|
||||
assertEquals("2026-01-01", info.firstQsoDate)
|
||||
assertTrue("nothing written", storage.files.isEmpty())
|
||||
assertNull("no certificate stored", repo.certificate())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wrong password fails as certificate password and wipes the attempt`() {
|
||||
runBlocking {
|
||||
val storage = MemStorage()
|
||||
val repo = repository(storage)
|
||||
val password = "definitely-wrong".toCharArray()
|
||||
val error = assertThrows(LoTWOperationException::class.java) {
|
||||
runBlocking { repo.previewCertificate(fixture("test_pbes2.p12"), password) }
|
||||
}
|
||||
assertEquals(LoTWProblem.CERTIFICATE_PASSWORD, error.reason)
|
||||
assertTrue("password wiped", password.all { it == '\u0000' })
|
||||
assertTrue("nothing written", storage.files.isEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.rtbishop.look4sat.core.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())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsEmptyPasswordCertpbeNoneLayout() {
|
||||
// Certificates exported without a password must import with an empty char
|
||||
// array — the reader must decrypt, not demand a password.
|
||||
val (key, cert) = Pkcs12Reader.read(fixture("test_pbes2_empty.p12"), charArrayOf())
|
||||
assertEquals("RSA", key.algorithm)
|
||||
assertTrue("key/cert pair", keyMatches(key, cert))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsEmptyPasswordEncryptedCertLayout() {
|
||||
val (key, cert) = Pkcs12Reader.read(fixture("test_pbes2_empty_enc.p12"), charArrayOf())
|
||||
assertEquals("RSA", key.algorithm)
|
||||
assertTrue("key/cert pair", keyMatches(key, cert))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyPasswordOnProtectedFileFailsAsDecrypt() {
|
||||
// Blank input on a password-protected file must fail as a decryption error
|
||||
// (mapped to "password" upstream) — never silently succeed.
|
||||
val e = assertThrows(Exception::class.java) {
|
||||
Pkcs12Reader.read(fixture(), charArrayOf())
|
||||
}
|
||||
assertTrue("BadPadding", e is javax.crypto.BadPaddingException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun platformKeystoreAcceptsEmptyPassword() {
|
||||
// LoTWKeyMaterial first tries the platform PKCS12 KeyStore: verify the
|
||||
// empty-password load/getKey call convention this fix relies on.
|
||||
val ks = java.security.KeyStore.getInstance("PKCS12")
|
||||
ks.load(fixture("test_pbes2_empty.p12").inputStream(), charArrayOf())
|
||||
val alias = ks.aliases().nextElement()
|
||||
assertNotNull(ks.getKey(alias, charArrayOf()))
|
||||
}
|
||||
}
|
||||
+136
@@ -1,5 +1,24 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
@@ -125,6 +144,123 @@ class AmSatRepositoryTest {
|
||||
val page = repository.fetchStatus()
|
||||
assertEquals(4, page?.statuses?.single()?.days?.get(0)?.streakCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildStatusesStrictMajorityTakesItsColorAndCount() = runTest {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
|
||||
val heardMost = firstSlotStatus(
|
||||
report("h1", "AO-7", "Heard", nowSec - 3600),
|
||||
report("h2", "AO-7", "Heard", nowSec - 4500),
|
||||
report("t1", "AO-7", "Telemetry Only", nowSec - 5400)
|
||||
)
|
||||
assertEquals(0xFF648FFFL, heardMost?.statusColor) // blue
|
||||
assertEquals(2, heardMost?.count) // majority count, not the block total
|
||||
|
||||
val telemetryMost = firstSlotStatus(
|
||||
report("t1", "AO-7", "Telemetry Only", nowSec - 3600),
|
||||
report("t2", "AO-7", "Telemetry Only", nowSec - 4500),
|
||||
report("h1", "AO-7", "Heard", nowSec - 5400)
|
||||
)
|
||||
assertEquals(0xFFFFB000L, telemetryMost?.statusColor) // amber
|
||||
assertEquals(2, telemetryMost?.count)
|
||||
|
||||
val notHeardMost = firstSlotStatus(
|
||||
report("n1", "AO-7", "Not Heard", nowSec - 3600),
|
||||
report("n2", "AO-7", "Not Heard", nowSec - 4500),
|
||||
report("n3", "AO-7", "Not Heard", nowSec - 5400),
|
||||
report("t1", "AO-7", "Telemetry Only", nowSec - 6000)
|
||||
)
|
||||
assertEquals(0xFFDC267FL, notHeardMost?.statusColor) // pink
|
||||
assertEquals(3, notHeardMost?.count)
|
||||
|
||||
val single = firstSlotStatus(report("h1", "AO-7", "Heard", nowSec - 3600))
|
||||
assertEquals(0xFF648FFFL, single?.statusColor)
|
||||
assertEquals(1, single?.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildStatusesShowsConflictWhenNoStrictMajority() = runTest {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
|
||||
val oneToOne = firstSlotStatus(
|
||||
report("h1", "AO-7", "Heard", nowSec - 3600),
|
||||
report("n1", "AO-7", "Not Heard", nowSec - 4500)
|
||||
)
|
||||
assertEquals(0xFFFE6100L, oneToOne?.statusColor) // conflicting
|
||||
assertEquals(2, oneToOne?.count)
|
||||
|
||||
val twoToTwo = firstSlotStatus(
|
||||
report("h1", "AO-7", "Heard", nowSec - 3600),
|
||||
report("h2", "AO-7", "Heard", nowSec - 4500),
|
||||
report("t1", "AO-7", "Telemetry Only", nowSec - 5400),
|
||||
report("t2", "AO-7", "Telemetry Only", nowSec - 6000)
|
||||
)
|
||||
assertEquals(0xFFFE6100L, twoToTwo?.statusColor)
|
||||
|
||||
val threeWay = firstSlotStatus(
|
||||
report("h1", "AO-7", "Heard", nowSec - 3600),
|
||||
report("n1", "AO-7", "Not Heard", nowSec - 4500),
|
||||
report("t1", "AO-7", "Telemetry Only", nowSec - 5400)
|
||||
)
|
||||
assertEquals(0xFFFE6100L, threeWay?.statusColor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildStatusesCrewActiveWinsWithCrewPlusHeardCount() = runTest {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
|
||||
val crewWithHeard = firstSlotStatus(
|
||||
report("c1", "AO-7", "Crew Active", nowSec - 3600),
|
||||
report("h1", "AO-7", "Heard", nowSec - 4500),
|
||||
report("h2", "AO-7", "Heard", nowSec - 5400),
|
||||
report("h3", "AO-7", "Heard", nowSec - 6000)
|
||||
)
|
||||
assertEquals(0xFF785EF0L, crewWithHeard?.statusColor) // purple
|
||||
assertEquals(4, crewWithHeard?.count) // crew + heard
|
||||
|
||||
val crewAlone = firstSlotStatus(
|
||||
report("c1", "AO-7", "Crew Active", nowSec - 3600),
|
||||
report("n1", "AO-7", "Not Heard", nowSec - 4500),
|
||||
report("n2", "AO-7", "Not Heard", nowSec - 5400)
|
||||
)
|
||||
assertEquals(0xFF785EF0L, crewAlone?.statusColor)
|
||||
assertEquals(1, crewAlone?.count) // crew + heard (0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildStatusesMarksOnlyNoStrictMajoritySlotsAsConflicted() = runTest {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
|
||||
val majority = firstSlotStatus(
|
||||
report("h1", "AO-7", "Heard", nowSec - 3600),
|
||||
report("h2", "AO-7", "Heard", nowSec - 4500),
|
||||
report("t1", "AO-7", "Telemetry Only", nowSec - 5400)
|
||||
)
|
||||
assertEquals(false, majority?.isConflicted)
|
||||
|
||||
val tie = firstSlotStatus(
|
||||
report("h1", "AO-7", "Heard", nowSec - 3600),
|
||||
report("t1", "AO-7", "Telemetry Only", nowSec - 4500)
|
||||
)
|
||||
assertEquals(true, tie?.isConflicted)
|
||||
|
||||
val crew = firstSlotStatus(
|
||||
report("c1", "AO-7", "Crew Active", nowSec - 3600),
|
||||
report("n1", "AO-7", "Not Heard", nowSec - 4500)
|
||||
)
|
||||
assertEquals(false, crew?.isConflicted)
|
||||
|
||||
val empty = firstSlotStatus()
|
||||
assertEquals(false, empty?.isConflicted)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun firstSlotStatus(vararg reports: String): SatSlot? {
|
||||
val page = AmSatRepository(
|
||||
FakeAmSatRemoteSource(reportsJson = amSatReportsJson(*reports))
|
||||
).fetchStatus()
|
||||
return page?.statuses?.single()?.days?.get(0)?.slots?.get(0)
|
||||
}
|
||||
|
||||
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
|
||||
|
||||
+166
-6
@@ -44,6 +44,7 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.InputStream
|
||||
import java.time.LocalDate
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DatabaseRepoTest {
|
||||
@@ -51,6 +52,10 @@ class DatabaseRepoTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
private val dataParser = DataParser(dispatcher)
|
||||
|
||||
// fixtures carry a current epoch: stale entries are pruned on every remote update
|
||||
private val todayEpoch = LocalDate.now().let { "%02d%03d".format(it.year % 100, it.dayOfYear) }
|
||||
private val fresherEpoch = "$todayEpoch.71955234".toDouble()
|
||||
|
||||
@Test
|
||||
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
|
||||
val uri = "content://look4sat/import/satellites"
|
||||
@@ -150,20 +155,144 @@ class DatabaseRepoTest {
|
||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remote update takes the freshest elements and the preferred name`() = runTest(dispatcher) {
|
||||
val primaryUrl = "https://example.com/primary.txt"
|
||||
val secondaryUrl = "https://example.com/secondary.txt"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[primaryUrl] = { validTleStream() }
|
||||
networkStreams[secondaryUrl] = { fresherTleStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(primaryUrl, secondaryUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
repository.updateFromRemote()
|
||||
|
||||
val entry = localSource.insertedEntries.single { it.catnum == 25544 }
|
||||
assertEquals("ISS (ZARYA)", entry.name)
|
||||
assertEquals(fresherEpoch, entry.epoch, 1e-8)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual satellite import does not overwrite fresher data`() = runTest(dispatcher) {
|
||||
val freshUri = "content://look4sat/import/fresh"
|
||||
val staleUri = "content://look4sat/import/stale"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
fileStreams[freshUri] = { fresherTleStream() }
|
||||
fileStreams[staleUri] = { validTleStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo()
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
assertEquals(1, repository.updateTLEFromFile(freshUri))
|
||||
assertEquals(1, repository.updateTLEFromFile(staleUri))
|
||||
assertEquals(fresherEpoch, localSource.insertedEntries.single().epoch, 1e-8)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remote update renames satellites even when their elements are not newer`() = runTest(dispatcher) {
|
||||
val preferredUrl = "https://example.com/preferred.txt"
|
||||
val freshUrl = "https://example.com/fresh.txt"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[freshUrl] = { fresherTleStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(freshUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
repository.updateFromRemote()
|
||||
assertEquals("ISS", localSource.insertedEntries.single().name)
|
||||
|
||||
// the user moves a source with older elements, but a better name, to the top of the list
|
||||
remoteSource.networkStreams[preferredUrl] = { validTleStream() }
|
||||
settingsRepo.dataSourcesSettings.value = DataSourcesSettings(
|
||||
satelliteUrls = listOf(preferredUrl, freshUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
repository.updateFromRemote()
|
||||
|
||||
val entry = localSource.insertedEntries.single()
|
||||
assertEquals("ISS (ZARYA)", entry.name)
|
||||
assertEquals(fresherEpoch, entry.epoch, 1e-8)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed remote update records the attempt without claiming success`() = runTest(dispatcher) {
|
||||
val failingUrl = "https://example.com/offline.txt"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource()
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(failingUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
)
|
||||
settingsRepo.databaseState.value = DatabaseState(0, 0, 1_000L)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
repository.updateFromRemote()
|
||||
|
||||
// the update timestamp stays put so the data is not presented as fresh, while the recorded
|
||||
// attempt keeps the next app launch from hammering a source that is already failing
|
||||
assertEquals(1_000L, settingsRepo.databaseState.value.updateTimestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remote update prunes satellites no source refreshed for a month`() = runTest(dispatcher) {
|
||||
val url = "https://example.com/fresh.txt"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[url] = { fresherTleStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(url),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
// a satellite that disappeared from every catalog: elements stored a month and a half ago
|
||||
val staleDate = LocalDate.now().minusDays(45)
|
||||
val staleEpoch = "%02d%03d".format(staleDate.year % 100, staleDate.dayOfYear).toDouble() + 0.5
|
||||
localSource.insertEntries(
|
||||
listOf(OrbitalData("OLD SAT", staleEpoch, 15.5, 0.001, 51.6, 309.4, 203.6, 299.8, 99999, 0.0003, 0.0000128))
|
||||
)
|
||||
|
||||
repository.updateFromRemote()
|
||||
|
||||
assertEquals(listOf(25544), localSource.insertedEntries.map { it.catnum })
|
||||
}
|
||||
|
||||
private fun validCsvStream(): InputStream = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
ISS (ZARYA),1998-067A,${LocalDate.now()}T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
private fun validTleStream(): InputStream = """
|
||||
ISS (ZARYA)
|
||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
1 25544U 98067A $todayEpoch.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
private fun fresherTleStream(): InputStream = """
|
||||
ISS
|
||||
1 25544U 98067A $todayEpoch.71955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
private fun jamxTleStream(): InputStream = """
|
||||
JAMX-0825b
|
||||
1 98248U 26237.16675926 .00015724 00000-0 97477-3 0 00013
|
||||
1 98248U $todayEpoch.16675926 .00015724 00000-0 97477-3 0 00013
|
||||
2 98248 097.5373 310.9694 0011309 278.1232 340.7230 15.09766181000012
|
||||
""".trimIndent().byteInputStream()
|
||||
}
|
||||
@@ -195,6 +324,21 @@ private class FakeLocalSource : ILocalSource {
|
||||
|
||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||
|
||||
override suspend fun getEntriesEpochs(): Map<Int, Double> =
|
||||
insertedEntries.associate { entry -> entry.catnum to entry.epoch }
|
||||
|
||||
override suspend fun getEntriesNames(): Map<Int, String> =
|
||||
insertedEntries.associate { entry -> entry.catnum to entry.name }
|
||||
|
||||
override suspend fun renameEntries(names: Map<Int, String>) = names.forEach { (catnum, name) ->
|
||||
val index = insertedEntries.indexOfFirst { entry -> entry.catnum == catnum }
|
||||
if (index >= 0) insertedEntries[index] = insertedEntries[index].copy(name = name)
|
||||
}
|
||||
|
||||
override suspend fun deleteEntriesWithIds(ids: List<Int>) {
|
||||
insertedEntries.removeAll { entry -> entry.catnum in ids }
|
||||
}
|
||||
|
||||
override suspend fun insertEntries(entries: List<OrbitalData>) {
|
||||
insertedEntries += entries
|
||||
}
|
||||
@@ -271,6 +415,8 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
|
||||
override fun setStationPosition(locator: String): Boolean = true
|
||||
|
||||
override fun getCurrentGrid(): String? = null
|
||||
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
|
||||
|
||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
||||
@@ -298,6 +444,8 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
override fun getSatelliteMode(catnum: Int): String = ""
|
||||
override fun setSatelliteMode(catnum: Int, mode: String) {}
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
|
||||
@@ -305,10 +453,14 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
|
||||
override fun setAmSatCallsign(callsign: String) = Unit
|
||||
|
||||
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
|
||||
|
||||
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
|
||||
|
||||
|
||||
|
||||
override val wavelogUploadSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings())
|
||||
|
||||
override fun updateWavelogUploadSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings) = Unit
|
||||
|
||||
override fun getWorkedGrids(): Set<String> = emptySet()
|
||||
|
||||
@@ -326,6 +478,14 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
override fun setLastLotwSyncDate(date: String) = Unit
|
||||
override fun getLastLotwSyncCallsign(): String = ""
|
||||
override fun setLastLotwSyncCallsign(callsign: String) = Unit
|
||||
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList()
|
||||
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) = Unit
|
||||
override fun getWavelogSyncCursors(): Map<String, Long> = emptyMap()
|
||||
override fun setWavelogSyncCursors(cursors: Map<String, Long>) = Unit
|
||||
override fun getWavelogSyncUrl(): String = ""
|
||||
override fun setWavelogSyncUrl(url: String) = Unit
|
||||
override fun getLastWavelogSyncEpochMs(): Long = 0L
|
||||
override fun setLastWavelogSyncEpochMs(value: Long) = Unit
|
||||
|
||||
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
|
||||
|
||||
|
||||
+47
-3
@@ -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
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||
@@ -112,9 +130,12 @@ class LoTWRepositoryTest {
|
||||
assertEquals("A50QO", qso.call)
|
||||
assertEquals("FO-29", qso.satName)
|
||||
assertEquals("CW", qso.mode)
|
||||
assertEquals("2M", qso.bandUp)
|
||||
assertEquals("70CM", qso.bandDown)
|
||||
assertEquals("V/U", qso.bandLabel)
|
||||
// ADIF BAND is the transmitted (uplink) band, BAND_RX the received (downlink) one.
|
||||
// Confirmed against the live report of the own uploads: SO-50 QSOs arrive as
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -146,6 +167,29 @@ class LoTWRepositoryTest {
|
||||
result.values.forEach { assertEquals(1, it.size) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosCapturesOppositeGridSet() {
|
||||
// The opposite station's grids (GRIDSQUARE + VUCC_GRIDS) must ride on the
|
||||
// GridQso so the logbook can show multi-grid confirmations in its QSL slot.
|
||||
val single = "<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
assertEquals(
|
||||
listOf("NL47"),
|
||||
repo.parseConfirmedGridQsos(report(single))!!["NL47"]!!.first().theirGrids
|
||||
)
|
||||
val pair = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<VUCC_GRIDS:11>EN52en,EN53fa\n<EOR>\n"
|
||||
assertEquals(
|
||||
listOf("EN52", "EN53"),
|
||||
repo.parseConfirmedGridQsos(report(pair))!!["EN52"]!!.first().theirGrids
|
||||
)
|
||||
// A 6-char GRIDSQUARE plus a VUCC pair: every field, truncated to 4 chars, in order.
|
||||
val six = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
|
||||
"<GRIDSQUARE:6>OM60IL\n<VUCC_GRIDS:11>EN52en,EN53fa\n<EOR>\n"
|
||||
assertEquals(
|
||||
listOf("OM60", "EN52", "EN53"),
|
||||
repo.parseConfirmedGridQsos(report(six))!!["OM60"]!!.first().theirGrids
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosRejectsBodyWithoutEoh() {
|
||||
assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>"))
|
||||
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
/*
|
||||
* 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)
|
||||
// The opposite station's grid set from the report lands on the row.
|
||||
assertEquals("EN52", rows.first().theirGrid)
|
||||
assertEquals("EN52,EN53", rows.first().theirVuccGrids)
|
||||
}
|
||||
|
||||
@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 consolidateConfirmations_foldsADoubledConfirmedPairKeepingTheRicherRow() = runBlocking {
|
||||
// The mirrored import the old parser created (no frequencies, no report-side grids —
|
||||
// the pre-fix parser filled neither — band direction reversed)... seeded FIRST so it
|
||||
// carries the lower id.
|
||||
dao.save(
|
||||
reportConfirmation(satName = "SO-50")
|
||||
.copy(band = "70CM", rxBand = "2M", theirGrid = "", theirVuccGrids = emptyList())
|
||||
.toEntity()
|
||||
)
|
||||
// ...plus the app row that was confirmed on its own later (frequencies, upload state).
|
||||
val localId = dao.save(
|
||||
loggedInApp("SO-50", lotwUploaded = true).copy(lotwConfirmed = true).toEntity()
|
||||
)
|
||||
|
||||
assertEquals(1, repository.consolidateConfirmations())
|
||||
|
||||
val rows = dao.getAll()
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals(localId, rows.first().id)
|
||||
// The surviving row keeps the correct uplink-first bands and gains the report fills.
|
||||
assertEquals("2M", rows.first().band)
|
||||
assertEquals("70CM", rows.first().rxBand)
|
||||
assertEquals("OL62", rows.first().myGrid)
|
||||
assertEquals(318, rows.first().dxcc)
|
||||
assertEquals("China", rows.first().country)
|
||||
assertTrue(rows.first().lotwUploaded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeLoTW_healsADoubledMirroredContact() = runBlocking {
|
||||
// Two mirrored copies of one confirmation (as the pre-fix parser imported on separate
|
||||
// syncs — no frequencies, no report-side grids): the full sync must consolidate them
|
||||
// into one row with the report's direction.
|
||||
val stale = reportConfirmation(satName = "SO-50")
|
||||
.copy(band = "70CM", rxBand = "2M", theirGrid = "", theirVuccGrids = emptyList())
|
||||
dao.save(stale.toEntity())
|
||||
dao.save(stale.copy(id = 0L).toEntity())
|
||||
|
||||
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
|
||||
|
||||
val rows = dao.getAll()
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals("2M", rows.first().band)
|
||||
assertEquals("70CM", rows.first().rxBand)
|
||||
// The report's fills land on the healed row.
|
||||
assertEquals("EN52", rows.first().theirGrid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeLoTW_healsAMirroredRowWhoseDedupeKeyDivergedFromTheReport() = runBlocking {
|
||||
// The doubled contacts of the reported bug: a mirrored row written by an older release
|
||||
// (millisecond-precision timestamp here — enough to make the dedupe key, which holds
|
||||
// the exact time, differ from the report's) used to gain a second row next to it
|
||||
// instead of being healed, because every net relied on the key or the strict bands.
|
||||
val staleId = dao.save(
|
||||
reportConfirmation(satName = "SO-50")
|
||||
.copy(
|
||||
band = "70CM", rxBand = "2M", theirGrid = "", theirVuccGrids = emptyList(),
|
||||
startUtcMillis = qsoStart + 456L
|
||||
)
|
||||
.toEntity()
|
||||
)
|
||||
|
||||
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
|
||||
|
||||
val rows = dao.getAll()
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals(staleId, rows.first().id)
|
||||
assertEquals("2M", rows.first().band)
|
||||
assertEquals("70CM", rows.first().rxBand)
|
||||
assertTrue(rows.first().lotwConfirmed)
|
||||
assertEquals("EN52", rows.first().theirGrid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeLoTW_foldsAMirroredRowSpelledAsPlainMfskIntoItsFt4Report() = runBlocking {
|
||||
// Releases that persisted no sub-mode stored FT4 rows as plain MFSK while the report
|
||||
// carries MFSK + FT4. The spelling difference must not block the heal either.
|
||||
dao.save(
|
||||
reportConfirmation(satName = "SO-50")
|
||||
.copy(
|
||||
band = "70CM", rxBand = "2M", mode = "MFSK", submode = "",
|
||||
theirGrid = "", theirVuccGrids = emptyList()
|
||||
)
|
||||
.toEntity()
|
||||
)
|
||||
|
||||
repository.mergeLoTW(
|
||||
listOf(reportConfirmation(satName = "SO-50").copy(mode = "MFSK", submode = "FT4"))
|
||||
)
|
||||
|
||||
val rows = dao.getAll()
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals("2M", rows.first().band)
|
||||
assertEquals("70CM", rows.first().rxBand)
|
||||
assertTrue(rows.first().lotwConfirmed)
|
||||
assertEquals("EN52", rows.first().theirGrid)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markWavelogUploaded_setsTheFlagAndTheStationStamp() = runBlocking {
|
||||
val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).toEntity())
|
||||
|
||||
repository.markWavelogUploaded(listOf(id), "3")
|
||||
|
||||
val row = dao.getAll().single()
|
||||
assertTrue(row.wavelogUploaded)
|
||||
assertFalse(row.lotwUploaded)
|
||||
assertEquals("3", row.wavelogStation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun save_editingAWavelogUploadedRecordCreatesANewRecord() = runBlocking {
|
||||
val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).copy(wavelogUploaded = true).toEntity())
|
||||
|
||||
repository.save(loggedInApp("SO-50", lotwUploaded = false).copy(id = id, theirCallsign = "BG5JVM"))
|
||||
|
||||
assertEquals(2, dao.getAll().size)
|
||||
assertEquals(1, dao.getAll().count { it.wavelogUploaded })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeWavelog_insertsNewRecordsAlreadyMarkedAndStationTagged() = runBlocking {
|
||||
val result = repository.mergeWavelog(
|
||||
listOf(loggedInApp("SO-50", lotwUploaded = false).copy(wavelogStation = "2"))
|
||||
)
|
||||
|
||||
assertEquals(1, result.imported)
|
||||
val row = dao.getAll().single()
|
||||
assertTrue(row.wavelogUploaded)
|
||||
assertEquals("2", row.wavelogStation)
|
||||
assertFalse(row.lotwUploaded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeWavelog_matchesALocalRowInsteadOfDuplicating() = runBlocking {
|
||||
val id = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = false).copy(wavelogUploaded = true).toEntity())
|
||||
|
||||
repository.mergeWavelog(
|
||||
listOf(loggedInApp("SAUDISAT 1C", lotwUploaded = false).copy(wavelogStation = "1", theirGrid = "EN52"))
|
||||
)
|
||||
|
||||
val rows = dao.getAll()
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals(id, rows.first().id)
|
||||
assertEquals("1", rows.first().wavelogStation)
|
||||
assertEquals("EN52", rows.first().theirGrid)
|
||||
// The row joins the ARRL spelling like every other import path.
|
||||
assertEquals("SO-50", rows.first().satelliteName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeWavelog_isIdempotentAcrossSyncs() = runBlocking {
|
||||
val records = listOf(loggedInApp("SO-50", lotwUploaded = false).copy(wavelogStation = "1"))
|
||||
|
||||
repository.mergeWavelog(records)
|
||||
repository.mergeWavelog(records)
|
||||
|
||||
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"),
|
||||
theirGrids = listOf("EN52", "EN53")
|
||||
).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,
|
||||
wavelogUploaded = wavelogUploaded,
|
||||
wavelogStation = wavelogStation,
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.joinToString(","),
|
||||
theirVuccGrids = theirVuccGrids.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) } }
|
||||
}
|
||||
|
||||
override suspend fun markWavelogUploaded(ids: List<Long>, stationId: String) {
|
||||
ids.forEach { id ->
|
||||
rows[id]?.let {
|
||||
rows[id] = it.copy(
|
||||
wavelogUploaded = true,
|
||||
wavelogStation = if (stationId.isBlank()) it.wavelogStation else stationId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
-3
@@ -23,6 +23,7 @@ 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 com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -241,6 +242,26 @@ class SelectionRepoSearchTest {
|
||||
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `All with empty persisted ids falls back to no filtering`() = runTest {
|
||||
// CelesTrak answers 403 to a repeated download inside its 2-hour update window,
|
||||
// so the persisted "All" id list can be empty. Selecting All then means "all
|
||||
// satellites" (no filtering) instead of blanking the list.
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setTypes(listOf(Sources.allSourceType))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `other source type with empty persisted ids still shows empty`() = runTest {
|
||||
// Only "All" falls back to no filtering; a regular source without persisted ids
|
||||
// keeps the previous behaviour (empty list).
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setTypes(listOf("Amateur"))
|
||||
assertTrue(repo.getEntriesFlow().first().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getTypesList excludes only the Other placeholder`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
@@ -282,6 +303,10 @@ private class FakeLocalSourceForSearch(
|
||||
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 getEntriesEpochs(): Map<Int, Double> = emptyMap()
|
||||
override suspend fun getEntriesNames(): Map<Int, String> = emptyMap()
|
||||
override suspend fun renameEntries(names: Map<Int, String>) = Unit
|
||||
override suspend fun deleteEntriesWithIds(ids: List<Int>) = Unit
|
||||
override suspend fun insertEntries(entries: List<OrbitalData>) = Unit
|
||||
override suspend fun deleteEntries() = Unit
|
||||
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = sstvIds
|
||||
@@ -319,8 +344,10 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
|
||||
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 wavelogUploadSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings())
|
||||
override fun updateWavelogUploadSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings) = Unit
|
||||
override val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
|
||||
|
||||
@@ -330,6 +357,7 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
|
||||
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 getCurrentGrid(): String? = null
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> =
|
||||
types.flatMap { typeIds[it].orEmpty() }.distinct()
|
||||
|
||||
@@ -343,10 +371,12 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
|
||||
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()
|
||||
@@ -360,4 +390,12 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
|
||||
override fun setLastLotwSyncDate(date: String) = Unit
|
||||
override fun getLastLotwSyncCallsign(): String = ""
|
||||
override fun setLastLotwSyncCallsign(callsign: String) = Unit
|
||||
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList()
|
||||
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) = Unit
|
||||
override fun getWavelogSyncCursors(): Map<String, Long> = emptyMap()
|
||||
override fun setWavelogSyncCursors(cursors: Map<String, Long>) = Unit
|
||||
override fun getWavelogSyncUrl(): String = ""
|
||||
override fun setWavelogSyncUrl(url: String) = Unit
|
||||
override fun getLastWavelogSyncEpochMs(): Long = 0L
|
||||
override fun setLastWavelogSyncEpochMs(value: Long) = Unit
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Parsing of the `get_contacts_adif` response envelope (transport-level wiring is exercised on device). */
|
||||
class WavelogSyncRepositoryTest {
|
||||
|
||||
private val repository = WavelogSyncRepository()
|
||||
|
||||
@Test
|
||||
fun successfulPageParsesRecordsAndCursor() {
|
||||
val adif = "<ADIF_VER:5>3.1.7\n<EOH>\n" +
|
||||
"<QSO_DATE:8>20260916<TIME_ON:4>0745<CALL:6>BG5JSB<GRIDSQUARE:4>EN52" +
|
||||
"<MODE:2>FM<PROP_MODE:3>SAT<SAT_NAME:5>SO-50<EOR>\n"
|
||||
val text = JSONObject()
|
||||
.put("status", "successful")
|
||||
.put("lastfetchedid", 42)
|
||||
.put("exported_qsos", 1)
|
||||
.put("adif", adif)
|
||||
.toString()
|
||||
|
||||
val page = repository.parsePage(text)
|
||||
|
||||
assertNotNull(page)
|
||||
assertEquals(42L, page!!.cursor)
|
||||
assertEquals(1, page.exported)
|
||||
assertEquals(1, page.records.size)
|
||||
assertEquals("BG5JSB", page.records.single().theirCallsign)
|
||||
assertEquals("EN52", page.records.single().theirGrid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failedStatusIsRejected() {
|
||||
assertNull(repository.parsePage("""{"status":"failed","reason":"missing api key"}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingCursorIsRejected() {
|
||||
assertNull(repository.parsePage("""{"status":"successful","exported_qsos":0,"adif":null}"""))
|
||||
assertNull(repository.parsePage("not json at all"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyExportKeepsTheCursorAndYieldsNoRecords() {
|
||||
val page = repository.parsePage("""{"status":"successful","lastfetchedid":7,"exported_qsos":0,"adif":null}""")
|
||||
|
||||
assertNotNull(page)
|
||||
assertEquals(7L, page!!.cursor)
|
||||
assertTrue(page.records.isEmpty())
|
||||
}
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogProblem
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class WavelogUploadRepositoryTest {
|
||||
|
||||
// region # gridCompatible (mirror of the server's adif_grid_check_location prefix rule)
|
||||
|
||||
@Test
|
||||
fun grid_sameGridsPass() {
|
||||
assertTrue(gridCompatible("OL62TI", "OL62TI"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun grid_longerAdifGridPasses() {
|
||||
assertTrue(gridCompatible("OL62TI", "OL62"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun grid_longerStationGridPasses() {
|
||||
assertTrue(gridCompatible("OL62", "OL62TI"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun grid_differentSquaresFail() {
|
||||
assertFalse(gridCompatible("PM01", "OL62"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun grid_blankSidesDisableTheCheck() {
|
||||
assertTrue(gridCompatible("", "OL62"))
|
||||
assertTrue(gridCompatible("OL62", ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun grid_stationMarginAcceptsAPrefixPart() {
|
||||
assertTrue(gridCompatible("OL63AA", "OL62,OL63"))
|
||||
assertFalse(gridCompatible("PM01AA", "OL62,OL63"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun grid_caseInsensitive() {
|
||||
assertTrue(gridCompatible("ol62ti", "OL62"))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region # classifyUploadResponse
|
||||
|
||||
private val calls = mapOf(1L to "BG5JVM", 2L to "BH6RJD", 3L to "BI3SIR")
|
||||
|
||||
@Test
|
||||
fun createdMarksEverything() {
|
||||
val outcome = classifyUploadResponse(
|
||||
201, """{"status":"created","adif_count":3,"adif_errors":0,"messages":[""]}""", calls
|
||||
)
|
||||
assertEquals(WavelogUploadOutcome.Imported(3, 0, 0, listOf(1L, 2L, 3L)), outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicatesAreCountedAndStillMarked() {
|
||||
val body = """{"status":"abort","messages":["Date/Time: 2026-10-04 12:34:00 Callsign: BG5JVM Band: 70CM Duplicate for BA7OPF<br>"]}"""
|
||||
val outcome = classifyUploadResponse(400, body, calls)
|
||||
assertEquals(WavelogUploadOutcome.Imported(2, 1, 0, listOf(1L, 2L, 3L)), outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skippedRecordIsNotMarked() {
|
||||
val body = """{"status":"abort","messages":["Differing locator <b>PM01</b> while importing QSO with <b>BH6RJD</b> for station locator <b>OL62TI</b>: SKIPPED<br>"]}"""
|
||||
val outcome = classifyUploadResponse(400, body, calls)
|
||||
assertEquals(WavelogUploadOutcome.Imported(2, 0, 1, listOf(1L, 3L)), outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicateAndSkipTogether() {
|
||||
val body = """{"status":"abort","messages":["Differing locator <b>PM01</b> while importing QSO with <b>BH6RJD</b> for station locator <b>OL62TI</b>: SKIPPED<br>Date/Time: 2026-10-04 12:34:00 Callsign: BG5JVM Band: 70CM Duplicate for BA7OPF<br>"]}"""
|
||||
val outcome = classifyUploadResponse(400, body, calls)
|
||||
assertEquals(WavelogUploadOutcome.Imported(1, 1, 1, listOf(1L, 3L)), outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unmappedSkipMarksNothing() {
|
||||
val body = """{"status":"abort","messages":["Differing locator <b>PM01</b> while importing QSO with <b>N0CALL</b> for station locator <b>OL62TI</b>: SKIPPED<br>"]}"""
|
||||
val outcome = classifyUploadResponse(400, body, calls)
|
||||
assertTrue(outcome is WavelogUploadOutcome.Rejected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun otherMessagesAreRejected() {
|
||||
val body = """{"status":"abort","messages":["QSO on 2026-10-04: You tried to import a QSO without any given CALL. This QSO wasn't imported. It's invalid.<br>"]}"""
|
||||
val outcome = classifyUploadResponse(400, body, calls)
|
||||
assertTrue(outcome is WavelogUploadOutcome.Rejected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authFailuresAreActionable() {
|
||||
assertTrue(classifyUploadResponse(401, "", calls) is WavelogUploadOutcome.Failed)
|
||||
val forbidden = classifyUploadResponse(403, """{"reason":"API key does not have write permissions"}""", calls)
|
||||
assertTrue(forbidden is WavelogUploadOutcome.Failed)
|
||||
assertTrue((forbidden as WavelogUploadOutcome.Failed).message.contains("write"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun htmlGarbageOrEmptyBodyMarksNothing() {
|
||||
assertTrue(classifyUploadResponse(400, "<html>oops</html>", calls) is WavelogUploadOutcome.Rejected)
|
||||
assertTrue(classifyUploadResponse(400, "", calls) is WavelogUploadOutcome.Failed)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region # parseStations + prepare
|
||||
|
||||
@Test
|
||||
fun stationsParse() {
|
||||
val body = """[{"station_id":"1","station_profile_name":"Shenzhen","station_gridsquare":"OL62TI","station_callsign":"BA7OPF","station_active":"1"}]"""
|
||||
val stations = parseStations(body)
|
||||
assertEquals(1, stations.size)
|
||||
assertEquals("1", stations.single().id)
|
||||
assertEquals("Shenzhen", stations.single().name)
|
||||
assertEquals("BA7OPF", stations.single().callsign)
|
||||
assertEquals("OL62TI", stations.single().grid)
|
||||
assertTrue(stations.single().active)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prepareWithoutConfigIsBlocked() = runBlocking {
|
||||
val repo = WavelogUploadRepository()
|
||||
val preview = repo.prepare(listOf(sampleRecord(start = 1_700_000_000_000L)), WavelogUploadSettings())
|
||||
assertEquals(WavelogProblem.NOT_CONFIGURED, preview.blockedBy)
|
||||
assertEquals(0, preview.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prepareWithoutStationIsBlocked() = runBlocking {
|
||||
val repo = WavelogUploadRepository()
|
||||
val settings = WavelogUploadSettings(url = "http://example.org", apiKey = "k")
|
||||
val preview = repo.prepare(listOf(sampleRecord(start = 1_700_000_000_000L)), settings)
|
||||
assertEquals(WavelogProblem.NO_STATION, preview.blockedBy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prepareSkipsGridMismatchAndDeduplicates() = runBlocking {
|
||||
val repo = WavelogUploadRepository()
|
||||
val settings = WavelogUploadSettings(
|
||||
url = "http://example.org", apiKey = "k", stationId = "1",
|
||||
stationName = "Shenzhen", stationCallsign = "BA7OPF", stationGrid = "OL62TI"
|
||||
)
|
||||
val records = listOf(
|
||||
sampleRecord(start = 1_700_000_000_000L, myGrid = "PM01AA"),
|
||||
sampleRecord(start = 1_700_000_120_000L, myGrid = "OL62TI"),
|
||||
sampleRecord(start = 1_700_000_120_000L, myGrid = "OL62TI") // batch duplicate of the previous
|
||||
)
|
||||
val preview = repo.prepare(records, settings)
|
||||
assertEquals(1, preview.count)
|
||||
assertEquals(2, preview.skipped)
|
||||
assertEquals(1, preview.reasons[WavelogProblem.GRID_MISMATCH])
|
||||
assertEquals(1, preview.submittedIds.size)
|
||||
assertEquals("Shenzhen · BA7OPF · OL62TI", preview.stationLabel)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
private fun sampleRecord(start: Long, myGrid: String = "OL62TI") = QsoRecord(
|
||||
id = start,
|
||||
startUtcMillis = start,
|
||||
theirCallsign = "BG5JVM",
|
||||
myCallsign = "BA7OPF",
|
||||
myGrid = myGrid,
|
||||
txFrequencyHz = 145_850_000L,
|
||||
rxFrequencyHz = 436_795_000L,
|
||||
band = "2M",
|
||||
rxBand = "70CM",
|
||||
mode = "FM",
|
||||
satelliteName = "SO-50",
|
||||
propagationMode = "SAT",
|
||||
status = QsoStatus.COMPLETE
|
||||
)
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
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>, includeStationCallsign: Boolean = true): 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))
|
||||
if (includeStationCallsign) 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,54 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
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)
|
||||
/**
|
||||
* Marks the batch as uploaded and stamps the station-location grids the batch was
|
||||
* signed with (1–4 gridsquares: inside a grid / on a line / on a corner), so the
|
||||
* logbook row can show which grids the QSO went out under.
|
||||
*/
|
||||
suspend fun markUploaded(ids: List<Long>, grids: List<String> = emptyList())
|
||||
|
||||
/**
|
||||
* Marks the batch as uploaded to Wavelog (independent of the LoTW upload state) and
|
||||
* stamps the station profile (台址) the batch went out through, so the logbook's
|
||||
* 台址 selector can group the records. An empty [stationId] leaves the stamp as-is.
|
||||
*/
|
||||
suspend fun markWavelogUploaded(ids: List<Long>, stationId: String = "")
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* Merges QSO records pulled from Wavelog by a sync (the download direction):
|
||||
* matches by the same stable identity as every other import, tags matched and
|
||||
* freshly inserted rows with their Wavelog station profile, and marks them as
|
||||
* already uploaded so a later upload never sends them straight back.
|
||||
*/
|
||||
suspend fun mergeWavelog(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
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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(),
|
||||
theirGrid = theirGrids.firstOrNull().orEmpty(),
|
||||
theirVuccGrids = theirGrids,
|
||||
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)
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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.QSO_FUTURE -> "record time is in the future"
|
||||
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"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* The records a resubmit actually re-signs: whatever the operator checked, complete rows only.
|
||||
*
|
||||
* Unlike a normal upload this deliberately keeps already-uploaded and confirmed records —
|
||||
* the point is to send them again under a corrected station location, and LoTW treats an
|
||||
* identical contact (call/band/mode/time/satellite) as an update of the existing record.
|
||||
*/
|
||||
fun resubmitCandidates(records: List<QsoRecord>, selectedIds: Set<Long>): List<QsoRecord> =
|
||||
records.filter { it.id in selectedIds && it.status == QsoStatus.COMPLETE }
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* 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.matchableMode == remote.matchableMode &&
|
||||
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), matchableMode
|
||||
).joinToString("|")
|
||||
|
||||
/** A row to keep and the duplicate row to fold into it (then delete), as indices into one list. */
|
||||
data class SplitConfirmationPair(val localIndex: Int, val confirmationIndex: Int)
|
||||
|
||||
/**
|
||||
* Rows that should have been one row but were not, so they can be consolidated back into one.
|
||||
*
|
||||
* Two known holes leave a contact stored twice:
|
||||
*
|
||||
* 1. 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 taken as the uplink) — so an unconfirmed local row and its confirmation stayed
|
||||
* apart. Pass 1 folds the confirmation into the local row.
|
||||
* 2. A contact can also end up with TWO confirmed rows: a mirrored import the old parser
|
||||
* created plus the row that was confirmed on its own later, or a duplicate import added
|
||||
* while ambiguous candidates made the match fail. Pass 1 cannot fold those — both sides
|
||||
* are confirmed — so pass 2 pairs confirmed rows with each other.
|
||||
*
|
||||
* Both passes accept a pair only when the contact identity matches and the bands are either
|
||||
* identical or exactly mirrored — the mirror being the fingerprint of the pre-fix parser —
|
||||
* which keeps unrelated QSOs of the same operator and minute apart. In pass 2 the row with
|
||||
* more content is kept (frequencies/grids/reports beat an empty mirrored import) and the
|
||||
* other one is folded into it, so no data is lost.
|
||||
*/
|
||||
fun splitConfirmationPairs(records: List<QsoRecord>): List<SplitConfirmationPair> {
|
||||
val taken = mutableSetOf<Int>()
|
||||
val pairs = mutableListOf<SplitConfirmationPair>()
|
||||
// Pass 1: fold a downloaded confirmation into the unconfirmed local row it belongs to.
|
||||
val pendingIndices = records.indices.filter { !records[it].lotwConfirmed }
|
||||
records.indices.filter { records[it].lotwConfirmed }.forEach { confirmationIndex ->
|
||||
val confirmation = records[confirmationIndex]
|
||||
val localIndex = pendingIndices.firstOrNull { index ->
|
||||
index !in taken && sameContactIdentity(records[index], confirmation) &&
|
||||
bandsMatchOrMirror(records[index], confirmation)
|
||||
} ?: return@forEach
|
||||
taken += localIndex
|
||||
taken += confirmationIndex
|
||||
pairs += SplitConfirmationPair(localIndex, confirmationIndex)
|
||||
}
|
||||
// Pass 2: two CONFIRMED rows describing the same contact (mirrored import + the confirmed
|
||||
// row it never folded into, or a duplicate import). Keep the richer row, drop the other.
|
||||
val confirmedLeft = records.indices.filter { records[it].lotwConfirmed && it !in taken }
|
||||
confirmedLeft.forEach { index ->
|
||||
if (index in taken) return@forEach
|
||||
val cluster = mutableListOf(index)
|
||||
confirmedLeft.forEach { candidate ->
|
||||
if (candidate != index && candidate !in taken &&
|
||||
cluster.all {
|
||||
sameContactIdentity(records[it], records[candidate]) &&
|
||||
bandsMatchOrMirror(records[it], records[candidate])
|
||||
}
|
||||
) {
|
||||
cluster += candidate
|
||||
}
|
||||
}
|
||||
if (cluster.size < 2) return@forEach
|
||||
cluster.forEach { taken += it }
|
||||
val keep = cluster.maxBy { contentScore(records[it]) }
|
||||
cluster.filter { it != keep }.forEach { pairs += SplitConfirmationPair(keep, it) }
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
/** More operator/report detail first — decides which row survives a pass-2 fold. */
|
||||
fun contentScore(record: QsoRecord): Int {
|
||||
var score = 0
|
||||
// Frequencies are the strongest operator-entered signal; a band label consistent with its
|
||||
// frequency (frequencyBand(tx) == band) beats the mirrored parser output, whose labels
|
||||
// contradict the side they describe.
|
||||
record.txFrequencyHz?.let { hz -> score += 2; if (record.band == frequencyBand(hz)) score += 1 }
|
||||
record.rxFrequencyHz?.let { hz -> score += 2; if (record.rxBand == frequencyBand(hz)) score += 1 }
|
||||
score += listOf(record.sentReport, record.receivedReport, record.myGrid, record.theirGrid, record.comment)
|
||||
.count { it.isNotBlank() }
|
||||
score += record.vuccGrids.size
|
||||
score += record.theirVuccGrids.size
|
||||
if (record.lotwUploaded) score += 1
|
||||
if (record.lotwReceived) score += 1
|
||||
if (record.dxcc != null) score += 1
|
||||
if (record.country.isNotBlank()) score += 1
|
||||
if (record.lotwQslDate.isNotBlank()) score += 1
|
||||
return score
|
||||
}
|
||||
|
||||
/** Same band on both sides, or the mirrored pair the pre-fix parser produced. */
|
||||
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 },
|
||||
theirVuccGrids = confirmed.theirVuccGrids.ifEmpty { theirVuccGrids },
|
||||
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)
|
||||
}
|
||||
|
||||
/** The mode label as the merge compares it: satellite FT4 is written as MFSK + FT4, and
|
||||
* releases that dropped the sub-mode persisted plain MFSK — the same mode, spelled
|
||||
* differently, so both spellings must match. */
|
||||
val QsoRecord.matchableMode: String
|
||||
get() = displayMode.let { if (it == "MFSK") "FT4" else it }
|
||||
|
||||
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,59 @@
|
||||
/*
|
||||
* 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 = "",
|
||||
/** Every 4-char grid the OPPOSITE station logged this QSO under (ADIF
|
||||
* <GRIDSQUARE> + <VUCC_GRIDS>; 1–4 grids). [theirGrid] keeps the first;
|
||||
* the logbook's QSL slot shows the abbreviated set. */
|
||||
val theirVuccGrids: List<String> = emptyList(),
|
||||
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,
|
||||
/** Internal: set after a successful Wavelog upload; keeps the pending filter small. Not shown in any list. */
|
||||
val wavelogUploaded: Boolean = false,
|
||||
/** Wavelog station profile (台址) this QSO belongs to: the profile it was uploaded
|
||||
* through, or the profile a sync pulled it from. Empty for records the app never
|
||||
* involved with Wavelog — the logbook's 台址 selector shows those under "All" only. */
|
||||
val wavelogStation: String = "",
|
||||
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)
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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.repository.WavelogProblem
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
|
||||
|
||||
/**
|
||||
* The records a Wavelog upload may submit: locally recorded contacts only.
|
||||
*
|
||||
* Pure LoTW-imported confirmation rows never carry [QsoRecord.lotwUploaded] (the ADIF decoder
|
||||
* does not set it), while a locally recorded row keeps its value through a confirmation merge —
|
||||
* [withConfirmation] leaves `lotwUploaded` untouched. Confirmed local rows uploaded to LoTW
|
||||
* from this app therefore stay eligible; imported history stays out of the upload.
|
||||
*/
|
||||
fun wavelogUploadCandidates(records: List<QsoRecord>): List<QsoRecord> = records.filter {
|
||||
it.status == QsoStatus.COMPLETE && !it.wavelogUploaded && (!it.lotwConfirmed || it.lotwUploaded)
|
||||
}
|
||||
|
||||
/** Operator-facing wording for why a record cannot enter a Wavelog upload. */
|
||||
fun WavelogProblem.label(): String = when (this) {
|
||||
WavelogProblem.NOT_CONFIGURED -> "Wavelog upload not configured"
|
||||
WavelogProblem.NO_STATION -> "no station profile selected"
|
||||
WavelogProblem.GRID_MISMATCH -> "logged grid outside the Wavelog station grid"
|
||||
}
|
||||
|
||||
/** One-line summary of the records excluded before a Wavelog upload (empty when none). */
|
||||
fun wavelogSkipSummary(preview: WavelogUploadPreview): String {
|
||||
if (preview.skipped <= 0) return ""
|
||||
val detail = preview.reasons.entries.sortedByDescending { it.value }
|
||||
.joinToString(", ") { (problem, count) -> "$count× ${problem.label()}" }
|
||||
return "${preview.skipped} QSO(s) skipped" + if (detail.isNotBlank()) " — $detail" else ""
|
||||
}
|
||||
|
||||
/** One-line result wording for a finished Wavelog upload. */
|
||||
fun wavelogResultMessage(result: WavelogUploadOutcome): String = when (result) {
|
||||
is WavelogUploadOutcome.Imported -> buildString {
|
||||
if (result.imported == 0 && result.duplicates > 0) {
|
||||
append("All ${result.markIds.size} record(s) already in Wavelog")
|
||||
} else {
|
||||
append("Uploaded ${result.imported} QSO(s) to Wavelog")
|
||||
if (result.duplicates > 0) append(" — ${result.duplicates} already there")
|
||||
}
|
||||
if (result.skipped > 0) append(" — ${result.skipped} skipped by Wavelog")
|
||||
}
|
||||
is WavelogUploadOutcome.Rejected -> result.message
|
||||
is WavelogUploadOutcome.Failed -> result.message
|
||||
WavelogUploadOutcome.Expired -> "Preview expired — tap upload again"
|
||||
}
|
||||
|
||||
/**
|
||||
* Message for the piggybacked Wavelog side when nothing can be submitted —
|
||||
* null when there is nothing to say (fully caught up, no held-back records).
|
||||
*/
|
||||
fun wavelogIdleSegment(preview: WavelogUploadPreview): String? = when {
|
||||
preview.blockedBy != null -> "Wavelog: ${preview.blockedBy.label()}"
|
||||
preview.skipped > 0 -> "Wavelog: ${wavelogSkipSummary(preview)}"
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Message for a finished piggybacked Wavelog upload: the server outcome plus
|
||||
* the records held back before the POST (e.g. grids outside the station profile).
|
||||
*/
|
||||
fun wavelogConfirmedSegment(outcome: WavelogUploadOutcome, preview: WavelogUploadPreview): String {
|
||||
val base = wavelogResultMessage(outcome)
|
||||
val head = if (outcome is WavelogUploadOutcome.Imported) base else "Wavelog: $base"
|
||||
val skip = wavelogSkipSummary(preview)
|
||||
return if (skip.isNotBlank()) "$head\n$skip" else head
|
||||
}
|
||||
|
||||
/** Message when the piggybacked upload threw outright — records stay unmarked for retry. */
|
||||
fun wavelogTransportFailureSegment(): String = "Wavelog: upload failed — it will retry with your next upload"
|
||||
+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
|
||||
|
||||
/** One public AMSAT satellite status report submission. */
|
||||
|
||||
+56
-29
@@ -81,52 +81,79 @@ object AwardCalculator {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The award unit one QSO counts toward for [type] — the match key of the
|
||||
* award boundary assets' region "code" (WAPC -> "GD"/"HK", WAJA -> "34",
|
||||
* WAZ -> "24", WAS -> "CA", DXCC -> "318"). Null when the QSO does not
|
||||
* count toward that award (wrong entity or missing field). VUCC has no
|
||||
* per-QSO key — its units are the store's grid keys — and returns null.
|
||||
*
|
||||
* Single source of truth shared by the progress counters above and the
|
||||
* map's region-detail dialogs, so a tapped region lists exactly the QSOs
|
||||
* that light it up.
|
||||
*/
|
||||
fun awardKey(type: AwardType, q: GridQso): String? = when (type) {
|
||||
AwardType.DXCC -> (q.dxcc ?: prefixEntityCode(q.call))?.toString()
|
||||
AwardType.WAPC -> wapcKey(q)
|
||||
AwardType.WAJA -> if (entityOf(q) == Entity.JAPAN) {
|
||||
q.state?.trim()?.takeIf { it.length == 2 && it.all(Char::isDigit) }
|
||||
} else null
|
||||
|
||||
AwardType.WAZ -> q.cqz?.takeIf { it in 1..40 }?.toString()
|
||||
AwardType.WAS -> if (entityOf(q) == Entity.USA) {
|
||||
q.state?.trim()?.uppercase()?.takeIf { it in US_STATES }
|
||||
} else null
|
||||
|
||||
AwardType.VUCC -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every confirmed QSO counted for award region [code] of [type], oldest
|
||||
* first. The store keeps one entry per WORKED GRID, so a QSO spanning
|
||||
* several grids arrives as several copies — [distinct] collapses them.
|
||||
* Not scoped by 台址: the non-VUCC awards count (and fill the map)
|
||||
* globally, so their detail lists do too.
|
||||
*/
|
||||
fun regionQsos(
|
||||
qsosByGrid: Map<String, List<GridQso>>,
|
||||
type: AwardType,
|
||||
code: String
|
||||
): List<GridQso> =
|
||||
qsosByGrid.values.flatten()
|
||||
.filter { awardKey(type, it) == code }
|
||||
.distinct()
|
||||
.sortedBy { it.epochMs }
|
||||
|
||||
private fun wapcKey(q: GridQso): String? = when (entityOf(q)) {
|
||||
Entity.CHINA -> q.state?.trim()?.uppercase()?.takeIf { it in CN_STATES }
|
||||
Entity.HONG_KONG -> HK_CODE
|
||||
Entity.MACAO -> MO_CODE
|
||||
Entity.TAIWAN -> TW_CODE
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun calculateDxcc(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
val code = q.dxcc ?: prefixEntityCode(q.call)
|
||||
if (code != null) keys.add(code.toString())
|
||||
}
|
||||
val keys = all.mapNotNull { awardKey(AwardType.DXCC, it) }.toSet()
|
||||
return AwardProgress(AwardType.DXCC, keys, keys.size, AwardTargets.DXCC)
|
||||
}
|
||||
|
||||
private fun calculateWapc(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
when (entityOf(q)) {
|
||||
Entity.CHINA -> q.state?.trim()?.uppercase()?.takeIf { it in CN_STATES }?.let { keys.add(it) }
|
||||
Entity.HONG_KONG -> keys.add(HK_CODE)
|
||||
Entity.MACAO -> keys.add(MO_CODE)
|
||||
Entity.TAIWAN -> keys.add(TW_CODE)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
val keys = all.mapNotNull { awardKey(AwardType.WAPC, it) }.toSet()
|
||||
return AwardProgress(AwardType.WAPC, keys, keys.size, AwardTargets.WAPC)
|
||||
}
|
||||
|
||||
private fun calculateWaja(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
if (entityOf(q) != Entity.JAPAN) continue
|
||||
q.state?.trim()?.takeIf { it.length == 2 && it.all(Char::isDigit) }?.let { keys.add(it) }
|
||||
}
|
||||
val keys = all.mapNotNull { awardKey(AwardType.WAJA, it) }.toSet()
|
||||
return AwardProgress(AwardType.WAJA, keys, keys.size, AwardTargets.WAJA)
|
||||
}
|
||||
|
||||
private fun calculateWaz(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
q.cqz?.takeIf { it in 1..40 }?.let { keys.add(it.toString()) }
|
||||
}
|
||||
val keys = all.mapNotNull { awardKey(AwardType.WAZ, it) }.toSet()
|
||||
return AwardProgress(AwardType.WAZ, keys, keys.size, AwardTargets.WAZ)
|
||||
}
|
||||
|
||||
private fun calculateWas(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
if (entityOf(q) != Entity.USA) continue
|
||||
q.state?.trim()?.uppercase()?.takeIf { it in US_STATES }?.let { keys.add(it) }
|
||||
}
|
||||
val keys = all.mapNotNull { awardKey(AwardType.WAS, it) }.toSet()
|
||||
return AwardProgress(AwardType.WAS, keys, keys.size, AwardTargets.WAS)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,12 @@ package com.rtbishop.look4sat.core.domain.model
|
||||
* @param epochMs QSO date+time in UTC milliseconds
|
||||
* @param satName satellite name (e.g. "FO-29")
|
||||
* @param mode ADIF mode (FM / CW / SSB ...)
|
||||
* @param bandUp uplink band as reported by LoTW (BAND_RX: "70CM", "2M", "10M"...)
|
||||
* @param bandDown downlink band (BAND: "2M", "70CM"...) — may be empty when
|
||||
* LoTW did not include it
|
||||
* @param bandUp uplink band, the band of the transmitted frequency (ADIF BAND:
|
||||
* "2M", "70CM", "10M"...). Measured against the live report of the
|
||||
* 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
|
||||
* <DXCC>), or null when LoTW omitted it
|
||||
* @param country DXCC entity name (ADIF <COUNTRY>), or null
|
||||
@@ -69,7 +72,13 @@ data class GridQso(
|
||||
* 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
|
||||
val stationKey: String? = null,
|
||||
/** Every 4-char grid of the OPPOSITE station for this QSO — ADIF
|
||||
* <GRIDSQUARE> plus every field of <VUCC_GRIDS> (comma-separated when
|
||||
* the contact spans several squares; up to four in one QSO). The logbook
|
||||
* shows the set in the QSL slot of a confirmed row; [myGrids] is the
|
||||
* same thing for the user's own side. */
|
||||
val theirGrids: List<String> = emptyList()
|
||||
) {
|
||||
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
|
||||
val bandLabel: String
|
||||
@@ -95,3 +104,50 @@ data class GridQso(
|
||||
else -> band.trim().uppercase()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every MY-side grid of this QSO: [myGrids] (MY_GRIDSQUARE + MY_VUCC_GRIDS)
|
||||
* with a single-grid fallback to [myGrid] for records synced before
|
||||
* multi-grid support. Empty when the record carries no MY-side grid at all.
|
||||
*/
|
||||
fun GridQso.myStationGrids(): Set<String> =
|
||||
myGrids.ifEmpty { myGrid?.let { setOf(it) }.orEmpty() }
|
||||
|
||||
/**
|
||||
* Station-location (台址) grouping key: the QSO's MY grid set, sorted and
|
||||
* comma-joined ("OL62", "OM60,PM01"). Records covering the same grid set
|
||||
* belong to the same 台址 — they are VUCC-equivalent. Null when the record
|
||||
* carries no MY-side grid. Backs both the map's operated-grid selector and
|
||||
* its per-台址 grid-detail filtering, so the two scope by one identity.
|
||||
*/
|
||||
fun GridQso.stationGridSetKey(): String? =
|
||||
myStationGrids().takeIf { it.isNotEmpty() }?.sorted()?.joinToString(",")
|
||||
|
||||
/**
|
||||
* Scopes a per-grid QSO list to the operated-grid selector's choice with
|
||||
* COVERING semantics (2026-10-05): [stationId] is a grid-set key such as
|
||||
* "OL62" or "OL62,OL63", and a record stays when its own [myStationGrids]
|
||||
* COVER that key (record grid set ⊇ key set). So a multi-grid 台址 that
|
||||
* covers a single-grid one (OL62/63 covers OL62) contributes its records to
|
||||
* the single-grid scope's statistics — map fills, dialog lists and first-call
|
||||
* labels all derive from this one rule. The reverse does NOT hold: records
|
||||
* under a narrower 台址 never leak into a wider scope (single OL62 records
|
||||
* stay out of the OL62/63 scope). Records without a MY-side grid belong to
|
||||
* no 台址 and are dropped under any specific scope.
|
||||
*/
|
||||
fun List<GridQso>.scopedToStation(stationId: String?): List<GridQso> =
|
||||
if (stationId == null) this
|
||||
else {
|
||||
val scope = stationId.split(',').toSet()
|
||||
filter { it.myStationGrids().containsAll(scope) }
|
||||
}
|
||||
|
||||
/**
|
||||
* For every grid in the store, the callsign of the earliest QSO — scoped to the
|
||||
* operated-grid selector's choice first, so a specific 台址 labels each worked
|
||||
* cell with ITS first contact; null ("All") keeps the global first call.
|
||||
*/
|
||||
fun Map<String, List<GridQso>>.firstCallsByGrid(stationId: String?): Map<String, String> =
|
||||
mapNotNull { (grid, qsos) ->
|
||||
qsos.scopedToStation(stationId).minByOrNull { it.epochMs }?.let { grid to it.call }
|
||||
}.toMap()
|
||||
@@ -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
|
||||
|
||||
/** Latest release info fetched from the fork's GitHub releases API. */
|
||||
|
||||
@@ -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
|
||||
|
||||
/** One satellite status report (AMSAT site tooltip data) */
|
||||
@@ -12,9 +30,10 @@ data class SatReport(
|
||||
|
||||
/** State of one 2-hour slot */
|
||||
data class SatSlot(
|
||||
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
|
||||
val count: Int, // 报告数量(0 = 无)
|
||||
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
|
||||
val statusColor: Long, // ARGB 状态色(灰=无报告;判定见 AmSatRepository.slotStatusOf)
|
||||
val count: Int, // 官网页口径:多数方计数(冲突槽/异常槽=总条数;0 = 无)
|
||||
val reportIds: List<String> = emptyList(), // 该槽报告 ID 列表
|
||||
val isConflicted: Boolean = false // 官网页"Conflicting reports":该槽无严格多数(弹窗据此标注)
|
||||
)
|
||||
|
||||
/** One satellite day (12 two-hour slots) */
|
||||
|
||||
@@ -102,11 +102,17 @@ data class DataSourcesSettings(
|
||||
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
|
||||
}
|
||||
|
||||
data class WavelogSettings(
|
||||
/** Wavelog configuration — QSO upload both ways; one of LoTW or Wavelog (mutually exclusive). */
|
||||
data class WavelogUploadSettings(
|
||||
val url: String = "",
|
||||
val token: String = ""
|
||||
val apiKey: String = "",
|
||||
val stationId: String = "",
|
||||
val stationName: String = "",
|
||||
val stationCallsign: String = "",
|
||||
val stationGrid: String = ""
|
||||
) {
|
||||
val isConfigured: Boolean get() = url.isNotBlank() && token.isNotBlank()
|
||||
val isConfigured: Boolean get() = url.isNotBlank() && apiKey.isNotBlank()
|
||||
val isReady: Boolean get() = isConfigured && stationId.isNotBlank()
|
||||
}
|
||||
|
||||
data class LoTWSettings(
|
||||
|
||||
+29
-17
@@ -37,30 +37,42 @@ data class OrbitalData(
|
||||
val xno: Double = meanmo * TWO_PI / MIN_PER_DAY
|
||||
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
|
||||
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
|
||||
|
||||
/** Absolute age of this element set, used to pick the freshest data among several sources. */
|
||||
val epochDaynum: Double = epochToDaynum(epoch)
|
||||
|
||||
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
|
||||
|
||||
/** Check if satellite has likely decayed by the given time. */
|
||||
fun hasDecayed(currentTimeMillis: Long): Boolean {
|
||||
if (ndot == 0.0) return false
|
||||
val currentDaynum = (currentTimeMillis - 315446400000L) / 86400000.0
|
||||
val epochDaynum = epochToDaynum(epoch)
|
||||
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, currentDaynum)
|
||||
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, timeToDaynum(currentTimeMillis))
|
||||
}
|
||||
|
||||
private fun epochToDaynum(epoch: Double): Double {
|
||||
var year = kotlin.math.floor(epoch * 1E-3)
|
||||
val day = (epoch * 1E-3 - year) * 1000.0
|
||||
year = if (year < 57) year + 2000 else year + 1900
|
||||
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
|
||||
val jan1Jd = julianDateOfYear(year)
|
||||
return jan1Jd + day - 2444238.5
|
||||
}
|
||||
companion object {
|
||||
/** Days since 31 Dec 1979, the reference point every daynum in the app is based on. */
|
||||
fun timeToDaynum(timeMillis: Long): Double = (timeMillis - 315446400000L) / 86400000.0
|
||||
|
||||
private fun julianDateOfYear(theYear: Double): Double {
|
||||
val aYear = theYear - 1
|
||||
val a = kotlin.math.floor(aYear / 100).toLong()
|
||||
val b = 2 - a + a / 4
|
||||
val i = kotlin.math.floor(365.25 * aYear).toLong()
|
||||
return i + (30.6001 * 14).toLong() + 1720994.5 + b
|
||||
/**
|
||||
* Days since 31 Dec 1979 for a TLE style YYDDD.ffffffff epoch. Raw epochs are not
|
||||
* comparable across decades (a 1999 epoch reads as 99xxx, a 2026 one as 26xxx), so
|
||||
* freshness checks must always go through this conversion.
|
||||
*/
|
||||
fun epochToDaynum(epoch: Double): Double {
|
||||
var year = kotlin.math.floor(epoch * 1E-3)
|
||||
val day = (epoch * 1E-3 - year) * 1000.0
|
||||
year = if (year < 57) year + 2000 else year + 1900
|
||||
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
|
||||
val jan1Jd = julianDateOfYear(year)
|
||||
return jan1Jd + day - 2444238.5
|
||||
}
|
||||
|
||||
private fun julianDateOfYear(theYear: Double): Double {
|
||||
val aYear = theYear - 1
|
||||
val a = kotlin.math.floor(aYear / 100).toLong()
|
||||
val b = 2 - a + a / 4
|
||||
val i = kotlin.math.floor(365.25 * aYear).toLong()
|
||||
return i + (30.6001 * 14).toLong() + 1720994.5 + b
|
||||
}
|
||||
}
|
||||
}
|
||||
+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
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* 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
|
||||
import java.util.Locale
|
||||
|
||||
interface ILoTWUploadRepository {
|
||||
suspend fun certificate(): LoTWCertificate?
|
||||
suspend fun station(): LoTWStation?
|
||||
suspend fun importCertificate(data: ByteArray, password: CharArray): LoTWCertificate
|
||||
/** Parse a .p12 without persisting it — enough to learn the certificate's DXCC
|
||||
* entity so the station form can offer its region field before the import. */
|
||||
suspend fun previewCertificate(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(),
|
||||
/** The full station-location grid set this batch is signed with (1 inside a grid,
|
||||
* 2 on a line, 4 on a corner). Stamped onto the uploaded records so the logbook
|
||||
* can show which gridsquares the QSO went out under. */
|
||||
val grids: List<String> = emptyList(),
|
||||
/** True when this batch is a resubmit: already-uploaded/confirmed records were allowed
|
||||
* through so the corrected station location reaches LoTW as an update of the contact. */
|
||||
val resubmit: Boolean = false
|
||||
)
|
||||
|
||||
/** Pre-upload location check: the freshest known position grid is not covered by the
|
||||
* station grids an upload would be signed with. */
|
||||
data class LoTWPositionWarning(
|
||||
/** Normalized 4-character grid of the freshest known position. */
|
||||
val currentGrid: String,
|
||||
/** Distinct station-location grids the upload would be signed with. */
|
||||
val stationGrids: List<String>
|
||||
)
|
||||
|
||||
/** 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, QSO_FUTURE, STATION_GRID,
|
||||
STATION_REGION, STATION_ZONE, STATION_IOTA, MODE, BAND, SATELLITE, INVALID_CONTACT,
|
||||
LOCATION_MISMATCH, TOO_MANY_CONTACTS
|
||||
}
|
||||
|
||||
/**
|
||||
* Roaming check run before upload: warns when the operator's freshest known position
|
||||
* (see [ISettingsRepo.getCurrentGrid]) lies outside every grid of the station location the
|
||||
* batch would be signed with — the signature of operating while roaming with a station
|
||||
* location that was never updated. An unknown position, missing station grids, or a covered
|
||||
* position (boundary operations included) produce no warning.
|
||||
*/
|
||||
fun positionWarning(currentGrid: String?, stationGrids: List<String>): LoTWPositionWarning? {
|
||||
val current = currentGrid?.grid4() ?: return null
|
||||
val station = stationGrids.mapNotNull { it.grid4() }.toSet()
|
||||
if (station.isEmpty()) return null
|
||||
if (current in station) return null
|
||||
return LoTWPositionWarning(current, station.sorted())
|
||||
}
|
||||
|
||||
/** Normalized 4-character grid, null when the value is not a usable grid. */
|
||||
private fun String.grid4(): String? = trim().uppercase(Locale.US).take(4).takeIf { it.length >= 4 }
|
||||
+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()
|
||||
}
|
||||
+9
-1
@@ -16,6 +16,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||
@@ -32,11 +33,18 @@ interface IMainContainer {
|
||||
val databaseRepo: IDatabaseRepo
|
||||
val amSatRepo: IAmSatRepository
|
||||
val updateRepo: IUpdateRepository
|
||||
val wavelogRepo: IWavelogRepository
|
||||
val wavelogUploadRepository: IWavelogUploadRepository
|
||||
val wavelogSyncRepository: IWavelogSyncRepository
|
||||
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||
val qsoRepository: IQsoRepository
|
||||
val lotwUploadRepository: ILoTWUploadRepository
|
||||
val radioTrackingService: IRadioTrackingService
|
||||
val locationRepo: ILocationRepo
|
||||
val mutualPassData: StateFlow<MutualPassData>
|
||||
/** Grid Finder → LoTW station page prefill, consumed once by that page. */
|
||||
val pendingLoTWStationGrid: StateFlow<String?>
|
||||
fun setMutualPassData(data: MutualPassData)
|
||||
fun setPendingLoTWStationGrid(grid: String?)
|
||||
fun provideAddToCalendar(): IAddToCalendar
|
||||
fun provideShowToast(): IShowToast
|
||||
fun provideBluetoothReporter(): IReporter
|
||||
|
||||
+35
-4
@@ -23,7 +23,8 @@ 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.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@@ -48,6 +49,12 @@ interface ISettingsRepo {
|
||||
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
|
||||
fun setStationPosition(): Boolean
|
||||
fun setStationPosition(locator: String): Boolean
|
||||
/**
|
||||
* Freshest known position grid for the roaming check, read-only: the newer of a fresh
|
||||
* (≤ 24 h) system location fix and the stored station position. Null only when neither
|
||||
* yields a usable locator. Never writes the station position.
|
||||
*/
|
||||
fun getCurrentGrid(): String?
|
||||
//endregion
|
||||
|
||||
//region # Database update settings
|
||||
@@ -89,14 +96,18 @@ interface ISettingsRepo {
|
||||
fun setSatelliteOffset(catnum: Int, offset: String)
|
||||
//endregion
|
||||
|
||||
//region # Per-satellite logbook mode preset settings
|
||||
/** Last selected logbook mode (CW/SSB/FT4) for a satellite; empty when unset. */
|
||||
fun getSatelliteMode(catnum: Int): String
|
||||
fun setSatelliteMode(catnum: Int, mode: String)
|
||||
//endregion
|
||||
|
||||
//region # AMSAT status report settings
|
||||
fun getAmSatCallsign(): String
|
||||
fun setAmSatCallsign(callsign: String)
|
||||
//endregion
|
||||
|
||||
//region # Wavelog worked-grids settings
|
||||
val wavelogSettings: StateFlow<WavelogSettings>
|
||||
fun updateWavelogSettings(settings: WavelogSettings)
|
||||
//region # Worked-grid data (written by the LoTW / Wavelog syncs)
|
||||
fun getWorkedGrids(): Set<String>
|
||||
fun setWorkedGrids(grids: Set<String>)
|
||||
/** Confirmed satellite QSOs grouped by worked 4-char gridsquare (map tap detail). */
|
||||
@@ -112,6 +123,26 @@ interface ISettingsRepo {
|
||||
fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>)
|
||||
//endregion
|
||||
|
||||
//region # Wavelog upload settings
|
||||
val wavelogUploadSettings: StateFlow<WavelogUploadSettings>
|
||||
fun updateWavelogUploadSettings(settings: WavelogUploadSettings)
|
||||
//endregion
|
||||
|
||||
//region # Wavelog sync bookkeeping
|
||||
/** Station profiles as last reported by the server — labels for the 台址 selector and targets for sync. */
|
||||
fun getWavelogStations(): List<WavelogStationInfo>
|
||||
fun setWavelogStations(stations: List<WavelogStationInfo>)
|
||||
/** stationId -> lastfetchedid cursors of the last successful sync (per station profile). */
|
||||
fun getWavelogSyncCursors(): Map<String, Long>
|
||||
fun setWavelogSyncCursors(cursors: Map<String, Long>)
|
||||
/** Wavelog base URL the stored cursors belong to (empty when never synced). */
|
||||
fun getWavelogSyncUrl(): String
|
||||
fun setWavelogSyncUrl(url: String)
|
||||
/** Epoch ms of the last successful Wavelog sync (0 when never synced). */
|
||||
fun getLastWavelogSyncEpochMs(): Long
|
||||
fun setLastWavelogSyncEpochMs(value: Long)
|
||||
//endregion
|
||||
|
||||
//region # LoTW confirmed-grids settings
|
||||
val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
|
||||
fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings)
|
||||
|
||||
+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
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.LatestRelease
|
||||
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
/** Fetches worked gridsquares from a self-hosted Wavelog instance (API v2). */
|
||||
interface IWavelogRepository {
|
||||
|
||||
/**
|
||||
* Fetch worked grids for the configured Wavelog URL/token.
|
||||
* Returns the grid set, or null on any failure.
|
||||
*/
|
||||
suspend fun fetchWorkedGrids(url: String, token: String): Set<String>?
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/** What a Wavelog sync pulls: everything, or only records newer than the stored cursors. */
|
||||
enum class WavelogSyncMode { Full, Incremental }
|
||||
|
||||
/** Outcome of one sync fetch. */
|
||||
data class WavelogSyncFetch(
|
||||
/** Records pulled this run, each tagged with the station profile it came from. */
|
||||
val records: List<QsoRecord>,
|
||||
/** stationId -> lastfetchedid to resume from on the next incremental run. */
|
||||
val cursors: Map<String, Long>,
|
||||
/** Station ids that could not be fetched (transport/parse); empty when all succeeded. */
|
||||
val failedStations: List<String>
|
||||
)
|
||||
|
||||
/**
|
||||
* Pulls QSO records from a self-hosted Wavelog (v1 API, `api/get_contacts_adif`).
|
||||
* One request per station profile; the server exports only rows newer than the
|
||||
* `fetchfromid` cursor, so repeated syncs stay incremental per station.
|
||||
*/
|
||||
interface IWavelogSyncRepository {
|
||||
|
||||
/**
|
||||
* Fetches records for every entry of [stations]. [cursors] maps stationId to the
|
||||
* last id already synced (missing -> 0); [full] re-pulls each station from the
|
||||
* beginning, ignoring the cursors. Returns null when the fetch cannot run at all
|
||||
* (blank url/key or no stations); individual station failures land in
|
||||
* [WavelogSyncFetch.failedStations] with the remaining stations still fetched.
|
||||
*/
|
||||
suspend fun fetchNewRecords(
|
||||
url: String,
|
||||
apiKey: String,
|
||||
stations: List<WavelogStationInfo>,
|
||||
cursors: Map<String, Long>,
|
||||
full: Boolean
|
||||
): WavelogSyncFetch?
|
||||
}
|
||||
+79
@@ -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.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
|
||||
/** One station profile as returned by the v1 station_info endpoint. */
|
||||
data class WavelogStationInfo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val callsign: String,
|
||||
val grid: String,
|
||||
val active: Boolean
|
||||
)
|
||||
|
||||
/** Result of probing a Wavelog server (check_auth + station_info). */
|
||||
data class WavelogProbe(val rights: String, val stations: List<WavelogStationInfo>)
|
||||
|
||||
/** Why a record (or the whole upload) cannot be submitted. */
|
||||
enum class WavelogProblem { NOT_CONFIGURED, NO_STATION, GRID_MISMATCH }
|
||||
|
||||
/** Records skipped before the POST (with reasons), plus the records that will be submitted. */
|
||||
data class WavelogUploadPreview(
|
||||
val count: Int,
|
||||
val skipped: Int,
|
||||
val reasons: Map<WavelogProblem, Int>,
|
||||
val stationLabel: String,
|
||||
val firstUtc: String,
|
||||
val lastUtc: String,
|
||||
val contacts: List<String>,
|
||||
val submittedIds: List<Long>,
|
||||
val blockedBy: WavelogProblem? = null
|
||||
)
|
||||
|
||||
sealed interface WavelogUploadOutcome {
|
||||
/** [markIds] can be smaller than the submission when Wavelog reported skipped records. */
|
||||
data class Imported(
|
||||
val imported: Int,
|
||||
val duplicates: Int,
|
||||
val skipped: Int,
|
||||
val markIds: List<Long>
|
||||
) : WavelogUploadOutcome
|
||||
|
||||
data class Rejected(val message: String) : WavelogUploadOutcome
|
||||
data class Failed(val message: String) : WavelogUploadOutcome
|
||||
data object Expired : WavelogUploadOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads local QSO records to a self-hosted Wavelog via the v1 API
|
||||
* (`POST {base}/index.php/api/qso` with a batch ADIF string).
|
||||
*/
|
||||
interface IWavelogUploadRepository {
|
||||
/** check_auth + station_info; null on any transport/parse failure. */
|
||||
suspend fun probe(url: String, apiKey: String): WavelogProbe?
|
||||
|
||||
/** Filters + encodes the batch and keeps a one-shot pending payload for [upload]. */
|
||||
suspend fun prepare(records: List<QsoRecord>, settings: WavelogUploadSettings): WavelogUploadPreview
|
||||
|
||||
/** POSTs the prepared batch and classifies Wavelog's response. */
|
||||
suspend fun upload(preview: WavelogUploadPreview, settings: WavelogUploadSettings): WavelogUploadOutcome
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* 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
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
|
||||
/** Trimmed base form of a Wavelog server URL (".../wavelog/" -> ".../wavelog"). */
|
||||
fun normalizeWavelogUrl(url: String): String = url.trim().trimEnd('/')
|
||||
|
||||
/**
|
||||
* Resolves the effective sync mode for a request:
|
||||
* - an explicit [WavelogSyncMode.Full] is always honored;
|
||||
* - an [WavelogSyncMode.Incremental] request is honored only when the stored sync URL
|
||||
* matches the current one — a first sync or a server switch falls back to
|
||||
* [WavelogSyncMode.Full], so cursors from another server never leak in.
|
||||
*/
|
||||
fun resolveWavelogSyncMode(storedUrl: String, currentUrl: String, requested: WavelogSyncMode?): WavelogSyncMode {
|
||||
if (requested == WavelogSyncMode.Full) return WavelogSyncMode.Full
|
||||
val sameServer = storedUrl.isNotBlank() && storedUrl == normalizeWavelogUrl(currentUrl)
|
||||
return if (sameServer) WavelogSyncMode.Incremental else WavelogSyncMode.Full
|
||||
}
|
||||
|
||||
/** Grid data derived from Wavelog-synced records, in the same shape the LoTW sync produces. */
|
||||
data class WavelogGridData(
|
||||
val grids: Set<String>,
|
||||
val gridQsos: Map<String, List<GridQso>>,
|
||||
val roamedGrids: Set<String>
|
||||
)
|
||||
|
||||
internal fun isValidGrid4(grid: String): Boolean =
|
||||
grid.length == 4 && grid[0] in 'A'..'R' && grid[1] in 'A'..'R' &&
|
||||
grid[2] in '0'..'9' && grid[3] in '0'..'9'
|
||||
|
||||
/**
|
||||
* Derives the worked-grid set, the per-grid QSO detail and the roamed (own) grids from
|
||||
* records pulled by a Wavelog sync — mirroring what the LoTW report parser produces, so
|
||||
* both sync sources feed the map's highlight and its 台址 selector the same data shape.
|
||||
*/
|
||||
fun buildWavelogGridData(records: List<QsoRecord>): WavelogGridData {
|
||||
val grids = linkedSetOf<String>()
|
||||
val roamed = linkedSetOf<String>()
|
||||
val gridQsos = linkedMapOf<String, MutableList<GridQso>>()
|
||||
records.forEach { record ->
|
||||
val isSatellite = record.satelliteName.isNotBlank() || record.propagationMode.equals("SAT", true)
|
||||
if (!isSatellite) return@forEach
|
||||
val theirGrids = (record.theirVuccGrids.ifEmpty { listOf(record.theirGrid) })
|
||||
.map { it.trim().uppercase(Locale.US).take(4) }
|
||||
.filter(::isValidGrid4)
|
||||
.distinct()
|
||||
if (theirGrids.isEmpty()) return@forEach
|
||||
val myGrids = (record.vuccGrids.ifEmpty { listOf(record.myGrid) })
|
||||
.map { it.trim().uppercase(Locale.US).take(4) }
|
||||
.filter(::isValidGrid4)
|
||||
.toSet()
|
||||
roamed += myGrids
|
||||
val qso = GridQso(
|
||||
call = record.theirCallsign,
|
||||
epochMs = record.startUtcMillis,
|
||||
satName = record.satelliteName,
|
||||
mode = record.mode,
|
||||
bandUp = record.band,
|
||||
bandDown = record.rxBand,
|
||||
dxcc = record.dxcc,
|
||||
country = record.country.ifBlank { null },
|
||||
cqz = record.cqZone,
|
||||
state = record.region.ifBlank { null },
|
||||
myGrid = myGrids.sorted().firstOrNull(),
|
||||
myGrids = myGrids,
|
||||
myCallsign = record.myCallsign.ifBlank { null },
|
||||
theirGrids = theirGrids
|
||||
)
|
||||
theirGrids.forEach { grid ->
|
||||
grids += grid
|
||||
gridQsos.getOrPut(grid) { mutableListOf() }.add(qso)
|
||||
}
|
||||
}
|
||||
return WavelogGridData(grids, gridQsos, roamed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a successful Wavelog sync to the stored map data and sync bookkeeping.
|
||||
*
|
||||
* Grids, per-grid QSO detail and roamed grids only ever GROW (union): both the
|
||||
* incremental and the full re-pull merge into the stored sets, matching how the LoTW
|
||||
* sync treats stale data — a full re-pull heals missing records but never deletes map
|
||||
* data the app already knows about. Cursors are replaced wholesale ([cursors] covers
|
||||
* every station just fetched), and the sync URL/epoch advance so the next run resumes
|
||||
* incrementally. Persistence runs on the IO dispatcher — building the per-grid QSO
|
||||
* JSON blocks the caller, and for large accounts that froze the main thread after a
|
||||
* manual sync.
|
||||
*
|
||||
* @return the resulting number of worked grids.
|
||||
*/
|
||||
suspend fun applyWavelogSyncResult(
|
||||
settingsRepo: ISettingsRepo,
|
||||
records: List<QsoRecord>,
|
||||
cursors: Map<String, Long>,
|
||||
url: String,
|
||||
now: Long = System.currentTimeMillis()
|
||||
): Int {
|
||||
val data = buildWavelogGridData(records)
|
||||
val existingGrids = settingsRepo.getWorkedGrids()
|
||||
val existingQsos = settingsRepo.getWorkedGridQsos()
|
||||
val existingRoamed = settingsRepo.getRoamedGrids()
|
||||
val mergedGrids = existingGrids + data.grids
|
||||
val mergedQsos = mergeGridQsos(existingQsos, data.gridQsos)
|
||||
val mergedRoamed = existingRoamed + data.roamedGrids
|
||||
settingsRepo.setWavelogSyncCursors(cursors)
|
||||
settingsRepo.setWavelogSyncUrl(normalizeWavelogUrl(url))
|
||||
settingsRepo.setLastWavelogSyncEpochMs(now)
|
||||
withContext(Dispatchers.IO) {
|
||||
settingsRepo.setWorkedGrids(mergedGrids)
|
||||
settingsRepo.setWorkedGridQsos(mergedQsos)
|
||||
settingsRepo.setRoamedGrids(mergedRoamed)
|
||||
}
|
||||
return mergedGrids.size
|
||||
}
|
||||
@@ -26,6 +26,10 @@ interface ILocalSource {
|
||||
suspend fun getEntriesTotal(): Int
|
||||
suspend fun getEntriesList(): List<SatItem>
|
||||
suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject>
|
||||
suspend fun getEntriesEpochs(): Map<Int, Double>
|
||||
suspend fun getEntriesNames(): Map<Int, String>
|
||||
suspend fun renameEntries(names: Map<Int, String>)
|
||||
suspend fun deleteEntriesWithIds(ids: List<Int>)
|
||||
suspend fun insertEntries(entries: List<OrbitalData>)
|
||||
suspend fun deleteEntries()
|
||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||
|
||||
@@ -18,41 +18,32 @@
|
||||
package com.rtbishop.look4sat.core.domain.source
|
||||
|
||||
object Sources {
|
||||
// Default TLE sources, aligned with upstream's "most specific to most generic" order:
|
||||
// the first source that knows a satellite gets to name it, with the full catalogue
|
||||
// (CelesTrak active) as the fallback. Upstream dropped the old CelesTrak category
|
||||
// groups (amateur/cubesat/weather/...) — a single `active` group covers them and keeps
|
||||
// the request count (and CelesTrak's 2-hourly "already downloaded" 403s) down.
|
||||
// The three BA7OPF additions keep their own spots: Kaggle LAPAN-A2 first and the two
|
||||
// AutoTLE mirrors next to R4UAB (Chinese amateur catalogues, freshest for CN sats).
|
||||
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",
|
||||
"ARISS" to "https://live.ariss.org/iss.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",
|
||||
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
||||
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
|
||||
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
|
||||
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
||||
"Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv",
|
||||
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
|
||||
"Education" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=education&FORMAT=csv",
|
||||
"Engineer" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=engineering&FORMAT=csv",
|
||||
"Geostationary" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=geo&FORMAT=csv",
|
||||
"Globalstar" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=globalstar&FORMAT=csv",
|
||||
"GNSS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=gnss&FORMAT=csv",
|
||||
"Intelsat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=intelsat&FORMAT=csv",
|
||||
"Iridium" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=iridium-NEXT&FORMAT=csv",
|
||||
"Military" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=military&FORMAT=csv",
|
||||
"New" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=last-30-days&FORMAT=csv",
|
||||
"OneWeb" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=oneweb&FORMAT=csv",
|
||||
"Orbcomm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=orbcomm&FORMAT=csv",
|
||||
"Resource" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=resource&FORMAT=csv",
|
||||
"CelesTrak SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv",
|
||||
"Science" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=science&FORMAT=csv",
|
||||
"Spire" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=spire&FORMAT=csv",
|
||||
"Starlink" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=csv",
|
||||
"Swarm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=swarm&FORMAT=csv",
|
||||
"Weather" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=csv",
|
||||
"X-Comm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=x-comm&FORMAT=csv",
|
||||
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
|
||||
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
||||
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
|
||||
"ARISS" to "https://live.ariss.org/iss.txt",
|
||||
"Other" to "" // key for sats filter
|
||||
)
|
||||
// Transceiver sources. Both publish the same SatNOGS-uuid records (measured 2026-10-04:
|
||||
// 2760 shared uuids), and the sync keeps the FIRST source's copy for a shared uuid
|
||||
// (distinctBy { uuid }). SatNOGS is kept first on purpose: its copies carry the
|
||||
// up-to-date descriptions (e.g. AO-91 "no CTCSS any longer") and it is the only source
|
||||
// with the newest satellites' transmitters (T-18 objects etc.), while R4UAB mainly adds
|
||||
// long-dead entries plus a handful of live extras. Upstream lists R4UAB first; this fork
|
||||
// intentionally differs.
|
||||
val transceiversDataUrls = mapOf(
|
||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
|
||||
"R4UAB" to "https://r4uab.ru/transmitters.json"
|
||||
@@ -78,4 +69,12 @@ object Sources {
|
||||
* 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")
|
||||
|
||||
/** Key of the CelesTrak "active" group source in [satelliteDataUrls] — the whole
|
||||
* catalogue. Selecting it means "all satellites", so when its persisted id list
|
||||
* is empty the picker must fall back to no filtering instead of showing an empty
|
||||
* list. The list can legitimately be empty: CelesTrak answers 403 ("GP data has
|
||||
* not updated since your last successful download", groups update every 2 hours)
|
||||
* when a group is re-requested, and the sync then persists nothing for that type. */
|
||||
val allSourceType = "All"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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,102 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodeOmitsStationCallsignForWavelog() {
|
||||
val record = QsoRecord(
|
||||
startUtcMillis = 1_700_000_000_000L,
|
||||
theirCallsign = "N0CALL",
|
||||
myCallsign = "BA7OPF",
|
||||
txFrequencyHz = 145_850_000L,
|
||||
band = "2M",
|
||||
satelliteName = "SO-50"
|
||||
)
|
||||
val adif = AdifCodec.encode(listOf(record), includeStationCallsign = false)
|
||||
assertTrue(!adif.contains("<STATION_CALLSIGN"))
|
||||
assertTrue(adif.contains("<CALL:6>N0CALL"))
|
||||
assertTrue(adif.contains("<SAT_NAME:5>SO-50"))
|
||||
}
|
||||
}
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
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"),
|
||||
theirVuccGrids = listOf("EN52", "EN53")
|
||||
)
|
||||
|
||||
@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 confirmationLookupKey_groupsMfskAndFt4Spellings() {
|
||||
// Satellite FT4 is MFSK + FT4; a row persisted by an older release as plain MFSK is
|
||||
// the same mode and must resolve to the same contact.
|
||||
val plainMfsk = confirmed.copy(mode = "MFSK", submode = "")
|
||||
val ft4 = confirmed.copy(mode = "MFSK", submode = "FT4")
|
||||
assertEquals(plainMfsk.confirmationLookupKey(), ft4.confirmationLookupKey())
|
||||
assertTrue(sameContactIdentity(plainMfsk, ft4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withConfirmation_marksConfirmedAndMergesVuccGrids() {
|
||||
val merged = local.withConfirmation(confirmed)
|
||||
assertTrue(merged.lotwConfirmed)
|
||||
assertTrue(merged.lotwReceived)
|
||||
assertEquals(listOf("OM60", "OM50"), merged.vuccGrids)
|
||||
// The opposite station's grid set arrives with the confirmation too.
|
||||
assertEquals(listOf("EN52", "EN53"), merged.theirVuccGrids)
|
||||
// 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"),
|
||||
theirGrids = listOf("EN52", "EN53")
|
||||
)
|
||||
val record = qso.toConfirmedRecord("ba7opf")
|
||||
assertEquals("BH6RJD", record.theirCallsign)
|
||||
assertEquals("BA7OPF", record.myCallsign)
|
||||
assertEquals("OM60", record.myGrid)
|
||||
assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
|
||||
assertEquals("EN52", record.theirGrid)
|
||||
assertEquals(listOf("EN52", "EN53"), record.theirVuccGrids)
|
||||
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_foldsTwoConfirmedRowsAndKeepsTheRicher() {
|
||||
// A contact stored twice with BOTH sides confirmed: the mirrored import the old parser
|
||||
// created (no frequencies, report-only fills) plus the app row that was confirmed on
|
||||
// its own later (frequencies, upload state). Pass 1 cannot fold those; without pass 2
|
||||
// they stay doubled in the logbook forever.
|
||||
val mirroredImport = reportedRecord("SO-50", band = "70CM", rxBand = "2M").copy(id = 1L)
|
||||
val confirmedLocal = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
|
||||
.copy(id = 2L, lotwConfirmed = true)
|
||||
val pairs = splitConfirmationPairs(listOf(mirroredImport, confirmedLocal))
|
||||
assertEquals(1, pairs.size)
|
||||
// The richer row (frequencies + consistent bands + upload state) is the survivor.
|
||||
assertEquals(1, pairs.first().localIndex)
|
||||
assertEquals(0, pairs.first().confirmationIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun splitConfirmationPairs_keepsTwoConfirmedRowsOfDifferentMinutesApart() {
|
||||
val one = reportedRecord("SO-50", band = "70CM", rxBand = "2M").copy(id = 1L)
|
||||
val otherMinute = reportedRecord("SO-50", band = "2M", rxBand = "70CM")
|
||||
.copy(id = 2L, startUtcMillis = REPORTED_START + 90_000L)
|
||||
assertTrue(splitConfirmationPairs(listOf(one, otherMinute)).isEmpty())
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class LogbookResubmitTest {
|
||||
|
||||
private fun record(
|
||||
id: Long,
|
||||
status: QsoStatus = QsoStatus.COMPLETE,
|
||||
uploaded: Boolean = false,
|
||||
confirmed: Boolean = false
|
||||
) = QsoRecord(
|
||||
id = id,
|
||||
startUtcMillis = 1_760_000_000_000L + id,
|
||||
theirCallsign = "BG5JSB",
|
||||
myCallsign = "BA7OPF",
|
||||
mode = "FM",
|
||||
satelliteName = "SO-50",
|
||||
status = status,
|
||||
lotwUploaded = uploaded,
|
||||
lotwConfirmed = confirmed
|
||||
)
|
||||
|
||||
@Test
|
||||
fun keepsUploadedAndConfirmedRecords() {
|
||||
// The whole point of a resubmit: rows already on LoTW must not be filtered out.
|
||||
val records = listOf(record(1, uploaded = true), record(2, confirmed = true), record(3))
|
||||
assertEquals(listOf(1L, 2L, 3L), resubmitCandidates(records, setOf(1, 2, 3)).map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dropsRecordsOutsideTheSelection() {
|
||||
val records = listOf(record(1), record(2), record(3))
|
||||
assertEquals(listOf(2L), resubmitCandidates(records, setOf(2)).map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dropsIncompleteRecords() {
|
||||
val records = listOf(
|
||||
record(1, status = QsoStatus.DRAFT),
|
||||
record(2, status = QsoStatus.ABORTED),
|
||||
record(3, status = QsoStatus.COMPLETE)
|
||||
)
|
||||
assertEquals(listOf(3L), resubmitCandidates(records, setOf(1, 2, 3)).map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptySelectionYieldsNothing() {
|
||||
assertEquals(emptyList<Long>(), resubmitCandidates(listOf(record(1)), emptySet()).map { it.id })
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogProblem
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class WavelogUploadMessagesTest {
|
||||
|
||||
private fun record(
|
||||
complete: Boolean = true,
|
||||
wavelogUploaded: Boolean = false,
|
||||
lotwConfirmed: Boolean = false,
|
||||
lotwUploaded: Boolean = false
|
||||
) = QsoRecord(
|
||||
startUtcMillis = 1_700_000_000_000L,
|
||||
theirCallsign = "BG5JVM",
|
||||
myCallsign = "BA7OPF",
|
||||
status = if (complete) QsoStatus.COMPLETE else QsoStatus.DRAFT,
|
||||
lotwConfirmed = lotwConfirmed,
|
||||
lotwUploaded = lotwUploaded,
|
||||
wavelogUploaded = wavelogUploaded
|
||||
)
|
||||
|
||||
private fun preview(
|
||||
count: Int = 0,
|
||||
skipped: Int = 0,
|
||||
reasons: Map<WavelogProblem, Int> = emptyMap(),
|
||||
blockedBy: WavelogProblem? = null
|
||||
) = WavelogUploadPreview(
|
||||
count = count,
|
||||
skipped = skipped,
|
||||
reasons = reasons,
|
||||
stationLabel = "OL62TI · BA7OPF",
|
||||
firstUtc = "",
|
||||
lastUtc = "",
|
||||
contacts = emptyList(),
|
||||
submittedIds = List(count) { it.toLong() },
|
||||
blockedBy = blockedBy
|
||||
)
|
||||
|
||||
@Test
|
||||
fun freshLocalRecordIsEligible() {
|
||||
assertTrue(wavelogUploadCandidates(listOf(record())).isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun locallyUploadedToLotwStaysEligible() {
|
||||
assertTrue(wavelogUploadCandidates(listOf(record(lotwUploaded = true))).isNotEmpty())
|
||||
assertTrue(
|
||||
wavelogUploadCandidates(listOf(record(lotwConfirmed = true, lotwUploaded = true))).isNotEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importedConfirmationRowIsExcluded() {
|
||||
assertTrue(wavelogUploadCandidates(listOf(record(lotwConfirmed = true))).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun draftAndAlreadyUploadedRecordsAreExcluded() {
|
||||
assertTrue(wavelogUploadCandidates(listOf(record(complete = false))).isEmpty())
|
||||
assertTrue(wavelogUploadCandidates(listOf(record(wavelogUploaded = true))).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun problemLabelsAreOperatorFacing() {
|
||||
assertEquals("Wavelog upload not configured", WavelogProblem.NOT_CONFIGURED.label())
|
||||
assertEquals("no station profile selected", WavelogProblem.NO_STATION.label())
|
||||
assertEquals("logged grid outside the Wavelog station grid", WavelogProblem.GRID_MISMATCH.label())
|
||||
}
|
||||
|
||||
// --- Piggyback segments (Wavelog rides the LoTW upload flow) ---
|
||||
|
||||
@Test
|
||||
fun `idle segment stays silent when nothing was held back`() {
|
||||
assertNull(wavelogIdleSegment(preview()))
|
||||
assertNull(wavelogIdleSegment(preview(count = 0, skipped = 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `idle segment explains a blocked profile`() {
|
||||
assertEquals(
|
||||
"Wavelog: no station profile selected",
|
||||
wavelogIdleSegment(preview(blockedBy = WavelogProblem.NO_STATION))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `idle segment lists held-back records with reasons`() {
|
||||
assertEquals(
|
||||
"Wavelog: 3 QSO(s) skipped — 3× logged grid outside the Wavelog station grid",
|
||||
wavelogIdleSegment(preview(skipped = 3, reasons = mapOf(WavelogProblem.GRID_MISMATCH to 3)))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirmed segment reports the outcome and pre-POST skips`() {
|
||||
val outcome = WavelogUploadOutcome.Imported(imported = 2, duplicates = 0, skipped = 0, markIds = listOf(1, 2))
|
||||
assertEquals("Uploaded 2 QSO(s) to Wavelog", wavelogConfirmedSegment(outcome, preview()))
|
||||
assertEquals(
|
||||
"Uploaded 2 QSO(s) to Wavelog\n1 QSO(s) skipped — 1× logged grid outside the Wavelog station grid",
|
||||
wavelogConfirmedSegment(
|
||||
outcome,
|
||||
preview(skipped = 1, reasons = mapOf(WavelogProblem.GRID_MISMATCH to 1))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirmed segment keeps the all-duplicates wording unprefixed`() {
|
||||
val outcome = WavelogUploadOutcome.Imported(imported = 0, duplicates = 2, skipped = 0, markIds = listOf(1, 2))
|
||||
assertEquals("All 2 record(s) already in Wavelog", wavelogConfirmedSegment(outcome, preview()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirmed segment prefixes failures with Wavelog`() {
|
||||
assertEquals("Wavelog: boom", wavelogConfirmedSegment(WavelogUploadOutcome.Failed("boom"), preview()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transport failure segment asks for a retry on the next upload`() {
|
||||
assertEquals(
|
||||
"Wavelog: upload failed — it will retry with your next upload",
|
||||
wavelogTransportFailureSegment()
|
||||
)
|
||||
}
|
||||
}
|
||||
+45
@@ -152,4 +152,49 @@ class AwardCalculatorTest {
|
||||
assertEquals(40, progress.first { it.type == AwardType.WAZ }.target)
|
||||
assertEquals(50, progress.first { it.type == AwardType.WAS }.target)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `award key matches the region codes for every award type`() {
|
||||
val cn = qso("BG7XYZ", dxcc = 318, cqz = 24, state = "GD")
|
||||
assertEquals("318", AwardCalculator.awardKey(AwardType.DXCC, cn))
|
||||
assertEquals("GD", AwardCalculator.awardKey(AwardType.WAPC, cn))
|
||||
assertEquals("24", AwardCalculator.awardKey(AwardType.WAZ, cn))
|
||||
// DXCC code falls back to a prefix guess when LoTW omitted the field
|
||||
assertEquals("152", AwardCalculator.awardKey(AwardType.DXCC, qso("XX9A")))
|
||||
assertEquals(null, AwardCalculator.awardKey(AwardType.DXCC, qso("ZS1ABC")))
|
||||
// WAPC special entities
|
||||
assertEquals("HK", AwardCalculator.awardKey(AwardType.WAPC, qso("VR2X", dxcc = 321)))
|
||||
assertEquals("MO", AwardCalculator.awardKey(AwardType.WAPC, qso("XX9A", dxcc = 152)))
|
||||
assertEquals("TW", AwardCalculator.awardKey(AwardType.WAPC, qso("BV2A", dxcc = 386)))
|
||||
// WAJA: only Japanese prefecture numbers count
|
||||
assertEquals("34", AwardCalculator.awardKey(AwardType.WAJA, qso("JH0ABC", dxcc = 339, state = "34")))
|
||||
assertEquals(null, AwardCalculator.awardKey(AwardType.WAJA, qso("BG7XYZ", dxcc = 318, state = "34")))
|
||||
assertEquals(null, AwardCalculator.awardKey(AwardType.WAJA, qso("JH0ABC", dxcc = 339, state = "GD")))
|
||||
// WAS: only US states count
|
||||
assertEquals("CA", AwardCalculator.awardKey(AwardType.WAS, qso("W1AW", dxcc = 291, state = "CA")))
|
||||
assertEquals(null, AwardCalculator.awardKey(AwardType.WAS, qso("BG7XYZ", dxcc = 318, state = "CA")))
|
||||
// out-of-range CQ zone and VUCC have no key
|
||||
assertEquals(null, AwardCalculator.awardKey(AwardType.WAZ, qso("BG7XYZ", cqz = 41)))
|
||||
assertEquals(null, AwardCalculator.awardKey(AwardType.VUCC, cn))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `region qsos dedupe multi-grid copies and sort oldest first`() {
|
||||
val older = GridQso(
|
||||
call = "BA7AAA", epochMs = 100L, satName = "SO-50", mode = "FM",
|
||||
bandUp = "2M", bandDown = "70CM", dxcc = 318
|
||||
)
|
||||
val newer = GridQso(
|
||||
call = "BA7BBB", epochMs = 200L, satName = "SO-50", mode = "FM",
|
||||
bandUp = "2M", bandDown = "70CM", dxcc = 318
|
||||
)
|
||||
val store = mapOf(
|
||||
"OL62" to listOf(newer, older),
|
||||
"PM95" to listOf(older), // same QSO recorded under a second worked grid
|
||||
"OL62,PM01" to listOf(qso("W1AW", dxcc = 291))
|
||||
)
|
||||
val result = AwardCalculator.regionQsos(store, AwardType.DXCC, "318")
|
||||
assertEquals(listOf(older, newer), result)
|
||||
assertEquals(emptyList<GridQso>(), AwardCalculator.regionQsos(store, AwardType.DXCC, "292"))
|
||||
}
|
||||
}
|
||||
+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.model
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The map's VUCC view scopes QSO data to the operated-grid selector's choice
|
||||
* (台址 = station location): [stationGridSetKey] identifies a record's 台址 and
|
||||
* [scopedToStation] applies the choice — the grid-detail dialog relies on both.
|
||||
*/
|
||||
class GridQsoStationScopeTest {
|
||||
|
||||
private fun qso(
|
||||
call: String = "BG7XYZ",
|
||||
myGrid: String? = null,
|
||||
myGrids: Set<String> = emptySet()
|
||||
) = GridQso(
|
||||
call = call, epochMs = 0L, satName = "SO-50", mode = "FM",
|
||||
bandUp = "70CM", bandDown = "2M", myGrid = myGrid, myGrids = myGrids
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `multi-grid record keys by its full sorted grid set`() {
|
||||
assertEquals("OL62,PM01", qso(myGrids = setOf("PM01", "OL62")).stationGridSetKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy single-grid record falls back to myGrid`() {
|
||||
assertEquals("OL62", qso(myGrid = "OL62").stationGridSetKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `myGrids wins over myGrid when both are present`() {
|
||||
val q = qso(myGrid = "OL62", myGrids = setOf("OM60", "PM01"))
|
||||
assertEquals("OM60,PM01", q.stationGridSetKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `record without any MY grid carries no key`() {
|
||||
assertNull(qso().stationGridSetKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `key does not depend on the grid set's iteration order`() {
|
||||
assertEquals(
|
||||
qso(myGrids = setOf("PM01", "OL62")).stationGridSetKey(),
|
||||
qso(myGrids = setOf("OL62", "PM01")).stationGridSetKey()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null scope keeps every record`() {
|
||||
val a = qso("A", myGrid = "OL62")
|
||||
val b = qso("B", myGrid = "PM01")
|
||||
assertEquals(listOf(a, b), listOf(a, b).scopedToStation(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `specific scope keeps covering station's records too`() {
|
||||
// 方案 A 覆盖语义: 选中单格台址 OL62 时,覆盖它的多格台址 (OL62,PM01)
|
||||
// 的通联也计入;反向不泄漏。
|
||||
val multi = qso("A", myGrids = setOf("OL62", "PM01"))
|
||||
val single = qso("B", myGrid = "OL62")
|
||||
val list = listOf(multi, single)
|
||||
assertEquals(listOf(multi), list.scopedToStation("OL62,PM01"))
|
||||
assertEquals(listOf(multi, single), list.scopedToStation("OL62"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `narrower station never leaks into a wider scope`() {
|
||||
val single = qso("B", myGrid = "OL62")
|
||||
val pair = qso("A", myGrids = setOf("OL62", "PM01"))
|
||||
// 宽台址 (OL62,PM01) 选中时,单格 OL62 的记录不出现
|
||||
assertEquals(emptyList<GridQso>(), listOf(single, pair).scopedToStation("OL62,PM01").filter { it === single })
|
||||
assertEquals(listOf(pair), listOf(single, pair).scopedToStation("OL62,PM01"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `covering is per-cell set containment not string equality`() {
|
||||
// 多格台址的 key 是排序拼接,覆盖判定必须按网格集合而非字符串
|
||||
val quad = qso("A", myGrids = setOf("OL62", "OL63", "OL64", "OL65"))
|
||||
assertEquals(listOf(quad), listOf(quad).scopedToStation("OL63"))
|
||||
assertEquals(listOf(quad), listOf(quad).scopedToStation("OL62,OL65"))
|
||||
assertEquals(emptyList<GridQso>(), listOf(quad).scopedToStation("OL62,PM01"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `records without a MY grid belong to no station scope`() {
|
||||
val orphan = qso("A")
|
||||
val mine = qso("B", myGrid = "OL62")
|
||||
assertEquals(listOf(mine), listOf(orphan, mine).scopedToStation("OL62"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scoping to a station absent from the list yields empty`() {
|
||||
val list = listOf(qso("A", myGrid = "OL62"))
|
||||
assertEquals(emptyList<GridQso>(), list.scopedToStation("PM01"))
|
||||
}
|
||||
|
||||
// --- firstCallsByGrid ---
|
||||
|
||||
@Test
|
||||
fun `first call per grid picks the earliest of the scoped records`() {
|
||||
val store = mapOf(
|
||||
"OL62" to listOf(
|
||||
qso("OTHER", myGrid = "PM01").copy(epochMs = 10),
|
||||
qso("MINE", myGrid = "OL62").copy(epochMs = 20)
|
||||
),
|
||||
"PM95" to listOf(qso("MINE2", myGrid = "OL62").copy(epochMs = 30))
|
||||
)
|
||||
assertEquals(mapOf("OL62" to "MINE", "PM95" to "MINE2"), store.firstCallsByGrid("OL62"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first call per grid with All uses every record`() {
|
||||
val store = mapOf(
|
||||
"OL62" to listOf(
|
||||
qso("OTHER", myGrid = "PM01").copy(epochMs = 10),
|
||||
qso("MINE", myGrid = "OL62").copy(epochMs = 20)
|
||||
)
|
||||
)
|
||||
assertEquals(mapOf("OL62" to "OTHER"), store.firstCallsByGrid(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `grids without scoped records get no first call`() {
|
||||
val store = mapOf("OL62" to listOf(qso("A", myGrid = "PM01")))
|
||||
assertEquals(emptyMap<String, String>(), store.firstCallsByGrid("OL62"))
|
||||
}
|
||||
}
|
||||
+22
-3
@@ -26,7 +26,8 @@ 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.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -247,7 +248,8 @@ class LoTWGridSyncTest {
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
|
||||
|
||||
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> = MutableStateFlow(WavelogUploadSettings())
|
||||
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
|
||||
|
||||
private var workedGrids: Set<String> = emptySet()
|
||||
@@ -270,12 +272,26 @@ class LoTWGridSyncTest {
|
||||
override fun getLastLotwSyncCallsign(): String = lastSyncCallsign
|
||||
override fun setLastLotwSyncCallsign(callsign: String) { lastSyncCallsign = callsign }
|
||||
|
||||
private var wavelogStations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList()
|
||||
private var wavelogSyncCursors: Map<String, Long> = emptyMap()
|
||||
private var wavelogSyncUrl: String = ""
|
||||
private var lastWavelogSyncEpochMs: Long = 0L
|
||||
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = wavelogStations
|
||||
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) { wavelogStations = stations }
|
||||
override fun getWavelogSyncCursors(): Map<String, Long> = wavelogSyncCursors
|
||||
override fun setWavelogSyncCursors(cursors: Map<String, Long>) { wavelogSyncCursors = cursors }
|
||||
override fun getWavelogSyncUrl(): String = wavelogSyncUrl
|
||||
override fun setWavelogSyncUrl(url: String) { wavelogSyncUrl = url }
|
||||
override fun getLastWavelogSyncEpochMs(): Long = lastWavelogSyncEpochMs
|
||||
override fun setLastWavelogSyncEpochMs(value: Long) { lastWavelogSyncEpochMs = value }
|
||||
|
||||
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 getCurrentGrid(): String? = 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()
|
||||
@@ -285,10 +301,13 @@ class LoTWGridSyncTest {
|
||||
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 updateWavelogUploadSettings(settings: WavelogUploadSettings) = TODO()
|
||||
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* 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 org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class LoTWPositionWarningTest {
|
||||
|
||||
@Test
|
||||
fun coveredPositionProducesNoWarning() {
|
||||
assertNull(positionWarning("OL62aa", listOf("OL62")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun positionInsideAnyStationGridIsCovered() {
|
||||
assertNull(positionWarning("OL61", listOf("OL61", "OL62", "OM60", "OM61")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun positionOutsideTheStationWarns() {
|
||||
val warning = positionWarning("om91", listOf("OL62"))
|
||||
assertEquals("OM91", warning?.currentGrid)
|
||||
assertEquals(listOf("OL62"), warning?.stationGrids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stationGridsAreNormalizedForComparison() {
|
||||
assertNull(positionWarning("OL62", listOf("ol62aa")))
|
||||
assertNull(positionWarning("ol62", listOf("OL62")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownPositionOrStationProducesNoWarning() {
|
||||
assertNull(positionWarning(null, listOf("OL62")))
|
||||
assertNull(positionWarning("", listOf("OL62")))
|
||||
assertNull(positionWarning("OM91", emptyList()))
|
||||
assertNull(positionWarning("OM91", listOf("")))
|
||||
}
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
* 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
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
import com.rtbishop.look4sat.core.domain.model.LoTWSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.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.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class WavelogSyncTest {
|
||||
|
||||
// region # resolveWavelogSyncMode
|
||||
|
||||
@Test
|
||||
fun fullRequestIsAlwaysHonored() {
|
||||
assertEquals(WavelogSyncMode.Full, resolveWavelogSyncMode("", "http://a", WavelogSyncMode.Full))
|
||||
assertEquals(WavelogSyncMode.Full, resolveWavelogSyncMode("http://a", "http://a", WavelogSyncMode.Full))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun incrementalIsHonoredOnlyOnTheSameServer() {
|
||||
assertEquals(
|
||||
WavelogSyncMode.Incremental,
|
||||
resolveWavelogSyncMode("http://a", "http://a/", WavelogSyncMode.Incremental)
|
||||
)
|
||||
assertEquals(WavelogSyncMode.Full, resolveWavelogSyncMode("", "http://a", WavelogSyncMode.Incremental))
|
||||
assertEquals(
|
||||
WavelogSyncMode.Full,
|
||||
resolveWavelogSyncMode("http://old", "http://a", WavelogSyncMode.Incremental)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun urlNormalizationTrimsWhitespaceAndSlashes() {
|
||||
assertEquals("http://a/wavelog", normalizeWavelogUrl(" http://a/wavelog/ "))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region # buildWavelogGridData
|
||||
|
||||
@Test
|
||||
fun gridDataCollectsTheirGridsAndRoamedOwnGrids() {
|
||||
val data = buildWavelogGridData(
|
||||
listOf(
|
||||
record(start = 1_000L, theirGrid = "EN52", myGrid = "OL62"),
|
||||
record(start = 2_000L, theirGrid = "EN53XX", myGrid = "OL62TI")
|
||||
)
|
||||
)
|
||||
assertEquals(setOf("EN52", "EN53"), data.grids)
|
||||
assertEquals(setOf("OL62"), data.roamedGrids)
|
||||
assertEquals(1, data.gridQsos.getValue("EN52").size)
|
||||
assertEquals(1, data.gridQsos.getValue("EN53").size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gridDataSpreadsAMultiGridQsoAcrossItsGrids() {
|
||||
val data = buildWavelogGridData(
|
||||
listOf(record(start = 1_000L, theirGrid = "EN52", theirVucc = listOf("EN52", "EN53")))
|
||||
)
|
||||
assertEquals(setOf("EN52", "EN53"), data.grids)
|
||||
assertEquals(1, data.gridQsos.getValue("EN52").size)
|
||||
assertEquals(1, data.gridQsos.getValue("EN53").size)
|
||||
// The same QSO object is shared, so its detail matches from either grid.
|
||||
assertEquals(data.gridQsos.getValue("EN52").single(), data.gridQsos.getValue("EN53").single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gridDataIgnoresNonSatelliteRecordsAndBlankGrids() {
|
||||
val data = buildWavelogGridData(
|
||||
listOf(
|
||||
record(start = 1L, theirGrid = "", sat = ""),
|
||||
record(start = 2L, theirGrid = "XX99", sat = "SO-50")
|
||||
)
|
||||
)
|
||||
assertTrue(data.grids.isEmpty())
|
||||
assertTrue(data.roamedGrids.isEmpty())
|
||||
assertTrue(data.gridQsos.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gridQsoCarriesTheFullRecordDetails() {
|
||||
val data = buildWavelogGridData(listOf(record(start = 1_000L, theirGrid = "EN52", myGrid = "OL62TI")))
|
||||
val qso = data.gridQsos.getValue("EN52").single()
|
||||
assertEquals("BG5JVM", qso.call)
|
||||
assertEquals(1_000L, qso.epochMs)
|
||||
assertEquals("SO-50", qso.satName)
|
||||
assertEquals("FM", qso.mode)
|
||||
assertEquals("2M", qso.bandUp)
|
||||
assertEquals("70CM", qso.bandDown)
|
||||
assertEquals("OL62", qso.myGrid)
|
||||
assertEquals(setOf("OL62"), qso.myGrids)
|
||||
assertEquals(listOf("EN52"), qso.theirGrids)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region # applyWavelogSyncResult
|
||||
|
||||
@Test
|
||||
fun applySyncMergesGridsAndAdvancesBookkeeping() = runBlocking {
|
||||
val settings = FakeSettings()
|
||||
settings.setWorkedGrids(setOf("PM01"))
|
||||
|
||||
val count = applyWavelogSyncResult(
|
||||
settings,
|
||||
listOf(record(start = 1_000L, theirGrid = "EN52", myGrid = "OL62")),
|
||||
mapOf("1" to 500L),
|
||||
"http://a/wavelog/",
|
||||
now = 42L
|
||||
)
|
||||
|
||||
assertEquals(setOf("PM01", "EN52"), settings.getWorkedGrids())
|
||||
assertEquals(setOf("OL62"), settings.getRoamedGrids())
|
||||
assertEquals(setOf("EN52"), settings.getWorkedGridQsos().keys)
|
||||
assertEquals(mapOf("1" to 500L), settings.getWavelogSyncCursors())
|
||||
assertEquals("http://a/wavelog", settings.getWavelogSyncUrl())
|
||||
assertEquals(42L, settings.getLastWavelogSyncEpochMs())
|
||||
assertEquals(2, count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun applySyncNeverShrinksStoredData() = runBlocking {
|
||||
val settings = FakeSettings()
|
||||
settings.setWorkedGrids(setOf("PM01"))
|
||||
settings.setRoamedGrids(setOf("OM60"))
|
||||
|
||||
applyWavelogSyncResult(settings, listOf(record(1_000L, "EN52", myGrid = "OL62")), mapOf("1" to 10L), "http://a")
|
||||
applyWavelogSyncResult(settings, listOf(record(2_000L, "EN53", myGrid = "OL63")), mapOf("1" to 20L), "http://a")
|
||||
|
||||
assertEquals(setOf("PM01", "EN52", "EN53"), settings.getWorkedGrids())
|
||||
assertEquals(setOf("OM60", "OL62", "OL63"), settings.getRoamedGrids())
|
||||
assertEquals(mapOf("1" to 20L), settings.getWavelogSyncCursors())
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
private fun record(
|
||||
start: Long,
|
||||
theirGrid: String = "",
|
||||
theirVucc: List<String> = emptyList(),
|
||||
myGrid: String = "OL62TI",
|
||||
sat: String = "SO-50"
|
||||
) = QsoRecord(
|
||||
startUtcMillis = start,
|
||||
theirCallsign = "BG5JVM",
|
||||
myCallsign = "BA7OPF",
|
||||
theirGrid = theirGrid,
|
||||
theirVuccGrids = theirVucc,
|
||||
myGrid = myGrid,
|
||||
txFrequencyHz = 145_850_000L,
|
||||
rxFrequencyHz = 436_795_000L,
|
||||
band = "2M",
|
||||
rxBand = "70CM",
|
||||
mode = "FM",
|
||||
satelliteName = sat,
|
||||
propagationMode = if (sat.isNotBlank()) "SAT" else "",
|
||||
status = QsoStatus.COMPLETE
|
||||
)
|
||||
|
||||
private class FakeSettings : ISettingsRepo {
|
||||
private var workedGrids: Set<String> = emptySet()
|
||||
private var workedGridQsos: Map<String, List<GridQso>> = emptyMap()
|
||||
private var roamedGrids: Set<String> = emptySet()
|
||||
private var cursors: Map<String, Long> = emptyMap()
|
||||
private var syncUrl: String = ""
|
||||
private var lastSync: Long = 0L
|
||||
|
||||
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 }
|
||||
override fun getWavelogSyncCursors(): Map<String, Long> = cursors
|
||||
override fun setWavelogSyncCursors(cursors: Map<String, Long>) { this.cursors = cursors }
|
||||
override fun getWavelogSyncUrl(): String = syncUrl
|
||||
override fun setWavelogSyncUrl(url: String) { syncUrl = url }
|
||||
override fun getLastWavelogSyncEpochMs(): Long = lastSync
|
||||
override fun setLastWavelogSyncEpochMs(value: Long) { lastSync = value }
|
||||
|
||||
override val appVersionName: String get() = TODO()
|
||||
override val selectedIds: StateFlow<List<Int>> get() = TODO()
|
||||
override val selectedTypes: StateFlow<List<String>> get() = TODO()
|
||||
override fun setSelectedIds(ids: List<Int>) = TODO()
|
||||
override fun setSelectedTypes(types: List<String>) = TODO()
|
||||
override val passesSettings: StateFlow<PassesSettings> get() = TODO()
|
||||
override fun setPassesSettings(settings: PassesSettings) = TODO()
|
||||
override val stationPosition: StateFlow<GeoPos> get() = 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 getCurrentGrid(): String? = TODO()
|
||||
override val databaseState: StateFlow<DatabaseState> get() = 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 val rcSettings: StateFlow<RCSettings> get() = TODO()
|
||||
override fun updateRCSettings(settings: RCSettings) = TODO()
|
||||
override val otherSettings: StateFlow<OtherSettings> get() = TODO()
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
|
||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> get() = TODO()
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
|
||||
override val dataSourcesStatus: StateFlow<Map<String, Int>> get() = TODO()
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> get() = TODO()
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
|
||||
override fun getSatelliteOffset(catnum: Int): String = TODO()
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||
override fun getSatelliteMode(catnum: Int): String = TODO()
|
||||
override fun setSatelliteMode(catnum: Int, mode: String) = TODO()
|
||||
override fun getAmSatCallsign(): String = TODO()
|
||||
override fun setAmSatCallsign(callsign: String) = TODO()
|
||||
|
||||
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> get() = TODO()
|
||||
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) = TODO()
|
||||
override fun getWavelogStations(): List<WavelogStationInfo> = emptyList()
|
||||
override fun setWavelogStations(stations: List<WavelogStationInfo>) = TODO()
|
||||
override fun getMarkedGridStations(): Map<String, List<MarkedStation>> = TODO()
|
||||
override fun setMarkedGridStations(stations: Map<String, List<MarkedStation>>) = TODO()
|
||||
override val lotwSettings: StateFlow<LoTWSettings> get() = TODO()
|
||||
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
|
||||
override fun getLastLotwSyncDate(): String = TODO()
|
||||
override fun setLastLotwSyncDate(date: String) = TODO()
|
||||
override fun getLastLotwSyncCallsign(): String = TODO()
|
||||
override fun setLastLotwSyncCallsign(callsign: String) = 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))
|
||||
}
|
||||
}
|
||||
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user