mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-05 12:07:25 +00:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# 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 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/`, and the Ural river centerline used
|
||||
for the European/Asiatic Russia split.
|
||||
@@ -105,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,
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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>)
|
||||
|
||||
|
||||
@@ -47,4 +47,7 @@ interface QsoDao {
|
||||
|
||||
@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)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
|
||||
* upload receipts). Kept apart from [Look4SatDb] so existing installations
|
||||
* never need a migration of the satellite database.
|
||||
*/
|
||||
@Database(entities = [QsoEntity::class], version = 2, exportSchema = false)
|
||||
@Database(entities = [QsoEntity::class], version = 5, exportSchema = false)
|
||||
abstract class QsoDatabase : RoomDatabase() {
|
||||
abstract fun qsoDao(): QsoDao
|
||||
}
|
||||
@@ -31,3 +31,24 @@ val MIGRATION_QSO_1_2 = object : Migration(1, 2) {
|
||||
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 ''")
|
||||
}
|
||||
}
|
||||
@@ -44,9 +44,12 @@ data class QsoEntity(
|
||||
@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,
|
||||
|
||||
@@ -17,16 +17,22 @@
|
||||
*/
|
||||
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
|
||||
@@ -43,7 +49,10 @@ 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
|
||||
@@ -59,11 +68,14 @@ 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
|
||||
@@ -94,11 +106,14 @@ 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)
|
||||
.addMigrations(MIGRATION_QSO_1_2, MIGRATION_QSO_2_3, MIGRATION_QSO_3_4)
|
||||
.addMigrations(MIGRATION_QSO_4_5)
|
||||
.build()
|
||||
QsoRepository(database.qsoDao(), Dispatchers.IO)
|
||||
}
|
||||
@@ -107,13 +122,25 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
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)
|
||||
|
||||
|
||||
@@ -39,7 +39,14 @@ internal class LoTWSigner(private val config: LoTWConfig) {
|
||||
!call.any(Char::isLetter) || !call.any(Char::isDigit)) fail(LoTWProblem.INVALID_CONTACT, call)
|
||||
if (!record.myCallsign.trim().equals(key.info.callsign, true)) fail(LoTWProblem.CALLSIGN_MISMATCH, call)
|
||||
val date = utc(record.startUtcMillis, "yyyy-MM-dd")
|
||||
if (date < key.info.firstQsoDate || (key.info.lastQsoDate.isNotBlank() && date > key.info.lastQsoDate) || record.startUtcMillis > now) {
|
||||
// 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()
|
||||
|
||||
+23
-1
@@ -114,6 +114,19 @@ class LoTWUploadRepository internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -279,6 +292,13 @@ class LoTWUploadRepository internal constructor(
|
||||
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,
|
||||
@@ -289,7 +309,9 @@ class LoTWUploadRepository internal constructor(
|
||||
unavailable,
|
||||
reasons.toMap(),
|
||||
duplicates,
|
||||
contacts.map { it.record.id }
|
||||
contacts.map { it.record.id },
|
||||
locationGrids,
|
||||
resubmit
|
||||
)
|
||||
if (contacts.isNotEmpty()) pending = Pending(
|
||||
preview,
|
||||
|
||||
+34
-11
@@ -52,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,
|
||||
@@ -214,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"))
|
||||
@@ -230,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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -287,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 {
|
||||
@@ -301,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)
|
||||
|
||||
+3
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
+120
-22
@@ -16,8 +16,11 @@ 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
|
||||
@@ -48,20 +51,23 @@ class QsoRepository(
|
||||
// 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) &&
|
||||
if (previous != null &&
|
||||
(previous.lotwUploaded || previous.lotwConfirmed || previous.wavelogUploaded) &&
|
||||
!record.sameEditableContentAs(previous)
|
||||
) {
|
||||
dao.save(record.copy(
|
||||
id = 0L,
|
||||
lotwUploaded = false, lotwConfirmed = false, lotwReceived = false,
|
||||
lotwQslDate = "", vuccGrids = emptyList(),
|
||||
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(),
|
||||
lotwConfirmed = false, lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = emptyList(),
|
||||
dxcc = null, country = "", cqZone = null, region = ""
|
||||
) else record
|
||||
val received = if (previous != null && (
|
||||
@@ -76,9 +82,23 @@ class QsoRepository(
|
||||
|
||||
override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } }
|
||||
|
||||
override suspend fun markUploaded(ids: List<Long>) = withContext(dispatcher) {
|
||||
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) {
|
||||
@@ -105,6 +125,19 @@ class QsoRepository(
|
||||
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()
|
||||
@@ -132,9 +165,7 @@ class QsoRepository(
|
||||
val renames = rewriteOfficialNames(working)
|
||||
val lookup = working.indices.groupBy { working[it].confirmationLookupKey() }
|
||||
.mapValues { it.value.toMutableList() }.toMutableMap()
|
||||
val keyIndex = working.indices.groupBy { stableQsoKey(working[it]) }
|
||||
.mapValues { it.value.toMutableList() }.toMutableMap()
|
||||
val knownKeys = keyIndex.keys.toMutableSet()
|
||||
val knownKeys = working.map { stableQsoKey(it) }.toMutableSet()
|
||||
val changes = linkedMapOf<Int, QsoRecord>().apply {
|
||||
putAll(consolidation.merged)
|
||||
putAll(renames)
|
||||
@@ -149,27 +180,38 @@ class QsoRepository(
|
||||
val exact = candidates.filter { working[it].startUtcMillis == remote.startUtcMillis }
|
||||
val match = exact.singleOrNull() ?: candidates.singleOrNull()
|
||||
if (match == null) {
|
||||
// A confirmation downloaded by the pre-fix parser sits as its own row with the
|
||||
// band direction read mirrored (the downlink was stored as the uplink). It has no
|
||||
// local partner (so nothing folds it) and its dedupe key equals the fresh report,
|
||||
// so it would otherwise be skipped forever, keeping the wrong band on display.
|
||||
// When the bands are exactly mirrored, adopt the report's direction instead.
|
||||
val stale = if (fromLoTW) keyIndex[stableQsoKey(remote)]?.firstOrNull()
|
||||
?.takeIf { index -> working[index].lotwConfirmed &&
|
||||
working[index].band.equals(remote.rxBand, true) &&
|
||||
working[index].rxBand.equals(remote.band, true) } else null
|
||||
if (stale != null) {
|
||||
val fixed = working[stale].copy(band = remote.band, rxBand = remote.rxBand)
|
||||
working[stale] = fixed
|
||||
changes[stale] = fixed
|
||||
updated++
|
||||
// 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)
|
||||
keyIndex.getOrPut(stableQsoKey(added)) { mutableListOf() }.add(working.size)
|
||||
knownKeys += stableQsoKey(added)
|
||||
working += added
|
||||
imported++
|
||||
@@ -188,6 +230,56 @@ class QsoRepository(
|
||||
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). */
|
||||
@@ -265,9 +357,12 @@ private fun QsoEntity.toDomain() = QsoRecord(
|
||||
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,
|
||||
@@ -301,9 +396,12 @@ private fun QsoRecord.toEntity() = QsoEntity(
|
||||
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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+137
-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()
|
||||
|
||||
-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()
|
||||
|
||||
+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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,4 +131,40 @@ class Pkcs12ReaderTest {
|
||||
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()))
|
||||
}
|
||||
}
|
||||
+118
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
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
|
||||
@@ -143,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) {
|
||||
|
||||
+164
-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>) {
|
||||
@@ -307,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()
|
||||
|
||||
@@ -328,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()
|
||||
|
||||
|
||||
+23
@@ -167,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>"))
|
||||
|
||||
+181
-1
@@ -52,6 +52,9 @@ class QsoRepositoryTest {
|
||||
// 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
|
||||
@@ -124,6 +127,106 @@ class QsoRepositoryTest {
|
||||
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
|
||||
@@ -192,6 +295,68 @@ class QsoRepositoryTest {
|
||||
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,
|
||||
@@ -221,7 +386,8 @@ class QsoRepositoryTest {
|
||||
cqz = 24,
|
||||
state = "GD",
|
||||
myGrid = "OL62",
|
||||
myGrids = setOf("OL62", "OL63")
|
||||
myGrids = setOf("OL62", "OL63"),
|
||||
theirGrids = listOf("EN52", "EN53")
|
||||
).toConfirmedRecord("BA7OPF")
|
||||
|
||||
private fun QsoRecord.toEntity() = QsoEntity(
|
||||
@@ -251,9 +417,12 @@ class QsoRepositoryTest {
|
||||
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,
|
||||
@@ -288,5 +457,16 @@ class QsoRepositoryTest {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
-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()
|
||||
|
||||
@@ -348,7 +376,7 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
|
||||
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()
|
||||
@@ -362,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.
@@ -18,7 +18,7 @@ import java.util.TimeZone
|
||||
object AdifCodec {
|
||||
private val utc = TimeZone.getTimeZone("UTC")
|
||||
|
||||
fun encode(records: List<QsoRecord>): String = buildString {
|
||||
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")
|
||||
@@ -32,7 +32,7 @@ object AdifCodec {
|
||||
append(field("TIME_OFF", format(it, "HHmmss")))
|
||||
}
|
||||
append(field("CALL", record.theirCallsign))
|
||||
append(field("STATION_CALLSIGN", record.myCallsign))
|
||||
if (includeStationCallsign) append(field("STATION_CALLSIGN", record.myCallsign))
|
||||
appendOptional("MODE", record.mode)
|
||||
appendOptional("SUBMODE", record.submode)
|
||||
appendOptional("GRIDSQUARE", record.theirGrid)
|
||||
|
||||
+22
-1
@@ -17,12 +17,33 @@ interface IQsoRepository {
|
||||
suspend fun find(id: Long): QsoRecord?
|
||||
suspend fun save(record: QsoRecord): Long
|
||||
suspend fun delete(id: Long)
|
||||
suspend fun markUploaded(ids: List<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]).
|
||||
|
||||
+2
@@ -27,6 +27,8 @@ 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,
|
||||
|
||||
+1
@@ -31,6 +31,7 @@ fun LoTWProblem.label(): String = when (this) {
|
||||
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"
|
||||
|
||||
+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 }
|
||||
+77
-16
@@ -80,7 +80,7 @@ fun sameContactIdentity(local: QsoRecord, remote: QsoRecord): Boolean {
|
||||
local.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) &&
|
||||
(local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) &&
|
||||
local.isSatellite == remote.isSatellite &&
|
||||
local.displayMode == remote.displayMode &&
|
||||
local.matchableMode == remote.matchableMode &&
|
||||
abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L
|
||||
}
|
||||
|
||||
@@ -93,45 +93,100 @@ fun sameConfirmedContact(local: QsoRecord, remote: QsoRecord): Boolean =
|
||||
sameContactIdentity(local, remote) && sameBandOrBlank(local.band, remote.band)
|
||||
|
||||
fun QsoRecord.confirmationLookupKey(): String = listOf(
|
||||
theirCallsign.trim().uppercase(Locale.US), satelliteIdentity(satelliteName), displayMode
|
||||
theirCallsign.trim().uppercase(Locale.US), satelliteIdentity(satelliteName), matchableMode
|
||||
).joinToString("|")
|
||||
|
||||
/** A local row and the confirmation row that belongs to it, as indices into one list. */
|
||||
/** 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 a confirmation should have been folded into but was not, so they can be
|
||||
* consolidated back into one.
|
||||
* Rows that should have been one row but were not, so they can be consolidated back into one.
|
||||
*
|
||||
* Confirmations merged before the identity fix landed as their own rows: the tracker
|
||||
* name vs ARRL name comparison failed, and the band direction of the report was read
|
||||
* mirrored (BAND_RX was taken as the uplink), so even satellites with matching names
|
||||
* were stored twice. The pair is only accepted when the contact identity matches and
|
||||
* the bands are either identical or exactly mirrored — the signature of that second
|
||||
* mismatch — which keeps unrelated QSOs of the same operator and minute apart.
|
||||
* 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 pendingIndices = records.indices.filter { !records[it].lotwConfirmed }
|
||||
val taken = mutableSetOf<Int>()
|
||||
return records.indices.filter { records[it].lotwConfirmed }.mapNotNull { confirmationIndex ->
|
||||
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@mapNotNull null
|
||||
} ?: return@forEach
|
||||
taken += localIndex
|
||||
SplitConfirmationPair(localIndex, confirmationIndex)
|
||||
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. */
|
||||
private fun bandsMatchOrMirror(local: QsoRecord, confirmation: QsoRecord): Boolean =
|
||||
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 },
|
||||
@@ -159,6 +214,12 @@ val QsoRecord.displayMode: String
|
||||
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())
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ data class QsoRecord(
|
||||
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 = "",
|
||||
@@ -36,6 +40,12 @@ data class QsoRecord(
|
||||
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(),
|
||||
|
||||
+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"
|
||||
@@ -72,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
|
||||
@@ -98,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()
|
||||
@@ -30,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
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
-2
@@ -19,11 +19,15 @@
|
||||
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()
|
||||
@@ -96,7 +100,23 @@ data class LoTWUploadPreview(
|
||||
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()
|
||||
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. */
|
||||
@@ -128,7 +148,25 @@ class LoTWOperationException(val reason: LoTWProblem, val detail: String = "") :
|
||||
|
||||
enum class LoTWProblem {
|
||||
CERTIFICATE_PASSWORD, CERTIFICATE_INVALID, CERTIFICATE_EXPIRED, CERTIFICATE_MISSING,
|
||||
CERTIFICATE_FORMAT, STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, STATION_GRID,
|
||||
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()
|
||||
}
|
||||
+6
-1
@@ -33,13 +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
|
||||
|
||||
+29
-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
|
||||
@@ -100,9 +107,7 @@ interface ISettingsRepo {
|
||||
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). */
|
||||
@@ -118,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)
|
||||
|
||||
-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)
|
||||
}
|
||||
|
||||
@@ -83,4 +83,20 @@ class AdifCodecTest {
|
||||
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"))
|
||||
}
|
||||
}
|
||||
+42
-2
@@ -40,7 +40,8 @@ class LogbookMergeTest {
|
||||
propagationMode = "SAT",
|
||||
status = QsoStatus.COMPLETE,
|
||||
lotwConfirmed = true,
|
||||
vuccGrids = listOf("OM60", "OM50")
|
||||
vuccGrids = listOf("OM60", "OM50"),
|
||||
theirVuccGrids = listOf("EN52", "EN53")
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -73,12 +74,24 @@ class LogbookMergeTest {
|
||||
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)
|
||||
}
|
||||
@@ -97,13 +110,16 @@ class LogbookMergeTest {
|
||||
cqz = 24,
|
||||
state = "GD",
|
||||
myGrid = "OM60",
|
||||
myGrids = setOf("OM60", "OM50")
|
||||
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)
|
||||
@@ -198,6 +214,30 @@ class LogbookMergeTest {
|
||||
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)
|
||||
|
||||
+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()
|
||||
)
|
||||
}
|
||||
}
|
||||
+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"))
|
||||
}
|
||||
}
|
||||
+20
-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()
|
||||
@@ -290,7 +306,8 @@ class LoTWGridSyncTest {
|
||||
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))
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -492,6 +492,7 @@ fun SwipeRevealRow(
|
||||
controller: SwipeController,
|
||||
modifier: Modifier = Modifier,
|
||||
revealWidth: Dp = 64.dp,
|
||||
gesturesEnabled: Boolean = true,
|
||||
revealAction: () -> Unit,
|
||||
revealButton: @Composable () -> Unit = {
|
||||
Box(
|
||||
@@ -555,7 +556,9 @@ fun SwipeRevealRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer { translationX = offsetX }
|
||||
.pointerInput(revealPx) {
|
||||
.pointerInput(revealPx, gesturesEnabled) {
|
||||
// Selection mode turns reveals off; rows must not shift under the checkboxes.
|
||||
if (!gesturesEnabled) return@pointerInput
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { settleJob?.cancel() },
|
||||
onHorizontalDrag = { change, dragAmount ->
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.presentation
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Grid sets abbreviated the way operators read them: a grid is shortened to its
|
||||
* last two characters while its field (first two characters) stays the same as
|
||||
* the previous grid's; a field change is spelled out — "OL61/62" (same field)
|
||||
* but "OM91/PM01" (a shared prefix would read as OM01, the wrong square).
|
||||
* Used for the station grids a QSO was uploaded under (logbook rows and the
|
||||
* radar log page) and for the opposite station's confirmed grid set.
|
||||
*/
|
||||
fun gridsLabel(grids: List<String>): String {
|
||||
val clean = grids.map { it.trim().uppercase(Locale.US).take(4) }
|
||||
.filter { it.length >= 4 }.distinct().sorted()
|
||||
if (clean.isEmpty()) return ""
|
||||
return buildString {
|
||||
append(clean.first())
|
||||
for (index in 1 until clean.size) {
|
||||
val grid = clean[index]
|
||||
if (grid.take(2) == clean[index - 1].take(2)) append('/').append(grid.takeLast(2))
|
||||
else append('/').append(grid)
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.presentation
|
||||
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning
|
||||
|
||||
/**
|
||||
* Pre-upload position check: the operator's current position grid falls outside the station
|
||||
* location the prepared batch would be signed with — the fingerprint of operating while
|
||||
* roaming with a station location that was never updated. The operator either jumps to the
|
||||
* station location to fix it, or ignores the warning and proceeds to the normal preview.
|
||||
*/
|
||||
@Composable
|
||||
fun LoTWPositionWarningDialog(
|
||||
warning: LoTWPositionWarning,
|
||||
onFixStation: () -> Unit,
|
||||
onIgnore: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onIgnore,
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle(stringResource(R.string.lotw_upload_grid_title)) },
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.lotw_upload_grid_mismatch,
|
||||
warning.currentGrid,
|
||||
gridsLabel(warning.stationGrids)
|
||||
),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onFixStation) { Text(stringResource(R.string.lotw_upload_grid_fix)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onIgnore) { Text(stringResource(R.string.lotw_upload_grid_ignore)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -45,6 +45,14 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
||||
@Serializable
|
||||
data object RadarDestination : NavKey
|
||||
|
||||
/** Full-screen field tool: guides the operator onto a VUCC grid line or corner. */
|
||||
@Serializable
|
||||
data object GridFinderDestination : NavKey
|
||||
|
||||
/** LoTW upload certificate + station location page, reached from the Grid Finder. */
|
||||
@Serializable
|
||||
data object LoTWUploadDestination : NavKey
|
||||
|
||||
interface IDeeplinkMatcher {
|
||||
fun match(deeplink: String): NavKey?
|
||||
}
|
||||
|
||||
+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.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogSkipSummary
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
|
||||
|
||||
/**
|
||||
* Confirmation sheet for a Wavelog-mode upload (radar log page and settings logbook):
|
||||
* shows the station profile, the batch size and the records held back before the POST,
|
||||
* so the operator commits the batch after seeing what goes out (the Wavelog analog of
|
||||
* the LoTW preview flow).
|
||||
*/
|
||||
@Composable
|
||||
fun WavelogUploadPreviewDialog(
|
||||
preview: WavelogUploadPreview,
|
||||
busy: Boolean,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle(stringResource(R.string.prefs_logbook_upload_wavelog_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (preview.stationLabel.isNotBlank()) {
|
||||
Text(preview.stationLabel, fontSize = 13.sp)
|
||||
}
|
||||
Text("${preview.count} QSO(s) · ${preview.firstUtc} – ${preview.lastUtc}", fontSize = 13.sp)
|
||||
val skip = wavelogSkipSummary(preview)
|
||||
if (skip.isNotBlank()) {
|
||||
Text(skip, fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm, enabled = !busy) {
|
||||
Text(stringResource(R.string.prefs_logbook_upload_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!--
|
||||
Grid Finder icon: a GPS crosshair.
|
||||
Ported from Material Design Icons (crosshairs-gps), Apache License 2.0.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M3.05,13H1V11H3.05C3.5,6.83 6.83,3.5 11,3.05V1H13V3.05C17.17,3.5 20.5,6.83 20.95,11H23V13H20.95C20.5,17.17 17.17,20.5 13,20.95V23H11V20.95C6.83,20.5 3.5,17.17 3.05,13M12,5A7,7 0 0,0 5,12A7,7 0 0,0 12,19A7,7 0 0,0 19,12A7,7 0 0,0 12,5Z" />
|
||||
</vector>
|
||||
@@ -112,6 +112,8 @@
|
||||
<string name="prefs_data_update_success">Actualización completada con éxito</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||
<string name="prefs_data_sources_sat_hint">Prioridad arriba: nombres de satélites</string>
|
||||
<string name="prefs_data_sources_transceivers_hint">Prioridad arriba: datos de transceptores</string>
|
||||
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||
|
||||
@@ -111,6 +111,8 @@
|
||||
<string name="prefs_data_update_success">Обновление прошло успешно</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Свои источники данных</string>
|
||||
<string name="prefs_data_sources_sat_hint">Приоритет сверху: названия спутников</string>
|
||||
<string name="prefs_data_sources_transceivers_hint">Приоритет сверху: данные трансиверов</string>
|
||||
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||
|
||||
@@ -112,6 +112,8 @@
|
||||
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||
<string name="prefs_data_sources_sat_hint">ඉහළ ප්රමුඛතාව: චන්ද්රිකා නම්</string>
|
||||
<string name="prefs_data_sources_transceivers_hint">ඉහළ ප්රමුඛතාව: සම්ප්රේෂක දත්ත</string>
|
||||
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||
|
||||
@@ -142,6 +142,8 @@
|
||||
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Özel veri kaynakları</string>
|
||||
<string name="prefs_data_sources_sat_hint">Öncelik üstte: uydu adları</string>
|
||||
<string name="prefs_data_sources_transceivers_hint">Öncelik üstte: transceiver verileri</string>
|
||||
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
||||
|
||||
@@ -112,6 +112,8 @@
|
||||
<string name="prefs_data_update_success">Оновлення успішне</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||
<string name="prefs_data_sources_sat_hint">Пріоритет зверху: назви супутників</string>
|
||||
<string name="prefs_data_sources_transceivers_hint">Пріоритет зверху: дані трансиверів</string>
|
||||
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||
|
||||
@@ -157,15 +157,29 @@
|
||||
<string name="prefs_station_lat_text">输入站点的纬度</string>
|
||||
<string name="prefs_station_lon_text">输入站点的经度</string>
|
||||
<string name="prefs_locator_title">QTH 网格</string>
|
||||
<string name="prefs_wavelog_title">Wavelog 已通联网格</string>
|
||||
<string name="prefs_wavelog_configure">配置</string>
|
||||
<string name="prefs_wavelog_configured">已从 Wavelog 同步 — 通联 %1$d 个网格</string>
|
||||
<string name="prefs_wavelog_not_configured">未配置 — 连接你的 Wavelog 日志以在地图上高亮已通联网格</string>
|
||||
<string name="prefs_wavelog_url">Wavelog 地址</string>
|
||||
<string name="prefs_wavelog_token">API key :日志 ID</string>
|
||||
<string name="prefs_wavelog_hint">已同步网格数:%1$d。格式:API key:日志ID。填好直接点同步,会自动保存并拉取。</string>
|
||||
<string name="prefs_wavelog_sync">立即同步</string>
|
||||
<string name="prefs_wavelog_syncing">同步中…</string>
|
||||
<string name="prefs_wavelog_upload_title">Wavelog 配置</string>
|
||||
<string name="prefs_wavelog_exclusive">与 LoTW 二选一配置,两者不兼容。</string>
|
||||
<string name="prefs_wavelog_sync_incremental">增量同步</string>
|
||||
<string name="prefs_wavelog_sync_full">全量同步</string>
|
||||
<string name="prefs_wavelog_clear">清除配置</string>
|
||||
<string name="prefs_wavelog_clear_title">清除 Wavelog 配置?</string>
|
||||
<string name="prefs_wavelog_clear_message">清除后将切回 LoTW 模式,LoTW 功能恢复可用。</string>
|
||||
<string name="prefs_wavelog_switch_title">配置提醒</string>
|
||||
<string name="prefs_wavelog_switch_message">已配置 LoTW。配置 Wavelog 后,App 内 LoTW 的上传与同步将不可用(两者二选一,不兼容)。继续?</string>
|
||||
<string name="prefs_wavelog_switch_continue">继续</string>
|
||||
<string name="prefs_lotw_disabled_wavelog">Wavelog 模式中不可用(二选一)</string>
|
||||
<string name="prefs_wavelog_upload_not_configured">未配置 — 把本机通联记录上传到 Wavelog</string>
|
||||
<string name="prefs_wavelog_upload_configured">站点 %1$s — 可以上传</string>
|
||||
<string name="prefs_wavelog_upload_apikey">API key(读写权限)</string>
|
||||
<string name="prefs_wavelog_upload_fetch">获取站点</string>
|
||||
<string name="prefs_wavelog_upload_probing">检测中…</string>
|
||||
<string name="prefs_wavelog_upload_station_none">未选择站点 — 点「获取站点」</string>
|
||||
<string name="prefs_wavelog_upload_rights_rw">密钥:读写 — 可上传</string>
|
||||
<string name="prefs_wavelog_upload_rights_r">密钥只读 — 请在 Wavelog → API 生成「读写」密钥</string>
|
||||
<string name="prefs_wavelog_upload_hint">点「获取站点」选择上传使用的台站档案;密钥需要「读写」权限。</string>
|
||||
<string name="prefs_lotw_title">LoTW 已确认网格</string>
|
||||
<string name="prefs_lotw_callsign">LoTW 呼号</string>
|
||||
<string name="prefs_lotw_password">LoTW 密码</string>
|
||||
@@ -197,12 +211,25 @@
|
||||
<string name="prefs_logbook_upload">上传</string>
|
||||
<string name="prefs_logbook_upload_title">上传到 LoTW</string>
|
||||
<string name="prefs_logbook_upload_confirm">确认上传</string>
|
||||
<string name="prefs_logbook_resubmit">重传选中 (%1$d)</string>
|
||||
<string name="prefs_logbook_selected">已选 %1$d 条</string>
|
||||
<string name="prefs_logbook_select_hint">长按记录行可多选重传</string>
|
||||
<string name="prefs_logbook_resubmit_note">已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。</string>
|
||||
<string name="prefs_logbook_station_label">台址</string>
|
||||
<string name="prefs_logbook_station_all">全部</string>
|
||||
<string name="prefs_logbook_upload_wavelog_title">上传到 Wavelog</string>
|
||||
<string name="lotw_upload_grid_title">上传前核对</string>
|
||||
<string name="lotw_upload_grid_fix">修改台址</string>
|
||||
<string name="lotw_upload_grid_ignore">忽略</string>
|
||||
<string name="lotw_upload_grid_mismatch">当前定位 %1$s 不在证书台址网格 %2$s 内——如果正在此处操作,请先更新台址再上传。</string>
|
||||
<string name="lotw_upload_pos_hint">当前定位 %1$s 不在证书台址网格 %2$s 内 · 点此更新</string>
|
||||
<string name="prefs_lotw_upload_title">LoTW 上传证书</string>
|
||||
<string name="prefs_lotw_upload_configured">证书已导入 · 台址网格 %1$s</string>
|
||||
<string name="prefs_lotw_upload_not_configured">未配置 — 导入 TrustedQSL 证书后即可上传通联</string>
|
||||
<string name="prefs_lotw_upload_busy">处理中…</string>
|
||||
<string name="prefs_lotw_upload_cert_hint">导入你的 TrustedQSL .p12 证书(与 TQSL 同款)用于签名上传通联。密码仅加密保存在手机本地。</string>
|
||||
<string name="prefs_lotw_upload_password">证书密码</string>
|
||||
<string name="prefs_lotw_upload_password_hint">证书无密码时留空即可</string>
|
||||
<string name="prefs_lotw_upload_import">选择 .p12 文件</string>
|
||||
<string name="prefs_lotw_upload_import_confirm">导入</string>
|
||||
<string name="prefs_lotw_upload_error_password">证书密码错误,请检查 TQSL 导出 .p12 时设置的密码</string>
|
||||
@@ -215,6 +242,8 @@
|
||||
<string name="prefs_lotw_upload_remove">移除证书</string>
|
||||
<string name="prefs_lotw_upload_station_title">台址</string>
|
||||
<string name="prefs_lotw_upload_region_hint">未选择</string>
|
||||
<string name="prefs_lotw_upload_region_label">省份</string>
|
||||
<string name="prefs_lotw_upload_region_pending">导入证书后可选</string>
|
||||
<string name="prefs_lotw_upload_grid">网格(逗号分隔,可多格)</string>
|
||||
<string name="prefs_lotw_upload_save">保存</string>
|
||||
<string name="prefs_lotw_upload_close">关闭</string>
|
||||
@@ -232,6 +261,8 @@
|
||||
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
|
||||
|
||||
<string name="prefs_data_sources_title">自定义数据源</string>
|
||||
<string name="prefs_data_sources_sat_hint">轨道数据优先取列表最新星历,此顺序仅影响卫星名称</string>
|
||||
<string name="prefs_data_sources_transceivers_hint">靠前优先:收发器数据</string>
|
||||
<string name="prefs_data_sources_restore">恢复默认数据源</string>
|
||||
<string name="prefs_data_sources_tle_switch">自定义TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string>
|
||||
@@ -406,6 +437,9 @@
|
||||
<string name="qso_edit_comment">备注</string>
|
||||
<string name="qso_edit_save">保存</string>
|
||||
<string name="qso_edit_cancel">取消</string>
|
||||
<string name="log_out_window_title">不在过境窗口</string>
|
||||
<string name="log_out_window_message">当前时间不在该过境窗口内,将默认按过境中点 %1$s 录入;如需修改,请点击该条通联记录。</string>
|
||||
<string name="log_out_window_confirm">录入</string>
|
||||
<string name="qso_edit_err_callsign">呼号不能为空</string>
|
||||
<string name="qso_edit_err_datetime">日期或时间格式不对 —— 应为 yyyy-MM-dd 与 HH:mm:ss(UTC)</string>
|
||||
<string name="qso_edit_err_frequency">频率格式不对 —— 请输入 MHz,例如 145.850</string>
|
||||
@@ -416,4 +450,61 @@
|
||||
<string name="qso_edit_satellite_official">保存并签名时使用 %1$s</string>
|
||||
<string name="qso_edit_warn_no_freq">没有上行频率:LoTW 要求 BAND,补上频率前这条无法上传</string>
|
||||
|
||||
<!-- 网格寻线(VUCC 网格线/网格角实地导航) -->
|
||||
<string name="gridfinder_title">网格寻线</string>
|
||||
<string name="gridfinder_status_corner">位于网格点上</string>
|
||||
<string name="gridfinder_status_line">位于网格线上</string>
|
||||
<string name="gridfinder_status_inside">在网格内部</string>
|
||||
<string name="gridfinder_status_unknown">位置不可用</string>
|
||||
<string name="gridfinder_status_waiting">正在获取定位…</string>
|
||||
<string name="gridfinder_status_blocked">需要定位权限</string>
|
||||
<string name="gridfinder_status_disabled">本机定位服务未开启</string>
|
||||
<string name="gridfinder_status_confirmed">已由 %1$d 次连续满足 20 英尺规则的定位确认</string>
|
||||
<string name="gridfinder_status_unconfirmed">尚未确认:精度达 6.1 m 的连续定位 %1$d/%2$d 次</string>
|
||||
<string name="gridfinder_grant_permission">授予定位权限</string>
|
||||
<string name="gridfinder_card_location">位置</string>
|
||||
<string name="gridfinder_card_corner">最近网格点引导</string>
|
||||
<string name="gridfinder_card_line">最近网格线引导</string>
|
||||
<string name="gridfinder_card_lines">两条边界线</string>
|
||||
<string name="gridfinder_card_map">近距离网格图</string>
|
||||
<string name="gridfinder_card_fix">定位质量</string>
|
||||
<string name="gridfinder_card_compass">罗盘</string>
|
||||
<string name="gridfinder_label_claimable">此处可计分的网格</string>
|
||||
<string name="gridfinder_label_grid4">网格 (4)</string>
|
||||
<string name="gridfinder_label_grid6">网格 (6)</string>
|
||||
<string name="gridfinder_label_grid8">网格 (8)</string>
|
||||
<string name="gridfinder_label_latitude">纬度</string>
|
||||
<string name="gridfinder_label_longitude">经度</string>
|
||||
<string name="gridfinder_label_altitude">海拔</string>
|
||||
<string name="gridfinder_label_accuracy">水平精度</string>
|
||||
<string name="gridfinder_label_fix_age">定位时刻</string>
|
||||
<string name="gridfinder_label_provider">定位来源</string>
|
||||
<string name="gridfinder_value_provider_on">已开启</string>
|
||||
<string name="gridfinder_value_provider_off">未开启</string>
|
||||
<string name="gridfinder_label_distance">距离</string>
|
||||
<string name="gridfinder_label_bearing">方位角</string>
|
||||
<string name="gridfinder_label_direction">方向</string>
|
||||
<string name="gridfinder_label_grids">网格</string>
|
||||
<string name="gridfinder_label_lat_line">纬线</string>
|
||||
<string name="gridfinder_label_lon_line">经线</string>
|
||||
<string name="gridfinder_line_value">位于%2$s方向 %1$s</string>
|
||||
<string name="gridfinder_line_latitude">这是纬度边界,东西走向</string>
|
||||
<string name="gridfinder_line_longitude">这是经度边界,南北走向</string>
|
||||
<string name="gridfinder_label_heading">朝向</string>
|
||||
<string name="gridfinder_label_compass_accuracy">罗盘精度</string>
|
||||
<string name="gridfinder_action_set_station">设为我的台址</string>
|
||||
<string name="gridfinder_action_station_saved">台址已更新 —— 过境预报将使用这个位置</string>
|
||||
<string name="gridfinder_action_calibrate">校准罗盘</string>
|
||||
<string name="gridfinder_map_legend">中心点是你的位置,虚线是正北与正东,直线是两条网格边界,绿点是最近的网格点,圆环是最近网格线的垂足,红色指针是你的朝向</string>
|
||||
<string name="gridfinder_compass_disabled_hint">设置里未开启罗盘,表盘按正北朝上显示。方位角数字仍可引导你</string>
|
||||
<string name="gridfinder_compass_unavailable_hint">本机没有磁力传感器 —— 请按方位角数字导航</string>
|
||||
<string name="gridfinder_compass_hint">请平持手机,箭头指向应走的方向</string>
|
||||
<string name="gridfinder_vucc_rule_note">VUCC 20 英尺规则:%1$.1f m</string>
|
||||
<string name="gridfinder_direction_north">北</string>
|
||||
<string name="gridfinder_direction_south">南</string>
|
||||
<string name="gridfinder_direction_east">东</string>
|
||||
<string name="gridfinder_direction_west">西</string>
|
||||
<string name="gridfinder_action_set_lotw_station">设为 LoTW 上传台址(%1$s)</string>
|
||||
<string name="gridfinder_lotw_station_hint">预填上传台站网格:上传时 LoTW 按这些格记录(线上 2 格、角上 4 格);证书呼号保持不变。</string>
|
||||
|
||||
</resources>
|
||||
@@ -186,15 +186,29 @@
|
||||
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
|
||||
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
|
||||
<string name="prefs_locator_title">QTH locator settings</string>
|
||||
<string name="prefs_wavelog_title">Wavelog worked grids</string>
|
||||
<string name="prefs_wavelog_configure">Configure</string>
|
||||
<string name="prefs_wavelog_configured">Synced from Wavelog — %1$d grids worked</string>
|
||||
<string name="prefs_wavelog_not_configured">Not configured — connect your Wavelog logbook to highlight worked grids on the map</string>
|
||||
<string name="prefs_wavelog_url">Wavelog URL</string>
|
||||
<string name="prefs_wavelog_token">API key : logbook ID</string>
|
||||
<string name="prefs_wavelog_hint">Worked grids synced: %1$d. Format: API key, colon, logbook ID. Just tap Sync — credentials are saved automatically.</string>
|
||||
<string name="prefs_wavelog_sync">Sync now</string>
|
||||
<string name="prefs_wavelog_syncing">Syncing…</string>
|
||||
<string name="prefs_wavelog_upload_title">Wavelog configuration</string>
|
||||
<string name="prefs_wavelog_exclusive">Configure either LoTW or Wavelog — the two cannot be used together.</string>
|
||||
<string name="prefs_wavelog_sync_incremental">Incremental sync</string>
|
||||
<string name="prefs_wavelog_sync_full">Full sync</string>
|
||||
<string name="prefs_wavelog_clear">Clear configuration</string>
|
||||
<string name="prefs_wavelog_clear_title">Clear the Wavelog configuration?</string>
|
||||
<string name="prefs_wavelog_clear_message">LoTW features become available again.</string>
|
||||
<string name="prefs_wavelog_switch_title">Notice</string>
|
||||
<string name="prefs_wavelog_switch_message">LoTW is already configured. With Wavelog configured, LoTW upload and sync in this app become unavailable (the two are mutually exclusive). Continue?</string>
|
||||
<string name="prefs_wavelog_switch_continue">Continue</string>
|
||||
<string name="prefs_lotw_disabled_wavelog">Unavailable while Wavelog is configured</string>
|
||||
<string name="prefs_wavelog_upload_not_configured">Not configured — upload your local QSO records to Wavelog</string>
|
||||
<string name="prefs_wavelog_upload_configured">Station %1$s — ready to upload</string>
|
||||
<string name="prefs_wavelog_upload_apikey">API key (read & write)</string>
|
||||
<string name="prefs_wavelog_upload_fetch">Fetch stations</string>
|
||||
<string name="prefs_wavelog_upload_probing">Checking…</string>
|
||||
<string name="prefs_wavelog_upload_station_none">No station selected — tap Fetch stations</string>
|
||||
<string name="prefs_wavelog_upload_rights_rw">Key: read & write — uploads enabled</string>
|
||||
<string name="prefs_wavelog_upload_rights_r">Key is read-only — create a read & write key in Wavelog → API</string>
|
||||
<string name="prefs_wavelog_upload_hint">Tap Fetch stations to pick the station profile uploads go through. The key needs read & write permission.</string>
|
||||
<string name="prefs_lotw_title">LoTW confirmed grids</string>
|
||||
<string name="prefs_lotw_callsign">LoTW callsign</string>
|
||||
<string name="prefs_lotw_password">LoTW password</string>
|
||||
@@ -226,12 +240,25 @@
|
||||
<string name="prefs_logbook_upload">Upload</string>
|
||||
<string name="prefs_logbook_upload_title">Upload to LoTW</string>
|
||||
<string name="prefs_logbook_upload_confirm">Upload</string>
|
||||
<string name="prefs_logbook_resubmit">Resubmit (%1$d)</string>
|
||||
<string name="prefs_logbook_selected">%1$d selected</string>
|
||||
<string name="prefs_logbook_select_hint">Long-press a row to re-upload corrected records</string>
|
||||
<string name="prefs_logbook_resubmit_note">Existing contacts will be updated in place — a grid already used for an award may be rejected.</string>
|
||||
<string name="prefs_logbook_station_label">Station</string>
|
||||
<string name="prefs_logbook_station_all">All</string>
|
||||
<string name="prefs_logbook_upload_wavelog_title">Upload to Wavelog</string>
|
||||
<string name="lotw_upload_grid_title">Upload check</string>
|
||||
<string name="lotw_upload_grid_fix">Fix station location</string>
|
||||
<string name="lotw_upload_grid_ignore">Ignore</string>
|
||||
<string name="lotw_upload_grid_mismatch">Current position %1$s is outside the certificate station grids %2$s — if you are operating here, update the station location before uploading.</string>
|
||||
<string name="lotw_upload_pos_hint">Current position %1$s is outside the station grids %2$s — tap to update</string>
|
||||
<string name="prefs_lotw_upload_title">LoTW upload certificate</string>
|
||||
<string name="prefs_lotw_upload_configured">Certificate imported · station grid %1$s</string>
|
||||
<string name="prefs_lotw_upload_not_configured">Not configured — import your TrustedQSL certificate to upload QSOs</string>
|
||||
<string name="prefs_lotw_upload_busy">Working…</string>
|
||||
<string name="prefs_lotw_upload_cert_hint">Import your TrustedQSL .p12 certificate (same one you use in TQSL) to sign and upload QSOs. The password is stored encrypted on this device only.</string>
|
||||
<string name="prefs_lotw_upload_password">Certificate password</string>
|
||||
<string name="prefs_lotw_upload_password_hint">Leave blank if the certificate has no password</string>
|
||||
<string name="prefs_lotw_upload_import">Choose .p12 file</string>
|
||||
<string name="prefs_lotw_upload_import_confirm">Import</string>
|
||||
<string name="prefs_lotw_upload_error_password">Incorrect certificate password. Check the password you set when exporting the .p12 from TQSL.</string>
|
||||
@@ -244,6 +271,8 @@
|
||||
<string name="prefs_lotw_upload_remove">Remove certificate</string>
|
||||
<string name="prefs_lotw_upload_station_title">Station location</string>
|
||||
<string name="prefs_lotw_upload_region_hint">Not selected</string>
|
||||
<string name="prefs_lotw_upload_region_label">Region</string>
|
||||
<string name="prefs_lotw_upload_region_pending">Import certificate to select</string>
|
||||
<string name="prefs_lotw_upload_grid">Grid(s), comma-separated</string>
|
||||
<string name="prefs_lotw_upload_save">Save</string>
|
||||
<string name="prefs_lotw_upload_close">Close</string>
|
||||
@@ -260,6 +289,8 @@
|
||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||
<string name="prefs_data_sources_sat_hint">Orbital data takes the freshest elements; this order only affects satellite names</string>
|
||||
<string name="prefs_data_sources_transceivers_hint">Top priority affects transceivers data</string>
|
||||
<string name="prefs_data_sources_restore">Restore default sources</string>
|
||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||
@@ -434,6 +465,9 @@ Upstream: https://github.com/rt-bishop/Look4Sat</string>
|
||||
<string name="qso_edit_comment">Comment</string>
|
||||
<string name="qso_edit_save">Save</string>
|
||||
<string name="qso_edit_cancel">Cancel</string>
|
||||
<string name="log_out_window_title">Outside pass window</string>
|
||||
<string name="log_out_window_message">The current time is outside this pass window. The contact will be logged at the pass midpoint %1$s by default; tap the record afterwards to change the time if needed.</string>
|
||||
<string name="log_out_window_confirm">Log it</string>
|
||||
<string name="qso_edit_err_callsign">Callsign is required</string>
|
||||
<string name="qso_edit_err_datetime">Invalid date/time — use yyyy-MM-dd and HH:mm:ss (UTC)</string>
|
||||
<string name="qso_edit_err_frequency">Invalid frequency — enter MHz, e.g. 145.850</string>
|
||||
@@ -444,4 +478,61 @@ Upstream: https://github.com/rt-bishop/Look4Sat</string>
|
||||
<string name="qso_edit_satellite_official">Saved and signed as %1$s</string>
|
||||
<string name="qso_edit_warn_no_freq">No uplink frequency: LoTW requires a band, so this QSO cannot be uploaded yet</string>
|
||||
|
||||
<!-- Grid Finder (VUCC grid line / corner field guide) -->
|
||||
<string name="gridfinder_title">Grid Finder</string>
|
||||
<string name="gridfinder_status_corner">ON GRID POINT</string>
|
||||
<string name="gridfinder_status_line">ON GRID LINE</string>
|
||||
<string name="gridfinder_status_inside">INSIDE GRID</string>
|
||||
<string name="gridfinder_status_unknown">Position unavailable</string>
|
||||
<string name="gridfinder_status_waiting">Waiting for a fix…</string>
|
||||
<string name="gridfinder_status_blocked">Location permission required</string>
|
||||
<string name="gridfinder_status_disabled">Location services are off on this device</string>
|
||||
<string name="gridfinder_status_confirmed">Confirmed by %1$d consecutive fixes inside the 20 ft rule</string>
|
||||
<string name="gridfinder_status_unconfirmed">Not confirmed yet: %1$d of %2$d consecutive fixes good to 6.1 m</string>
|
||||
<string name="gridfinder_grant_permission">Grant location access</string>
|
||||
<string name="gridfinder_card_location">Location</string>
|
||||
<string name="gridfinder_card_corner">Guide to the nearest grid point</string>
|
||||
<string name="gridfinder_card_line">Guide to the nearest grid line</string>
|
||||
<string name="gridfinder_card_lines">Both boundary lines</string>
|
||||
<string name="gridfinder_card_map">Close-up grid map</string>
|
||||
<string name="gridfinder_card_fix">Fix quality</string>
|
||||
<string name="gridfinder_card_compass">Compass</string>
|
||||
<string name="gridfinder_label_claimable">VUCC grids here</string>
|
||||
<string name="gridfinder_label_grid4">Grid (4)</string>
|
||||
<string name="gridfinder_label_grid6">Grid (6)</string>
|
||||
<string name="gridfinder_label_grid8">Grid (8)</string>
|
||||
<string name="gridfinder_label_latitude">Latitude</string>
|
||||
<string name="gridfinder_label_longitude">Longitude</string>
|
||||
<string name="gridfinder_label_altitude">Altitude</string>
|
||||
<string name="gridfinder_label_accuracy">Horizontal accuracy</string>
|
||||
<string name="gridfinder_label_fix_age">Fix age</string>
|
||||
<string name="gridfinder_label_provider">Location provider</string>
|
||||
<string name="gridfinder_value_provider_on">On</string>
|
||||
<string name="gridfinder_value_provider_off">Off</string>
|
||||
<string name="gridfinder_label_distance">Distance</string>
|
||||
<string name="gridfinder_label_bearing">Bearing</string>
|
||||
<string name="gridfinder_label_direction">Direction</string>
|
||||
<string name="gridfinder_label_grids">Grids</string>
|
||||
<string name="gridfinder_label_lat_line">Latitude line</string>
|
||||
<string name="gridfinder_label_lon_line">Longitude line</string>
|
||||
<string name="gridfinder_line_value">%1$s to the %2$s</string>
|
||||
<string name="gridfinder_line_latitude">This is the latitude boundary, which runs east to west</string>
|
||||
<string name="gridfinder_line_longitude">This is the longitude boundary, which runs north to south</string>
|
||||
<string name="gridfinder_label_heading">Heading</string>
|
||||
<string name="gridfinder_label_compass_accuracy">Compass accuracy</string>
|
||||
<string name="gridfinder_action_set_station">Set as my station</string>
|
||||
<string name="gridfinder_action_station_saved">Station position updated — pass predictions now use this fix</string>
|
||||
<string name="gridfinder_action_calibrate">Calibrate compass</string>
|
||||
<string name="gridfinder_map_legend">The centre dot is you, dashed axes are north and east, the straight lines are the two grid boundaries, the green dot is the nearest grid point, the ring is the closest point of the nearest line and the red needle is your heading</string>
|
||||
<string name="gridfinder_compass_disabled_hint">The compass is switched off in Settings, so the dials stay north-up. The bearing numbers still guide you</string>
|
||||
<string name="gridfinder_compass_unavailable_hint">This device has no magnetic sensor — navigate with the bearing numbers</string>
|
||||
<string name="gridfinder_compass_hint">Hold the phone flat; the arrow points the way to walk</string>
|
||||
<string name="gridfinder_vucc_rule_note">VUCC 20 ft rule: %1$.1f m</string>
|
||||
<string name="gridfinder_direction_north">north</string>
|
||||
<string name="gridfinder_direction_south">south</string>
|
||||
<string name="gridfinder_direction_east">east</string>
|
||||
<string name="gridfinder_direction_west">west</string>
|
||||
<string name="gridfinder_action_set_lotw_station">Set as LoTW station (%1$s)</string>
|
||||
<string name="gridfinder_lotw_station_hint">Prefills the upload station grid — LoTW then records these squares (2 on a line, 4 on a corner). The certificate callsign stays unchanged.</string>
|
||||
|
||||
</resources>
|
||||
@@ -44,6 +44,9 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
|
||||
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
|
||||
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
|
||||
import com.ve3nea.morse_expert.MainActivity
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -91,7 +94,9 @@ fun CwSettingsDialog(controller: MainActivity, onDismiss: () -> Unit) {
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("CW Settings") },
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle("CW Settings") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.featurePlugin)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.rtbishop.look4sat.feature.gridfinder"
|
||||
testOptions {
|
||||
unitTests {
|
||||
// Robolectric needs real Android resources for Compose UI tests.
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation(libs.test.junit4)
|
||||
testImplementation(libs.test.coroutines)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.androidx.test.core)
|
||||
testImplementation(libs.compose.ui.test.junit4)
|
||||
testImplementation(libs.compose.navigation3)
|
||||
debugImplementation(libs.compose.debug.manifest)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest>
|
||||
|
||||
</manifest>
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.gridfinder
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.utility.GridBoundaryDirection
|
||||
import com.rtbishop.look4sat.core.domain.utility.GridGeometry
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* Compass rose with a needle pointing along the true-north [bearingDegrees].
|
||||
* When [headingDegrees] is available the rose turns with the device, so the
|
||||
* needle is read against the top edge of the phone ("walk the way the arrow
|
||||
* points"); without a magnetometer the rose stays north-up.
|
||||
*/
|
||||
@Composable
|
||||
fun GridCompassDial(
|
||||
bearingDegrees: Double,
|
||||
headingDegrees: Float?,
|
||||
tint: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dialColor = MaterialTheme.colorScheme.outline
|
||||
// North is a longer, brighter tick; red is reserved for the heading needle
|
||||
// in the proximity map, so the legend stays unambiguous.
|
||||
val northColor = MaterialTheme.colorScheme.onSurface
|
||||
val labelColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val measurer = rememberTextMeasurer()
|
||||
Canvas(modifier = modifier) {
|
||||
val center = Offset(size.width / 2f, size.height / 2f)
|
||||
val radius = minOf(size.width, size.height) / 2f - 8.dp.toPx()
|
||||
if (radius <= 0f) return@Canvas
|
||||
val roseRotation = headingDegrees?.let { -it } ?: 0f
|
||||
|
||||
drawCircle(color = dialColor, radius = radius, center = center, style = Stroke(1.5.dp.toPx()))
|
||||
drawCircle(
|
||||
color = dialColor.copy(alpha = 0.35f),
|
||||
radius = radius * 0.62f,
|
||||
center = center,
|
||||
style = Stroke(1.dp.toPx())
|
||||
)
|
||||
|
||||
for (degrees in 0 until 360 step 30) {
|
||||
val angle = Math.toRadians((degrees + roseRotation).toDouble())
|
||||
val isNorth = degrees == 0
|
||||
val innerRadius = radius * if (isNorth) 0.70f else 0.86f
|
||||
val start = center.at(innerRadius, angle)
|
||||
val end = center.at(radius, angle)
|
||||
drawLine(
|
||||
color = if (isNorth) northColor else dialColor,
|
||||
start = start,
|
||||
end = end,
|
||||
strokeWidth = if (isNorth) 3.dp.toPx() else 1.dp.toPx(),
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
}
|
||||
|
||||
listOf(0 to "N", 90 to "E", 180 to "S", 270 to "W").forEach { (degrees, label) ->
|
||||
val angle = Math.toRadians((degrees + roseRotation).toDouble())
|
||||
val position = center.at(radius * 0.50f, angle)
|
||||
val layout = measurer.measure(
|
||||
AnnotatedString(label),
|
||||
TextStyle(fontSize = 12.sp, color = if (degrees == 0) northColor else labelColor)
|
||||
)
|
||||
drawText(
|
||||
textLayoutResult = layout,
|
||||
topLeft = Offset(
|
||||
position.x - layout.size.width / 2f,
|
||||
position.y - layout.size.height / 2f
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val needleAngle = Math.toRadians(bearingDegrees - (headingDegrees ?: 0f))
|
||||
val tip = center.at(radius * 0.88f, needleAngle)
|
||||
drawLine(
|
||||
color = tint,
|
||||
start = center,
|
||||
end = tip,
|
||||
strokeWidth = 3.dp.toPx(),
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
drawCircle(color = tint, radius = 4.dp.toPx(), center = tip)
|
||||
drawCircle(color = tint, radius = 2.5.dp.toPx(), center = center)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* North-up proximity sketch around the fix: dashed lines are the nearest grid
|
||||
* boundaries, the green dot is the grid corner, the ring marks the closest
|
||||
* point of the nearest line, and the amber needle is the device heading.
|
||||
*/
|
||||
@Composable
|
||||
fun GridProximityMap(
|
||||
geometry: GridGeometry,
|
||||
headingDegrees: Float?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val outline = MaterialTheme.colorScheme.outline
|
||||
val gridColor = MaterialTheme.colorScheme.primary
|
||||
val lineColor = MaterialTheme.colorScheme.tertiary
|
||||
val cornerColor = MaterialTheme.colorScheme.secondary
|
||||
val headingColor = MaterialTheme.colorScheme.error
|
||||
val youColor = MaterialTheme.colorScheme.onSurface
|
||||
val dash = PathEffect.dashPathEffect(floatArrayOf(14f, 12f))
|
||||
Canvas(modifier = modifier) {
|
||||
val center = Offset(size.width / 2f, size.height / 2f)
|
||||
val radius = minOf(size.width, size.height) / 2f - 8.dp.toPx()
|
||||
if (radius <= 0f) return@Canvas
|
||||
val dNorth = when (geometry.latLineDirection) {
|
||||
GridBoundaryDirection.NORTH -> geometry.latLineMeters
|
||||
else -> -geometry.latLineMeters
|
||||
}
|
||||
val dEast = when (geometry.lonLineDirection) {
|
||||
GridBoundaryDirection.EAST -> geometry.lonLineMeters
|
||||
else -> -geometry.lonLineMeters
|
||||
}
|
||||
val span = max(max(abs(dNorth), abs(dEast)), geometry.cornerMeters)
|
||||
val scale = radius / max(span * 1.15, 15.0).toFloat()
|
||||
|
||||
drawCircle(color = outline.copy(alpha = 0.35f), radius = radius, center = center, style = Stroke(1.dp.toPx()))
|
||||
drawLine(outline, Offset(center.x, center.y - radius), Offset(center.x, center.y + radius), 1.dp.toPx(), pathEffect = dash)
|
||||
drawLine(outline, Offset(center.x - radius, center.y), Offset(center.x + radius, center.y), 1.dp.toPx(), pathEffect = dash)
|
||||
|
||||
val latY = center.y - (dNorth * scale).toFloat()
|
||||
val lonX = center.x + (dEast * scale).toFloat()
|
||||
drawLine(lineColor, Offset(center.x - radius, latY), Offset(center.x + radius, latY), 2.dp.toPx())
|
||||
drawLine(lineColor, Offset(lonX, center.y - radius), Offset(lonX, center.y + radius), 2.dp.toPx())
|
||||
|
||||
val corner = Offset(lonX, latY)
|
||||
drawCircle(color = cornerColor, radius = 5.dp.toPx(), center = corner)
|
||||
|
||||
val nearestPoint = if (geometry.nearestLineIsLatitude) {
|
||||
Offset(center.x, latY)
|
||||
} else {
|
||||
Offset(lonX, center.y)
|
||||
}
|
||||
drawCircle(color = gridColor, radius = 5.dp.toPx(), center = nearestPoint, style = Stroke(2.dp.toPx()))
|
||||
|
||||
headingDegrees?.let { heading ->
|
||||
val angle = Math.toRadians(heading.toDouble())
|
||||
val tip = center.at(radius * 0.92f, angle)
|
||||
drawLine(headingColor, center, tip, 2.dp.toPx(), cap = StrokeCap.Round)
|
||||
drawCircle(headingColor, 3.dp.toPx(), tip)
|
||||
}
|
||||
|
||||
drawCircle(color = youColor, radius = 3.dp.toPx(), center = center)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Offset.at(radius: Float, angleRadians: Double): Offset {
|
||||
return Offset(
|
||||
x + (radius * sin(angleRadians)).toFloat(),
|
||||
y - (radius * cos(angleRadians)).toFloat()
|
||||
)
|
||||
}
|
||||
+676
@@ -0,0 +1,676 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.gridfinder
|
||||
|
||||
import android.Manifest
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.utility.GridBoundaryDirection
|
||||
import com.rtbishop.look4sat.core.domain.utility.GridFixStatus
|
||||
import com.rtbishop.look4sat.core.domain.utility.GridGeometry
|
||||
import com.rtbishop.look4sat.core.domain.utility.VUCC_BOUNDARY_TOLERANCE_METERS
|
||||
import com.rtbishop.look4sat.core.presentation.ElevationHighColor
|
||||
import com.rtbishop.look4sat.core.presentation.ElevationLowColor
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Full-screen field tool: walks the operator onto a VUCC grid line or corner.
|
||||
* Reached from the map's grid view and from Settings; the live fix it uses
|
||||
* never moves the station unless the operator taps "Set as my station".
|
||||
*/
|
||||
@Composable
|
||||
fun GridFinderDestination(
|
||||
navigateUp: () -> Unit,
|
||||
onOpenLoTWStation: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: GridFinderViewModel = viewModel(factory = GridFinderViewModel.factory(container))
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> viewModel.onAction(GridFinderAction.LocationPermissionResult(granted)) }
|
||||
val requestLocationPermission = { permissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }
|
||||
var hasAskedPermission by rememberSaveable { mutableStateOf(false) }
|
||||
// "Set as LoTW station": hand the grids the operator currently stands on
|
||||
// (1 inside, 2 on a line, 4 on a corner) to the upload station page.
|
||||
val openLoTWStation: () -> Unit = {
|
||||
val grids = uiState.claimableGrids
|
||||
if (grids.isNotEmpty()) {
|
||||
container.setPendingLoTWStationGrid(grids.joinToString(","))
|
||||
onOpenLoTWStation()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(viewModel) {
|
||||
viewModel.onAction(GridFinderAction.StartUpdates)
|
||||
onDispose { viewModel.onAction(GridFinderAction.StopUpdates) }
|
||||
}
|
||||
// Ask once automatically on the first visit; after a refusal the banner
|
||||
// offers the request again, so returning to the screen is not a prompt loop.
|
||||
LaunchedEffect(uiState.permissionGranted) {
|
||||
if (!uiState.permissionGranted && !hasAskedPermission) {
|
||||
hasAskedPermission = true
|
||||
requestLocationPermission()
|
||||
}
|
||||
}
|
||||
|
||||
GridFinderScreen(
|
||||
uiState = uiState,
|
||||
onAction = viewModel::onAction,
|
||||
navigateUp = navigateUp,
|
||||
openLoTWStation = openLoTWStation,
|
||||
requestLocationPermission = requestLocationPermission
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GridFinderScreen(
|
||||
uiState: GridFinderState,
|
||||
onAction: (GridFinderAction) -> Unit,
|
||||
navigateUp: () -> Unit,
|
||||
openLoTWStation: () -> Unit,
|
||||
requestLocationPermission: () -> Unit
|
||||
) {
|
||||
// Same top-bar → content rhythm as the settings page: ScreenColumn puts a
|
||||
// small gap between the header row and the rounded surfaceContainer panel.
|
||||
ScreenColumn(
|
||||
topBar = {
|
||||
TopBar {
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_title),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().clip(MaterialTheme.shapes.medium),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = PaddingValues(top = 0.dp, bottom = 24.dp)
|
||||
) {
|
||||
item { StandingCard(uiState, requestLocationPermission) }
|
||||
uiState.geometry?.let { geometry ->
|
||||
item { LocationCard(uiState, onAction, openLoTWStation) }
|
||||
item {
|
||||
GuideCard(
|
||||
title = stringResource(R.string.gridfinder_card_corner),
|
||||
bearing = geometry.cornerBearingDegrees,
|
||||
distanceMeters = geometry.cornerMeters,
|
||||
direction = null,
|
||||
grids = geometry.cornerGrids,
|
||||
tint = ElevationHighColor,
|
||||
heading = uiState.headingDegrees
|
||||
)
|
||||
}
|
||||
item {
|
||||
GuideCard(
|
||||
title = stringResource(R.string.gridfinder_card_line),
|
||||
bearing = geometry.nearestLineBearingDegrees,
|
||||
distanceMeters = geometry.nearestLineMeters,
|
||||
direction = null,
|
||||
grids = geometry.nearestLineGrids,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
heading = uiState.headingDegrees,
|
||||
extra = stringResource(
|
||||
if (geometry.nearestLineIsLatitude) {
|
||||
R.string.gridfinder_line_latitude
|
||||
} else {
|
||||
R.string.gridfinder_line_longitude
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
item { BoundaryLinesCard(geometry) }
|
||||
item { ProximityCard(geometry, uiState.headingDegrees) }
|
||||
}
|
||||
item { FixQualityCard(uiState) }
|
||||
item { CompassCard(uiState, onAction) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StandingCard(uiState: GridFinderState, requestLocationPermission: () -> Unit) {
|
||||
val grids = uiState.claimableGrids.joinToString(" · ")
|
||||
val (headline, tint) = when {
|
||||
!uiState.permissionGranted -> stringResource(R.string.gridfinder_status_blocked) to ElevationLowColor
|
||||
!uiState.providerEnabled -> stringResource(R.string.gridfinder_status_disabled) to ElevationLowColor
|
||||
uiState.fix == null -> stringResource(R.string.gridfinder_status_waiting) to MaterialTheme.colorScheme.onSurface
|
||||
uiState.status == null -> stringResource(R.string.gridfinder_status_unknown) to ElevationLowColor
|
||||
uiState.status == GridFixStatus.ON_GRID_CORNER -> stringResource(R.string.gridfinder_status_corner) to ElevationHighColor
|
||||
uiState.status == GridFixStatus.ON_GRID_LINE -> stringResource(R.string.gridfinder_status_line) to ElevationHighColor
|
||||
else -> stringResource(R.string.gridfinder_status_inside) to MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
GridCard(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = headline,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = tint,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (grids.isNotEmpty()) {
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_claimable),
|
||||
value = grids,
|
||||
valueColor = when (uiState.status) {
|
||||
GridFixStatus.ON_GRID_CORNER, GridFixStatus.ON_GRID_LINE -> ElevationHighColor
|
||||
else -> null
|
||||
}
|
||||
)
|
||||
}
|
||||
when {
|
||||
uiState.fix == null -> Unit
|
||||
uiState.status == GridFixStatus.INSIDE_GRID -> Text(
|
||||
text = stringResource(R.string.gridfinder_vucc_rule_note, VUCC_BOUNDARY_TOLERANCE_METERS),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
uiState.isStandingConfirmed -> Text(
|
||||
text = stringResource(R.string.gridfinder_status_confirmed, PRECISE_FIXES_REQUIRED),
|
||||
fontSize = 12.sp,
|
||||
color = ElevationHighColor
|
||||
)
|
||||
else -> Text(
|
||||
text = stringResource(
|
||||
R.string.gridfinder_status_unconfirmed,
|
||||
uiState.preciseFixesInARow,
|
||||
PRECISE_FIXES_REQUIRED
|
||||
),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (!uiState.permissionGranted) {
|
||||
Button(onClick = requestLocationPermission) {
|
||||
Text(text = stringResource(R.string.gridfinder_grant_permission))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocationCard(
|
||||
uiState: GridFinderState,
|
||||
onAction: (GridFinderAction) -> Unit,
|
||||
openLoTWStation: () -> Unit
|
||||
) {
|
||||
GridCard(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_card_location),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
val geometry = uiState.geometry
|
||||
val fix = uiState.fix
|
||||
GridMetricRow(stringResource(R.string.gridfinder_label_grid4), geometry?.grid4 ?: "-")
|
||||
GridMetricRow(stringResource(R.string.gridfinder_label_grid6), geometry?.grid6 ?: "-")
|
||||
GridMetricRow(stringResource(R.string.gridfinder_label_grid8), geometry?.grid8 ?: "-")
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_latitude),
|
||||
value = fix?.let { String.format("%+.5f°", it.latitude) } ?: "-"
|
||||
)
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_longitude),
|
||||
value = fix?.let { String.format("%+.5f°", it.longitude) } ?: "-"
|
||||
)
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_altitude),
|
||||
value = fix?.let { String.format("%.0f m", it.altitudeMeters) } ?: "-"
|
||||
)
|
||||
if (uiState.fix != null) {
|
||||
Button(
|
||||
onClick = { onAction(GridFinderAction.SetAsStation) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = stringResource(R.string.gridfinder_action_set_station))
|
||||
}
|
||||
if (uiState.isStationSaved) {
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_action_station_saved),
|
||||
fontSize = 12.sp,
|
||||
color = ElevationHighColor
|
||||
)
|
||||
}
|
||||
val claimable = uiState.claimableGrids
|
||||
if (claimable.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Button(
|
||||
onClick = openLoTWStation,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.gridfinder_action_set_lotw_station,
|
||||
claimable.joinToString(",")
|
||||
)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_lotw_station_hint),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GuideCard(
|
||||
title: String,
|
||||
bearing: Double,
|
||||
distanceMeters: Double,
|
||||
direction: GridBoundaryDirection?,
|
||||
grids: List<String>,
|
||||
tint: Color,
|
||||
heading: Float?,
|
||||
extra: String? = null
|
||||
) {
|
||||
GridCard(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
GridCompassDial(
|
||||
bearingDegrees = bearing,
|
||||
headingDegrees = heading,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(108.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_distance),
|
||||
value = formatDistance(distanceMeters),
|
||||
valueColor = tint
|
||||
)
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_bearing),
|
||||
value = String.format("%.0f° %s", bearing, cardinal(bearing))
|
||||
)
|
||||
if (direction != null) {
|
||||
GridMetricRow(stringResource(R.string.gridfinder_label_direction), directionLabel(direction))
|
||||
}
|
||||
if (grids.isNotEmpty()) {
|
||||
GridMetricRow(stringResource(R.string.gridfinder_label_grids), grids.joinToString(" / "))
|
||||
}
|
||||
extra?.let {
|
||||
Text(text = it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoundaryLinesCard(geometry: GridGeometry) {
|
||||
GridCard(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_card_lines),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_lat_line),
|
||||
value = stringResource(
|
||||
R.string.gridfinder_line_value,
|
||||
formatDistance(geometry.latLineMeters),
|
||||
directionLabel(geometry.latLineDirection)
|
||||
)
|
||||
)
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_lon_line),
|
||||
value = stringResource(
|
||||
R.string.gridfinder_line_value,
|
||||
formatDistance(geometry.lonLineMeters),
|
||||
directionLabel(geometry.lonLineDirection)
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_vucc_rule_note, VUCC_BOUNDARY_TOLERANCE_METERS),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProximityCard(
|
||||
geometry: GridGeometry,
|
||||
heading: Float?
|
||||
) {
|
||||
GridCard(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_card_map),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
GridProximityMap(
|
||||
geometry = geometry,
|
||||
headingDegrees = heading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(240.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_map_legend),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FixQualityCard(uiState: GridFinderState) {
|
||||
val fix = uiState.fix
|
||||
GridCard(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_card_fix),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_accuracy),
|
||||
value = fix?.let { String.format("± %.1f m", it.accuracyMeters) } ?: "-",
|
||||
valueColor = fix?.let {
|
||||
if (it.isPreciseEnough(VUCC_FIX_ACCURACY_LIMIT_METERS)) ElevationHighColor else ElevationLowColor
|
||||
}
|
||||
)
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_fix_age),
|
||||
value = fix?.let { formatAge(it.epochMs) } ?: "-"
|
||||
)
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_provider),
|
||||
value = stringResource(
|
||||
if (uiState.providerEnabled) {
|
||||
R.string.gridfinder_value_provider_on
|
||||
} else {
|
||||
R.string.gridfinder_value_provider_off
|
||||
}
|
||||
)
|
||||
)
|
||||
LinearProgressIndicator(
|
||||
progress = { uiState.preciseFixesInARow.toFloat() / PRECISE_FIXES_REQUIRED },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompassCard(uiState: GridFinderState, onAction: (GridFinderAction) -> Unit) {
|
||||
var showCalibration by rememberSaveable { mutableStateOf(false) }
|
||||
GridCard(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_card_compass),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_heading),
|
||||
value = uiState.headingDegrees?.let { String.format("%.0f° %s", it, cardinal(it.toDouble())) } ?: "-"
|
||||
)
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_compass_accuracy),
|
||||
value = stringResource(compassAccuracyRes(uiState.compassAccuracy))
|
||||
)
|
||||
if (uiState.isCompassEnabled) {
|
||||
Button(
|
||||
onClick = { showCalibration = true },
|
||||
enabled = uiState.compassAccuracy != CompassAccuracy.UNAVAILABLE,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = stringResource(R.string.gridfinder_action_calibrate))
|
||||
}
|
||||
}
|
||||
if (!uiState.isCompassEnabled) {
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_compass_disabled_hint),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else if (uiState.compassAccuracy == CompassAccuracy.UNAVAILABLE) {
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_compass_unavailable_hint),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_compass_hint),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showCalibration) {
|
||||
GridCompassCalibrationDialog(
|
||||
accuracy = uiState.compassAccuracy,
|
||||
heading = uiState.headingDegrees,
|
||||
onSaveOffset = { offset -> onAction(GridFinderAction.SetCompassOffset(offset)) },
|
||||
onDismiss = { showCalibration = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact figure-eight calibration dialog. It mirrors the settings screen's
|
||||
* compass calibration but is self-contained in this module; the trim is saved
|
||||
* through the same setting the radar page uses, so both stay in sync.
|
||||
*/
|
||||
@Composable
|
||||
private fun GridCompassCalibrationDialog(
|
||||
accuracy: CompassAccuracy,
|
||||
heading: Float?,
|
||||
onSaveOffset: (Float) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var offsetText by rememberSaveable { mutableStateOf("") }
|
||||
val parsedOffset = offsetText.replace(',', '.').toFloatOrNull()?.takeIf { it in -180f..180f }
|
||||
val progress = when (accuracy) {
|
||||
CompassAccuracy.HIGH -> 1f
|
||||
CompassAccuracy.MEDIUM -> 0.66f
|
||||
CompassAccuracy.LOW -> 0.33f
|
||||
else -> 0f
|
||||
}
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.prefs_compass_calibration_title),
|
||||
onDismissRequest = onDismiss,
|
||||
onCancel = onDismiss,
|
||||
acceptEnabled = parsedOffset != null,
|
||||
onAccept = {
|
||||
parsedOffset?.let(onSaveOffset)
|
||||
onDismiss()
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = padding)
|
||||
) {
|
||||
Text(
|
||||
text = "∞",
|
||||
fontSize = 84.sp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
lineHeight = 84.sp
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_compass_calibration_instruction),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_compass_accuracy, stringResource(compassAccuracyRes(accuracy))),
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = heading?.let { String.format("%.0f° %s", it, cardinal(it.toDouble())) } ?: "-",
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_compass_offset_support),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = offsetText,
|
||||
onValueChange = { offsetText = it },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
label = { Text(text = stringResource(R.string.prefs_compass_offset)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GridCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
|
||||
ElevatedCard(modifier = modifier) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(12.dp),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GridMetricRow(label: String, value: String, valueColor: Color? = null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = valueColor ?: MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun directionLabel(direction: GridBoundaryDirection): String = stringResource(
|
||||
when (direction) {
|
||||
GridBoundaryDirection.NORTH -> R.string.gridfinder_direction_north
|
||||
GridBoundaryDirection.SOUTH -> R.string.gridfinder_direction_south
|
||||
GridBoundaryDirection.EAST -> R.string.gridfinder_direction_east
|
||||
GridBoundaryDirection.WEST -> R.string.gridfinder_direction_west
|
||||
}
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun compassAccuracyRes(accuracy: CompassAccuracy): Int = when (accuracy) {
|
||||
CompassAccuracy.HIGH -> R.string.prefs_compass_accuracy_high
|
||||
CompassAccuracy.MEDIUM -> R.string.prefs_compass_accuracy_medium
|
||||
CompassAccuracy.LOW -> R.string.prefs_compass_accuracy_low
|
||||
CompassAccuracy.UNRELIABLE -> R.string.prefs_compass_accuracy_unreliable
|
||||
CompassAccuracy.UNAVAILABLE -> R.string.prefs_compass_accuracy_unavailable
|
||||
}
|
||||
|
||||
private fun formatDistance(meters: Double): String {
|
||||
return if (abs(meters) < 1000.0) {
|
||||
String.format("%.1f m", meters)
|
||||
} else {
|
||||
String.format("%.2f km", meters / 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatAge(epochMs: Long): String {
|
||||
val seconds = ((System.currentTimeMillis() - epochMs) / 1000L).coerceAtLeast(0L)
|
||||
return if (seconds < 60L) "${seconds}s" else "${seconds / 60L}min"
|
||||
}
|
||||
|
||||
private fun cardinal(bearingDegrees: Double): String {
|
||||
val normalized = (bearingDegrees % 360.0 + 360.0) % 360.0
|
||||
val names = listOf("N", "NE", "E", "SE", "S", "SW", "W", "NW", "N")
|
||||
return names[((normalized + 22.5) / 45.0).toInt().coerceIn(0, 8)]
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.gridfinder
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
|
||||
import com.rtbishop.look4sat.core.domain.repository.LocationFix
|
||||
import com.rtbishop.look4sat.core.domain.utility.GridFixStatus
|
||||
import com.rtbishop.look4sat.core.domain.utility.GridGeometry
|
||||
|
||||
/**
|
||||
* How many consecutive precise fixes are required before a grid-line / corner
|
||||
* claim is presented as confirmed. A single fix is not trustworthy enough to
|
||||
* tell a walker that they are standing on a 6 m boundary.
|
||||
*/
|
||||
const val PRECISE_FIXES_REQUIRED = 3
|
||||
|
||||
/** A fix must be at least this good before its grid line/corner claim counts. */
|
||||
const val VUCC_FIX_ACCURACY_LIMIT_METERS = 6.1f
|
||||
|
||||
/** Outcome of pushing one fix through the 20 ft precision gate. */
|
||||
data class FixGateResult(val streak: Int, val isConfirmed: Boolean)
|
||||
|
||||
/**
|
||||
* Counts consecutive fixes that are both good to the 20 ft rule and inside a
|
||||
* grid-square boundary. Any one bad fix resets the counter, so a claim is only
|
||||
* confirmed once the walker has held it for several readings.
|
||||
*/
|
||||
fun nextFixGate(previousStreak: Int, fix: LocationFix): FixGateResult {
|
||||
val streak = if (fix.isPreciseEnough(VUCC_FIX_ACCURACY_LIMIT_METERS)) {
|
||||
(previousStreak + 1).coerceAtMost(PRECISE_FIXES_REQUIRED)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
return FixGateResult(streak = streak, isConfirmed = streak >= PRECISE_FIXES_REQUIRED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid Finder screen state. [status] and [claimableGrids] are always derived
|
||||
* from the latest fix's distance to the boundary; [isStandingConfirmed] says
|
||||
* whether that reading is backed by [PRECISE_FIXES_REQUIRED] consecutive fixes
|
||||
* that are themselves good to the 20 ft rule.
|
||||
*/
|
||||
data class GridFinderState(
|
||||
val permissionGranted: Boolean = false,
|
||||
val providerEnabled: Boolean = true,
|
||||
val providerReady: Boolean = true,
|
||||
val fix: LocationFix? = null,
|
||||
val geometry: GridGeometry? = null,
|
||||
val claimableGrids: List<String> = emptyList(),
|
||||
val status: GridFixStatus? = null,
|
||||
val preciseFixesInARow: Int = 0,
|
||||
val isStandingConfirmed: Boolean = false,
|
||||
val headingDegrees: Float? = null,
|
||||
val compassAccuracy: CompassAccuracy = CompassAccuracy.UNAVAILABLE,
|
||||
val isCompassEnabled: Boolean = false,
|
||||
val isStationSaved: Boolean = false
|
||||
) {
|
||||
val isWaitingForFix: Boolean get() = fix == null
|
||||
}
|
||||
|
||||
sealed interface GridFinderAction {
|
||||
/** Called when the screen appears: starts location and (if enabled) compass updates. */
|
||||
data object StartUpdates : GridFinderAction
|
||||
|
||||
/** Called when the screen goes away: releases both listeners. */
|
||||
data object StopUpdates : GridFinderAction
|
||||
|
||||
data class LocationPermissionResult(val granted: Boolean) : GridFinderAction
|
||||
|
||||
/** Copies the current fix into the station position that drives pass predictions. */
|
||||
data object SetAsStation : GridFinderAction
|
||||
|
||||
data object StationSavedShown : GridFinderAction
|
||||
|
||||
/** Saves the compass trim, shared with the radar page's calibration. */
|
||||
data class SetCompassOffset(val offsetDegrees: Float) : GridFinderAction
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.gridfinder
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILocationRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.LocationFix
|
||||
import com.rtbishop.look4sat.core.domain.repository.LocationState
|
||||
import com.rtbishop.look4sat.core.domain.utility.gridFixStatus
|
||||
import com.rtbishop.look4sat.core.domain.utility.gridGeometry
|
||||
import com.rtbishop.look4sat.core.domain.utility.vuccClaimableGrids
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
|
||||
/** Declination is only recomputed once the fix has moved this far (degrees). */
|
||||
private const val DECLINATION_REFRESH_DEGREES = 0.01
|
||||
|
||||
/**
|
||||
* Drives the Grid Finder: streams the live fix, turns it into grid-line /
|
||||
* grid-corner geometry, and gates the VUCC claim on [PRECISE_FIXES_REQUIRED]
|
||||
* consecutive fixes good to the 20 ft rule.
|
||||
*
|
||||
* The live fix never touches the station position - [GridFinderAction.SetAsStation]
|
||||
* is the only thing that writes it, and only when the operator asks.
|
||||
*/
|
||||
class GridFinderViewModel(
|
||||
private val settingsRepo: ISettingsRepo,
|
||||
private val locationRepo: ILocationRepo,
|
||||
private val sensorsRepo: ISensorsRepo
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(GridFinderState())
|
||||
val uiState: StateFlow<GridFinderState> = _uiState.asStateFlow()
|
||||
|
||||
private var locationJob: Job? = null
|
||||
private var compassJob: Job? = null
|
||||
private var accuracyJob: Job? = null
|
||||
private var sensorsEnabledByUs = false
|
||||
private var declinationCache: Pair<GeoPos, Float>? = null
|
||||
private var lastSensorAzimuth: Float? = null
|
||||
|
||||
fun onAction(action: GridFinderAction) {
|
||||
when (action) {
|
||||
GridFinderAction.StartUpdates -> startUpdates()
|
||||
GridFinderAction.StopUpdates -> stopUpdates()
|
||||
is GridFinderAction.LocationPermissionResult -> startUpdates()
|
||||
GridFinderAction.SetAsStation -> setAsStation()
|
||||
GridFinderAction.StationSavedShown -> _uiState.update { it.copy(isStationSaved = false) }
|
||||
is GridFinderAction.SetCompassOffset -> setCompassOffset(action.offsetDegrees)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the compass trim shared with the radar page, then recomputes the
|
||||
* heading from the last raw sensor sample so the dial reacts immediately.
|
||||
*/
|
||||
private fun setCompassOffset(offsetDegrees: Float) {
|
||||
settingsRepo.updateOtherSettings { it.copy(compassOffsetDegrees = offsetDegrees) }
|
||||
lastSensorAzimuth?.let { azimuth ->
|
||||
_uiState.update { it.copy(headingDegrees = correctedHeading(azimuth)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun startUpdates() {
|
||||
val permissionGranted = locationRepo.hasPermission()
|
||||
_uiState.update { it.copy(permissionGranted = permissionGranted) }
|
||||
if (!permissionGranted) return
|
||||
locationRepo.startUpdates()
|
||||
collectLocation()
|
||||
collectCompass()
|
||||
}
|
||||
|
||||
private fun collectLocation() {
|
||||
if (locationJob != null) return
|
||||
locationJob = viewModelScope.launch {
|
||||
locationRepo.state.collect { state ->
|
||||
when (state) {
|
||||
is LocationState.Fix -> onFix(state.location)
|
||||
is LocationState.Waiting -> _uiState.update {
|
||||
it.copy(
|
||||
permissionGranted = state.permissionGranted,
|
||||
providerEnabled = state.providerEnabled
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectCompass() {
|
||||
val compassEnabled = settingsRepo.otherSettings.value.stateOfSensors
|
||||
_uiState.update {
|
||||
it.copy(isCompassEnabled = compassEnabled, compassAccuracy = sensorsRepo.compassAccuracy.value)
|
||||
}
|
||||
if (!compassEnabled || compassJob != null) return
|
||||
sensorsRepo.enableSensor()
|
||||
sensorsEnabledByUs = true
|
||||
accuracyJob = viewModelScope.launch {
|
||||
sensorsRepo.compassAccuracy.collect { accuracy ->
|
||||
_uiState.update { it.copy(compassAccuracy = accuracy) }
|
||||
}
|
||||
}
|
||||
compassJob = viewModelScope.launch {
|
||||
sensorsRepo.sensorData.collect { data ->
|
||||
lastSensorAzimuth = data.first
|
||||
_uiState.update { it.copy(headingDegrees = correctedHeading(data.first)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Same convention as the radar page: sensor azimuth + magnetic declination + manual trim. */
|
||||
private fun correctedHeading(sensorAzimuth: Float): Float {
|
||||
val offset = settingsRepo.otherSettings.value.compassOffsetDegrees
|
||||
val azimuth = sensorAzimuth + magDeclination() + offset
|
||||
return (azimuth % 360f + 360f) % 360f
|
||||
}
|
||||
|
||||
private fun magDeclination(): Float {
|
||||
val position = _uiState.value.fix?.let { GeoPos(it.latitude, it.longitude, it.altitudeMeters) }
|
||||
?: settingsRepo.stationPosition.value
|
||||
val cached = declinationCache
|
||||
if (cached != null &&
|
||||
abs(cached.first.latitude - position.latitude) < DECLINATION_REFRESH_DEGREES &&
|
||||
abs(cached.first.longitude - position.longitude) < DECLINATION_REFRESH_DEGREES
|
||||
) {
|
||||
return cached.second
|
||||
}
|
||||
val declination = sensorsRepo.getMagDeclination(position)
|
||||
declinationCache = position to declination
|
||||
return declination
|
||||
}
|
||||
|
||||
private fun onFix(fix: LocationFix) {
|
||||
val geometry = gridGeometry(fix.latitude, fix.longitude)
|
||||
val claimable = vuccClaimableGrids(fix.latitude, fix.longitude)
|
||||
val gate = nextFixGate(_uiState.value.preciseFixesInARow, fix)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
permissionGranted = true,
|
||||
providerReady = geometry != null,
|
||||
fix = fix,
|
||||
geometry = geometry,
|
||||
claimableGrids = claimable,
|
||||
status = if (claimable.isEmpty()) null else gridFixStatus(claimable),
|
||||
preciseFixesInARow = gate.streak,
|
||||
isStandingConfirmed = gate.isConfirmed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAsStation() {
|
||||
val fix = _uiState.value.fix ?: return
|
||||
val saved = settingsRepo.setStationPosition(fix.latitude, fix.longitude, fix.altitudeMeters)
|
||||
if (saved) _uiState.update { it.copy(isStationSaved = true) }
|
||||
}
|
||||
|
||||
private fun stopUpdates() {
|
||||
locationRepo.stopUpdates()
|
||||
locationJob?.cancel()
|
||||
locationJob = null
|
||||
compassJob?.cancel()
|
||||
compassJob = null
|
||||
accuracyJob?.cancel()
|
||||
accuracyJob = null
|
||||
if (sensorsEnabledByUs) {
|
||||
sensorsRepo.disableSensor()
|
||||
sensorsEnabledByUs = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
stopUpdates()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(container: IMainContainer) = viewModelFactory {
|
||||
initializer {
|
||||
GridFinderViewModel(
|
||||
settingsRepo = container.settingsRepo,
|
||||
locationRepo = container.locationRepo,
|
||||
sensorsRepo = container.provideSensorsRepo()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.gridfinder
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.LocationFix
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class GridFinderGateTest {
|
||||
|
||||
@Test
|
||||
fun `fix within the 20 ft rule is precise enough`() {
|
||||
assertTrue(fix(accuracy = 6.0f).isPreciseEnough(VUCC_FIX_ACCURACY_LIMIT_METERS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fix worse than the 20 ft rule is not precise enough`() {
|
||||
assertFalse(fix(accuracy = 12.0f).isPreciseEnough(VUCC_FIX_ACCURACY_LIMIT_METERS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `streak grows on good fixes and confirms on the third`() {
|
||||
val first = nextFixGate(0, fix(accuracy = 4.0f))
|
||||
assertEquals(1, first.streak)
|
||||
assertFalse(first.isConfirmed)
|
||||
|
||||
val second = nextFixGate(first.streak, fix(accuracy = 4.0f))
|
||||
assertEquals(2, second.streak)
|
||||
assertFalse(second.isConfirmed)
|
||||
|
||||
val third = nextFixGate(second.streak, fix(accuracy = 4.0f))
|
||||
assertEquals(PRECISE_FIXES_REQUIRED, third.streak)
|
||||
assertTrue(third.isConfirmed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `streak stays capped once confirmed`() {
|
||||
val capped = nextFixGate(PRECISE_FIXES_REQUIRED, fix(accuracy = 1.0f))
|
||||
assertEquals(PRECISE_FIXES_REQUIRED, capped.streak)
|
||||
assertTrue(capped.isConfirmed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one coarse fix resets a confirmed streak`() {
|
||||
val reset = nextFixGate(PRECISE_FIXES_REQUIRED, fix(accuracy = 25.0f))
|
||||
assertEquals(0, reset.streak)
|
||||
assertFalse(reset.isConfirmed)
|
||||
}
|
||||
|
||||
private fun fix(accuracy: Float) = LocationFix(
|
||||
latitude = 22.0,
|
||||
longitude = 114.0,
|
||||
altitudeMeters = 30.0,
|
||||
accuracyMeters = accuracy,
|
||||
epochMs = 0L
|
||||
)
|
||||
}
|
||||
@@ -8,4 +8,6 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation(libs.other.osmdroid)
|
||||
// Real org.json for unit tests (android.jar stub throws "not mocked")
|
||||
testImplementation(libs.test.json)
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"type":"dxcc","regions":[{"id":0,"code":176,"name":"Fiji","label_lon":177.9759,"label_lat":-17.9376,"rings":[[[180.0,-16.0671],[180.0,-16.5552],[179.3641,-16.8014],[178.7251,-17.012],[178.5968,-16.6392],[179.0966,-16.434],[179.4135,-16.3791],[180.0,-16.0671]],[[178.1256,-17.5048],[178.3736,-17.3399],[178.7181,-17.6285],[178.5527,-18.1506],[177.9327,-18.288],[177.3815,-18.1643],[177.285,-17.7247],[177.6709,-17.3811]],[[-179.7933,-16.0209],[-179.9174,-16.5018],[-180.0,-16.5552],[-180.0,-16.0671]]]},{"id":1,"code":470,"name":"Tanzania","label_lon":34.1421,"label_lat":-6.2078,"rings":[[[33.9037,-0.95],[34.0726,-1.0598],[37.6987,-3.097],[37.7669,-3.6771],[39.2022,-4.6768],[38.7405,-5.9089],[38.7998,-6.4757],[39.44,-6.84],[39.47,-7.1],[39.1947,-7.7039],[39.252,-8.0078],[39.1865,-8.4855],[39.5357,-9.1124],[39.9496,-10.0984],[40.3166,-10.3171],[40.3166,-10.3171],[39.521,-10.8969],[38.4276,-11.2852],[37.8276,-11.2688],[37.4713,-11.5688],[36.7752,-11.5945],[36.5141,-11.7209],[35.3124,-11.4391],[34.56,-11.52],[34.28,-10.16],[33.9408,-9.6937],[33.7397,-9.4171],[32.7594,-9.2306],[32.1919,-8.9304],[31.5563,-8.762],[31.1578,-8.5946],[30.74,-8.34],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.7535,-4.4524],[30.1163,-4.0901],[30.5055,-3.5686],[30.7522,-3.3593],[30.743,-3.0343],[30.5277,-2.8076],[30.4697,-2.4139],[30.4697,-2.4138],[30.7583,-2.2872],[30.8161,-1.6989],[30.4191,-1.1347],[30.7699,-1.0146],[31.8662,-1.0274]]]},{"id":2,"code":302,"name":"W. Sahara","label_lon":-12.572,"label_lat":24.2306,"rings":[[[-8.6656,27.6564],[-8.6651,27.5895],[-8.6844,27.3957],[-8.6873,25.8811],[-11.9694,25.9334],[-11.9372,23.3746],[-12.8742,23.2848],[-13.1188,22.7712],[-12.9291,21.3271],[-16.8452,21.3333],[-17.0634,20.9998],[-17.0204,21.4223],[-17.003,21.4207],[-14.751,21.5006],[-14.6308,21.8609],[-14.2212,22.3102],[-13.8911,23.691],[-12.501,24.7701],[-12.0308,26.0309],[-11.7182,26.1041],[-11.3926,26.8834],[-10.5513,26.9908],[-10.1894,26.8609],[-9.7353,26.8609],[-9.413,27.0885],[-8.7949,27.1207],[-8.8178,27.6564]]]},{"id":3,"code":1,"name":"Canada","label_lon":-110.2438,"label_lat":56.7019,"rings":[[[-122.84,49.0],[-122.9742,49.0025],[-124.9102,49.9846],[-125.6246,50.4166],[-127.4356,50.8306],[-127.9928,51.7158],[-127.8503,52.3296],[-129.1298,52.7554],[-129.3052,53.5616],[-130.515,54.2876],[-130.5361,54.8028],[-130.5361,54.8028],[-129.98,55.285],[-130.0078,55.9158],[-131.7078,56.5521],[-132.7304,57.6929],[-133.3556,58.4103],[-134.2711,58.8611],[-134.945,59.2706],[-135.4758,59.7878],[-136.4797,59.4639],[-137.4525,58.905],[-138.3409,59.5621],[-139.039,60.0],[-140.013,60.2768],[-140.9978,60.3064],[-140.9925,66.0],[-140.986,69.712],[-140.986,69.712],[-139.1205,69.471],[-137.5464,68.99],[-136.5036,68.898],[-135.6258,69.3151],[-134.4146,69.6274],[-132.9292,69.5053],[-131.4314,69.9445],[-129.7947,70.1937],[-129.1077,69.7793],[-128.3616,70.0129],[-128.1382,70.4838],[-127.4471,70.3772],[-125.7563,69.4806],[-124.4248,70.1584],[-124.2897,69.3997],[-123.0611,69.5637],[-122.6835,69.8555],[-121.4723,69.7978],[-119.9429,69.3779],[-117.6027,69.0113],[-116.2264,68.8415],[-115.2469,68.9059],[-113.8979,68.3989],[-115.3049,67.9026],[-113.4973,67.6881],[-110.798,67.8061],[-109.9462,67.981],[-108.8802,67.3814],[-107.7924,67.8874],[-108.813,68.3116],[-108.1672,68.6539],[-106.95,68.7],[-106.15,68.8],[-105.3428,68.5612],[-104.3379,68.018],[-103.2211,68.0978],[-101.4543,67.6469],[-99.9019,67.8057],[-98.4432,67.7816],[-98.5586,68.4039],[-97.6695,68.5786],[-96.1199,68.2394],[-96.1259,67.2934],[-95.4894,68.0907],[-94.685,68.0638],[-94.2328,69.069],[-95.3041,69.6857],[-96.4713,70.0898],[-96.3911,71.1948],[-95.2088,71.9205],[-93.89,71.7601],[-92.8782,71.3187],[-91.5196,70.1913],[-92.4069,69.7],[-90.5471,69.4977],[-90.5515,68.475],[-89.2151,69.2587],[-88.0197,68.6151],[-88.3175,67.8734],[-87.3502,67.1987],[-86.3061,67.9215],[-85.5766,68.7846],[-85.522,69.8821],[-84.1008,69.8054],[-82.6226,69.6583],[-81.2804,69.162],[-81.2202,68.6657],[-81.9644,68.1325],[-81.2593,67.5972],[-81.3865,67.1108],[-83.3446,66.4115],[-84.7354,66.2573],[-85.7694,66.5583],[-86.0676,66.0563],[-87.0314,65.213],[-87.3232,64.7756],[-88.483,64.099],[-89.9144,64.0327],[-90.704,63.6102],[-90.77,62.9602],[-91.9334,62.8351],[-93.157,62.0247],[-94.2415,60.8987],[-94.6293,60.1102],[-94.6846,58.9488],[-93.215,58.7821],[-92.7646,57.8457],[-92.297,57.0871],[-90.8977,57.2847],[-89.0395,56.8517],[-88.0398,56.4716],[-87.3242,55.9991],[-86.0712,55.7238],[-85.0118,55.3026],[-83.3606,55.2449],[-82.2729,55.1483],[-82.4362,54.2823],[-82.125,53.277],[-81.4008,52.1579],[-79.9129,51.2084],[-79.143,51.5339],[-78.6019,52.5621],[-79.1242,54.1414],[-79.8296,54.6677],[-78.2287,55.1365],[-77.0956,55.8374],[-76.5414,56.5342],[-76.6232,57.2026],[-77.3023,58.0521],[-78.5169,58.8046],[-77.3368,59.8526],[-77.7727,60.7579],[-78.1069,62.3196],[-77.4107,62.5505],[-75.6962,62.2784],[-74.6682,62.1811],[-73.8399,62.4438],[-72.9085,62.1051],[-71.6771,61.5254],[-71.3737,61.1372],[-69.5904,61.0614],[Line truncated
|
||||
{"type":"dxcc","regions":[{"id":0,"code":176,"name":"Fiji","label_lon":177.9759,"label_lat":-17.9376,"rings":[[[180.0,-16.0671],[180.0,-16.5552],[179.3641,-16.8014],[178.7251,-17.012],[178.5968,-16.6392],[179.0966,-16.434],[179.4135,-16.3791],[180.0,-16.0671]],[[178.1256,-17.5048],[178.3736,-17.3399],[178.7181,-17.6285],[178.5527,-18.1506],[177.9327,-18.288],[177.3815,-18.1643],[177.285,-17.7247],[177.6709,-17.3811]],[[-179.7933,-16.0209],[-179.9174,-16.5018],[-180.0,-16.5552],[-180.0,-16.0671]]]},{"id":1,"code":470,"name":"Tanzania","label_lon":34.1421,"label_lat":-6.2078,"rings":[[[33.9037,-0.95],[34.0726,-1.0598],[37.6987,-3.097],[37.7669,-3.6771],[39.2022,-4.6768],[38.7405,-5.9089],[38.7998,-6.4757],[39.44,-6.84],[39.47,-7.1],[39.1947,-7.7039],[39.252,-8.0078],[39.1865,-8.4855],[39.5357,-9.1124],[39.9496,-10.0984],[40.3166,-10.3171],[40.3166,-10.3171],[39.521,-10.8969],[38.4276,-11.2852],[37.8276,-11.2688],[37.4713,-11.5688],[36.7752,-11.5945],[36.5141,-11.7209],[35.3124,-11.4391],[34.56,-11.52],[34.28,-10.16],[33.9408,-9.6937],[33.7397,-9.4171],[32.7594,-9.2306],[32.1919,-8.9304],[31.5563,-8.762],[31.1578,-8.5946],[30.74,-8.34],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.7535,-4.4524],[30.1163,-4.0901],[30.5055,-3.5686],[30.7522,-3.3593],[30.743,-3.0343],[30.5277,-2.8076],[30.4697,-2.4139],[30.4697,-2.4138],[30.7583,-2.2872],[30.8161,-1.6989],[30.4191,-1.1347],[30.7699,-1.0146],[31.8662,-1.0274]]]},{"id":2,"code":302,"name":"W. Sahara","label_lon":-12.572,"label_lat":24.2306,"rings":[[[-8.6656,27.6564],[-8.6651,27.5895],[-8.6844,27.3957],[-8.6873,25.8811],[-11.9694,25.9334],[-11.9372,23.3746],[-12.8742,23.2848],[-13.1188,22.7712],[-12.9291,21.3271],[-16.8452,21.3333],[-17.0634,20.9998],[-17.0204,21.4223],[-17.003,21.4207],[-14.751,21.5006],[-14.6308,21.8609],[-14.2212,22.3102],[-13.8911,23.691],[-12.501,24.7701],[-12.0308,26.0309],[-11.7182,26.1041],[-11.3926,26.8834],[-10.5513,26.9908],[-10.1894,26.8609],[-9.7353,26.8609],[-9.413,27.0885],[-8.7949,27.1207],[-8.8178,27.6564]]]},{"id":3,"code":1,"name":"Canada","label_lon":-110.2438,"label_lat":56.7019,"rings":[[[-122.84,49.0],[-122.9742,49.0025],[-124.9102,49.9846],[-125.6246,50.4166],[-127.4356,50.8306],[-127.9928,51.7158],[-127.8503,52.3296],[-129.1298,52.7554],[-129.3052,53.5616],[-130.515,54.2876],[-130.5361,54.8028],[-130.5361,54.8028],[-129.98,55.285],[-130.0078,55.9158],[-131.7078,56.5521],[-132.7304,57.6929],[-133.3556,58.4103],[-134.2711,58.8611],[-134.945,59.2706],[-135.4758,59.7878],[-136.4797,59.4639],[-137.4525,58.905],[-138.3409,59.5621],[-139.039,60.0],[-140.013,60.2768],[-140.9978,60.3064],[-140.9925,66.0],[-140.986,69.712],[-140.986,69.712],[-139.1205,69.471],[-137.5464,68.99],[-136.5036,68.898],[-135.6258,69.3151],[-134.4146,69.6274],[-132.9292,69.5053],[-131.4314,69.9445],[-129.7947,70.1937],[-129.1077,69.7793],[-128.3616,70.0129],[-128.1382,70.4838],[-127.4471,70.3772],[-125.7563,69.4806],[-124.4248,70.1584],[-124.2897,69.3997],[-123.0611,69.5637],[-122.6835,69.8555],[-121.4723,69.7978],[-119.9429,69.3779],[-117.6027,69.0113],[-116.2264,68.8415],[-115.2469,68.9059],[-113.8979,68.3989],[-115.3049,67.9026],[-113.4973,67.6881],[-110.798,67.8061],[-109.9462,67.981],[-108.8802,67.3814],[-107.7924,67.8874],[-108.813,68.3116],[-108.1672,68.6539],[-106.95,68.7],[-106.15,68.8],[-105.3428,68.5612],[-104.3379,68.018],[-103.2211,68.0978],[-101.4543,67.6469],[-99.9019,67.8057],[-98.4432,67.7816],[-98.5586,68.4039],[-97.6695,68.5786],[-96.1199,68.2394],[-96.1259,67.2934],[-95.4894,68.0907],[-94.685,68.0638],[-94.2328,69.069],[-95.3041,69.6857],[-96.4713,70.0898],[-96.3911,71.1948],[-95.2088,71.9205],[-93.89,71.7601],[-92.8782,71.3187],[-91.5196,70.1913],[-92.4069,69.7],[-90.5471,69.4977],[-90.5515,68.475],[-89.2151,69.2587],[-88.0197,68.6151],[-88.3175,67.8734],[-87.3502,67.1987],[-86.3061,67.9215],[-85.5766,68.7846],[-85.522,69.8821],[-84.1008,69.8054],[-82.6226,69.6583],[-81.2804,69.162],[-81.2202,68.6657],[-81.9644,68.1325],[-81.2593,67.5972],[-81.3865,67.1108],[-83.3446,66.4115],[-84.7354,66.2573],[-85.7694,66.5583],[-86.0676,66.0563],[-87.0314,65.213],[-87.3232,64.7756],[-88.483,64.099],[-89.9144,64.0327],[-90.704,63.6102],[-90.77,62.9602],[-91.9334,62.8351],[-93.157,62.0247],[-94.2415,60.8987],[-94.6293,60.1102],[-94.6846,58.9488],[-93.215,58.7821],[-92.7646,57.8457],[-92.297,57.0871],[-90.8977,57.2847],[-89.0395,56.8517],[-88.0398,56.4716],[-87.3242,55.9991],[-86.0712,55.7238],[-85.0118,55.3026],[-83.3606,55.2449],[-82.2729,55.1483],[-82.4362,54.2823],[-82.125,53.277],[-81.4008,52.1579],[-79.9129,51.2084],[-79.143,51.5339],[-78.6019,52.5621],[-79.1242,54.1414],[-79.8296,54.6677],[-78.2287,55.1365],[-77.0956,55.8374],[-76.5414,56.5342],[-76.6232,57.2026],[-77.3023,58.0521],[-78.5169,58.8046],[-77.3368,59.8526],[-77.7727,60.7579],[-78.1069,62.3196],[-77.4107,62.5505],[-75.6962,62.2784],[-74.6682,62.1811],[-73.8399,62.4438],[-72.9085,62.1051],[-71.6771,61.5254],[-71.3737,61.1372],[-69.5904,61.0614],[Line truncated
|
||||
@@ -85,7 +85,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.model.AwardCalculator
|
||||
import com.rtbishop.look4sat.core.domain.model.AwardProgress
|
||||
import com.rtbishop.look4sat.core.domain.model.AwardType
|
||||
import com.rtbishop.look4sat.core.domain.model.firstCallsByGrid
|
||||
import com.rtbishop.look4sat.core.domain.model.MapSource
|
||||
import com.rtbishop.look4sat.core.domain.model.myStationGrids
|
||||
import com.rtbishop.look4sat.core.domain.model.scopedToStation
|
||||
import com.rtbishop.look4sat.core.domain.model.stationGridSetKey
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
@@ -93,10 +97,13 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
|
||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
|
||||
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
|
||||
import org.osmdroid.tileprovider.MapTileProviderBasic
|
||||
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
||||
import org.osmdroid.tileprovider.tilesource.XYTileSource
|
||||
@@ -269,7 +276,14 @@ fun MapDestination(
|
||||
viewModel.onAction(MapAction.SetVisible(false))
|
||||
}
|
||||
}
|
||||
MapScreen(uiState, viewModel::onAction, mapView, mapFilterViewModel, onMatchGrid, matchCalculating)
|
||||
MapScreen(
|
||||
uiState = uiState,
|
||||
onAction = viewModel::onAction,
|
||||
mapView = mapView,
|
||||
mapFilterViewModel = mapFilterViewModel,
|
||||
onMatchGrid = onMatchGrid,
|
||||
matchCalculating = matchCalculating
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -293,13 +307,6 @@ private fun MapScreen(
|
||||
// Selected award filter. Lives in an Activity-scoped ViewModel so it
|
||||
// survives page switches; defaults to VUCC only once per process (cold start).
|
||||
var selectedAward by mapFilterViewModel.selectedAward
|
||||
// First callsign worked in each grid (earliest QSO by time), used by the
|
||||
// "首通呼号" label mode — derived from the same QSO store as the worked fills.
|
||||
val firstCallsByGrid: Map<String, String> = remember(uiState.workedGridQsos) {
|
||||
uiState.workedGridQsos.mapNotNull { (grid, qsos) ->
|
||||
qsos.minByOrNull { it.epochMs }?.let { grid to it.call }
|
||||
}.toMap()
|
||||
}
|
||||
// 台址名单: QSOs grouped by the 台址's GRID SET. A station location (台址)
|
||||
// can span several grids (MY_GRIDSQUARE + MY_VUCC_GRIDS), and QSOs whose
|
||||
// location covers the same grid set belong to the same 台址; groups with an
|
||||
@@ -307,18 +314,24 @@ private fun MapScreen(
|
||||
// can differ between records of the same physical 台址 when LoTW omits
|
||||
// optional fields). Data synced before multi-grid support falls back to
|
||||
// one group per grid, preserving the old selector exactly.
|
||||
// Each group's `worked` cells are derived through scopedToStation (the
|
||||
// covering rule): a single-grid 台址 also counts the cells worked by any
|
||||
// multi-grid 台址 covering it (OL62 ← OL62/63, 四格点同理) — the reverse
|
||||
// never leaks (2026-10-05 用户定稿, 方案 A).
|
||||
val stationGroups: List<StationGroup> = remember(uiState.workedGridQsos) {
|
||||
val byGridSet = LinkedHashMap<String, Pair<Set<String>, MutableSet<String>>>()
|
||||
for ((grid, qsos) in uiState.workedGridQsos) {
|
||||
val gridSets = LinkedHashMap<String, Set<String>>()
|
||||
for ((_, qsos) in uiState.workedGridQsos) {
|
||||
for (q in qsos) {
|
||||
val gs = q.myGrids.ifEmpty { q.myGrid?.let { setOf(it) }.orEmpty() }
|
||||
if (gs.isEmpty()) continue
|
||||
val key = gs.sorted().joinToString(",")
|
||||
byGridSet.getOrPut(key) { gs to mutableSetOf() }.second.add(grid)
|
||||
val key = q.stationGridSetKey() ?: continue
|
||||
gridSets.getOrPut(key) { q.myStationGrids() }
|
||||
}
|
||||
}
|
||||
byGridSet.map { (key, v) -> StationGroup(id = key, grids = v.first, worked = v.second) }
|
||||
.sortedByDescending { it.workedCount }
|
||||
gridSets.map { (key, grids) ->
|
||||
val worked = uiState.workedGridQsos.filterValues { qsos ->
|
||||
qsos.scopedToStation(key).isNotEmpty()
|
||||
}.keys
|
||||
StationGroup(id = key, grids = grids, worked = worked)
|
||||
}.sortedByDescending { it.workedCount }
|
||||
}
|
||||
// Selected 台址 for VUCC counting; defaults to the group with the most
|
||||
// worked grids. Null = "All" (every 台址 combined). Empty when the store
|
||||
@@ -342,6 +355,13 @@ private fun MapScreen(
|
||||
if (selectedStationId == null) uiState.workedGrids
|
||||
else stationGroups.firstOrNull { it.id == selectedStationId }?.worked ?: emptySet()
|
||||
}
|
||||
// First callsign worked in each grid (earliest QSO by time), used by the
|
||||
// "首通呼号" label mode. Scoped to the selected 台址 like the fills: with a
|
||||
// specific operated grid chosen, the label must show ITS first contact, not
|
||||
// another 台址's (user decision 2026-10-05); "All" = global first call.
|
||||
val firstCallsByGrid: Map<String, String> = remember(uiState.workedGridQsos, selectedStationId) {
|
||||
uiState.workedGridQsos.firstCallsByGrid(selectedStationId)
|
||||
}
|
||||
val isGridMode = uiState.isGridMode
|
||||
// True when this composition restored a saved viewport. Only grid mode
|
||||
// restores the viewport the user left (satellite mode keeps its original
|
||||
@@ -529,7 +549,11 @@ private fun MapScreen(
|
||||
selectedGrid?.let { grid ->
|
||||
WorkedGridQsoDialog(
|
||||
grid = grid,
|
||||
qsos = uiState.workedGridQsos[grid].orEmpty().sortedBy { it.epochMs },
|
||||
// Scope to the selected 台址 (null = All): a specific station location
|
||||
// must not show other locations' QSOs (bug report 2026-10-05).
|
||||
qsos = uiState.workedGridQsos[grid].orEmpty()
|
||||
.scopedToStation(selectedStationId)
|
||||
.sortedBy { it.epochMs },
|
||||
marked = uiState.markedGrids[grid].orEmpty(),
|
||||
isUtc = uiState.isUtc,
|
||||
matchCalculating = matchCalculating,
|
||||
@@ -669,7 +693,9 @@ private fun WorkedGridQsoDialog(
|
||||
if (showMarkInput) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { showMarkInput = false },
|
||||
title = { Text(stringResource(R.string.grid_mark_station)) },
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle(stringResource(R.string.grid_mark_station)) },
|
||||
text = {
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value = callInput,
|
||||
@@ -718,11 +744,12 @@ private fun MarkedStationRow(
|
||||
)
|
||||
// "标记" column: pin icon (only when the grid has multiple marks) sits
|
||||
// left of the label; pinned (first) row's icon is grey, others pale
|
||||
// yellow — tapping moves that mark to the top.
|
||||
// yellow — tapping moves that mark to the top. Wrap-content (no weight),
|
||||
// same as the count column of the QSO rows, so the callsign column next
|
||||
// to it takes the leftover width while these glyphs stay row-centred.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
if (showPin) {
|
||||
Icon(
|
||||
@@ -795,8 +822,13 @@ private fun WorkedGridCallRow(
|
||||
else stringResource(R.string.grid_qso_count_many, callQsos.size),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
// No weight: the count column wraps its content, so the callsign
|
||||
// column (weight 1f, opposite the date column) absorbs the slack
|
||||
// that used to sit left of the centred count text. The count text
|
||||
// keeps its exact on-screen position: with equal weights on the
|
||||
// outer columns the wrap-content middle column starts where the
|
||||
// centred text used to start, i.e. its centre stays at row centre.
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
@@ -94,22 +94,11 @@ class MapViewModel(
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.wavelogSettings.collectLatest { _ ->
|
||||
val workedGrids = settingsRepo.getWorkedGrids()
|
||||
val markedGrids = settingsRepo.getMarkedGridStations()
|
||||
// Marked stations auto-clear once their grid becomes worked:
|
||||
// the reminder has served its purpose (user req: 网格变绿后标记自动清除).
|
||||
val stale = markedGrids.keys.filter { it in workedGrids }
|
||||
val activeMarks = if (stale.isEmpty()) markedGrids else markedGrids - stale.toSet()
|
||||
if (stale.isNotEmpty()) settingsRepo.setMarkedGridStations(activeMarks)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
workedGrids = workedGrids,
|
||||
workedGridQsos = settingsRepo.getWorkedGridQsos(),
|
||||
roamedGrids = settingsRepo.getRoamedGrids(),
|
||||
markedGrids = activeMarks
|
||||
)
|
||||
}
|
||||
// Grid + mark data reloads whenever the screen becomes visible (and once at
|
||||
// init): the LoTW/Wavelog syncs run from Settings or the app start, so
|
||||
// re-entering the map picks their results up.
|
||||
isScreenVisible.collect { visible ->
|
||||
if (visible) refreshMapGridData()
|
||||
}
|
||||
}
|
||||
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
|
||||
@@ -117,6 +106,25 @@ class MapViewModel(
|
||||
getStationPosition()
|
||||
}
|
||||
|
||||
/** Loads the worked-grid / mark data into the map state (see the visibility collector). */
|
||||
private fun refreshMapGridData() {
|
||||
val workedGrids = settingsRepo.getWorkedGrids()
|
||||
val markedGrids = settingsRepo.getMarkedGridStations()
|
||||
// Marked stations auto-clear once their grid becomes worked:
|
||||
// the reminder has served its purpose (user req: 网格变绿后标记自动清除).
|
||||
val stale = markedGrids.keys.filter { it in workedGrids }
|
||||
val activeMarks = if (stale.isEmpty()) markedGrids else markedGrids - stale.toSet()
|
||||
if (stale.isNotEmpty()) settingsRepo.setMarkedGridStations(activeMarks)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
workedGrids = workedGrids,
|
||||
workedGridQsos = settingsRepo.getWorkedGridQsos(),
|
||||
roamedGrids = settingsRepo.getRoamedGrids(),
|
||||
markedGrids = activeMarks
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onAction(action: MapAction) {
|
||||
when (action) {
|
||||
MapAction.SelectPrev -> scrollSelection(true)
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.map
|
||||
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Guards `assets/awards/dxcc.json`, the DXCC boundary asset behind the grid-mode
|
||||
* award overlay (`AwardBoundaryOverlay` matches worked codes against
|
||||
* `region.code`). The 2026-10 rebuild split Russia into European (54) /
|
||||
* Asiatic (15), extracted sub-entities (Alaska, Hawaii, East Malaysia,
|
||||
* French Guiana, Corsica, Crete, Sardinia, Kaliningrad, Franz Josef Land)
|
||||
* and partitioned the UK into England/Scotland/Wales/Northern Ireland —
|
||||
* and re-keyed Germany (81 -> 230) / Palestine (196 -> 510) to current ADIF
|
||||
* codes — these tests pin the invariants that regeneration must keep.
|
||||
*/
|
||||
class DxccAssetTest {
|
||||
|
||||
private val assetRelPath = "src/main/assets/awards/dxcc.json"
|
||||
|
||||
private data class CityCheck(
|
||||
val name: String, val lon: Double, val lat: Double,
|
||||
val inside: Int, val outside: Int
|
||||
)
|
||||
|
||||
private fun loadRegions(): List<JSONObject> {
|
||||
// Unit-test working dir varies (module dir vs repo root) — walk upwards.
|
||||
val file = generateSequence(File("").absoluteFile) { it.parentFile }
|
||||
.mapNotNull { base ->
|
||||
listOf(File(base, assetRelPath), File(base, "feature/map/$assetRelPath"))
|
||||
.firstOrNull { it.isFile }
|
||||
}
|
||||
.firstOrNull()
|
||||
?: error("dxcc.json not found starting from ${File("").absolutePath}")
|
||||
val root = JSONObject(file.readText())
|
||||
val arr = root.getJSONArray("regions")
|
||||
return (0 until arr.length()).map { arr.getJSONObject(it) }
|
||||
}
|
||||
|
||||
private fun List<JSONObject>.byCode(code: Int): JSONObject =
|
||||
first { it.getInt("code") == code }
|
||||
|
||||
/** Even-odd ray casting across all rings of a region (rings are disjoint parts). */
|
||||
private fun regionContains(region: JSONObject, lon: Double, lat: Double): Boolean {
|
||||
val rings = region.getJSONArray("rings")
|
||||
var inside = false
|
||||
for (r in 0 until rings.length()) {
|
||||
val ring = rings.getJSONArray(r)
|
||||
val n = ring.length()
|
||||
for (j in 0 until n) {
|
||||
val a = ring.getJSONArray(j)
|
||||
val b = ring.getJSONArray((j + 1) % n)
|
||||
val y1 = a.getDouble(1)
|
||||
val y2 = b.getDouble(1)
|
||||
if ((y1 > lat) != (y2 > lat)) {
|
||||
val x = a.getDouble(0) + (lat - y1) / (y2 - y1) * (b.getDouble(0) - a.getDouble(0))
|
||||
if (x > lon) inside = !inside
|
||||
}
|
||||
}
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `required ADIF entity codes are present exactly once`() {
|
||||
val regions = loadRegions()
|
||||
val codes = regions.map { it.getInt("code") }
|
||||
assertEquals("duplicate codes", codes.size, codes.toSet().size)
|
||||
val required = listOf(
|
||||
15, 54, // Asiatic / European Russia
|
||||
6, 110, 291, // Alaska / Hawaii / rest of USA
|
||||
46, 299, // East / West Malaysia
|
||||
63, 214, 227, // French Guiana / Corsica / rest of France
|
||||
40, 236, // Crete / rest of Greece
|
||||
225, 248, // Sardinia / rest of Italy
|
||||
126, 61, // Kaliningrad / Franz Josef Land
|
||||
223, 279, 294, 265, // England / Scotland / Wales / N. Ireland
|
||||
230, 510 // Germany / Palestine (current ADIF codes)
|
||||
)
|
||||
required.forEach { code -> assertTrue("missing code $code", code in codes) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no region uses an ADIF-deleted entity code`() {
|
||||
// ADIF 3.1.7 deleted DXCC codes. A polygon keyed to a deleted code
|
||||
// never matches LoTW exports (they carry the active code, e.g.
|
||||
// Germany 81 -> 230, Palestine 196 -> 510), so the award never fills.
|
||||
val deleted = setOf(
|
||||
2, 8, 19, 23, 25, 26, 28, 30, 39, 42, 44, 55, 57, 58, 59, 67, 68,
|
||||
81, 85, 93, 101, 102, 113, 115, 119, 127, 128, 134, 139, 151, 154, 155,
|
||||
164, 178, 183, 184, 186, 193, 194, 196, 198, 200, 208, 210, 218, 220,
|
||||
226, 228, 229, 231, 243, 244, 255, 258, 261, 264, 267, 268, 271, 307,
|
||||
488, 493
|
||||
)
|
||||
loadRegions().forEach { region ->
|
||||
val code = region.getInt("code")
|
||||
assertTrue("${region.getString("name")} uses deleted code $code", code !in deleted)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `entities carry their ADIF names`() {
|
||||
val regions = loadRegions()
|
||||
assertEquals("England", regions.byCode(223).getString("name"))
|
||||
assertEquals("European Russia", regions.byCode(54).getString("name"))
|
||||
assertEquals("Asiatic Russia", regions.byCode(15).getString("name"))
|
||||
assertEquals("Scotland", regions.byCode(279).getString("name"))
|
||||
assertEquals("Alaska", regions.byCode(6).getString("name"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every region has drawable rings within coordinate bounds`() {
|
||||
loadRegions().forEach { region ->
|
||||
val name = region.getString("name")
|
||||
val rings = region.getJSONArray("rings")
|
||||
assertTrue("$name: no rings", rings.length() >= 1)
|
||||
for (r in 0 until rings.length()) {
|
||||
val ring = rings.getJSONArray(r)
|
||||
assertTrue("$name: ring $r has ${ring.length()} pts", ring.length() >= 3)
|
||||
for (p in 0 until ring.length()) {
|
||||
val pt = ring.getJSONArray(p)
|
||||
val lon = pt.getDouble(0)
|
||||
val lat = pt.getDouble(1)
|
||||
assertTrue("$name: lon out of range $lon", lon in -180.0..180.0)
|
||||
assertTrue("$name: lat out of range $lat", lat in -90.0..90.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cities fall on the correct side of the rebuilt boundaries`() {
|
||||
val regions = loadRegions()
|
||||
val checks = listOf(
|
||||
// European/Asiatic Russia split along the Urals (OSM ridge + Ural river)
|
||||
CityCheck("Moscow", 37.61, 55.75, inside = 54, outside = 15),
|
||||
CityCheck("Perm", 56.24, 58.01, inside = 54, outside = 15),
|
||||
CityCheck("Yekaterinburg", 60.61, 56.84, inside = 15, outside = 54),
|
||||
CityCheck("Novosibirsk", 82.93, 55.03, inside = 15, outside = 54),
|
||||
CityCheck("Kaliningrad", 20.51, 54.71, inside = 126, outside = 54),
|
||||
// UK four-entity partition
|
||||
CityCheck("London", -0.13, 51.51, inside = 223, outside = 279),
|
||||
CityCheck("Edinburgh", -3.19, 55.95, inside = 279, outside = 223),
|
||||
CityCheck("Cardiff", -3.18, 51.48, inside = 294, outside = 223),
|
||||
CityCheck("Belfast", -5.93, 54.60, inside = 265, outside = 223),
|
||||
// Extracted sub-entities
|
||||
CityCheck("Anchorage", -149.90, 61.22, inside = 6, outside = 291),
|
||||
CityCheck("Central Oahu", -157.90, 21.45, inside = 110, outside = 291),
|
||||
CityCheck("Los Angeles", -118.24, 34.05, inside = 291, outside = 6),
|
||||
CityCheck("Kuching", 110.34, 1.55, inside = 46, outside = 299),
|
||||
CityCheck("Kuala Lumpur", 101.69, 3.14, inside = 299, outside = 46),
|
||||
CityCheck("Cayenne", -52.32, 4.94, inside = 63, outside = 227),
|
||||
CityCheck("Ajaccio", 8.74, 41.93, inside = 214, outside = 227),
|
||||
CityCheck("Heraklion", 25.14, 35.34, inside = 40, outside = 236),
|
||||
CityCheck("Cagliari", 9.12, 39.22, inside = 225, outside = 248)
|
||||
)
|
||||
for (c in checks) {
|
||||
assertTrue("${c.name} should be in ${c.inside}",
|
||||
regionContains(regions.byCode(c.inside), c.lon, c.lat))
|
||||
assertTrue("${c.name} should NOT be in ${c.outside}",
|
||||
!regionContains(regions.byCode(c.outside), c.lon, c.lat))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,8 @@ import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
@@ -106,7 +107,9 @@ class FakeSettingsRepo(
|
||||
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 fun updateWavelogUploadSettings(settings: WavelogUploadSettings) = Unit
|
||||
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = TODO()
|
||||
@@ -115,6 +118,7 @@ class FakeSettingsRepo(
|
||||
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()
|
||||
@@ -133,7 +137,7 @@ class FakeSettingsRepo(
|
||||
override fun setSatelliteMode(catnum: Int, mode: String) = TODO()
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
override fun setAmSatCallsign(callsign: String) = TODO()
|
||||
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
|
||||
|
||||
override fun getWorkedGrids(): Set<String> = TODO()
|
||||
override fun setWorkedGrids(grids: Set<String>) = TODO()
|
||||
override fun getWorkedGridQsos(): Map<String, List<GridQso>> = TODO()
|
||||
@@ -147,4 +151,12 @@ class FakeSettingsRepo(
|
||||
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
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -40,6 +41,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
@@ -60,14 +62,19 @@ import com.rtbishop.look4sat.core.domain.utility.uplinkHz
|
||||
import com.rtbishop.look4sat.core.domain.utility.voiceRepeater
|
||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||
import com.rtbishop.look4sat.core.presentation.QsoEditDialog
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
|
||||
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
|
||||
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
|
||||
import com.rtbishop.look4sat.core.presentation.gridsLabel
|
||||
import com.rtbishop.look4sat.core.presentation.LoTWPositionWarningDialog
|
||||
import com.rtbishop.look4sat.core.presentation.WavelogUploadPreviewDialog
|
||||
|
||||
@Composable
|
||||
fun LogPage(
|
||||
uiState: RadarState,
|
||||
logViewModel: LogViewModel,
|
||||
onFixGrid: (List<String>) -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val logUiState by logViewModel.uiState.collectAsStateWithLifecycle()
|
||||
@@ -100,6 +107,12 @@ fun LogPage(
|
||||
if (catnum != 0) logViewModel.selectSatellite(catnum)
|
||||
}
|
||||
|
||||
// Refresh the roaming hint whenever the page re-enters composition (e.g. returning
|
||||
// from the station-location fix jump).
|
||||
LaunchedEffect(Unit) {
|
||||
logViewModel.refreshPositionHint()
|
||||
}
|
||||
|
||||
val selectedTxHz = remember(selectedRadio) { selectedRadio?.uplinkHz() }
|
||||
val selectedRxHz = remember(selectedRadio) { selectedRadio?.downlinkHz() }
|
||||
val repeaterTxHz = voiceRepeater?.uplinkHz()
|
||||
@@ -139,14 +152,28 @@ fun LogPage(
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = {
|
||||
logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid)
|
||||
logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid, passWindow)
|
||||
})
|
||||
)
|
||||
Button(
|
||||
onClick = { logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid) },
|
||||
onClick = { logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid, passWindow) },
|
||||
enabled = logUiState.callsignInput.isNotBlank() && satName.isNotBlank()
|
||||
) { Text("Log") }
|
||||
}
|
||||
logUiState.stationMismatch?.let { mismatch ->
|
||||
Text(
|
||||
text = "⚠ " + stringResource(
|
||||
R.string.lotw_upload_pos_hint,
|
||||
mismatch.currentGrid,
|
||||
gridsLabel(mismatch.stationGrids)
|
||||
),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onFixGrid(listOf(mismatch.currentGrid)) }
|
||||
)
|
||||
}
|
||||
if (isLinear) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
listOf("CW", "SSB", "FT4").forEach { candidate ->
|
||||
@@ -178,7 +205,7 @@ fun LogPage(
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(
|
||||
onClick = { logViewModel.generatePost(satName, maxElev) },
|
||||
onClick = { logViewModel.generatePost(satName, maxElev, passWindow) },
|
||||
enabled = recent.isNotEmpty(),
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text("生成通联记录", fontSize = 13.sp) }
|
||||
@@ -186,7 +213,7 @@ fun LogPage(
|
||||
onClick = logViewModel::prepareUpload,
|
||||
enabled = !logUiState.busy,
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text("上传 LoTW", fontSize = 13.sp) }
|
||||
) { Text(if (logUiState.wavelogMode) "上传 Wavelog" else "上传 LoTW", fontSize = 13.sp) }
|
||||
}
|
||||
if (logUiState.busy) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -217,12 +244,35 @@ fun LogPage(
|
||||
)
|
||||
}
|
||||
|
||||
logUiState.preview?.let { preview ->
|
||||
UploadPreviewDialog(
|
||||
logUiState.uploadPositionWarning?.let { warning ->
|
||||
LoTWPositionWarningDialog(
|
||||
warning = warning,
|
||||
onFixStation = {
|
||||
logViewModel.abandonForGridFix()
|
||||
onFixGrid(listOf(warning.currentGrid))
|
||||
},
|
||||
onIgnore = logViewModel::ignorePositionWarning
|
||||
)
|
||||
}
|
||||
|
||||
// The preview only opens once the position check is out of the way.
|
||||
if (logUiState.uploadPositionWarning == null) {
|
||||
logUiState.preview?.let { preview ->
|
||||
UploadPreviewDialog(
|
||||
preview = preview,
|
||||
busy = logUiState.busy,
|
||||
onConfirm = logViewModel::confirmUpload,
|
||||
onDismiss = logViewModel::dismissPreview
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
logUiState.wavelogPreview?.let { preview ->
|
||||
WavelogUploadPreviewDialog(
|
||||
preview = preview,
|
||||
busy = logUiState.busy,
|
||||
onConfirm = logViewModel::confirmUpload,
|
||||
onDismiss = logViewModel::dismissPreview
|
||||
onConfirm = logViewModel::confirmWavelogUpload,
|
||||
onDismiss = logViewModel::dismissWavelogPreview
|
||||
)
|
||||
}
|
||||
|
||||
@@ -231,7 +281,7 @@ fun LogPage(
|
||||
onDismissRequest = logViewModel::clearMessage,
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle("LoTW Upload") },
|
||||
title = { SheetDialogTitle(if (logUiState.wavelogMode) "Wavelog Upload" else "LoTW Upload") },
|
||||
text = { Text(logUiState.message) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = logViewModel::clearMessage) { Text("OK") }
|
||||
@@ -239,6 +289,34 @@ fun LogPage(
|
||||
)
|
||||
}
|
||||
|
||||
// Out-of-window notice: the clock is outside the current pass [aos, los], so the record
|
||||
// would never show in the window-filtered list. Confirm first, then store it at the pass
|
||||
// midpoint (there is always an edit dialog afterwards for the exact time).
|
||||
logUiState.outOfWindowLog?.let { pending ->
|
||||
val midpointText = remember(pending.midpointUtcMillis) {
|
||||
java.text.SimpleDateFormat("HH:mm'Z'", java.util.Locale.US).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}.format(java.util.Date(pending.midpointUtcMillis))
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = logViewModel::dismissOutOfWindowLog,
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle(stringResource(R.string.log_out_window_title)) },
|
||||
text = { Text(stringResource(R.string.log_out_window_message, midpointText)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = logViewModel::confirmOutOfWindowLog) {
|
||||
Text(stringResource(R.string.log_out_window_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = logViewModel::dismissOutOfWindowLog) {
|
||||
Text(stringResource(R.string.btn_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
editTarget?.let { target ->
|
||||
QsoEditDialog(
|
||||
record = target,
|
||||
@@ -264,6 +342,8 @@ private fun LogRecordRow(
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}.format(java.util.Date(record.startUtcMillis))
|
||||
}
|
||||
// Own station grid: the upload-stamped set, else the grid the QSO was logged under.
|
||||
val ownGrids = gridsLabel(record.vuccGrids.ifEmpty { listOf(record.myGrid) })
|
||||
// 右划露出删除按钮(短信式);整行点击不再删除。
|
||||
SwipeRevealRow(
|
||||
key = record.id.toString(),
|
||||
@@ -289,6 +369,14 @@ private fun LogRecordRow(
|
||||
maxLines = 1
|
||||
)
|
||||
Text(text = " ${record.displayMode}", fontSize = 14.sp, maxLines = 1)
|
||||
if (ownGrids.isNotBlank()) {
|
||||
Text(
|
||||
text = " $ownGrids",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
when {
|
||||
record.lotwConfirmed -> Text("QSL", fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
|
||||
|
||||
@@ -21,12 +21,22 @@ import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
|
||||
import com.rtbishop.look4sat.core.domain.logbook.officialSatelliteName
|
||||
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
|
||||
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogConfirmedSegment
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogIdleSegment
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogTransportFailureSegment
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogUploadCandidates
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
|
||||
import com.rtbishop.look4sat.core.domain.repository.positionWarning
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -38,6 +48,23 @@ import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* A contact waiting for the operator to confirm logging while the clock is outside the
|
||||
* current pass window (after LOS, before AOS, or against a pass card that is not the one
|
||||
* being worked). Confirmed entries are stored at the pass midpoint — inside [aos, los] —
|
||||
* so the window-filtered log page can show them; the exact time can be re-dated from the
|
||||
* edit dialog afterwards.
|
||||
*/
|
||||
data class OutOfWindowLog(
|
||||
val callsign: String,
|
||||
val satName: String,
|
||||
val mode: String,
|
||||
val txHz: Long?,
|
||||
val rxHz: Long?,
|
||||
val myGrid: String,
|
||||
val midpointUtcMillis: Long
|
||||
)
|
||||
|
||||
data class LogUiState(
|
||||
val callsignInput: String = "",
|
||||
/** Current logbook mode for the selected satellite (FM/CW/SSB/FT4). */
|
||||
@@ -49,15 +76,28 @@ data class LogUiState(
|
||||
/** ARRL satellite names (config.tq6) — the only names a record may be signed with. */
|
||||
val satelliteCatalog: List<String> = emptyList(),
|
||||
val preview: LoTWUploadPreview? = null,
|
||||
/** Roaming guard: the current position grid is outside the station grids the prepared
|
||||
* batch would be signed with — shown as a dialog before the preview opens. */
|
||||
val uploadPositionWarning: LoTWPositionWarning? = null,
|
||||
/** Roaming hint on the log page: current position grid outside the station grids
|
||||
* (null when covered or unknown). Tapping it jumps to the station location. */
|
||||
val stationMismatch: LoTWPositionWarning? = null,
|
||||
/** User-facing upload / record message ("" when none). */
|
||||
val message: String = "",
|
||||
val postText: String? = null,
|
||||
val busy: Boolean = false
|
||||
val busy: Boolean = false,
|
||||
/** True when uploads route through Wavelog (Wavelog configured — LoTW paused). */
|
||||
val wavelogMode: Boolean = false,
|
||||
/** Wavelog mode: the prepared batch awaiting the operator's confirmation. */
|
||||
val wavelogPreview: WavelogUploadPreview? = null,
|
||||
/** Out-of-window contact waiting for the operator's confirmation (null when closed). */
|
||||
val outOfWindowLog: OutOfWindowLog? = null
|
||||
)
|
||||
|
||||
class LogViewModel(
|
||||
private val qsoRepository: IQsoRepository,
|
||||
private val lotwUploadRepository: ILoTWUploadRepository,
|
||||
private val wavelogUploadRepository: IWavelogUploadRepository,
|
||||
private val settingsRepo: ISettingsRepo
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -85,6 +125,17 @@ class LogViewModel(
|
||||
)
|
||||
}
|
||||
}
|
||||
// Upload routing follows the Wavelog configuration: once it is ready the log
|
||||
// page uploads through Wavelog and skips LoTW entirely (the one-of-two rule).
|
||||
viewModelScope.launch {
|
||||
settingsRepo.wavelogUploadSettings.collect { settings ->
|
||||
_uiState.update { it.copy(wavelogMode = settings.isReady) }
|
||||
}
|
||||
}
|
||||
// The roaming hint tracks the freshest position data: refresh on init and every fix.
|
||||
viewModelScope.launch {
|
||||
settingsRepo.stationPosition.collect { refreshPositionHintNow() }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCallsign(value: String) =
|
||||
@@ -101,19 +152,64 @@ class LogViewModel(
|
||||
settingsRepo.setSatelliteMode(catnum, mode)
|
||||
}
|
||||
|
||||
fun record(satName: String, mode: String, txHz: Long?, rxHz: Long?, myGrid: String) {
|
||||
fun record(
|
||||
satName: String,
|
||||
mode: String,
|
||||
txHz: Long?,
|
||||
rxHz: Long?,
|
||||
myGrid: String,
|
||||
passWindow: ClosedRange<Long>?
|
||||
) {
|
||||
val call = _uiState.value.callsignInput.trim()
|
||||
if (call.isEmpty()) return
|
||||
val normalizedMode = mode.ifBlank { "FM" }.uppercase(Locale.US)
|
||||
// Whole minutes (seconds zeroed): matches the edit dialog's HH:mm granularity and the
|
||||
// minute-resolution of LoTW reports, so re-saving an unchanged record never looks edited.
|
||||
val now = System.currentTimeMillis() / 60_000L * 60_000L
|
||||
// Logging while the clock sits outside the current pass window (after LOS, before AOS,
|
||||
// or against a pass card that is not the one being worked): the log page only shows
|
||||
// records inside [aos, los], so a "now" timestamp could never appear there. Ask the
|
||||
// operator first; the confirmed contact is stored at the pass midpoint (always inside
|
||||
// the window) and can be re-dated later from the edit dialog.
|
||||
if (passWindow != null && now !in passWindow) {
|
||||
val midpoint = ((passWindow.start + passWindow.endInclusive) / 2L)
|
||||
.let { it / 60_000L * 60_000L }
|
||||
.coerceIn(passWindow.start, passWindow.endInclusive)
|
||||
_uiState.update {
|
||||
it.copy(outOfWindowLog = OutOfWindowLog(call, satName, mode, txHz, rxHz, myGrid, midpoint))
|
||||
}
|
||||
return
|
||||
}
|
||||
storeRecord(call, satName, mode, txHz, rxHz, myGrid, now)
|
||||
}
|
||||
|
||||
/** The operator confirmed the out-of-window notice: store the contact at the pass midpoint. */
|
||||
fun confirmOutOfWindowLog() {
|
||||
val pending = _uiState.value.outOfWindowLog ?: return
|
||||
storeRecord(
|
||||
pending.callsign, pending.satName, pending.mode,
|
||||
pending.txHz, pending.rxHz, pending.myGrid, pending.midpointUtcMillis
|
||||
)
|
||||
_uiState.update { it.copy(outOfWindowLog = null) }
|
||||
}
|
||||
|
||||
fun dismissOutOfWindowLog() = _uiState.update { it.copy(outOfWindowLog = null) }
|
||||
|
||||
private fun storeRecord(
|
||||
call: String,
|
||||
satName: String,
|
||||
mode: String,
|
||||
txHz: Long?,
|
||||
rxHz: Long?,
|
||||
myGrid: String,
|
||||
timeMillis: Long
|
||||
) {
|
||||
val normalizedMode = mode.ifBlank { "FM" }.uppercase(Locale.US)
|
||||
// kHz granularity (3 decimals in MHz) — enough for operating and for the ADIF export.
|
||||
val tx = txHz?.let { (it + 500L) / 1000L * 1000L }
|
||||
val rx = rxHz?.let { (it + 500L) / 1000L * 1000L }
|
||||
val record = QsoRecord(
|
||||
startUtcMillis = now,
|
||||
endUtcMillis = now,
|
||||
startUtcMillis = timeMillis,
|
||||
endUtcMillis = timeMillis,
|
||||
theirCallsign = call,
|
||||
myCallsign = _uiState.value.certificateCallsign,
|
||||
myGrid = myGrid.take(6).uppercase(Locale.US),
|
||||
@@ -142,16 +238,16 @@ class LogViewModel(
|
||||
*/
|
||||
fun updateRecord(record: QsoRecord) = viewModelScope.launch { qsoRepository.save(record) }
|
||||
|
||||
/** English social post from the recorded QSOs of the current satellite (last 24h). */
|
||||
fun generatePost(satName: String, maxElev: Double) {
|
||||
/** English social post from the recorded QSOs of the current satellite.
|
||||
* Same window logic as the on-screen list: only the current pass (aos..los). */
|
||||
fun generatePost(satName: String, maxElev: Double, passWindow: ClosedRange<Long>?) {
|
||||
viewModelScope.launch {
|
||||
val now = System.currentTimeMillis()
|
||||
// Records carry the ARRL name while the pass carries the tracker's name, so the
|
||||
// operator's own contacts are collected by identity, not by the raw name.
|
||||
val identity = satelliteIdentity(satName)
|
||||
val list = qsoRepository.records.first()
|
||||
.filter { satelliteIdentity(it.satelliteName) == identity }
|
||||
.filter { it.startUtcMillis > now - 24 * 3_600_000L }
|
||||
.filter { passWindow?.contains(it.startUtcMillis) == true }
|
||||
.sortedBy { it.startUtcMillis }
|
||||
if (list.isEmpty()) return@launch
|
||||
val shortName = officialSatelliteName(satName).substringBefore('(').trim().uppercase(Locale.US)
|
||||
@@ -180,6 +276,12 @@ class LogViewModel(
|
||||
_uiState.update { it.copy(busy = true, message = "") }
|
||||
try {
|
||||
val all = qsoRepository.records.first()
|
||||
val wavelogSettings = settingsRepo.wavelogUploadSettings.value
|
||||
if (wavelogSettings.isReady) {
|
||||
// Wavelog mode: the upload goes to Wavelog only (LoTW stays out).
|
||||
prepareWavelogUpload(all, wavelogSettings)
|
||||
return@launch
|
||||
}
|
||||
// Only local (non-confirmed) records are candidates for upload;
|
||||
// LoTW-imported confirmations are the feedback side.
|
||||
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == QsoStatus.COMPLETE }
|
||||
@@ -200,7 +302,13 @@ class LogViewModel(
|
||||
// Only the records that actually made it into the TQ8 may be
|
||||
// marked uploaded later — never the whole candidate list.
|
||||
lastUploadedIds = preview.submittedIds
|
||||
_uiState.update { it.copy(busy = false, preview = preview) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
busy = false,
|
||||
preview = preview,
|
||||
uploadPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
|
||||
)
|
||||
}
|
||||
} catch (e: LoTWOperationException) {
|
||||
_uiState.update { it.copy(busy = false, message = "Upload unavailable: ${e.reason}") }
|
||||
} catch (_: Exception) {
|
||||
@@ -209,14 +317,33 @@ class LogViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Wavelog-mode branch of [prepareUpload]: prepare the batch and ask for confirmation. */
|
||||
private suspend fun prepareWavelogUpload(all: List<QsoRecord>, settings: WavelogUploadSettings) {
|
||||
val preview = runCatching {
|
||||
wavelogUploadRepository.prepare(wavelogUploadCandidates(all), settings)
|
||||
}.getOrNull()
|
||||
if (preview == null) {
|
||||
_uiState.update { it.copy(busy = false, message = "Wavelog upload failed to prepare — check the configuration") }
|
||||
return
|
||||
}
|
||||
if (preview.count == 0) {
|
||||
_uiState.update {
|
||||
it.copy(busy = false, message = wavelogIdleSegment(preview) ?: "No pending QSOs to upload to Wavelog")
|
||||
}
|
||||
return
|
||||
}
|
||||
_uiState.update { it.copy(busy = false, wavelogPreview = preview) }
|
||||
}
|
||||
|
||||
fun confirmUpload() {
|
||||
val preview = _uiState.value.preview ?: return
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(busy = true) }
|
||||
val result = lotwUploadRepository.upload(preview.id)
|
||||
if (result is LoTWUploadResult.Accepted && lastUploadedIds.isNotEmpty()) {
|
||||
// Mark the submitted QSOs as uploaded (distinct from confirmed).
|
||||
qsoRepository.markUploaded(lastUploadedIds)
|
||||
// Mark the submitted QSOs as uploaded (distinct from confirmed) and stamp
|
||||
// the station grids this batch went out under.
|
||||
qsoRepository.markUploaded(lastUploadedIds, preview.grids)
|
||||
lastUploadedIds = emptyList()
|
||||
}
|
||||
val msg = when (result) {
|
||||
@@ -229,14 +356,75 @@ class LogViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Confirms the Wavelog-mode batch prepared by [prepareUpload]. */
|
||||
fun confirmWavelogUpload() {
|
||||
val preview = _uiState.value.wavelogPreview ?: return
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(busy = true) }
|
||||
val msg = uploadWavelogNow(preview, settingsRepo.wavelogUploadSettings.value)
|
||||
_uiState.update { it.copy(busy = false, wavelogPreview = null, message = msg) }
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissPreview() = _uiState.update { it.copy(preview = null) }
|
||||
|
||||
fun dismissWavelogPreview() = _uiState.update { it.copy(wavelogPreview = null) }
|
||||
|
||||
/**
|
||||
* Uploads one prepared Wavelog batch and marks what the server accepted (stamped
|
||||
* with the station profile the batch went out through). Returns the operator-facing
|
||||
* message for this upload.
|
||||
*/
|
||||
private suspend fun uploadWavelogNow(
|
||||
preview: WavelogUploadPreview,
|
||||
settings: WavelogUploadSettings
|
||||
): String = try {
|
||||
val outcome = wavelogUploadRepository.upload(preview, settings)
|
||||
if (outcome is WavelogUploadOutcome.Imported && outcome.markIds.isNotEmpty()) {
|
||||
qsoRepository.markWavelogUploaded(outcome.markIds, settings.stationId)
|
||||
}
|
||||
wavelogConfirmedSegment(outcome, preview)
|
||||
} catch (_: Exception) {
|
||||
wavelogTransportFailureSegment()
|
||||
}
|
||||
|
||||
/** Operator chose "ignore" on the position check: keep the prepared preview. */
|
||||
fun ignorePositionWarning() = _uiState.update { it.copy(uploadPositionWarning = null) }
|
||||
|
||||
/** Operator chose to fix the station grid first: drop the prepared preview and leave. */
|
||||
fun abandonForGridFix() {
|
||||
lotwUploadRepository.discardPreview()
|
||||
lastUploadedIds = emptyList()
|
||||
_uiState.update { it.copy(uploadPositionWarning = null, preview = null) }
|
||||
}
|
||||
|
||||
/** Recompute the log-page roaming hint from the freshest station/position data. */
|
||||
fun refreshPositionHint() = viewModelScope.launch { refreshPositionHintNow() }
|
||||
|
||||
private suspend fun refreshPositionHintNow() {
|
||||
val station = runCatching { lotwUploadRepository.station() }.getOrNull()
|
||||
if (station == null) {
|
||||
_uiState.update { it.copy(stationMismatch = null) }
|
||||
return
|
||||
}
|
||||
val grids = station.grid.split(',').map(String::trim).filter(String::isNotBlank)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
stationMismatch = positionWarning(settingsRepo.getCurrentGrid(), grids),
|
||||
stationGrid = station.grid
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearMessage() = _uiState.update { it.copy(message = "") }
|
||||
|
||||
companion object {
|
||||
fun factory(container: IMainContainer) = viewModelFactory {
|
||||
initializer {
|
||||
LogViewModel(container.qsoRepository, container.lotwUploadRepository, container.settingsRepo)
|
||||
LogViewModel(
|
||||
container.qsoRepository, container.lotwUploadRepository,
|
||||
container.wavelogUploadRepository, container.settingsRepo
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ internal fun radarFillSizes(maxWidth: Dp, maxHeight: Dp): RadarFillSizes {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RadarDestination(navigateUp: () -> Unit) {
|
||||
fun RadarDestination(navigateUp: () -> Unit, onOpenLoTWStation: () -> Unit = {}) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
||||
@@ -216,9 +216,15 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
||||
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
||||
viewModel.onAction(RadarAction.CwPermissionResult(granted))
|
||||
}
|
||||
// Grid-check "fix it" jump from the log page: hand the affected records' grids to the
|
||||
// station-location page as its prefill (mirrors the Grid Finder's hand-off).
|
||||
val openLoTWStationForGridFix: (List<String>) -> Unit = { grids ->
|
||||
if (grids.isNotEmpty()) container.setPendingLoTWStationGrid(grids.joinToString(","))
|
||||
onOpenLoTWStation()
|
||||
}
|
||||
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, logViewModel, requestMicPermission = {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
})
|
||||
}, onFixGrid = openLoTWStationForGridFix)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -228,7 +234,8 @@ private fun RadarScreen(
|
||||
navigateUp: () -> Unit,
|
||||
mutualData: MutualPassData,
|
||||
logViewModel: LogViewModel,
|
||||
requestMicPermission: () -> Unit
|
||||
requestMicPermission: () -> Unit,
|
||||
onFixGrid: (List<String>) -> Unit
|
||||
) {
|
||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||
// 日程功能: 把当前过境写入系统日历(原仓库的 addToCalendar, ic_calendar 按钮)
|
||||
@@ -308,16 +315,17 @@ private fun RadarScreen(
|
||||
onAction = onAction,
|
||||
logViewModel = logViewModel,
|
||||
requestMicPermission = requestMicPermission,
|
||||
onFixGrid = onFixGrid,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
} else {
|
||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f), onFixGrid = onFixGrid)
|
||||
}
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f), onFixGrid = onFixGrid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -338,6 +346,7 @@ private fun RadarFillArea(
|
||||
onAction: (RadarAction) -> Unit,
|
||||
logViewModel: LogViewModel,
|
||||
requestMicPermission: () -> Unit,
|
||||
onFixGrid: (List<String>) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||
@@ -351,7 +360,7 @@ private fun RadarFillArea(
|
||||
verticalArrangement = Arrangement.spacedBy(ROW_GAP)
|
||||
) {
|
||||
RadarCard(uiState, trackB, trackBPosition, Modifier.height(sizes.radarSide).fillMaxWidth())
|
||||
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.height(sizes.pagerSpace))
|
||||
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.height(sizes.pagerSpace), onFixGrid = onFixGrid)
|
||||
}
|
||||
} else {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
@@ -361,6 +370,7 @@ private fun RadarFillArea(
|
||||
onAction = onAction,
|
||||
logViewModel = logViewModel,
|
||||
requestMicPermission = requestMicPermission,
|
||||
onFixGrid = onFixGrid,
|
||||
panelHeight = pagerPanelHeight,
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
@@ -380,6 +390,7 @@ private fun CompactPagerOverlay(
|
||||
onAction: (RadarAction) -> Unit,
|
||||
logViewModel: LogViewModel,
|
||||
requestMicPermission: () -> Unit,
|
||||
onFixGrid: (List<String>) -> Unit,
|
||||
panelHeight: Dp,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -420,6 +431,7 @@ private fun CompactPagerOverlay(
|
||||
onAction = onAction,
|
||||
logViewModel = logViewModel,
|
||||
requestMicPermission = requestMicPermission,
|
||||
onFixGrid = onFixGrid,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(panelHeight),
|
||||
@@ -453,7 +465,8 @@ private fun PagerCard(
|
||||
requestMicPermission: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
startPage: RadarPage? = null,
|
||||
onCollapse: (() -> Unit)? = null
|
||||
onCollapse: (() -> Unit)? = null,
|
||||
onFixGrid: (List<String>) -> Unit = {}
|
||||
) {
|
||||
val pages = rememberRadarPages(uiState)
|
||||
val pagerState = rememberPagerState(
|
||||
@@ -515,7 +528,8 @@ private fun PagerCard(
|
||||
)
|
||||
RadarPage.Log -> LogPage(
|
||||
uiState = uiState,
|
||||
logViewModel = logViewModel
|
||||
logViewModel = logViewModel,
|
||||
onFixGrid = onFixGrid
|
||||
)
|
||||
RadarPage.Sstv -> SstvPage(
|
||||
sstv = uiState.sstv,
|
||||
|
||||
+63
-16
@@ -44,6 +44,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
@@ -58,15 +59,21 @@ import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun LoTWUploadCard(
|
||||
hasCertificate: Boolean,
|
||||
stationGrid: String,
|
||||
enabled: Boolean = true,
|
||||
showUploadConfigDialog: () -> Unit
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth().clickable { showUploadConfigDialog() }) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth().clickable(enabled = enabled) { showUploadConfigDialog() }) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.alpha(if (enabled) 1f else 0.5f)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_lotw_upload_title),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
@@ -81,6 +88,15 @@ fun LoTWUploadCard(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2
|
||||
)
|
||||
if (!enabled) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_lotw_disabled_wavelog),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,14 +109,18 @@ fun LoTWUploadConfigDialog(
|
||||
busy: Boolean,
|
||||
error: LoTWUploadError?,
|
||||
errorDetail: String = "",
|
||||
initialGrid: String = "",
|
||||
onDismiss: () -> Unit,
|
||||
onImport: (ByteArray, CharArray) -> Unit,
|
||||
onPreview: (ByteArray, CharArray) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onSaveStation: (LoTWStation) -> Unit
|
||||
) {
|
||||
var password by remember { mutableStateOf("") }
|
||||
var selectedFile by remember { mutableStateOf<Uri?>(null) }
|
||||
var grid by remember { mutableStateOf(station?.grid.orEmpty()) }
|
||||
// A grid Finder prefill (e.g. "OL62,OL72" for a line, four grids for a
|
||||
// corner) wins over the saved station grid; otherwise keep what TQSL had.
|
||||
var grid by remember { mutableStateOf(initialGrid.ifBlank { station?.grid.orEmpty() }) }
|
||||
var cqZone by remember { mutableStateOf(station?.cqZone.orEmpty()) }
|
||||
var ituZone by remember { mutableStateOf(station?.ituZone.orEmpty()) }
|
||||
var iota by remember { mutableStateOf(station?.iota.orEmpty()) }
|
||||
@@ -137,6 +157,19 @@ fun LoTWUploadConfigDialog(
|
||||
}
|
||||
}.getOrNull() ?: uri.lastPathSegment.orEmpty()
|
||||
|
||||
// Surface the certificate's region field before the import is confirmed: re-read
|
||||
// the picked .p12 whenever file or password changes (debounced) and hand it to a
|
||||
// silent preview — a wrong password simply keeps the pending hint, a successful
|
||||
// parse fills the Province/State dropdown in the station form below.
|
||||
LaunchedEffect(selectedFile, password) {
|
||||
val uri = selectedFile ?: return@LaunchedEffect
|
||||
delay(400)
|
||||
val bytes = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()
|
||||
if (bytes != null && bytes.isNotEmpty()) onPreview(bytes, password.toCharArray())
|
||||
}
|
||||
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.prefs_lotw_upload_title),
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -197,6 +230,11 @@ fun LoTWUploadConfigDialog(
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_lotw_upload_password_hint),
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
CardButton(
|
||||
onClick = {
|
||||
val bytes = runCatching {
|
||||
@@ -209,7 +247,9 @@ fun LoTWUploadConfigDialog(
|
||||
}
|
||||
},
|
||||
text = stringResource(R.string.prefs_lotw_upload_import_confirm),
|
||||
isEnabled = password.isNotBlank() && !busy,
|
||||
// An empty field is a legitimate attempt: certificates exported
|
||||
// without a password import with a blank password.
|
||||
isEnabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
@@ -229,12 +269,15 @@ fun LoTWUploadConfigDialog(
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(capitalization = KeyboardCapitalization.Characters),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
// Country-specific region field (US_STATE, CN_PROVINCE, …) shown only when
|
||||
// the certificate's DXCC entity defines one; selecting it fills CQZ/ITUZ.
|
||||
// Country-specific region field (US_STATE, CN_PROVINCE, …): the entry is part
|
||||
// of the form from the first frame (pending hint while no DXCC is known), gets
|
||||
// its options as soon as a certificate is previewed/imported, and a selection
|
||||
// fills CQZ/ITUZ. Entities without a region field keep it hidden.
|
||||
// Drawn as a plain Box (not OutlinedTextField): a read-only text field's
|
||||
// internal gesture handler consumes the tap, so clickable never fires.
|
||||
// Options expand inline inside the sheet (no Popup/Dialog window stacking).
|
||||
if (regionField != null) {
|
||||
val regionPending = certificate == null && stationMeta == null
|
||||
if (regionField != null || regionPending) {
|
||||
var regionExpanded by remember { mutableStateOf(false) }
|
||||
val fieldShape = MaterialTheme.shapes.extraSmall
|
||||
Box(
|
||||
@@ -243,21 +286,25 @@ fun LoTWUploadConfigDialog(
|
||||
.clip(fieldShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline, fieldShape)
|
||||
.clickable { regionExpanded = !regionExpanded }
|
||||
.clickable(enabled = regionField != null) { regionExpanded = !regionExpanded }
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
) {
|
||||
Column {
|
||||
Text(regionField.label, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
text = regionField?.label ?: stringResource(R.string.prefs_lotw_upload_region_label),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(3.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = if (region.isBlank()) {
|
||||
stringResource(R.string.prefs_lotw_upload_region_hint)
|
||||
} else {
|
||||
"$region — $selectedRegionName"
|
||||
text = when {
|
||||
regionPending -> stringResource(R.string.prefs_lotw_upload_region_pending)
|
||||
region.isBlank() -> stringResource(R.string.prefs_lotw_upload_region_hint)
|
||||
else -> "$region — $selectedRegionName"
|
||||
},
|
||||
fontSize = 16.sp,
|
||||
color = if (region.isBlank()) {
|
||||
color = if (regionPending || region.isBlank()) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
@@ -274,9 +321,9 @@ fun LoTWUploadConfigDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = regionExpanded) {
|
||||
AnimatedVisibility(visible = regionExpanded && regionField != null) {
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 280.dp)) {
|
||||
items(regionField.options, key = { it.code }) { option ->
|
||||
items(regionField?.options.orEmpty(), key = { it.code }) { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${option.code} — ${option.name}", fontSize = 13.sp) },
|
||||
onClick = {
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.settings
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
|
||||
/**
|
||||
* Full-screen LoTW upload certificate + station location page.
|
||||
*
|
||||
* Reached from the Grid Finder's "Set as LoTW station location" button: the
|
||||
* grids the operator currently stands on (1 inside, 2 on a line, 4 on a corner)
|
||||
* are dropped into [container.pendingLoTWStationGrid] and consumed here as the
|
||||
* station grid prefill, so the next upload stamps those VUCC squares instead of
|
||||
* the certificate's home grid.
|
||||
*/
|
||||
@Composable
|
||||
fun LoTWUploadDestination(navigateUp: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container, context))
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
// Consume the prefill once; re-entering the page later keeps the saved grid.
|
||||
val initialGrid = container.pendingLoTWStationGrid.value.orEmpty()
|
||||
LaunchedEffect(Unit) {
|
||||
container.setPendingLoTWStationGrid(null)
|
||||
}
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
LoTWUploadConfigDialog(
|
||||
certificate = uiState.lotwCertificate,
|
||||
station = uiState.lotwStation,
|
||||
stationMeta = uiState.lotwStationMeta,
|
||||
busy = uiState.lotwUploadBusy,
|
||||
error = uiState.lotwUploadError,
|
||||
errorDetail = uiState.lotwUploadErrorDetail,
|
||||
initialGrid = initialGrid,
|
||||
onDismiss = navigateUp,
|
||||
onImport = { bytes, password ->
|
||||
viewModel.onAction(SettingsAction.ImportLoTWCertificate(bytes, password))
|
||||
},
|
||||
onPreview = { bytes, password ->
|
||||
viewModel.onAction(SettingsAction.PreviewLoTWCertificate(bytes, password))
|
||||
},
|
||||
onRemove = { viewModel.onAction(SettingsAction.RemoveLoTWCertificate) },
|
||||
onSaveStation = { viewModel.onAction(SettingsAction.SaveLoTWStation(it)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user