mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-04 19:47:22 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7c0df0954 | ||
|
|
5a8c4281a3 | ||
|
|
a459cfb046 | ||
|
|
7589f3f37d | ||
|
|
6a263d8422 | ||
|
|
1558919482 | ||
|
|
9e5b9b6ac6 | ||
|
|
c050e7bb9b | ||
|
|
73ac41ff85 | ||
|
|
733f660d78 | ||
|
|
cbe3e50f86 | ||
|
|
38b29e3015 | ||
|
|
1a3a05c8b6 | ||
|
|
1e33a10fb4 | ||
|
|
6546e32360 | ||
|
|
c86893f6b1 | ||
|
|
cad085c25b | ||
|
|
cba5eb7efe | ||
|
|
74c245a7d2 | ||
|
|
135ecb861d | ||
|
|
1fd1459ddf | ||
|
|
56a788138b | ||
|
|
2e704ee1d3 | ||
|
|
5b7c7d6df5 |
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.
|
- `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.
|
- 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
|
## Copilot Working Mode: Code-Only
|
||||||
|
|
||||||
- Default to code changes only. Provide explanations in chat only.
|
- Default to code changes only. Provide explanations in chat only.
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ fun NavRoot(deeplink: String? = null) {
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background
|
color = MaterialTheme.colorScheme.background
|
||||||
) {
|
) {
|
||||||
RadarDestination(navigateUp = navigateBack)
|
RadarDestination(navigateUp = navigateBack, onOpenLoTWStation = navigateToLoTWStation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entry<GridFinderDestination> {
|
entry<GridFinderDestination> {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ package com.rtbishop.look4sat.core.data.database
|
|||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
|
import androidx.room.MapColumn
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
@@ -39,6 +40,18 @@ interface Look4SatDao {
|
|||||||
@Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)")
|
@Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)")
|
||||||
suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry>
|
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)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertEntries(entries: List<SatEntry>)
|
suspend fun insertEntries(entries: List<SatEntry>)
|
||||||
|
|
||||||
|
|||||||
@@ -47,4 +47,7 @@ interface QsoDao {
|
|||||||
|
|
||||||
@Query("UPDATE qso_records SET lotwUploaded = 1 WHERE id IN (:ids)")
|
@Query("UPDATE qso_records SET lotwUploaded = 1 WHERE id IN (:ids)")
|
||||||
suspend fun markUploaded(ids: List<Long>)
|
suspend fun markUploaded(ids: List<Long>)
|
||||||
|
|
||||||
|
@Query("UPDATE qso_records SET wavelogUploaded = 1 WHERE id IN (:ids)")
|
||||||
|
suspend fun markWavelogUploaded(ids: List<Long>)
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,7 @@ import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
|
|||||||
* upload receipts). Kept apart from [Look4SatDb] so existing installations
|
* upload receipts). Kept apart from [Look4SatDb] so existing installations
|
||||||
* never need a migration of the satellite database.
|
* never need a migration of the satellite database.
|
||||||
*/
|
*/
|
||||||
@Database(entities = [QsoEntity::class], version = 2, exportSchema = false)
|
@Database(entities = [QsoEntity::class], version = 4, exportSchema = false)
|
||||||
abstract class QsoDatabase : RoomDatabase() {
|
abstract class QsoDatabase : RoomDatabase() {
|
||||||
abstract fun qsoDao(): QsoDao
|
abstract fun qsoDao(): QsoDao
|
||||||
}
|
}
|
||||||
@@ -31,3 +31,17 @@ val MIGRATION_QSO_1_2 = object : Migration(1, 2) {
|
|||||||
db.execSQL("ALTER TABLE qso_records ADD COLUMN lotwUploaded INTEGER NOT NULL DEFAULT 0")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,9 +44,11 @@ data class QsoEntity(
|
|||||||
@ColumnInfo(defaultValue = "''") val propagationMode: String = "",
|
@ColumnInfo(defaultValue = "''") val propagationMode: String = "",
|
||||||
@ColumnInfo(defaultValue = "0") val lotwConfirmed: Boolean = false,
|
@ColumnInfo(defaultValue = "0") val lotwConfirmed: Boolean = false,
|
||||||
@ColumnInfo(defaultValue = "0") val lotwUploaded: Boolean = false,
|
@ColumnInfo(defaultValue = "0") val lotwUploaded: Boolean = false,
|
||||||
|
@ColumnInfo(defaultValue = "0") val wavelogUploaded: Boolean = false,
|
||||||
@ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false,
|
@ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false,
|
||||||
@ColumnInfo(defaultValue = "''") val lotwQslDate: String = "",
|
@ColumnInfo(defaultValue = "''") val lotwQslDate: String = "",
|
||||||
@ColumnInfo(defaultValue = "''") val vuccGrids: String = "",
|
@ColumnInfo(defaultValue = "''") val vuccGrids: String = "",
|
||||||
|
@ColumnInfo(defaultValue = "''") val theirVuccGrids: String = "",
|
||||||
val dxcc: Int? = null,
|
val dxcc: Int? = null,
|
||||||
@ColumnInfo(defaultValue = "''") val country: String = "",
|
@ColumnInfo(defaultValue = "''") val country: String = "",
|
||||||
val cqZone: Int? = null,
|
val cqZone: Int? = null,
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
|||||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
|
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
|
||||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
|
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
|
||||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_1_2
|
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.QsoDatabase
|
import com.rtbishop.look4sat.core.data.database.QsoDatabase
|
||||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||||
@@ -48,6 +50,7 @@ import com.rtbishop.look4sat.core.data.repository.UpdateRepository
|
|||||||
import com.rtbishop.look4sat.core.data.repository.LoTWRepository
|
import com.rtbishop.look4sat.core.data.repository.LoTWRepository
|
||||||
import com.rtbishop.look4sat.core.data.repository.LocationRepo
|
import com.rtbishop.look4sat.core.data.repository.LocationRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.WavelogRepository
|
import com.rtbishop.look4sat.core.data.repository.WavelogRepository
|
||||||
|
import com.rtbishop.look4sat.core.data.repository.WavelogUploadRepository
|
||||||
import com.rtbishop.look4sat.core.data.source.LocalSource
|
import com.rtbishop.look4sat.core.data.source.LocalSource
|
||||||
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
||||||
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
||||||
@@ -69,6 +72,7 @@ import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
|
||||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
@@ -100,10 +104,11 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
|
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
|
||||||
override val updateRepo by lazy { UpdateRepository(remoteSource) }
|
override val updateRepo by lazy { UpdateRepository(remoteSource) }
|
||||||
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
|
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
|
||||||
|
override val wavelogUploadRepository: IWavelogUploadRepository by lazy { WavelogUploadRepository() }
|
||||||
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
|
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
|
||||||
override val qsoRepository: IQsoRepository by lazy {
|
override val qsoRepository: IQsoRepository by lazy {
|
||||||
val database = Room.databaseBuilder(context, QsoDatabase::class.java, "Look4SatQsoDB")
|
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)
|
||||||
.build()
|
.build()
|
||||||
QsoRepository(database.qsoDao(), Dispatchers.IO)
|
QsoRepository(database.qsoDao(), Dispatchers.IO)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,14 @@ internal class LoTWSigner(private val config: LoTWConfig) {
|
|||||||
!call.any(Char::isLetter) || !call.any(Char::isDigit)) fail(LoTWProblem.INVALID_CONTACT, call)
|
!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)
|
if (!record.myCallsign.trim().equals(key.info.callsign, true)) fail(LoTWProblem.CALLSIGN_MISMATCH, call)
|
||||||
val date = utc(record.startUtcMillis, "yyyy-MM-dd")
|
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)
|
fail(LoTWProblem.QSO_DATE, call)
|
||||||
}
|
}
|
||||||
fun mhz(hz: Long?): String = hz?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }.orEmpty()
|
fun mhz(hz: Long?): String = hz?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }.orEmpty()
|
||||||
|
|||||||
+10
-1
@@ -279,6 +279,13 @@ class LoTWUploadRepository internal constructor(
|
|||||||
else -> contact
|
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(
|
val preview = LoTWUploadPreview(
|
||||||
UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"),
|
UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"),
|
||||||
contacts.size, skipped,
|
contacts.size, skipped,
|
||||||
@@ -289,7 +296,9 @@ class LoTWUploadRepository internal constructor(
|
|||||||
unavailable,
|
unavailable,
|
||||||
reasons.toMap(),
|
reasons.toMap(),
|
||||||
duplicates,
|
duplicates,
|
||||||
contacts.map { it.record.id }
|
contacts.map { it.record.id },
|
||||||
|
locationGrids,
|
||||||
|
resubmit
|
||||||
)
|
)
|
||||||
if (contacts.isNotEmpty()) pending = Pending(
|
if (contacts.isNotEmpty()) pending = Pending(
|
||||||
preview,
|
preview,
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ class DatabaseRepo(
|
|||||||
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
||||||
var importedCount = 0
|
var importedCount = 0
|
||||||
remoteSource.getFileStream(uri)?.let { stream ->
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
|
val entries = mergeEntries(listOf(parseSatelliteStream(uri, unwrapIfZipped(uri, stream))))
|
||||||
localSource.insertEntries(entries)
|
insertFresherEntries(entries)
|
||||||
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
|
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
|
||||||
importedCount = entries.size
|
importedCount = entries.size
|
||||||
}
|
}
|
||||||
@@ -93,14 +93,15 @@ class DatabaseRepo(
|
|||||||
settingsRepo.updateDataSourcesStatus(
|
settingsRepo.updateDataSourcesStatus(
|
||||||
(tleResults + radioResults).associate { (url, result) -> url to result.code }
|
(tleResults + radioResults).associate { (url, result) -> url to result.code }
|
||||||
)
|
)
|
||||||
// parse fetched data concurrently and associate known built-in URLs with existing type filters.
|
// parse each source on its own: the type filters need every source's catnums, while the
|
||||||
val importedEntries = tleResults.flatMap { (rawUrl, result) ->
|
// stored elements are merged across sources below by freshness.
|
||||||
|
val parsedPerSource = tleResults.map { (rawUrl, result) ->
|
||||||
val normUrl = normalizeUrl(rawUrl)
|
val normUrl = normalizeUrl(rawUrl)
|
||||||
val entries = result.stream?.let { parseSatelliteStream(normUrl, unwrapIfZipped(normUrl, it)) }.orEmpty()
|
val entries = result.stream?.let { parseSatelliteStream(normUrl, unwrapIfZipped(normUrl, it)) }.orEmpty()
|
||||||
val type = builtinTypesByUrl[normUrl] ?: customSourceType
|
val type = builtinTypesByUrl[normUrl] ?: customSourceType
|
||||||
importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum })
|
importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum })
|
||||||
entries
|
entries
|
||||||
}.distinctBy { it.catnum }
|
}
|
||||||
importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
|
importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
|
||||||
val importedRadios = radioResults.flatMap { (rawUrl, result) ->
|
val importedRadios = radioResults.flatMap { (rawUrl, result) ->
|
||||||
val normUrl = normalizeUrl(rawUrl)
|
val normUrl = normalizeUrl(rawUrl)
|
||||||
@@ -115,8 +116,14 @@ class DatabaseRepo(
|
|||||||
localSource.deleteManagedRadios()
|
localSource.deleteManagedRadios()
|
||||||
localSource.insertRadios(importedRadios)
|
localSource.insertRadios(importedRadios)
|
||||||
}
|
}
|
||||||
if (importedEntries.isNotEmpty()) localSource.insertEntries(importedEntries)
|
// Elements always come from the freshest source for every satellite; only
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
// 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) {
|
override suspend fun clearAllData() = withContext(dispatcher) {
|
||||||
@@ -155,6 +162,62 @@ class DatabaseRepo(
|
|||||||
line.count { it == ',' } >= 4
|
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) {
|
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
||||||
settingsRepo.updateDatabaseState(
|
settingsRepo.updateDatabaseState(
|
||||||
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
|
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
|
||||||
|
|||||||
+3
-1
@@ -285,7 +285,9 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
myGrid = myGrids.firstOrNull(),
|
myGrid = myGrids.firstOrNull(),
|
||||||
myGrids = myGrids,
|
myGrids = myGrids,
|
||||||
myCallsign = myCallsign,
|
myCallsign = myCallsign,
|
||||||
stationKey = stationKey
|
stationKey = stationKey,
|
||||||
|
// Copy, never the live buffer — resetRecord() clears it.
|
||||||
|
theirGrids = gridsInRecord.distinct().toList()
|
||||||
)
|
)
|
||||||
for (grid in gridsInRecord) {
|
for (grid in gridsInRecord) {
|
||||||
result.getOrPut(grid) { mutableListOf() }.add(qso)
|
result.getOrPut(grid) { mutableListOf() }.add(qso)
|
||||||
|
|||||||
+54
-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.IQsoRepository
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
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.displayMode
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.sameConfirmedContact
|
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.satelliteIdentity
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs
|
import com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.withConfirmation
|
import com.rtbishop.look4sat.core.domain.logbook.withConfirmation
|
||||||
@@ -48,20 +51,22 @@ class QsoRepository(
|
|||||||
// place: that contact already exists on LoTW under the old values. The original row
|
// 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
|
// 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).
|
// 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)
|
!record.sameEditableContentAs(previous)
|
||||||
) {
|
) {
|
||||||
dao.save(record.copy(
|
dao.save(record.copy(
|
||||||
id = 0L,
|
id = 0L,
|
||||||
lotwUploaded = false, lotwConfirmed = false, lotwReceived = false,
|
lotwUploaded = false, lotwConfirmed = false, lotwReceived = false,
|
||||||
lotwQslDate = "", vuccGrids = emptyList(),
|
wavelogUploaded = false,
|
||||||
|
lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = emptyList(),
|
||||||
dxcc = null, country = "", cqZone = null, region = ""
|
dxcc = null, country = "", cqZone = null, region = ""
|
||||||
).toEntity())
|
).toEntity())
|
||||||
} else {
|
} else {
|
||||||
val saved = if (previous?.lotwConfirmed == true && sameConfirmedContact(record, previous)) {
|
val saved = if (previous?.lotwConfirmed == true && sameConfirmedContact(record, previous)) {
|
||||||
record.withConfirmation(previous)
|
record.withConfirmation(previous)
|
||||||
} else if (previous?.lotwConfirmed == true) record.copy(
|
} 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 = ""
|
dxcc = null, country = "", cqZone = null, region = ""
|
||||||
) else record
|
) else record
|
||||||
val received = if (previous != null && (
|
val received = if (previous != null && (
|
||||||
@@ -76,9 +81,23 @@ class QsoRepository(
|
|||||||
|
|
||||||
override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } }
|
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
|
if (ids.isEmpty()) return@withContext
|
||||||
dao.markUploaded(ids)
|
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>) = withContext(dispatcher) {
|
||||||
|
if (ids.isEmpty()) return@withContext
|
||||||
|
importMutex.withLock { dao.markWavelogUploaded(ids) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
|
override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
|
||||||
@@ -132,9 +151,7 @@ class QsoRepository(
|
|||||||
val renames = rewriteOfficialNames(working)
|
val renames = rewriteOfficialNames(working)
|
||||||
val lookup = working.indices.groupBy { working[it].confirmationLookupKey() }
|
val lookup = working.indices.groupBy { working[it].confirmationLookupKey() }
|
||||||
.mapValues { it.value.toMutableList() }.toMutableMap()
|
.mapValues { it.value.toMutableList() }.toMutableMap()
|
||||||
val keyIndex = working.indices.groupBy { stableQsoKey(working[it]) }
|
val knownKeys = working.map { stableQsoKey(it) }.toMutableSet()
|
||||||
.mapValues { it.value.toMutableList() }.toMutableMap()
|
|
||||||
val knownKeys = keyIndex.keys.toMutableSet()
|
|
||||||
val changes = linkedMapOf<Int, QsoRecord>().apply {
|
val changes = linkedMapOf<Int, QsoRecord>().apply {
|
||||||
putAll(consolidation.merged)
|
putAll(consolidation.merged)
|
||||||
putAll(renames)
|
putAll(renames)
|
||||||
@@ -149,27 +166,38 @@ class QsoRepository(
|
|||||||
val exact = candidates.filter { working[it].startUtcMillis == remote.startUtcMillis }
|
val exact = candidates.filter { working[it].startUtcMillis == remote.startUtcMillis }
|
||||||
val match = exact.singleOrNull() ?: candidates.singleOrNull()
|
val match = exact.singleOrNull() ?: candidates.singleOrNull()
|
||||||
if (match == null) {
|
if (match == null) {
|
||||||
// A confirmation downloaded by the pre-fix parser sits as its own row with the
|
// 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). It has no
|
// band direction read mirrored (the downlink was stored as the uplink). The
|
||||||
// local partner (so nothing folds it) and its dedupe key equals the fresh report,
|
// strict lookup above cannot see it: its bands fail the comparison, and its
|
||||||
// so it would otherwise be skipped forever, keeping the wrong band on display.
|
// dedupe key can differ too (fields written by older releases — timestamp
|
||||||
// When the bands are exactly mirrored, adopt the report's direction instead.
|
// precision, FT4 sub-mode spelling, missing callsign — so it is not treated
|
||||||
val stale = if (fromLoTW) keyIndex[stableQsoKey(remote)]?.firstOrNull()
|
// as known either). Fold the report into the richest mirrored row instead of
|
||||||
?.takeIf { index -> working[index].lotwConfirmed &&
|
// inserting a second row next to it; left as two rows, neither consolidation
|
||||||
working[index].band.equals(remote.rxBand, true) &&
|
// nor a later sync could reliably heal them.
|
||||||
working[index].rxBand.equals(remote.band, true) } else null
|
val mirroredRows = if (fromLoTW) lookup[remote.confirmationLookupKey()].orEmpty().filter { index ->
|
||||||
if (stale != null) {
|
working[index].band.isNotBlank() && remote.band.isNotBlank() &&
|
||||||
val fixed = working[stale].copy(band = remote.band, rxBand = remote.rxBand)
|
!working[index].band.equals(remote.band, true) &&
|
||||||
working[stale] = fixed
|
sameContactIdentity(working[index], remote) &&
|
||||||
changes[stale] = fixed
|
bandsMatchOrMirror(working[index], remote)
|
||||||
updated++
|
} 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
|
return@forEach
|
||||||
}
|
}
|
||||||
if (stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
|
if (stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
|
||||||
val added = remote.copy(id = 0L, satelliteName = officialSatelliteName(remote.satelliteName))
|
val added = remote.copy(id = 0L, satelliteName = officialSatelliteName(remote.satelliteName))
|
||||||
changes[working.size] = added
|
changes[working.size] = added
|
||||||
lookup.getOrPut(added.confirmationLookupKey()) { mutableListOf() }.add(working.size)
|
lookup.getOrPut(added.confirmationLookupKey()) { mutableListOf() }.add(working.size)
|
||||||
keyIndex.getOrPut(stableQsoKey(added)) { mutableListOf() }.add(working.size)
|
|
||||||
knownKeys += stableQsoKey(added)
|
knownKeys += stableQsoKey(added)
|
||||||
working += added
|
working += added
|
||||||
imported++
|
imported++
|
||||||
@@ -265,9 +293,11 @@ private fun QsoEntity.toDomain() = QsoRecord(
|
|||||||
propagationMode = propagationMode,
|
propagationMode = propagationMode,
|
||||||
lotwConfirmed = lotwConfirmed,
|
lotwConfirmed = lotwConfirmed,
|
||||||
lotwUploaded = lotwUploaded,
|
lotwUploaded = lotwUploaded,
|
||||||
|
wavelogUploaded = wavelogUploaded,
|
||||||
lotwReceived = lotwReceived,
|
lotwReceived = lotwReceived,
|
||||||
lotwQslDate = lotwQslDate,
|
lotwQslDate = lotwQslDate,
|
||||||
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
|
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
|
||||||
|
theirVuccGrids = theirVuccGrids.split(',').filter(String::isNotBlank),
|
||||||
dxcc = dxcc,
|
dxcc = dxcc,
|
||||||
country = country,
|
country = country,
|
||||||
cqZone = cqZone,
|
cqZone = cqZone,
|
||||||
@@ -301,9 +331,11 @@ private fun QsoRecord.toEntity() = QsoEntity(
|
|||||||
propagationMode = propagationMode.ifBlank { if (satelliteName.isNotBlank()) "SAT" else "" },
|
propagationMode = propagationMode.ifBlank { if (satelliteName.isNotBlank()) "SAT" else "" },
|
||||||
lotwConfirmed = lotwConfirmed,
|
lotwConfirmed = lotwConfirmed,
|
||||||
lotwUploaded = lotwUploaded,
|
lotwUploaded = lotwUploaded,
|
||||||
|
wavelogUploaded = wavelogUploaded,
|
||||||
lotwReceived = lotwReceived,
|
lotwReceived = lotwReceived,
|
||||||
lotwQslDate = lotwQslDate,
|
lotwQslDate = lotwQslDate,
|
||||||
vuccGrids = vuccGrids.joinToString(","),
|
vuccGrids = vuccGrids.joinToString(","),
|
||||||
|
theirVuccGrids = theirVuccGrids.joinToString(","),
|
||||||
dxcc = dxcc,
|
dxcc = dxcc,
|
||||||
country = country,
|
country = country,
|
||||||
cqZone = cqZone,
|
cqZone = cqZone,
|
||||||
|
|||||||
@@ -51,7 +51,15 @@ class SelectionRepo(
|
|||||||
null // null = no filtering
|
null // null = no filtering
|
||||||
} else {
|
} else {
|
||||||
val ids = resolveTypeIds(types)
|
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 ->
|
currentItems.map { items ->
|
||||||
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
|
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
|||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
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.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||||
@@ -386,6 +387,40 @@ class SettingsRepo(
|
|||||||
preferences.edit { putString(keyLastLotwSyncCallsign, callsign.trim().uppercase()) }
|
preferences.edit { putString(keyLastLotwSyncCallsign, callsign.trim().uppercase()) }
|
||||||
//endregion
|
//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 # Transceivers settings
|
//region # Transceivers settings
|
||||||
private val _passesSettings = MutableStateFlow(getPassesSettings())
|
private val _passesSettings = MutableStateFlow(getPassesSettings())
|
||||||
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
|
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
|
||||||
@@ -468,6 +503,34 @@ class SettingsRepo(
|
|||||||
return true
|
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 {
|
private fun getStationPosition(): GeoPos {
|
||||||
val latitude = (preferences.getString(keyStationLatitude, null) ?: "0.0").toDouble()
|
val latitude = (preferences.getString(keyStationLatitude, null) ?: "0.0").toDouble()
|
||||||
val longitude = (preferences.getString(keyStationLongitude, null) ?: "0.0").toDouble()
|
val longitude = (preferences.getString(keyStationLongitude, null) ?: "0.0").toDouble()
|
||||||
|
|||||||
+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
|
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 insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity())
|
||||||
|
|
||||||
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
|
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
|
||||||
|
|||||||
+154
-3
@@ -44,6 +44,7 @@ import org.junit.Assert.assertEquals
|
|||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class DatabaseRepoTest {
|
class DatabaseRepoTest {
|
||||||
@@ -51,6 +52,10 @@ class DatabaseRepoTest {
|
|||||||
private val dispatcher = StandardTestDispatcher()
|
private val dispatcher = StandardTestDispatcher()
|
||||||
private val dataParser = DataParser(dispatcher)
|
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
|
@Test
|
||||||
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
|
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
|
||||||
val uri = "content://look4sat/import/satellites"
|
val uri = "content://look4sat/import/satellites"
|
||||||
@@ -150,20 +155,144 @@ class DatabaseRepoTest {
|
|||||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
|
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 = """
|
private fun validCsvStream(): InputStream = """
|
||||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
ISS (ZARYA),1998-067A,${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()
|
""".trimIndent().byteInputStream()
|
||||||
|
|
||||||
private fun validTleStream(): InputStream = """
|
private fun validTleStream(): InputStream = """
|
||||||
ISS (ZARYA)
|
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
|
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||||
""".trimIndent().byteInputStream()
|
""".trimIndent().byteInputStream()
|
||||||
|
|
||||||
private fun jamxTleStream(): InputStream = """
|
private fun jamxTleStream(): InputStream = """
|
||||||
JAMX-0825b
|
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
|
2 98248 097.5373 310.9694 0011309 278.1232 340.7230 15.09766181000012
|
||||||
""".trimIndent().byteInputStream()
|
""".trimIndent().byteInputStream()
|
||||||
}
|
}
|
||||||
@@ -195,6 +324,21 @@ private class FakeLocalSource : ILocalSource {
|
|||||||
|
|
||||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
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>) {
|
override suspend fun insertEntries(entries: List<OrbitalData>) {
|
||||||
insertedEntries += entries
|
insertedEntries += entries
|
||||||
}
|
}
|
||||||
@@ -271,6 +415,8 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
|
|
||||||
override fun setStationPosition(locator: String): Boolean = true
|
override fun setStationPosition(locator: String): Boolean = true
|
||||||
|
|
||||||
|
override fun getCurrentGrid(): String? = null
|
||||||
|
|
||||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
|
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
|
||||||
|
|
||||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
||||||
@@ -312,6 +458,11 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
|
|
||||||
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
|
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()
|
override fun getWorkedGrids(): Set<String> = emptySet()
|
||||||
|
|
||||||
private val workedGrids = MutableStateFlow(emptySet<String>())
|
private val workedGrids = MutableStateFlow(emptySet<String>())
|
||||||
|
|||||||
+23
@@ -167,6 +167,29 @@ class LoTWRepositoryTest {
|
|||||||
result.values.forEach { assertEquals(1, it.size) }
|
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
|
@Test
|
||||||
fun parseQsosRejectsBodyWithoutEoh() {
|
fun parseQsosRejectsBodyWithoutEoh() {
|
||||||
assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>"))
|
assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>"))
|
||||||
|
|||||||
+132
-1
@@ -52,6 +52,9 @@ class QsoRepositoryTest {
|
|||||||
// The row is also renamed to the ARRL spelling while it is merged.
|
// The row is also renamed to the ARRL spelling while it is merged.
|
||||||
assertEquals("SO-50", rows.first().satelliteName)
|
assertEquals("SO-50", rows.first().satelliteName)
|
||||||
assertEquals(145_850_000L, rows.first().txFrequencyHz)
|
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
|
@Test
|
||||||
@@ -124,6 +127,106 @@ class QsoRepositoryTest {
|
|||||||
assertEquals(setOf("SO-50", "FOO-1"), dao.getAll().map { it.satelliteName }.toSet())
|
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
|
@Test
|
||||||
fun mergeLoTW_fixesBandDirectionOfAStaleConfirmationRow() = runBlocking {
|
fun mergeLoTW_fixesBandDirectionOfAStaleConfirmationRow() = runBlocking {
|
||||||
// A confirmation downloaded by the pre-fix parser: ARRL name but the band direction
|
// A confirmation downloaded by the pre-fix parser: ARRL name but the band direction
|
||||||
@@ -192,6 +295,27 @@ class QsoRepositoryTest {
|
|||||||
assertEquals(1, dao.getAll().size)
|
assertEquals(1, dao.getAll().size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun markWavelogUploaded_setsOnlyTheWavelogFlag() = runBlocking {
|
||||||
|
val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).toEntity())
|
||||||
|
|
||||||
|
repository.markWavelogUploaded(listOf(id))
|
||||||
|
|
||||||
|
val row = dao.getAll().single()
|
||||||
|
assertTrue(row.wavelogUploaded)
|
||||||
|
assertFalse(row.lotwUploaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 })
|
||||||
|
}
|
||||||
|
|
||||||
/** A record as the log page creates it: tracker name, repeater pair, uplink band. */
|
/** A record as the log page creates it: tracker name, repeater pair, uplink band. */
|
||||||
private fun loggedInApp(satelliteName: String, lotwUploaded: Boolean) = QsoRecord(
|
private fun loggedInApp(satelliteName: String, lotwUploaded: Boolean) = QsoRecord(
|
||||||
startUtcMillis = qsoStart,
|
startUtcMillis = qsoStart,
|
||||||
@@ -221,7 +345,8 @@ class QsoRepositoryTest {
|
|||||||
cqz = 24,
|
cqz = 24,
|
||||||
state = "GD",
|
state = "GD",
|
||||||
myGrid = "OL62",
|
myGrid = "OL62",
|
||||||
myGrids = setOf("OL62", "OL63")
|
myGrids = setOf("OL62", "OL63"),
|
||||||
|
theirGrids = listOf("EN52", "EN53")
|
||||||
).toConfirmedRecord("BA7OPF")
|
).toConfirmedRecord("BA7OPF")
|
||||||
|
|
||||||
private fun QsoRecord.toEntity() = QsoEntity(
|
private fun QsoRecord.toEntity() = QsoEntity(
|
||||||
@@ -251,9 +376,11 @@ class QsoRepositoryTest {
|
|||||||
propagationMode = propagationMode,
|
propagationMode = propagationMode,
|
||||||
lotwConfirmed = lotwConfirmed,
|
lotwConfirmed = lotwConfirmed,
|
||||||
lotwUploaded = lotwUploaded,
|
lotwUploaded = lotwUploaded,
|
||||||
|
wavelogUploaded = wavelogUploaded,
|
||||||
lotwReceived = lotwReceived,
|
lotwReceived = lotwReceived,
|
||||||
lotwQslDate = lotwQslDate,
|
lotwQslDate = lotwQslDate,
|
||||||
vuccGrids = vuccGrids.joinToString(","),
|
vuccGrids = vuccGrids.joinToString(","),
|
||||||
|
theirVuccGrids = theirVuccGrids.joinToString(","),
|
||||||
dxcc = dxcc,
|
dxcc = dxcc,
|
||||||
country = country,
|
country = country,
|
||||||
cqZone = cqZone,
|
cqZone = cqZone,
|
||||||
@@ -288,5 +415,9 @@ class QsoRepositoryTest {
|
|||||||
override suspend fun markUploaded(ids: List<Long>) {
|
override suspend fun markUploaded(ids: List<Long>) {
|
||||||
ids.forEach { id -> rows[id]?.let { rows[id] = it.copy(lotwUploaded = true) } }
|
ids.forEach { id -> rows[id]?.let { rows[id] = it.copy(lotwUploaded = true) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun markWavelogUploaded(ids: List<Long>) {
|
||||||
|
ids.forEach { id -> rows[id]?.let { rows[id] = it.copy(wavelogUploaded = true) } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+29
@@ -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.ISelectionRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -241,6 +242,26 @@ class SelectionRepoSearchTest {
|
|||||||
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
|
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
|
@Test
|
||||||
fun `getTypesList excludes only the Other placeholder`() = runTest {
|
fun `getTypesList excludes only the Other placeholder`() = runTest {
|
||||||
val repo = createRepo(sampleItems)
|
val repo = createRepo(sampleItems)
|
||||||
@@ -282,6 +303,10 @@ private class FakeLocalSourceForSearch(
|
|||||||
override suspend fun getEntriesTotal(): Int = items.size
|
override suspend fun getEntriesTotal(): Int = items.size
|
||||||
override suspend fun getEntriesList(): List<SatItem> = items
|
override suspend fun getEntriesList(): List<SatItem> = items
|
||||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
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 insertEntries(entries: List<OrbitalData>) = Unit
|
||||||
override suspend fun deleteEntries() = Unit
|
override suspend fun deleteEntries() = Unit
|
||||||
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = sstvIds
|
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = sstvIds
|
||||||
@@ -321,6 +346,9 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
|
|||||||
)
|
)
|
||||||
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
|
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
|
||||||
MutableStateFlow(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> =
|
override val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings> =
|
||||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
|
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
|
||||||
|
|
||||||
@@ -330,6 +358,7 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
|
|||||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
|
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
|
||||||
override fun setStationPosition(): Boolean = true
|
override fun setStationPosition(): Boolean = true
|
||||||
override fun setStationPosition(locator: String): Boolean = true
|
override fun setStationPosition(locator: String): Boolean = true
|
||||||
|
override fun getCurrentGrid(): String? = null
|
||||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> =
|
override fun getSatelliteTypesIds(types: List<String>): List<Int> =
|
||||||
types.flatMap { typeIds[it].orEmpty() }.distinct()
|
types.flatMap { typeIds[it].orEmpty() }.distinct()
|
||||||
|
|
||||||
|
|||||||
+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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import java.util.TimeZone
|
|||||||
object AdifCodec {
|
object AdifCodec {
|
||||||
private val utc = TimeZone.getTimeZone("UTC")
|
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("ADIF_VER", "3.1.7"))
|
||||||
append(field("PROGRAMID", "Look4Sat"))
|
append(field("PROGRAMID", "Look4Sat"))
|
||||||
append("<EOH>\r\n")
|
append("<EOH>\r\n")
|
||||||
@@ -32,7 +32,7 @@ object AdifCodec {
|
|||||||
append(field("TIME_OFF", format(it, "HHmmss")))
|
append(field("TIME_OFF", format(it, "HHmmss")))
|
||||||
}
|
}
|
||||||
append(field("CALL", record.theirCallsign))
|
append(field("CALL", record.theirCallsign))
|
||||||
append(field("STATION_CALLSIGN", record.myCallsign))
|
if (includeStationCallsign) append(field("STATION_CALLSIGN", record.myCallsign))
|
||||||
appendOptional("MODE", record.mode)
|
appendOptional("MODE", record.mode)
|
||||||
appendOptional("SUBMODE", record.submode)
|
appendOptional("SUBMODE", record.submode)
|
||||||
appendOptional("GRIDSQUARE", record.theirGrid)
|
appendOptional("GRIDSQUARE", record.theirGrid)
|
||||||
|
|||||||
+10
-1
@@ -17,7 +17,16 @@ interface IQsoRepository {
|
|||||||
suspend fun find(id: Long): QsoRecord?
|
suspend fun find(id: Long): QsoRecord?
|
||||||
suspend fun save(record: QsoRecord): Long
|
suspend fun save(record: QsoRecord): Long
|
||||||
suspend fun delete(id: 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). */
|
||||||
|
suspend fun markWavelogUploaded(ids: List<Long>)
|
||||||
|
|
||||||
suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
|
suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
|
||||||
suspend fun importAdi(content: String): AdifImportResult
|
suspend fun importAdi(content: String): AdifImportResult
|
||||||
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
|
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
|
||||||
|
|||||||
+2
@@ -27,6 +27,8 @@ fun GridQso.toConfirmedRecord(accountCallsign: String): QsoRecord = QsoRecord(
|
|||||||
startUtcMillis = epochMs,
|
startUtcMillis = epochMs,
|
||||||
theirCallsign = call,
|
theirCallsign = call,
|
||||||
myCallsign = accountCallsign.trim().uppercase(),
|
myCallsign = accountCallsign.trim().uppercase(),
|
||||||
|
theirGrid = theirGrids.firstOrNull().orEmpty(),
|
||||||
|
theirVuccGrids = theirGrids,
|
||||||
myGrid = (myGrids.firstOrNull() ?: myGrid).orEmpty(),
|
myGrid = (myGrids.firstOrNull() ?: myGrid).orEmpty(),
|
||||||
vuccGrids = myGrids.toList(),
|
vuccGrids = myGrids.toList(),
|
||||||
band = bandUp,
|
band = bandUp,
|
||||||
|
|||||||
+1
@@ -31,6 +31,7 @@ fun LoTWProblem.label(): String = when (this) {
|
|||||||
LoTWProblem.SATELLITE -> "satellite name not in ARRL's list"
|
LoTWProblem.SATELLITE -> "satellite name not in ARRL's list"
|
||||||
LoTWProblem.MODE -> "mode not accepted by LoTW"
|
LoTWProblem.MODE -> "mode not accepted by LoTW"
|
||||||
LoTWProblem.QSO_DATE -> "date outside the certificate"
|
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.CALLSIGN_MISMATCH -> "MY callsign does not match the certificate"
|
||||||
LoTWProblem.INVALID_CONTACT -> "invalid callsign/record"
|
LoTWProblem.INVALID_CONTACT -> "invalid callsign/record"
|
||||||
LoTWProblem.STATION_GRID -> "station grid missing"
|
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.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) &&
|
||||||
(local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) &&
|
(local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) &&
|
||||||
local.isSatellite == remote.isSatellite &&
|
local.isSatellite == remote.isSatellite &&
|
||||||
local.displayMode == remote.displayMode &&
|
local.matchableMode == remote.matchableMode &&
|
||||||
abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L
|
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)
|
sameContactIdentity(local, remote) && sameBandOrBlank(local.band, remote.band)
|
||||||
|
|
||||||
fun QsoRecord.confirmationLookupKey(): String = listOf(
|
fun QsoRecord.confirmationLookupKey(): String = listOf(
|
||||||
theirCallsign.trim().uppercase(Locale.US), satelliteIdentity(satelliteName), displayMode
|
theirCallsign.trim().uppercase(Locale.US), satelliteIdentity(satelliteName), matchableMode
|
||||||
).joinToString("|")
|
).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)
|
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
|
* Rows that should have been one row but were not, so they can be consolidated back into one.
|
||||||
* consolidated back into one.
|
|
||||||
*
|
*
|
||||||
* Confirmations merged before the identity fix landed as their own rows: the tracker
|
* Two known holes leave a contact stored twice:
|
||||||
* 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
|
* 1. Confirmations merged before the identity fix landed as their own rows: the tracker name
|
||||||
* were stored twice. The pair is only accepted when the contact identity matches and
|
* vs ARRL name comparison failed, and the band direction of the report was read mirrored
|
||||||
* the bands are either identical or exactly mirrored — the signature of that second
|
* (BAND_RX taken as the uplink) — so an unconfirmed local row and its confirmation stayed
|
||||||
* mismatch — which keeps unrelated QSOs of the same operator and minute apart.
|
* 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> {
|
fun splitConfirmationPairs(records: List<QsoRecord>): List<SplitConfirmationPair> {
|
||||||
val pendingIndices = records.indices.filter { !records[it].lotwConfirmed }
|
|
||||||
val taken = mutableSetOf<Int>()
|
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 confirmation = records[confirmationIndex]
|
||||||
val localIndex = pendingIndices.firstOrNull { index ->
|
val localIndex = pendingIndices.firstOrNull { index ->
|
||||||
index !in taken && sameContactIdentity(records[index], confirmation) &&
|
index !in taken && sameContactIdentity(records[index], confirmation) &&
|
||||||
bandsMatchOrMirror(records[index], confirmation)
|
bandsMatchOrMirror(records[index], confirmation)
|
||||||
} ?: return@mapNotNull null
|
} ?: return@forEach
|
||||||
taken += localIndex
|
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. */
|
/** 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.band) && sameBandOrBlank(local.rxBand, confirmation.rxBand)) ||
|
||||||
(sameBandOrBlank(local.band, confirmation.rxBand) && sameBandOrBlank(local.rxBand, confirmation.band))
|
(sameBandOrBlank(local.band, confirmation.rxBand) && sameBandOrBlank(local.rxBand, confirmation.band))
|
||||||
|
|
||||||
fun QsoRecord.withConfirmation(confirmed: QsoRecord): QsoRecord = copy(
|
fun QsoRecord.withConfirmation(confirmed: QsoRecord): QsoRecord = copy(
|
||||||
myCallsign = myCallsign.ifBlank { confirmed.myCallsign },
|
myCallsign = myCallsign.ifBlank { confirmed.myCallsign },
|
||||||
theirGrid = confirmed.theirGrid.ifBlank { theirGrid },
|
theirGrid = confirmed.theirGrid.ifBlank { theirGrid },
|
||||||
|
theirVuccGrids = confirmed.theirVuccGrids.ifEmpty { theirVuccGrids },
|
||||||
myGrid = myGrid.ifBlank { confirmed.myGrid },
|
myGrid = myGrid.ifBlank { confirmed.myGrid },
|
||||||
sentReport = sentReport.ifBlank { confirmed.sentReport },
|
sentReport = sentReport.ifBlank { confirmed.sentReport },
|
||||||
receivedReport = receivedReport.ifBlank { confirmed.receivedReport },
|
receivedReport = receivedReport.ifBlank { confirmed.receivedReport },
|
||||||
@@ -159,6 +214,12 @@ val QsoRecord.displayMode: String
|
|||||||
return label.trim().uppercase(Locale.US)
|
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
|
val QsoRecord.isSatellite: Boolean
|
||||||
get() = propagationMode.equals("SAT", true) || (propagationMode.isBlank() && satelliteName.isNotBlank())
|
get() = propagationMode.equals("SAT", true) || (propagationMode.isBlank() && satelliteName.isNotBlank())
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ data class QsoRecord(
|
|||||||
val theirCallsign: String,
|
val theirCallsign: String,
|
||||||
val myCallsign: String,
|
val myCallsign: String,
|
||||||
val theirGrid: 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 myGrid: String = "",
|
||||||
val sentReport: String = "",
|
val sentReport: String = "",
|
||||||
val receivedReport: String = "",
|
val receivedReport: String = "",
|
||||||
@@ -36,6 +40,8 @@ data class QsoRecord(
|
|||||||
val propagationMode: String = if (satelliteName.isNotBlank()) "SAT" else "",
|
val propagationMode: String = if (satelliteName.isNotBlank()) "SAT" else "",
|
||||||
val lotwConfirmed: Boolean = false,
|
val lotwConfirmed: Boolean = false,
|
||||||
val lotwUploaded: 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,
|
||||||
val lotwReceived: Boolean = false,
|
val lotwReceived: Boolean = false,
|
||||||
val lotwQslDate: String = "",
|
val lotwQslDate: String = "",
|
||||||
val vuccGrids: List<String> = emptyList(),
|
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
|
* 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
|
* operated-grid selector can group QSOs per 台址 and show each 台址 with
|
||||||
* its full grid set. Null for data synced before this field existed. */
|
* 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. */
|
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
|
||||||
val bandLabel: String
|
val bandLabel: String
|
||||||
@@ -98,3 +104,40 @@ data class GridQso(
|
|||||||
else -> band.trim().uppercase()
|
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:
|
||||||
|
* [stationId] == null ("All") keeps the list as-is, otherwise only records
|
||||||
|
* whose [stationGridSetKey] matches stay. 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 filter { it.stationGridSetKey() == stationId }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()
|
||||||
@@ -109,6 +109,19 @@ data class WavelogSettings(
|
|||||||
val isConfigured: Boolean get() = url.isNotBlank() && token.isNotBlank()
|
val isConfigured: Boolean get() = url.isNotBlank() && token.isNotBlank()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wavelog QSO-upload configuration (a separate block from the worked-grids sync). */
|
||||||
|
data class WavelogUploadSettings(
|
||||||
|
val url: String = "",
|
||||||
|
val apiKey: String = "",
|
||||||
|
val stationId: String = "",
|
||||||
|
val stationName: String = "",
|
||||||
|
val stationCallsign: String = "",
|
||||||
|
val stationGrid: String = ""
|
||||||
|
) {
|
||||||
|
val isConfigured: Boolean get() = url.isNotBlank() && apiKey.isNotBlank()
|
||||||
|
val isReady: Boolean get() = isConfigured && stationId.isNotBlank()
|
||||||
|
}
|
||||||
|
|
||||||
data class LoTWSettings(
|
data class LoTWSettings(
|
||||||
val callsign: String = "",
|
val callsign: String = "",
|
||||||
val password: String = ""
|
val password: String = ""
|
||||||
|
|||||||
+29
-17
@@ -37,30 +37,42 @@ data class OrbitalData(
|
|||||||
val xno: Double = meanmo * TWO_PI / MIN_PER_DAY
|
val xno: Double = meanmo * TWO_PI / MIN_PER_DAY
|
||||||
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
|
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
|
||||||
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
|
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)
|
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
|
||||||
|
|
||||||
/** Check if satellite has likely decayed by the given time. */
|
/** Check if satellite has likely decayed by the given time. */
|
||||||
fun hasDecayed(currentTimeMillis: Long): Boolean {
|
fun hasDecayed(currentTimeMillis: Long): Boolean {
|
||||||
if (ndot == 0.0) return false
|
if (ndot == 0.0) return false
|
||||||
val currentDaynum = (currentTimeMillis - 315446400000L) / 86400000.0
|
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, timeToDaynum(currentTimeMillis))
|
||||||
val epochDaynum = epochToDaynum(epoch)
|
|
||||||
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, currentDaynum)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun epochToDaynum(epoch: Double): Double {
|
companion object {
|
||||||
var year = kotlin.math.floor(epoch * 1E-3)
|
/** Days since 31 Dec 1979, the reference point every daynum in the app is based on. */
|
||||||
val day = (epoch * 1E-3 - year) * 1000.0
|
fun timeToDaynum(timeMillis: Long): Double = (timeMillis - 315446400000L) / 86400000.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
|
* Days since 31 Dec 1979 for a TLE style YYDDD.ffffffff epoch. Raw epochs are not
|
||||||
val a = kotlin.math.floor(aYear / 100).toLong()
|
* comparable across decades (a 1999 epoch reads as 99xxx, a 2026 one as 26xxx), so
|
||||||
val b = 2 - a + a / 4
|
* freshness checks must always go through this conversion.
|
||||||
val i = kotlin.math.floor(365.25 * aYear).toLong()
|
*/
|
||||||
return i + (30.6001 * 14).toLong() + 1720994.5 + b
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+37
-2
@@ -19,6 +19,7 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.repository
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
interface ILoTWUploadRepository {
|
interface ILoTWUploadRepository {
|
||||||
suspend fun certificate(): LoTWCertificate?
|
suspend fun certificate(): LoTWCertificate?
|
||||||
@@ -96,7 +97,23 @@ data class LoTWUploadPreview(
|
|||||||
val duplicateSkipped: Int = 0,
|
val duplicateSkipped: Int = 0,
|
||||||
/** Ids of the records that actually made it into this TQ8 batch. Only these
|
/** 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. */
|
* 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. */
|
/** Why the un-uploadable records of a selection cannot be signed, and what was left out. */
|
||||||
@@ -128,7 +145,25 @@ class LoTWOperationException(val reason: LoTWProblem, val detail: String = "") :
|
|||||||
|
|
||||||
enum class LoTWProblem {
|
enum class LoTWProblem {
|
||||||
CERTIFICATE_PASSWORD, CERTIFICATE_INVALID, CERTIFICATE_EXPIRED, CERTIFICATE_MISSING,
|
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,
|
STATION_REGION, STATION_ZONE, STATION_IOTA, MODE, BAND, SATELLITE, INVALID_CONTACT,
|
||||||
LOCATION_MISMATCH, TOO_MANY_CONTACTS
|
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 }
|
||||||
+1
@@ -34,6 +34,7 @@ interface IMainContainer {
|
|||||||
val amSatRepo: IAmSatRepository
|
val amSatRepo: IAmSatRepository
|
||||||
val updateRepo: IUpdateRepository
|
val updateRepo: IUpdateRepository
|
||||||
val wavelogRepo: IWavelogRepository
|
val wavelogRepo: IWavelogRepository
|
||||||
|
val wavelogUploadRepository: IWavelogUploadRepository
|
||||||
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||||
val qsoRepository: IQsoRepository
|
val qsoRepository: IQsoRepository
|
||||||
val lotwUploadRepository: ILoTWUploadRepository
|
val lotwUploadRepository: ILoTWUploadRepository
|
||||||
|
|||||||
+12
@@ -24,6 +24,7 @@ import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
|||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
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.GeoPos
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
@@ -48,6 +49,12 @@ interface ISettingsRepo {
|
|||||||
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
|
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
|
||||||
fun setStationPosition(): Boolean
|
fun setStationPosition(): Boolean
|
||||||
fun setStationPosition(locator: String): 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
|
//endregion
|
||||||
|
|
||||||
//region # Database update settings
|
//region # Database update settings
|
||||||
@@ -118,6 +125,11 @@ interface ISettingsRepo {
|
|||||||
fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>)
|
fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
//region # Wavelog upload settings
|
||||||
|
val wavelogUploadSettings: StateFlow<WavelogUploadSettings>
|
||||||
|
fun updateWavelogUploadSettings(settings: WavelogUploadSettings)
|
||||||
|
//endregion
|
||||||
|
|
||||||
//region # LoTW confirmed-grids settings
|
//region # LoTW confirmed-grids settings
|
||||||
val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
|
val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
|
||||||
fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings)
|
fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings)
|
||||||
|
|||||||
+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
|
||||||
|
}
|
||||||
@@ -26,6 +26,10 @@ interface ILocalSource {
|
|||||||
suspend fun getEntriesTotal(): Int
|
suspend fun getEntriesTotal(): Int
|
||||||
suspend fun getEntriesList(): List<SatItem>
|
suspend fun getEntriesList(): List<SatItem>
|
||||||
suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject>
|
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 insertEntries(entries: List<OrbitalData>)
|
||||||
suspend fun deleteEntries()
|
suspend fun deleteEntries()
|
||||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||||
|
|||||||
@@ -18,41 +18,32 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.source
|
package com.rtbishop.look4sat.core.domain.source
|
||||||
|
|
||||||
object Sources {
|
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(
|
val satelliteDataUrls = mapOf(
|
||||||
"LAPAN-A2" to "https://www.kaggle.com/api/v1/datasets/download/muazamnugroho/lapan-a2-satellite-two-line-element-tle-dataset/LAPAN-A2_TLE_latest.txt",
|
"LAPAN-A2" to "https://www.kaggle.com/api/v1/datasets/download/muazamnugroho/lapan-a2-satellite-two-line-element-tle-dataset/LAPAN-A2_TLE_latest.txt",
|
||||||
|
"ARISS" to "https://live.ariss.org/iss.txt",
|
||||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||||
"BI4PYM AutoTLE (GitHub)" to "https://raw.githubusercontent.com/BI4PYM/AutoTLE/refs/heads/master/AutoTLE.txt",
|
"BI4PYM AutoTLE (GitHub)" to "https://raw.githubusercontent.com/BI4PYM/AutoTLE/refs/heads/master/AutoTLE.txt",
|
||||||
"BI4PYM AutoTLE (Mirror)" to "https://autotle.bi4pym.cn/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",
|
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||||
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
|
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
|
||||||
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
|
||||||
"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
|
"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(
|
val transceiversDataUrls = mapOf(
|
||||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
|
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
|
||||||
"R4UAB" to "https://r4uab.ru/transmitters.json"
|
"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
|
* per-type IDs, and are mutually exclusive with the regular TLE-source
|
||||||
* types in the picker. */
|
* types in the picker. */
|
||||||
val virtualTypeNames = listOf("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV")
|
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"
|
||||||
}
|
}
|
||||||
@@ -83,4 +83,20 @@ class AdifCodecTest {
|
|||||||
assertTrue(record.lotwConfirmed)
|
assertTrue(record.lotwConfirmed)
|
||||||
assertTrue(record.isSatellite)
|
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",
|
propagationMode = "SAT",
|
||||||
status = QsoStatus.COMPLETE,
|
status = QsoStatus.COMPLETE,
|
||||||
lotwConfirmed = true,
|
lotwConfirmed = true,
|
||||||
vuccGrids = listOf("OM60", "OM50")
|
vuccGrids = listOf("OM60", "OM50"),
|
||||||
|
theirVuccGrids = listOf("EN52", "EN53")
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -73,12 +74,24 @@ class LogbookMergeTest {
|
|||||||
assertEquals(local.confirmationLookupKey(), confirmed.confirmationLookupKey())
|
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
|
@Test
|
||||||
fun withConfirmation_marksConfirmedAndMergesVuccGrids() {
|
fun withConfirmation_marksConfirmedAndMergesVuccGrids() {
|
||||||
val merged = local.withConfirmation(confirmed)
|
val merged = local.withConfirmation(confirmed)
|
||||||
assertTrue(merged.lotwConfirmed)
|
assertTrue(merged.lotwConfirmed)
|
||||||
assertTrue(merged.lotwReceived)
|
assertTrue(merged.lotwReceived)
|
||||||
assertEquals(listOf("OM60", "OM50"), merged.vuccGrids)
|
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.
|
// Local myCallsign is kept when present; only blanks are backfilled.
|
||||||
assertEquals("ba7opf", merged.myCallsign)
|
assertEquals("ba7opf", merged.myCallsign)
|
||||||
}
|
}
|
||||||
@@ -97,13 +110,16 @@ class LogbookMergeTest {
|
|||||||
cqz = 24,
|
cqz = 24,
|
||||||
state = "GD",
|
state = "GD",
|
||||||
myGrid = "OM60",
|
myGrid = "OM60",
|
||||||
myGrids = setOf("OM60", "OM50")
|
myGrids = setOf("OM60", "OM50"),
|
||||||
|
theirGrids = listOf("EN52", "EN53")
|
||||||
)
|
)
|
||||||
val record = qso.toConfirmedRecord("ba7opf")
|
val record = qso.toConfirmedRecord("ba7opf")
|
||||||
assertEquals("BH6RJD", record.theirCallsign)
|
assertEquals("BH6RJD", record.theirCallsign)
|
||||||
assertEquals("BA7OPF", record.myCallsign)
|
assertEquals("BA7OPF", record.myCallsign)
|
||||||
assertEquals("OM60", record.myGrid)
|
assertEquals("OM60", record.myGrid)
|
||||||
assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
|
assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
|
||||||
|
assertEquals("EN52", record.theirGrid)
|
||||||
|
assertEquals(listOf("EN52", "EN53"), record.theirVuccGrids)
|
||||||
assertTrue(record.lotwConfirmed)
|
assertTrue(record.lotwConfirmed)
|
||||||
assertTrue(record.isSatellite)
|
assertTrue(record.isSatellite)
|
||||||
assertEquals("70CM", record.band)
|
assertEquals("70CM", record.band)
|
||||||
@@ -198,6 +214,30 @@ class LogbookMergeTest {
|
|||||||
assertEquals(1, pairs.first().confirmationIndex)
|
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
|
@Test
|
||||||
fun splitConfirmationPairs_keepsUnrelatedRowsApart() {
|
fun splitConfirmationPairs_keepsUnrelatedRowsApart() {
|
||||||
val uploaded = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
|
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
/*
|
||||||
|
* 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 only the matching station's records`() {
|
||||||
|
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(single), list.scopedToStation("OL62"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -27,6 +27,7 @@ import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
|||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
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.GeoPos
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -248,6 +249,7 @@ class LoTWGridSyncTest {
|
|||||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||||
)
|
)
|
||||||
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
|
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
|
||||||
|
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> = MutableStateFlow(WavelogUploadSettings())
|
||||||
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
|
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
|
||||||
|
|
||||||
private var workedGrids: Set<String> = emptySet()
|
private var workedGrids: Set<String> = emptySet()
|
||||||
@@ -276,6 +278,7 @@ class LoTWGridSyncTest {
|
|||||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
|
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
|
||||||
override fun setStationPosition(): Boolean = TODO()
|
override fun setStationPosition(): Boolean = TODO()
|
||||||
override fun setStationPosition(locator: String): 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 getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
|
||||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
|
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
|
||||||
override fun updateDatabaseState(state: DatabaseState) = TODO()
|
override fun updateDatabaseState(state: DatabaseState) = TODO()
|
||||||
@@ -291,6 +294,7 @@ class LoTWGridSyncTest {
|
|||||||
override fun getAmSatCallsign(): String = ""
|
override fun getAmSatCallsign(): String = ""
|
||||||
override fun setAmSatCallsign(callsign: String) = TODO()
|
override fun setAmSatCallsign(callsign: String) = TODO()
|
||||||
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
|
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
|
||||||
|
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) = TODO()
|
||||||
override fun updateLoTWSettings(settings: LoTWSettings) = 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("")))
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -492,6 +492,7 @@ fun SwipeRevealRow(
|
|||||||
controller: SwipeController,
|
controller: SwipeController,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
revealWidth: Dp = 64.dp,
|
revealWidth: Dp = 64.dp,
|
||||||
|
gesturesEnabled: Boolean = true,
|
||||||
revealAction: () -> Unit,
|
revealAction: () -> Unit,
|
||||||
revealButton: @Composable () -> Unit = {
|
revealButton: @Composable () -> Unit = {
|
||||||
Box(
|
Box(
|
||||||
@@ -555,7 +556,9 @@ fun SwipeRevealRow(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.graphicsLayer { translationX = offsetX }
|
.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(
|
detectHorizontalDragGestures(
|
||||||
onDragStart = { settleJob?.cancel() },
|
onDragStart = { settleJob?.cancel() },
|
||||||
onHorizontalDrag = { change, dragAmount ->
|
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)) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -112,6 +112,8 @@
|
|||||||
<string name="prefs_data_update_success">Actualización completada con éxito</string>
|
<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_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_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_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||||
|
|||||||
@@ -111,6 +111,8 @@
|
|||||||
<string name="prefs_data_update_success">Обновление прошло успешно</string>
|
<string name="prefs_data_update_success">Обновление прошло успешно</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Свои источники данных</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_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_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||||
|
|||||||
@@ -112,6 +112,8 @@
|
|||||||
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
|
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Custom data sources</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_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_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
<string name="prefs_data_sources_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_update_success">Güncelleme başarıyla tamamlandı</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Özel veri kaynakları</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_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_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
|
||||||
|
|||||||
@@ -112,6 +112,8 @@
|
|||||||
<string name="prefs_data_update_success">Оновлення успішне</string>
|
<string name="prefs_data_update_success">Оновлення успішне</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Custom data sources</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_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_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||||
|
|||||||
@@ -166,6 +166,16 @@
|
|||||||
<string name="prefs_wavelog_hint">已同步网格数:%1$d。格式: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_sync">立即同步</string>
|
||||||
<string name="prefs_wavelog_syncing">同步中…</string>
|
<string name="prefs_wavelog_syncing">同步中…</string>
|
||||||
|
<string name="prefs_wavelog_upload_title">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_title">LoTW 已确认网格</string>
|
||||||
<string name="prefs_lotw_callsign">LoTW 呼号</string>
|
<string name="prefs_lotw_callsign">LoTW 呼号</string>
|
||||||
<string name="prefs_lotw_password">LoTW 密码</string>
|
<string name="prefs_lotw_password">LoTW 密码</string>
|
||||||
@@ -197,6 +207,16 @@
|
|||||||
<string name="prefs_logbook_upload">上传</string>
|
<string name="prefs_logbook_upload">上传</string>
|
||||||
<string name="prefs_logbook_upload_title">上传到 LoTW</string>
|
<string name="prefs_logbook_upload_title">上传到 LoTW</string>
|
||||||
<string name="prefs_logbook_upload_confirm">确认上传</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_wavelog_sync">同时同步 Wavelog:%1$d 条</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_title">LoTW 上传证书</string>
|
||||||
<string name="prefs_lotw_upload_configured">证书已导入 · 台址网格 %1$s</string>
|
<string name="prefs_lotw_upload_configured">证书已导入 · 台址网格 %1$s</string>
|
||||||
<string name="prefs_lotw_upload_not_configured">未配置 — 导入 TrustedQSL 证书后即可上传通联</string>
|
<string name="prefs_lotw_upload_not_configured">未配置 — 导入 TrustedQSL 证书后即可上传通联</string>
|
||||||
@@ -232,6 +252,8 @@
|
|||||||
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
|
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">自定义数据源</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_restore">恢复默认数据源</string>
|
||||||
<string name="prefs_data_sources_tle_switch">自定义TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">自定义TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string>
|
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string>
|
||||||
@@ -406,6 +428,9 @@
|
|||||||
<string name="qso_edit_comment">备注</string>
|
<string name="qso_edit_comment">备注</string>
|
||||||
<string name="qso_edit_save">保存</string>
|
<string name="qso_edit_save">保存</string>
|
||||||
<string name="qso_edit_cancel">取消</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_callsign">呼号不能为空</string>
|
||||||
<string name="qso_edit_err_datetime">日期或时间格式不对 —— 应为 yyyy-MM-dd 与 HH:mm:ss(UTC)</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>
|
<string name="qso_edit_err_frequency">频率格式不对 —— 请输入 MHz,例如 145.850</string>
|
||||||
|
|||||||
@@ -195,6 +195,16 @@
|
|||||||
<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_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_sync">Sync now</string>
|
||||||
<string name="prefs_wavelog_syncing">Syncing…</string>
|
<string name="prefs_wavelog_syncing">Syncing…</string>
|
||||||
|
<string name="prefs_wavelog_upload_title">Wavelog log upload</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_title">LoTW confirmed grids</string>
|
||||||
<string name="prefs_lotw_callsign">LoTW callsign</string>
|
<string name="prefs_lotw_callsign">LoTW callsign</string>
|
||||||
<string name="prefs_lotw_password">LoTW password</string>
|
<string name="prefs_lotw_password">LoTW password</string>
|
||||||
@@ -226,6 +236,16 @@
|
|||||||
<string name="prefs_logbook_upload">Upload</string>
|
<string name="prefs_logbook_upload">Upload</string>
|
||||||
<string name="prefs_logbook_upload_title">Upload to LoTW</string>
|
<string name="prefs_logbook_upload_title">Upload to LoTW</string>
|
||||||
<string name="prefs_logbook_upload_confirm">Upload</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_wavelog_sync">Also syncs to Wavelog: %1$d QSO(s)</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_title">LoTW upload certificate</string>
|
||||||
<string name="prefs_lotw_upload_configured">Certificate imported · station grid %1$s</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_not_configured">Not configured — import your TrustedQSL certificate to upload QSOs</string>
|
||||||
@@ -260,6 +280,8 @@
|
|||||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
<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_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_restore">Restore default sources</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||||
@@ -434,6 +456,9 @@ Upstream: https://github.com/rt-bishop/Look4Sat</string>
|
|||||||
<string name="qso_edit_comment">Comment</string>
|
<string name="qso_edit_comment">Comment</string>
|
||||||
<string name="qso_edit_save">Save</string>
|
<string name="qso_edit_save">Save</string>
|
||||||
<string name="qso_edit_cancel">Cancel</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_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_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>
|
<string name="qso_edit_err_frequency">Invalid frequency — enter MHz, e.g. 145.850</string>
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
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 com.ve3nea.morse_expert.MainActivity
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -91,7 +94,9 @@ fun CwSettingsDialog(controller: MainActivity, onDismiss: () -> Unit) {
|
|||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("CW Settings") },
|
shape = sheetDialogShape(),
|
||||||
|
containerColor = sheetDialogContainerColor(),
|
||||||
|
title = { SheetDialogTitle("CW Settings") },
|
||||||
text = {
|
text = {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -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.AwardCalculator
|
||||||
import com.rtbishop.look4sat.core.domain.model.AwardProgress
|
import com.rtbishop.look4sat.core.domain.model.AwardProgress
|
||||||
import com.rtbishop.look4sat.core.domain.model.AwardType
|
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.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.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
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.IconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
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.TimerRow
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
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.MapTileProviderBasic
|
||||||
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
||||||
import org.osmdroid.tileprovider.tilesource.XYTileSource
|
import org.osmdroid.tileprovider.tilesource.XYTileSource
|
||||||
@@ -300,13 +307,6 @@ private fun MapScreen(
|
|||||||
// Selected award filter. Lives in an Activity-scoped ViewModel so it
|
// Selected award filter. Lives in an Activity-scoped ViewModel so it
|
||||||
// survives page switches; defaults to VUCC only once per process (cold start).
|
// survives page switches; defaults to VUCC only once per process (cold start).
|
||||||
var selectedAward by mapFilterViewModel.selectedAward
|
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 (台址)
|
// 台址名单: QSOs grouped by the 台址's GRID SET. A station location (台址)
|
||||||
// can span several grids (MY_GRIDSQUARE + MY_VUCC_GRIDS), and QSOs whose
|
// 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
|
// location covers the same grid set belong to the same 台址; groups with an
|
||||||
@@ -318,10 +318,8 @@ private fun MapScreen(
|
|||||||
val byGridSet = LinkedHashMap<String, Pair<Set<String>, MutableSet<String>>>()
|
val byGridSet = LinkedHashMap<String, Pair<Set<String>, MutableSet<String>>>()
|
||||||
for ((grid, qsos) in uiState.workedGridQsos) {
|
for ((grid, qsos) in uiState.workedGridQsos) {
|
||||||
for (q in qsos) {
|
for (q in qsos) {
|
||||||
val gs = q.myGrids.ifEmpty { q.myGrid?.let { setOf(it) }.orEmpty() }
|
val key = q.stationGridSetKey() ?: continue
|
||||||
if (gs.isEmpty()) continue
|
byGridSet.getOrPut(key) { q.myStationGrids() to mutableSetOf() }.second.add(grid)
|
||||||
val key = gs.sorted().joinToString(",")
|
|
||||||
byGridSet.getOrPut(key) { gs to mutableSetOf() }.second.add(grid)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
byGridSet.map { (key, v) -> StationGroup(id = key, grids = v.first, worked = v.second) }
|
byGridSet.map { (key, v) -> StationGroup(id = key, grids = v.first, worked = v.second) }
|
||||||
@@ -349,6 +347,13 @@ private fun MapScreen(
|
|||||||
if (selectedStationId == null) uiState.workedGrids
|
if (selectedStationId == null) uiState.workedGrids
|
||||||
else stationGroups.firstOrNull { it.id == selectedStationId }?.worked ?: emptySet()
|
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
|
val isGridMode = uiState.isGridMode
|
||||||
// True when this composition restored a saved viewport. Only grid mode
|
// True when this composition restored a saved viewport. Only grid mode
|
||||||
// restores the viewport the user left (satellite mode keeps its original
|
// restores the viewport the user left (satellite mode keeps its original
|
||||||
@@ -536,7 +541,11 @@ private fun MapScreen(
|
|||||||
selectedGrid?.let { grid ->
|
selectedGrid?.let { grid ->
|
||||||
WorkedGridQsoDialog(
|
WorkedGridQsoDialog(
|
||||||
grid = grid,
|
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(),
|
marked = uiState.markedGrids[grid].orEmpty(),
|
||||||
isUtc = uiState.isUtc,
|
isUtc = uiState.isUtc,
|
||||||
matchCalculating = matchCalculating,
|
matchCalculating = matchCalculating,
|
||||||
@@ -676,7 +685,9 @@ private fun WorkedGridQsoDialog(
|
|||||||
if (showMarkInput) {
|
if (showMarkInput) {
|
||||||
androidx.compose.material3.AlertDialog(
|
androidx.compose.material3.AlertDialog(
|
||||||
onDismissRequest = { showMarkInput = false },
|
onDismissRequest = { showMarkInput = false },
|
||||||
title = { Text(stringResource(R.string.grid_mark_station)) },
|
shape = sheetDialogShape(),
|
||||||
|
containerColor = sheetDialogContainerColor(),
|
||||||
|
title = { SheetDialogTitle(stringResource(R.string.grid_mark_station)) },
|
||||||
text = {
|
text = {
|
||||||
androidx.compose.material3.OutlinedTextField(
|
androidx.compose.material3.OutlinedTextField(
|
||||||
value = callInput,
|
value = callInput,
|
||||||
@@ -725,11 +736,12 @@ private fun MarkedStationRow(
|
|||||||
)
|
)
|
||||||
// "标记" column: pin icon (only when the grid has multiple marks) sits
|
// "标记" column: pin icon (only when the grid has multiple marks) sits
|
||||||
// left of the label; pinned (first) row's icon is grey, others pale
|
// 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(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) {
|
) {
|
||||||
if (showPin) {
|
if (showPin) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -802,8 +814,13 @@ private fun WorkedGridCallRow(
|
|||||||
else stringResource(R.string.grid_qso_count_many, callQsos.size),
|
else stringResource(R.string.grid_qso_count_many, callQsos.size),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||||
modifier = Modifier.weight(1f)
|
// 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(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import com.rtbishop.look4sat.core.domain.model.RCSettings
|
|||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
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.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
@@ -107,6 +108,8 @@ class FakeSettingsRepo(
|
|||||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||||
)
|
)
|
||||||
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
|
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 val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
|
||||||
|
|
||||||
override fun setSelectedIds(ids: List<Int>) = TODO()
|
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(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
|
||||||
override fun setStationPosition(): Boolean = TODO()
|
override fun setStationPosition(): Boolean = TODO()
|
||||||
override fun setStationPosition(locator: String): 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 getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
|
||||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
|
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
|
||||||
override fun updateDatabaseState(state: DatabaseState) = TODO()
|
override fun updateDatabaseState(state: DatabaseState) = TODO()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
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.text.font.FontFamily
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
@@ -60,14 +62,18 @@ import com.rtbishop.look4sat.core.domain.utility.uplinkHz
|
|||||||
import com.rtbishop.look4sat.core.domain.utility.voiceRepeater
|
import com.rtbishop.look4sat.core.domain.utility.voiceRepeater
|
||||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||||
import com.rtbishop.look4sat.core.presentation.QsoEditDialog
|
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.SheetDialogTitle
|
||||||
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
|
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
|
||||||
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
|
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
|
||||||
|
import com.rtbishop.look4sat.core.presentation.gridsLabel
|
||||||
|
import com.rtbishop.look4sat.core.presentation.LoTWPositionWarningDialog
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LogPage(
|
fun LogPage(
|
||||||
uiState: RadarState,
|
uiState: RadarState,
|
||||||
logViewModel: LogViewModel,
|
logViewModel: LogViewModel,
|
||||||
|
onFixGrid: (List<String>) -> Unit = {},
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val logUiState by logViewModel.uiState.collectAsStateWithLifecycle()
|
val logUiState by logViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
@@ -100,6 +106,12 @@ fun LogPage(
|
|||||||
if (catnum != 0) logViewModel.selectSatellite(catnum)
|
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 selectedTxHz = remember(selectedRadio) { selectedRadio?.uplinkHz() }
|
||||||
val selectedRxHz = remember(selectedRadio) { selectedRadio?.downlinkHz() }
|
val selectedRxHz = remember(selectedRadio) { selectedRadio?.downlinkHz() }
|
||||||
val repeaterTxHz = voiceRepeater?.uplinkHz()
|
val repeaterTxHz = voiceRepeater?.uplinkHz()
|
||||||
@@ -139,14 +151,28 @@ fun LogPage(
|
|||||||
imeAction = ImeAction.Done
|
imeAction = ImeAction.Done
|
||||||
),
|
),
|
||||||
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = {
|
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(
|
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()
|
enabled = logUiState.callsignInput.isNotBlank() && satName.isNotBlank()
|
||||||
) { Text("Log") }
|
) { 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) {
|
if (isLinear) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
listOf("CW", "SSB", "FT4").forEach { candidate ->
|
listOf("CW", "SSB", "FT4").forEach { candidate ->
|
||||||
@@ -217,15 +243,30 @@ fun LogPage(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
logUiState.preview?.let { preview ->
|
logUiState.uploadPositionWarning?.let { warning ->
|
||||||
UploadPreviewDialog(
|
LoTWPositionWarningDialog(
|
||||||
preview = preview,
|
warning = warning,
|
||||||
busy = logUiState.busy,
|
onFixStation = {
|
||||||
onConfirm = logViewModel::confirmUpload,
|
logViewModel.abandonForGridFix()
|
||||||
onDismiss = logViewModel::dismissPreview
|
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,
|
||||||
|
wavelogCount = logUiState.wavelogPending?.count ?: 0,
|
||||||
|
onConfirm = logViewModel::confirmUpload,
|
||||||
|
onDismiss = logViewModel::dismissPreview
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (logUiState.message.isNotBlank()) {
|
if (logUiState.message.isNotBlank()) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = logViewModel::clearMessage,
|
onDismissRequest = logViewModel::clearMessage,
|
||||||
@@ -239,6 +280,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 ->
|
editTarget?.let { target ->
|
||||||
QsoEditDialog(
|
QsoEditDialog(
|
||||||
record = target,
|
record = target,
|
||||||
@@ -264,6 +333,8 @@ private fun LogRecordRow(
|
|||||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||||
}.format(java.util.Date(record.startUtcMillis))
|
}.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(
|
SwipeRevealRow(
|
||||||
key = record.id.toString(),
|
key = record.id.toString(),
|
||||||
@@ -289,6 +360,14 @@ private fun LogRecordRow(
|
|||||||
maxLines = 1
|
maxLines = 1
|
||||||
)
|
)
|
||||||
Text(text = " ${record.displayMode}", fontSize = 14.sp, 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 {
|
when {
|
||||||
record.lotwConfirmed -> Text("QSL", fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
|
record.lotwConfirmed -> Text("QSL", fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
|
||||||
@@ -328,6 +407,7 @@ private fun PostDialog(
|
|||||||
private fun UploadPreviewDialog(
|
private fun UploadPreviewDialog(
|
||||||
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
|
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
|
||||||
busy: Boolean,
|
busy: Boolean,
|
||||||
|
wavelogCount: Int = 0,
|
||||||
onConfirm: () -> Unit,
|
onConfirm: () -> Unit,
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -376,6 +456,13 @@ private fun UploadPreviewDialog(
|
|||||||
}
|
}
|
||||||
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
|
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
|
||||||
}
|
}
|
||||||
|
if (wavelogCount > 0) {
|
||||||
|
Text(
|
||||||
|
"同时同步 Wavelog:$wavelogCount 条",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
|
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.officialSatelliteName
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
|
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
|
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.ILoTWUploadRepository
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
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.LoTWOperationException
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
|
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
|
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.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -38,6 +48,23 @@ import java.util.Date
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.TimeZone
|
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(
|
data class LogUiState(
|
||||||
val callsignInput: String = "",
|
val callsignInput: String = "",
|
||||||
/** Current logbook mode for the selected satellite (FM/CW/SSB/FT4). */
|
/** Current logbook mode for the selected satellite (FM/CW/SSB/FT4). */
|
||||||
@@ -49,15 +76,27 @@ data class LogUiState(
|
|||||||
/** ARRL satellite names (config.tq6) — the only names a record may be signed with. */
|
/** ARRL satellite names (config.tq6) — the only names a record may be signed with. */
|
||||||
val satelliteCatalog: List<String> = emptyList(),
|
val satelliteCatalog: List<String> = emptyList(),
|
||||||
val preview: LoTWUploadPreview? = null,
|
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). */
|
/** User-facing upload / record message ("" when none). */
|
||||||
val message: String = "",
|
val message: String = "",
|
||||||
val postText: String? = null,
|
val postText: String? = null,
|
||||||
val busy: Boolean = false
|
val busy: Boolean = false,
|
||||||
|
/** Wavelog piggyback: batch prepared alongside the LoTW preview (see prepareUpload);
|
||||||
|
* uploaded right after a confirmed LoTW upload. Null when Wavelog is not ready. */
|
||||||
|
val wavelogPending: WavelogUploadPreview? = null,
|
||||||
|
/** Out-of-window contact waiting for the operator's confirmation (null when closed). */
|
||||||
|
val outOfWindowLog: OutOfWindowLog? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
class LogViewModel(
|
class LogViewModel(
|
||||||
private val qsoRepository: IQsoRepository,
|
private val qsoRepository: IQsoRepository,
|
||||||
private val lotwUploadRepository: ILoTWUploadRepository,
|
private val lotwUploadRepository: ILoTWUploadRepository,
|
||||||
|
private val wavelogUploadRepository: IWavelogUploadRepository,
|
||||||
private val settingsRepo: ISettingsRepo
|
private val settingsRepo: ISettingsRepo
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -85,6 +124,10 @@ class LogViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The roaming hint tracks the freshest position data: refresh on init and every fix.
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsRepo.stationPosition.collect { refreshPositionHintNow() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateCallsign(value: String) =
|
fun updateCallsign(value: String) =
|
||||||
@@ -101,19 +144,64 @@ class LogViewModel(
|
|||||||
settingsRepo.setSatelliteMode(catnum, mode)
|
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()
|
val call = _uiState.value.callsignInput.trim()
|
||||||
if (call.isEmpty()) return
|
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
|
// 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.
|
// minute-resolution of LoTW reports, so re-saving an unchanged record never looks edited.
|
||||||
val now = System.currentTimeMillis() / 60_000L * 60_000L
|
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.
|
// kHz granularity (3 decimals in MHz) — enough for operating and for the ADIF export.
|
||||||
val tx = txHz?.let { (it + 500L) / 1000L * 1000L }
|
val tx = txHz?.let { (it + 500L) / 1000L * 1000L }
|
||||||
val rx = rxHz?.let { (it + 500L) / 1000L * 1000L }
|
val rx = rxHz?.let { (it + 500L) / 1000L * 1000L }
|
||||||
val record = QsoRecord(
|
val record = QsoRecord(
|
||||||
startUtcMillis = now,
|
startUtcMillis = timeMillis,
|
||||||
endUtcMillis = now,
|
endUtcMillis = timeMillis,
|
||||||
theirCallsign = call,
|
theirCallsign = call,
|
||||||
myCallsign = _uiState.value.certificateCallsign,
|
myCallsign = _uiState.value.certificateCallsign,
|
||||||
myGrid = myGrid.take(6).uppercase(Locale.US),
|
myGrid = myGrid.take(6).uppercase(Locale.US),
|
||||||
@@ -184,6 +272,16 @@ class LogViewModel(
|
|||||||
// LoTW-imported confirmations are the feedback side.
|
// LoTW-imported confirmations are the feedback side.
|
||||||
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == QsoStatus.COMPLETE }
|
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == QsoStatus.COMPLETE }
|
||||||
val audit = lotwUploadRepository.audit(pending)
|
val audit = lotwUploadRepository.audit(pending)
|
||||||
|
// Wavelog piggyback (option A): prepare its batch while we are here, so
|
||||||
|
// the LoTW preview can show the count and confirm can post it directly.
|
||||||
|
val wavelogSettings = settingsRepo.wavelogUploadSettings.value
|
||||||
|
val wavelogPreview = if (wavelogSettings.isReady) {
|
||||||
|
runCatching {
|
||||||
|
wavelogUploadRepository.prepare(wavelogUploadCandidates(all), wavelogSettings)
|
||||||
|
}.getOrNull()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
if (audit.pending == 0) {
|
if (audit.pending == 0) {
|
||||||
val msg = when {
|
val msg = when {
|
||||||
audit.unavailable > 0 -> unavailableUploadSummary(
|
audit.unavailable > 0 -> unavailableUploadSummary(
|
||||||
@@ -193,14 +291,28 @@ class LogViewModel(
|
|||||||
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
|
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
|
||||||
else -> "No pending QSOs to upload"
|
else -> "No pending QSOs to upload"
|
||||||
}
|
}
|
||||||
_uiState.update { it.copy(busy = false, message = msg) }
|
// No LoTW work to do — still flush the Wavelog backlog (catch-up): a
|
||||||
|
// freshly configured Wavelog syncs without waiting for new QSOs.
|
||||||
|
val wavelogMsg = wavelogPreview?.let {
|
||||||
|
if (it.count > 0) uploadWavelogNow(it, wavelogSettings) else wavelogIdleSegment(it)
|
||||||
|
}
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(busy = false, message = listOfNotNull(msg, wavelogMsg).joinToString("\n"))
|
||||||
|
}
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val preview = lotwUploadRepository.prepare(pending, false)
|
val preview = lotwUploadRepository.prepare(pending, false)
|
||||||
// Only the records that actually made it into the TQ8 may be
|
// Only the records that actually made it into the TQ8 may be
|
||||||
// marked uploaded later — never the whole candidate list.
|
// marked uploaded later — never the whole candidate list.
|
||||||
lastUploadedIds = preview.submittedIds
|
lastUploadedIds = preview.submittedIds
|
||||||
_uiState.update { it.copy(busy = false, preview = preview) }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
busy = false,
|
||||||
|
preview = preview,
|
||||||
|
wavelogPending = wavelogPreview,
|
||||||
|
uploadPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (e: LoTWOperationException) {
|
} catch (e: LoTWOperationException) {
|
||||||
_uiState.update { it.copy(busy = false, message = "Upload unavailable: ${e.reason}") }
|
_uiState.update { it.copy(busy = false, message = "Upload unavailable: ${e.reason}") }
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
@@ -215,8 +327,9 @@ class LogViewModel(
|
|||||||
_uiState.update { it.copy(busy = true) }
|
_uiState.update { it.copy(busy = true) }
|
||||||
val result = lotwUploadRepository.upload(preview.id)
|
val result = lotwUploadRepository.upload(preview.id)
|
||||||
if (result is LoTWUploadResult.Accepted && lastUploadedIds.isNotEmpty()) {
|
if (result is LoTWUploadResult.Accepted && lastUploadedIds.isNotEmpty()) {
|
||||||
// Mark the submitted QSOs as uploaded (distinct from confirmed).
|
// Mark the submitted QSOs as uploaded (distinct from confirmed) and stamp
|
||||||
qsoRepository.markUploaded(lastUploadedIds)
|
// the station grids this batch went out under.
|
||||||
|
qsoRepository.markUploaded(lastUploadedIds, preview.grids)
|
||||||
lastUploadedIds = emptyList()
|
lastUploadedIds = emptyList()
|
||||||
}
|
}
|
||||||
val msg = when (result) {
|
val msg = when (result) {
|
||||||
@@ -225,18 +338,78 @@ class LogViewModel(
|
|||||||
LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry"
|
LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry"
|
||||||
LoTWUploadResult.ExpiredPreview -> "Preview expired — tap upload again"
|
LoTWUploadResult.ExpiredPreview -> "Preview expired — tap upload again"
|
||||||
}
|
}
|
||||||
_uiState.update { it.copy(busy = false, preview = null, message = msg) }
|
// Wavelog piggyback: rides the same confirmation, independent of the LoTW
|
||||||
|
// outcome — a Wavelog failure leaves records unmarked for the next upload.
|
||||||
|
val wavelogPending = _uiState.value.wavelogPending
|
||||||
|
val wavelogMsg = wavelogPending?.let { wl ->
|
||||||
|
if (wl.count > 0) uploadWavelogNow(wl, settingsRepo.wavelogUploadSettings.value)
|
||||||
|
else wavelogIdleSegment(wl)
|
||||||
|
}
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
busy = false, preview = null, wavelogPending = null,
|
||||||
|
message = listOfNotNull(msg, wavelogMsg).joinToString("\n")
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismissPreview() = _uiState.update { it.copy(preview = null) }
|
fun dismissPreview() = _uiState.update { it.copy(preview = null, wavelogPending = null) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads one prepared Wavelog batch (piggyback) and marks what the server
|
||||||
|
* accepted. Returns the operator-facing message segment 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)
|
||||||
|
}
|
||||||
|
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, wavelogPending = 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 = "") }
|
fun clearMessage() = _uiState.update { it.copy(message = "") }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
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
|
@Composable
|
||||||
fun RadarDestination(navigateUp: () -> Unit) {
|
fun RadarDestination(navigateUp: () -> Unit, onOpenLoTWStation: () -> Unit = {}) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
||||||
@@ -216,9 +216,15 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
|||||||
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
||||||
viewModel.onAction(RadarAction.CwPermissionResult(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 = {
|
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, logViewModel, requestMicPermission = {
|
||||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
})
|
}, onFixGrid = openLoTWStationForGridFix)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -228,7 +234,8 @@ private fun RadarScreen(
|
|||||||
navigateUp: () -> Unit,
|
navigateUp: () -> Unit,
|
||||||
mutualData: MutualPassData,
|
mutualData: MutualPassData,
|
||||||
logViewModel: LogViewModel,
|
logViewModel: LogViewModel,
|
||||||
requestMicPermission: () -> Unit
|
requestMicPermission: () -> Unit,
|
||||||
|
onFixGrid: (List<String>) -> Unit
|
||||||
) {
|
) {
|
||||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||||
// 日程功能: 把当前过境写入系统日历(原仓库的 addToCalendar, ic_calendar 按钮)
|
// 日程功能: 把当前过境写入系统日历(原仓库的 addToCalendar, ic_calendar 按钮)
|
||||||
@@ -308,16 +315,17 @@ private fun RadarScreen(
|
|||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
logViewModel = logViewModel,
|
logViewModel = logViewModel,
|
||||||
requestMicPermission = requestMicPermission,
|
requestMicPermission = requestMicPermission,
|
||||||
|
onFixGrid = onFixGrid,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
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 {
|
} else {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
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,
|
onAction: (RadarAction) -> Unit,
|
||||||
logViewModel: LogViewModel,
|
logViewModel: LogViewModel,
|
||||||
requestMicPermission: () -> Unit,
|
requestMicPermission: () -> Unit,
|
||||||
|
onFixGrid: (List<String>) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||||
@@ -351,7 +360,7 @@ private fun RadarFillArea(
|
|||||||
verticalArrangement = Arrangement.spacedBy(ROW_GAP)
|
verticalArrangement = Arrangement.spacedBy(ROW_GAP)
|
||||||
) {
|
) {
|
||||||
RadarCard(uiState, trackB, trackBPosition, Modifier.height(sizes.radarSide).fillMaxWidth())
|
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 {
|
} else {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
@@ -361,6 +370,7 @@ private fun RadarFillArea(
|
|||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
logViewModel = logViewModel,
|
logViewModel = logViewModel,
|
||||||
requestMicPermission = requestMicPermission,
|
requestMicPermission = requestMicPermission,
|
||||||
|
onFixGrid = onFixGrid,
|
||||||
panelHeight = pagerPanelHeight,
|
panelHeight = pagerPanelHeight,
|
||||||
modifier = Modifier.align(Alignment.BottomCenter)
|
modifier = Modifier.align(Alignment.BottomCenter)
|
||||||
)
|
)
|
||||||
@@ -380,6 +390,7 @@ private fun CompactPagerOverlay(
|
|||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
logViewModel: LogViewModel,
|
logViewModel: LogViewModel,
|
||||||
requestMicPermission: () -> Unit,
|
requestMicPermission: () -> Unit,
|
||||||
|
onFixGrid: (List<String>) -> Unit,
|
||||||
panelHeight: Dp,
|
panelHeight: Dp,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
@@ -420,6 +431,7 @@ private fun CompactPagerOverlay(
|
|||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
logViewModel = logViewModel,
|
logViewModel = logViewModel,
|
||||||
requestMicPermission = requestMicPermission,
|
requestMicPermission = requestMicPermission,
|
||||||
|
onFixGrid = onFixGrid,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(panelHeight),
|
.height(panelHeight),
|
||||||
@@ -453,7 +465,8 @@ private fun PagerCard(
|
|||||||
requestMicPermission: () -> Unit,
|
requestMicPermission: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
startPage: RadarPage? = null,
|
startPage: RadarPage? = null,
|
||||||
onCollapse: (() -> Unit)? = null
|
onCollapse: (() -> Unit)? = null,
|
||||||
|
onFixGrid: (List<String>) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val pages = rememberRadarPages(uiState)
|
val pages = rememberRadarPages(uiState)
|
||||||
val pagerState = rememberPagerState(
|
val pagerState = rememberPagerState(
|
||||||
@@ -515,7 +528,8 @@ private fun PagerCard(
|
|||||||
)
|
)
|
||||||
RadarPage.Log -> LogPage(
|
RadarPage.Log -> LogPage(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
logViewModel = logViewModel
|
logViewModel = logViewModel,
|
||||||
|
onFixGrid = onFixGrid
|
||||||
)
|
)
|
||||||
RadarPage.Sstv -> SstvPage(
|
RadarPage.Sstv -> SstvPage(
|
||||||
sstv = uiState.sstv,
|
sstv = uiState.sstv,
|
||||||
|
|||||||
+129
-12
@@ -9,7 +9,9 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.feature.settings
|
package com.rtbishop.look4sat.feature.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -26,6 +28,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -33,6 +36,8 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -47,6 +52,8 @@ import com.rtbishop.look4sat.core.presentation.QsoEditDialog
|
|||||||
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
|
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
|
||||||
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
|
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
|
||||||
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
|
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.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||||
import com.rtbishop.look4sat.core.presentation.SwipeController
|
import com.rtbishop.look4sat.core.presentation.SwipeController
|
||||||
@@ -82,24 +89,41 @@ fun LogbookDialog(
|
|||||||
uploadBusy: Boolean,
|
uploadBusy: Boolean,
|
||||||
uploadMessage: String,
|
uploadMessage: String,
|
||||||
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview?,
|
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview?,
|
||||||
|
positionWarning: com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning? = null,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onDelete: (Long) -> Unit,
|
onDelete: (Long) -> Unit,
|
||||||
onEdit: (QsoRecord) -> Unit,
|
onEdit: (QsoRecord) -> Unit,
|
||||||
onUpload: () -> Unit,
|
onUpload: () -> Unit,
|
||||||
onConfirmUpload: () -> Unit,
|
onConfirmUpload: () -> Unit,
|
||||||
onDismissPreview: () -> Unit,
|
onDismissPreview: () -> Unit,
|
||||||
onDismissMessage: () -> Unit
|
onDismissMessage: () -> Unit,
|
||||||
|
onIgnorePositionWarning: () -> Unit = {},
|
||||||
|
onFixGrid: (List<String>) -> Unit = {},
|
||||||
|
selectionMode: Boolean = false,
|
||||||
|
selectedIds: Set<Long> = emptySet(),
|
||||||
|
onStartSelection: (Long) -> Unit = {},
|
||||||
|
onToggleSelection: (Long) -> Unit = {},
|
||||||
|
onExitSelection: () -> Unit = {},
|
||||||
|
onResubmitSelected: () -> Unit = {},
|
||||||
|
/** Submit count of the Wavelog batch riding this upload (0 hides the note). */
|
||||||
|
wavelogCount: Int = 0
|
||||||
) {
|
) {
|
||||||
val swipeController = rememberSwipeController()
|
val swipeController = rememberSwipeController()
|
||||||
|
// Entering selection mode closes any row left swiped open — reveals are off there.
|
||||||
|
LaunchedEffect(selectionMode) { if (selectionMode) swipeController.close() }
|
||||||
// Record currently open in the edit dialog (null when closed).
|
// Record currently open in the edit dialog (null when closed).
|
||||||
var editTarget by remember { mutableStateOf<QsoRecord?>(null) }
|
var editTarget by remember { mutableStateOf<QsoRecord?>(null) }
|
||||||
SharedDialog(
|
SharedDialog(
|
||||||
title = stringResource(R.string.prefs_logbook_title),
|
title = stringResource(R.string.prefs_logbook_title),
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
onCancel = onDismiss,
|
onCancel = if (selectionMode) onExitSelection else onDismiss,
|
||||||
onAccept = onUpload,
|
onAccept = if (selectionMode) onResubmitSelected else onUpload,
|
||||||
acceptText = stringResource(R.string.prefs_logbook_upload),
|
acceptText = if (selectionMode) {
|
||||||
acceptEnabled = !uploadBusy
|
stringResource(R.string.prefs_logbook_resubmit, selectedIds.size)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.prefs_logbook_upload)
|
||||||
|
},
|
||||||
|
acceptEnabled = if (selectionMode) selectedIds.isNotEmpty() && !uploadBusy else !uploadBusy
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||||
@@ -108,6 +132,21 @@ fun LogbookDialog(
|
|||||||
if (records.isEmpty()) {
|
if (records.isEmpty()) {
|
||||||
Text(stringResource(R.string.prefs_logbook_empty), fontSize = 14.sp)
|
Text(stringResource(R.string.prefs_logbook_empty), fontSize = 14.sp)
|
||||||
} else {
|
} else {
|
||||||
|
// Selection mode shows the checked count; otherwise a hint that long-press
|
||||||
|
// opens it (the path to resubmitting already-uploaded records).
|
||||||
|
if (selectionMode) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.prefs_logbook_selected, selectedIds.size),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.prefs_logbook_select_hint),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp),
|
modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
@@ -115,9 +154,16 @@ fun LogbookDialog(
|
|||||||
items(records, key = { it.id }) { record ->
|
items(records, key = { it.id }) { record ->
|
||||||
LogbookRow(
|
LogbookRow(
|
||||||
record = record,
|
record = record,
|
||||||
|
selectionMode = selectionMode,
|
||||||
|
selected = record.id in selectedIds,
|
||||||
swipeController = swipeController,
|
swipeController = swipeController,
|
||||||
onDelete = { onDelete(record.id) },
|
onDelete = { onDelete(record.id) },
|
||||||
onClick = { editTarget = record }
|
onClick = {
|
||||||
|
if (selectionMode) onToggleSelection(record.id) else editTarget = record
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
if (selectionMode) onToggleSelection(record.id) else onStartSelection(record.id)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,10 +171,19 @@ fun LogbookDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preview != null) {
|
if (positionWarning != null) {
|
||||||
|
// Roaming guard: the current position sits outside the station location this batch
|
||||||
|
// would be signed with — confirm the station location before the preview opens.
|
||||||
|
LoTWPositionWarningDialog(
|
||||||
|
warning = positionWarning,
|
||||||
|
onFixStation = { onFixGrid(listOf(positionWarning.currentGrid)) },
|
||||||
|
onIgnore = onIgnorePositionWarning
|
||||||
|
)
|
||||||
|
} else if (preview != null) {
|
||||||
LogbookUploadPreviewDialog(
|
LogbookUploadPreviewDialog(
|
||||||
preview = preview,
|
preview = preview,
|
||||||
busy = uploadBusy,
|
busy = uploadBusy,
|
||||||
|
wavelogCount = wavelogCount,
|
||||||
onConfirm = onConfirmUpload,
|
onConfirm = onConfirmUpload,
|
||||||
onDismiss = onDismissPreview
|
onDismiss = onDismissPreview
|
||||||
)
|
)
|
||||||
@@ -163,6 +218,7 @@ fun LogbookDialog(
|
|||||||
private fun LogbookUploadPreviewDialog(
|
private fun LogbookUploadPreviewDialog(
|
||||||
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
|
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
|
||||||
busy: Boolean,
|
busy: Boolean,
|
||||||
|
wavelogCount: Int,
|
||||||
onConfirm: () -> Unit,
|
onConfirm: () -> Unit,
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -194,6 +250,22 @@ private fun LogbookUploadPreviewDialog(
|
|||||||
}
|
}
|
||||||
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
|
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
|
||||||
}
|
}
|
||||||
|
if (preview.resubmit) {
|
||||||
|
// LoTW treats an identical contact as an update, except grids already
|
||||||
|
// locked in by award credits — say so before the operator commits.
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.prefs_logbook_resubmit_note),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (wavelogCount > 0) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.prefs_logbook_wavelog_sync, wavelogCount),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
|
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -208,12 +280,19 @@ private fun LogbookUploadPreviewDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Confirmed-grid colour: the map's worked-grid green, so "confirmed" reads the same everywhere. */
|
||||||
|
private val ConfirmedGreen = Color(0xFF4CD964)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun LogbookRow(
|
private fun LogbookRow(
|
||||||
record: QsoRecord,
|
record: QsoRecord,
|
||||||
|
selectionMode: Boolean,
|
||||||
|
selected: Boolean,
|
||||||
swipeController: SwipeController,
|
swipeController: SwipeController,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val time = remember(record.startUtcMillis) {
|
val time = remember(record.startUtcMillis) {
|
||||||
SimpleDateFormat("MM-dd HH:mm'Z'", Locale.US).apply {
|
SimpleDateFormat("MM-dd HH:mm'Z'", Locale.US).apply {
|
||||||
@@ -222,11 +301,13 @@ private fun LogbookRow(
|
|||||||
}
|
}
|
||||||
val satShort = record.satelliteName.substringBefore('(').trim()
|
val satShort = record.satelliteName.substringBefore('(').trim()
|
||||||
val band = record.band.ifBlank { frequencyBand(record.txFrequencyHz) }
|
val band = record.band.ifBlank { frequencyBand(record.txFrequencyHz) }
|
||||||
// 右划露出删除按钮(短信式);整行点击不再删除。
|
val haptics = LocalHapticFeedback.current
|
||||||
|
// 右划露出删除按钮(短信式);整行点击不再删除。选择模式下右划禁用、长按进入多选。
|
||||||
SwipeRevealRow(
|
SwipeRevealRow(
|
||||||
key = record.id.toString(),
|
key = record.id.toString(),
|
||||||
controller = swipeController,
|
controller = swipeController,
|
||||||
revealAction = onDelete,
|
revealAction = onDelete,
|
||||||
|
gesturesEnabled = !selectionMode,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
// Row style mirrors the worked-grid QSO details on the map: callsign in
|
// Row style mirrors the worked-grid QSO details on the map: callsign in
|
||||||
@@ -234,10 +315,30 @@ private fun LogbookRow(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable { onClick() }
|
.combinedClickable(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
onLongClick()
|
||||||
|
}
|
||||||
|
)
|
||||||
.padding(horizontal = 4.dp, vertical = 8.dp)
|
.padding(horizontal = 4.dp, vertical = 8.dp)
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
// Checkbox slot: only while picking records for a resubmit.
|
||||||
|
if (selectionMode) {
|
||||||
|
Text(
|
||||||
|
text = if (selected) "✓" else "○",
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(end = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = record.theirCallsign,
|
text = record.theirCallsign,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
@@ -247,14 +348,30 @@ private fun LogbookRow(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
// Confirmed contacts show the opposite station's grid set from the LoTW
|
||||||
|
// report (single or multi-grid, abbreviated) in the worked-grid green
|
||||||
|
// instead of the word QSL; without any grid the QSL text stays.
|
||||||
if (record.lotwConfirmed) {
|
if (record.lotwConfirmed) {
|
||||||
Text(text = "QSL", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
|
val oppositeGrids = gridsLabel(
|
||||||
|
record.theirVuccGrids.ifEmpty { listOf(record.theirGrid) }
|
||||||
|
)
|
||||||
|
if (oppositeGrids.isNotEmpty()) {
|
||||||
|
Text(text = oppositeGrids, fontSize = 13.sp, color = ConfirmedGreen, fontFamily = FontFamily.Monospace)
|
||||||
|
} else {
|
||||||
|
Text(text = "QSL", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
|
||||||
|
}
|
||||||
} else if (record.lotwUploaded) {
|
} else if (record.lotwUploaded) {
|
||||||
Text(text = "UP", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
|
Text(text = "UP", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = listOf(satShort, record.displayMode, band, time).filter { it.isNotBlank() }.joinToString(" · "),
|
// Own station grid: the set the upload was stamped with, or — for rows from
|
||||||
|
// before the stamp existed — the grid the QSO was logged under, so every
|
||||||
|
// stored contact shows where the operator was.
|
||||||
|
text = listOf(
|
||||||
|
satShort, record.displayMode, band, time,
|
||||||
|
gridsLabel(record.vuccGrids.ifEmpty { listOf(record.myGrid) })
|
||||||
|
).filter { it.isNotBlank() }.joinToString(" · "),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|||||||
+123
@@ -225,6 +225,117 @@ fun WavelogDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WavelogUploadDialog(
|
||||||
|
initialSettings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings,
|
||||||
|
stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>,
|
||||||
|
rights: String,
|
||||||
|
probeBusy: Boolean,
|
||||||
|
message: String?,
|
||||||
|
dismiss: () -> Unit,
|
||||||
|
onSave: (com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings) -> Unit,
|
||||||
|
onFetchStations: (url: String, apiKey: String) -> Unit,
|
||||||
|
onSelectStation: (com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo) -> Unit
|
||||||
|
) {
|
||||||
|
val url = rememberSaveable { mutableStateOf(initialSettings.url) }
|
||||||
|
val apiKey = rememberSaveable { mutableStateOf(initialSettings.apiKey) }
|
||||||
|
SharedDialog(
|
||||||
|
title = stringResource(R.string.prefs_wavelog_upload_title),
|
||||||
|
onCancel = dismiss,
|
||||||
|
onAccept = {
|
||||||
|
onSave(initialSettings.copy(url = url.value, apiKey = apiKey.value))
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = url.value,
|
||||||
|
onValueChange = { url.value = it },
|
||||||
|
label = { Text(text = stringResource(id = R.string.prefs_wavelog_url)) },
|
||||||
|
placeholder = { Text(text = "http://100.x.x.x/wavelog") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = apiKey.value,
|
||||||
|
onValueChange = { apiKey.value = it },
|
||||||
|
label = { Text(text = stringResource(id = R.string.prefs_wavelog_upload_apikey)) },
|
||||||
|
placeholder = { Text(text = "wl…") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
|
||||||
|
) {
|
||||||
|
TextButton(onClick = { onFetchStations(url.value, apiKey.value) }, enabled = !probeBusy) {
|
||||||
|
Text(
|
||||||
|
text = if (probeBusy) stringResource(R.string.prefs_wavelog_upload_probing)
|
||||||
|
else stringResource(R.string.prefs_wavelog_upload_fetch)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rights.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = when (rights) {
|
||||||
|
"rw" -> stringResource(R.string.prefs_wavelog_upload_rights_rw)
|
||||||
|
else -> stringResource(R.string.prefs_wavelog_upload_rights_r)
|
||||||
|
},
|
||||||
|
color = if (rights == "rw") MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (stations.isEmpty() && initialSettings.stationId.isBlank()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.prefs_wavelog_upload_station_none),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
stations.forEach { station ->
|
||||||
|
val selected = station.id == initialSettings.stationId
|
||||||
|
Text(
|
||||||
|
text = (if (selected) "● " else "○ ") + listOf(station.name, station.callsign, station.grid)
|
||||||
|
.filter { it.isNotBlank() }.joinToString(" · "),
|
||||||
|
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onSelectStation(station) }
|
||||||
|
.padding(horizontal = LocalSpacing.current.large, vertical = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (initialSettings.stationId.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.prefs_wavelog_upload_configured,
|
||||||
|
listOf(initialSettings.stationName, initialSettings.stationCallsign, initialSettings.stationGrid)
|
||||||
|
.filter { it.isNotBlank() }.joinToString(" · ")
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (message != null) {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.prefs_wavelog_upload_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(0.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LoTWDialog(
|
fun LoTWDialog(
|
||||||
initialSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
initialSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
||||||
@@ -469,6 +580,7 @@ fun DataSourcesDialog(
|
|||||||
sourceSection(
|
sourceSection(
|
||||||
sectionKey = "sat",
|
sectionKey = "sat",
|
||||||
labelResId = R.string.prefs_data_sources_satellites_label,
|
labelResId = R.string.prefs_data_sources_satellites_label,
|
||||||
|
hintResId = R.string.prefs_data_sources_sat_hint,
|
||||||
urls = satUrls,
|
urls = satUrls,
|
||||||
listState = listState,
|
listState = listState,
|
||||||
draggedId = satDraggedId,
|
draggedId = satDraggedId,
|
||||||
@@ -483,6 +595,7 @@ fun DataSourcesDialog(
|
|||||||
sourceSection(
|
sourceSection(
|
||||||
sectionKey = "tx",
|
sectionKey = "tx",
|
||||||
labelResId = R.string.prefs_data_sources_transceivers_label,
|
labelResId = R.string.prefs_data_sources_transceivers_label,
|
||||||
|
hintResId = R.string.prefs_data_sources_transceivers_hint,
|
||||||
urls = txUrls,
|
urls = txUrls,
|
||||||
listState = listState,
|
listState = listState,
|
||||||
draggedId = txDraggedId,
|
draggedId = txDraggedId,
|
||||||
@@ -501,6 +614,7 @@ fun DataSourcesDialog(
|
|||||||
private fun LazyListScope.sourceSection(
|
private fun LazyListScope.sourceSection(
|
||||||
sectionKey: String,
|
sectionKey: String,
|
||||||
labelResId: Int,
|
labelResId: Int,
|
||||||
|
hintResId: Int,
|
||||||
urls: List<Pair<Long, String>>,
|
urls: List<Pair<Long, String>>,
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
draggedId: MutableState<Long>,
|
draggedId: MutableState<Long>,
|
||||||
@@ -523,6 +637,15 @@ private fun LazyListScope.sourceSection(
|
|||||||
)
|
)
|
||||||
IconCard(action = onAdd, resId = R.drawable.ic_add)
|
IconCard(action = onAdd, resId = R.drawable.ic_add)
|
||||||
}
|
}
|
||||||
|
// States what the order below actually decides: for satellites the elements come from
|
||||||
|
// whichever enabled source has the freshest epoch, so the order only picks the name.
|
||||||
|
Text(
|
||||||
|
text = stringResource(hintResId),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 2.dp, bottom = 8.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (id, url) ->
|
itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (id, url) ->
|
||||||
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant
|
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
|||||||
+103
-6
@@ -94,14 +94,29 @@ fun SettingsDestination(onOpenGridFinder: () -> Unit = {}) {
|
|||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container, context))
|
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container, context))
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
SettingsScreen(uiState, viewModel::onAction, onOpenGridFinder)
|
// Station-grid prefill for the "fix it" jump from the logbook grid check (single-use,
|
||||||
|
// mirrors the Grid Finder's "Set as LoTW station" hand-off).
|
||||||
|
val pendingStationGrid by container.pendingLoTWStationGrid.collectAsStateWithLifecycle()
|
||||||
|
SettingsScreen(
|
||||||
|
uiState = uiState,
|
||||||
|
onAction = viewModel::onAction,
|
||||||
|
onOpenGridFinder = onOpenGridFinder,
|
||||||
|
pendingStationGrid = pendingStationGrid.orEmpty(),
|
||||||
|
onFixStationGrid = { grids ->
|
||||||
|
if (grids.isNotEmpty()) container.setPendingLoTWStationGrid(grids.joinToString(","))
|
||||||
|
},
|
||||||
|
onClearStationGridPrefill = { container.setPendingLoTWStationGrid(null) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsScreen(
|
private fun SettingsScreen(
|
||||||
uiState: SettingsState,
|
uiState: SettingsState,
|
||||||
onAction: (SettingsAction) -> Unit,
|
onAction: (SettingsAction) -> Unit,
|
||||||
onOpenGridFinder: () -> Unit
|
onOpenGridFinder: () -> Unit,
|
||||||
|
pendingStationGrid: String = "",
|
||||||
|
onFixStationGrid: (List<String>) -> Unit = {},
|
||||||
|
onClearStationGridPrefill: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
var showUpdateChecker by rememberSaveable { mutableStateOf(false) }
|
var showUpdateChecker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showMapSettings by rememberSaveable { mutableStateOf(false) }
|
var showMapSettings by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -277,13 +292,34 @@ private fun SettingsScreen(
|
|||||||
uploadBusy = uiState.logbookUploadBusy,
|
uploadBusy = uiState.logbookUploadBusy,
|
||||||
uploadMessage = uiState.logbookUploadMessage,
|
uploadMessage = uiState.logbookUploadMessage,
|
||||||
preview = uiState.logbookPreview,
|
preview = uiState.logbookPreview,
|
||||||
onDismiss = { dialogs.logbook = false },
|
positionWarning = uiState.logbookPositionWarning,
|
||||||
|
onDismiss = {
|
||||||
|
// Leaving the logbook resets the resubmit selection: re-entering starts
|
||||||
|
// clean — the operator long-presses again to pick records.
|
||||||
|
onAction(SettingsAction.ExitLogbookSelection)
|
||||||
|
dialogs.logbook = false
|
||||||
|
},
|
||||||
onDelete = { onAction(SettingsAction.DeleteLogbookRecord(it)) },
|
onDelete = { onAction(SettingsAction.DeleteLogbookRecord(it)) },
|
||||||
onEdit = { onAction(SettingsAction.UpdateLogbookRecord(it)) },
|
onEdit = { onAction(SettingsAction.UpdateLogbookRecord(it)) },
|
||||||
onUpload = { onAction(SettingsAction.PrepareLogbookUpload) },
|
onUpload = { onAction(SettingsAction.PrepareLogbookUpload) },
|
||||||
onConfirmUpload = { onAction(SettingsAction.ConfirmLogbookUpload) },
|
onConfirmUpload = { onAction(SettingsAction.ConfirmLogbookUpload) },
|
||||||
onDismissPreview = { onAction(SettingsAction.DismissLogbookPreview) },
|
onDismissPreview = { onAction(SettingsAction.DismissLogbookPreview) },
|
||||||
onDismissMessage = { onAction(SettingsAction.ClearLogbookMessage) }
|
onDismissMessage = { onAction(SettingsAction.ClearLogbookMessage) },
|
||||||
|
onIgnorePositionWarning = { onAction(SettingsAction.IgnoreLogbookPositionWarning) },
|
||||||
|
onFixGrid = { grids ->
|
||||||
|
onAction(SettingsAction.AbandonLogbookForGridFix)
|
||||||
|
onAction(SettingsAction.ExitLogbookSelection)
|
||||||
|
dialogs.logbook = false
|
||||||
|
onFixStationGrid(grids)
|
||||||
|
dialogs.lotwUpload = true
|
||||||
|
},
|
||||||
|
selectionMode = uiState.logbookSelectionMode,
|
||||||
|
selectedIds = uiState.logbookSelectedIds,
|
||||||
|
onStartSelection = { onAction(SettingsAction.StartLogbookSelection(it)) },
|
||||||
|
onToggleSelection = { onAction(SettingsAction.ToggleLogbookSelection(it)) },
|
||||||
|
onExitSelection = { onAction(SettingsAction.ExitLogbookSelection) },
|
||||||
|
onResubmitSelected = { onAction(SettingsAction.ResubmitSelectedLogbook) },
|
||||||
|
wavelogCount = uiState.wavelogPending?.count ?: 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (dialogs.lotwUpload) {
|
if (dialogs.lotwUpload) {
|
||||||
@@ -294,13 +330,31 @@ private fun SettingsScreen(
|
|||||||
busy = uiState.lotwUploadBusy,
|
busy = uiState.lotwUploadBusy,
|
||||||
error = uiState.lotwUploadError,
|
error = uiState.lotwUploadError,
|
||||||
errorDetail = uiState.lotwUploadErrorDetail,
|
errorDetail = uiState.lotwUploadErrorDetail,
|
||||||
onDismiss = { dialogs.lotwUpload = false },
|
initialGrid = pendingStationGrid,
|
||||||
|
onDismiss = {
|
||||||
|
dialogs.lotwUpload = false
|
||||||
|
onClearStationGridPrefill()
|
||||||
|
},
|
||||||
onImport = { bytes, password -> onAction(SettingsAction.ImportLoTWCertificate(bytes, password)) },
|
onImport = { bytes, password -> onAction(SettingsAction.ImportLoTWCertificate(bytes, password)) },
|
||||||
onRemove = { onAction(SettingsAction.RemoveLoTWCertificate) },
|
onRemove = { onAction(SettingsAction.RemoveLoTWCertificate) },
|
||||||
onSaveStation = { onAction(SettingsAction.SaveLoTWStation(it)) }
|
onSaveStation = { onAction(SettingsAction.SaveLoTWStation(it)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dialogs.wavelogUpload) {
|
||||||
|
WavelogUploadDialog(
|
||||||
|
initialSettings = uiState.wavelogUploadSettings,
|
||||||
|
stations = uiState.wavelogUploadStations,
|
||||||
|
rights = uiState.wavelogUploadRights,
|
||||||
|
probeBusy = uiState.wavelogUploadProbeBusy,
|
||||||
|
message = uiState.wavelogUploadMessage,
|
||||||
|
dismiss = { dialogs.wavelogUpload = false },
|
||||||
|
onSave = { onAction(SettingsAction.UpdateWavelogUpload(it)) },
|
||||||
|
onFetchStations = { url, apiKey -> onAction(SettingsAction.FetchWavelogUploadStations(url, apiKey)) },
|
||||||
|
onSelectStation = { onAction(SettingsAction.SelectWavelogUploadStation(it)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// URLs for top bar
|
// URLs for top bar
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
val upstreamTitle = stringResource(R.string.prefs_upstream_title)
|
val upstreamTitle = stringResource(R.string.prefs_upstream_title)
|
||||||
@@ -422,6 +476,12 @@ private fun SettingsScreen(
|
|||||||
showLoTWDialog = { dialogs.lotw = true }
|
showLoTWDialog = { dialogs.lotw = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
WavelogUploadCard(
|
||||||
|
settings = uiState.wavelogUploadSettings,
|
||||||
|
showDialog = { dialogs.wavelogUpload = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
OtherCard(
|
OtherCard(
|
||||||
settings = uiState.otherSettings,
|
settings = uiState.otherSettings,
|
||||||
@@ -844,6 +904,41 @@ private fun LoTWCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WavelogUploadCard(
|
||||||
|
settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings,
|
||||||
|
showDialog: () -> Unit
|
||||||
|
) {
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.prefs_wavelog_upload_title),
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = if (settings.isReady) {
|
||||||
|
stringResource(
|
||||||
|
R.string.prefs_wavelog_upload_configured,
|
||||||
|
listOf(settings.stationName, settings.stationCallsign, settings.stationGrid)
|
||||||
|
.filter { it.isNotBlank() }.joinToString(" · ")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.prefs_wavelog_upload_not_configured)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 2
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
CardButton(
|
||||||
|
onClick = showDialog,
|
||||||
|
text = stringResource(id = R.string.prefs_wavelog_configure),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun formatUpdateTime(updateTime: Long): String {
|
private fun formatUpdateTime(updateTime: Long): String {
|
||||||
val timePattern = stringResource(id = R.string.prefs_updated_time)
|
val timePattern = stringResource(id = R.string.prefs_updated_time)
|
||||||
@@ -990,6 +1085,7 @@ private class DialogVisibility {
|
|||||||
var compassCalibration by mutableStateOf(false)
|
var compassCalibration by mutableStateOf(false)
|
||||||
var logbook by mutableStateOf(false)
|
var logbook by mutableStateOf(false)
|
||||||
var lotwUpload by mutableStateOf(false)
|
var lotwUpload by mutableStateOf(false)
|
||||||
|
var wavelogUpload by mutableStateOf(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -997,7 +1093,7 @@ private fun rememberDialogVisibility(): DialogVisibility {
|
|||||||
return rememberSaveable(saver = run {
|
return rememberSaveable(saver = run {
|
||||||
androidx.compose.runtime.saveable.Saver(
|
androidx.compose.runtime.saveable.Saver(
|
||||||
save = {
|
save = {
|
||||||
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw, it.compassCalibration, it.logbook, it.lotwUpload)
|
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw, it.compassCalibration, it.logbook, it.lotwUpload, it.wavelogUpload)
|
||||||
},
|
},
|
||||||
restore = {
|
restore = {
|
||||||
DialogVisibility().apply {
|
DialogVisibility().apply {
|
||||||
@@ -1007,6 +1103,7 @@ private fun rememberDialogVisibility(): DialogVisibility {
|
|||||||
compassCalibration = it.getOrElse(8) { false }
|
compassCalibration = it.getOrElse(8) { false }
|
||||||
logbook = it.getOrElse(9) { false }
|
logbook = it.getOrElse(9) { false }
|
||||||
lotwUpload = it.getOrElse(10) { false }
|
lotwUpload = it.getOrElse(10) { false }
|
||||||
|
wavelogUpload = it.getOrElse(11) { false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
+33
@@ -23,6 +23,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
|||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
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.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
|
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
||||||
@@ -61,6 +62,16 @@ data class SettingsState(
|
|||||||
val workedGridsCount: Int = 0,
|
val workedGridsCount: Int = 0,
|
||||||
val wavelogSyncing: Boolean = false,
|
val wavelogSyncing: Boolean = false,
|
||||||
val wavelogMessage: String? = null,
|
val wavelogMessage: String? = null,
|
||||||
|
/** Wavelog upload: its own config block + probe/upload transient state. */
|
||||||
|
val wavelogUploadSettings: WavelogUploadSettings = WavelogUploadSettings(),
|
||||||
|
val wavelogUploadStations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList(),
|
||||||
|
val wavelogUploadRights: String = "",
|
||||||
|
val wavelogUploadProbeBusy: Boolean = false,
|
||||||
|
/** Wavelog batch prepared alongside the logbook LoTW preview; uploaded when the
|
||||||
|
* LoTW upload is confirmed (null when Wavelog is not ready). */
|
||||||
|
val wavelogPending: com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview? = null,
|
||||||
|
/** Config-block feedback (probe failures etc.); also shown inside the config dialog. */
|
||||||
|
val wavelogUploadMessage: String? = null,
|
||||||
val lotwSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings = com.rtbishop.look4sat.core.domain.model.LoTWSettings(),
|
val lotwSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings = com.rtbishop.look4sat.core.domain.model.LoTWSettings(),
|
||||||
val lotwSyncing: Boolean = false,
|
val lotwSyncing: Boolean = false,
|
||||||
val lotwSyncMode: LoTWSyncMode? = null,
|
val lotwSyncMode: LoTWSyncMode? = null,
|
||||||
@@ -85,9 +96,16 @@ data class SettingsState(
|
|||||||
val lotwUploadErrorDetail: String = "",
|
val lotwUploadErrorDetail: String = "",
|
||||||
/** One-click logbook upload: prepared preview awaiting confirmation. */
|
/** One-click logbook upload: prepared preview awaiting confirmation. */
|
||||||
val logbookPreview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview? = null,
|
val logbookPreview: com.rtbishop.look4sat.core.domain.repository.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 logbookPositionWarning: com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning? = null,
|
||||||
val logbookUploadBusy: Boolean = false,
|
val logbookUploadBusy: Boolean = false,
|
||||||
/** User-facing upload message shown inside the logbook dialog ("" = none). */
|
/** User-facing upload message shown inside the logbook dialog ("" = none). */
|
||||||
val logbookUploadMessage: String = "",
|
val logbookUploadMessage: String = "",
|
||||||
|
/** Logbook multi-select mode (entered by long-pressing a row); checked records can be
|
||||||
|
* re-uploaded so a corrected station location updates them on LoTW (resubmit). */
|
||||||
|
val logbookSelectionMode: Boolean = false,
|
||||||
|
val logbookSelectedIds: Set<Long> = emptySet(),
|
||||||
/** 指南针校准精度等级 (校准对话框进度条). */
|
/** 指南针校准精度等级 (校准对话框进度条). */
|
||||||
val compassAccuracy: CompassAccuracy = CompassAccuracy.UNRELIABLE,
|
val compassAccuracy: CompassAccuracy = CompassAccuracy.UNRELIABLE,
|
||||||
/** 校正后航向(度, 含磁偏角+手动偏置), 校准对话框实时显示. */
|
/** 校正后航向(度, 含磁偏角+手动偏置), 校准对话框实时显示. */
|
||||||
@@ -143,6 +161,11 @@ sealed interface SettingsAction {
|
|||||||
data class UpdateWavelog(val settings: WavelogSettings) : SettingsAction
|
data class UpdateWavelog(val settings: WavelogSettings) : SettingsAction
|
||||||
data class SyncWorkedGrids(val settings: WavelogSettings) : SettingsAction
|
data class SyncWorkedGrids(val settings: WavelogSettings) : SettingsAction
|
||||||
|
|
||||||
|
// Wavelog upload (separate config block + upload flow)
|
||||||
|
data class UpdateWavelogUpload(val settings: WavelogUploadSettings) : SettingsAction
|
||||||
|
data class FetchWavelogUploadStations(val url: String, val apiKey: String) : SettingsAction
|
||||||
|
data class SelectWavelogUploadStation(val station: com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo) : SettingsAction
|
||||||
|
|
||||||
// LoTW confirmed grids
|
// LoTW confirmed grids
|
||||||
data class UpdateLoTW(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction
|
data class UpdateLoTW(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction
|
||||||
data class SyncLoTWGrids(
|
data class SyncLoTWGrids(
|
||||||
@@ -160,6 +183,16 @@ sealed interface SettingsAction {
|
|||||||
data object PrepareLogbookUpload : SettingsAction
|
data object PrepareLogbookUpload : SettingsAction
|
||||||
data object ConfirmLogbookUpload : SettingsAction
|
data object ConfirmLogbookUpload : SettingsAction
|
||||||
data object DismissLogbookPreview : SettingsAction
|
data object DismissLogbookPreview : SettingsAction
|
||||||
|
/** The operator acknowledged the position mismatch and wants to upload anyway. */
|
||||||
|
data object IgnoreLogbookPositionWarning : SettingsAction
|
||||||
|
/** The operator chose to fix the station location first; the preview is discarded. */
|
||||||
|
data object AbandonLogbookForGridFix : SettingsAction
|
||||||
|
/** Long-press on a row: enter selection mode with that record checked. */
|
||||||
|
data class StartLogbookSelection(val id: Long) : SettingsAction
|
||||||
|
data class ToggleLogbookSelection(val id: Long) : SettingsAction
|
||||||
|
data object ExitLogbookSelection : SettingsAction
|
||||||
|
/** Re-upload the checked records — uploaded/confirmed rows included (resubmit). */
|
||||||
|
data object ResubmitSelectedLogbook : SettingsAction
|
||||||
data object ClearLogbookMessage : SettingsAction
|
data object ClearLogbookMessage : SettingsAction
|
||||||
|
|
||||||
// LoTW upload configuration (certificate + station)
|
// LoTW upload configuration (certificate + station)
|
||||||
|
|||||||
+216
-6
@@ -24,6 +24,7 @@ import androidx.lifecycle.viewmodel.initializer
|
|||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
@@ -34,10 +35,21 @@ import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
|
import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
|
||||||
import com.rtbishop.look4sat.core.domain.repository.lotwCursorEpochMs
|
import com.rtbishop.look4sat.core.domain.repository.lotwCursorEpochMs
|
||||||
import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
|
import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.positionWarning
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
|
||||||
|
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 com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||||
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.resubmitCandidates
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
|
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
|
||||||
|
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.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -51,6 +63,7 @@ class SettingsViewModel(
|
|||||||
private val settingsRepo: ISettingsRepo,
|
private val settingsRepo: ISettingsRepo,
|
||||||
private val updateRepo: IUpdateRepository,
|
private val updateRepo: IUpdateRepository,
|
||||||
private val wavelogRepo: IWavelogRepository,
|
private val wavelogRepo: IWavelogRepository,
|
||||||
|
private val wavelogUploadRepository: IWavelogUploadRepository,
|
||||||
private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository,
|
private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository,
|
||||||
private val qsoRepository: com.rtbishop.look4sat.core.domain.logbook.IQsoRepository,
|
private val qsoRepository: com.rtbishop.look4sat.core.domain.logbook.IQsoRepository,
|
||||||
private val lotwUploadRepository: com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository,
|
private val lotwUploadRepository: com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository,
|
||||||
@@ -76,6 +89,7 @@ class SettingsViewModel(
|
|||||||
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
|
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
|
||||||
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
|
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
|
||||||
wavelogSettings = settingsRepo.wavelogSettings.value,
|
wavelogSettings = settingsRepo.wavelogSettings.value,
|
||||||
|
wavelogUploadSettings = settingsRepo.wavelogUploadSettings.value,
|
||||||
workedGridsCount = settingsRepo.getWorkedGrids().size,
|
workedGridsCount = settingsRepo.getWorkedGrids().size,
|
||||||
compassAccuracy = sensorsRepo.compassAccuracy.value,
|
compassAccuracy = sensorsRepo.compassAccuracy.value,
|
||||||
compassHeadingDegrees = correctedCompassHeading(),
|
compassHeadingDegrees = correctedCompassHeading(),
|
||||||
@@ -153,6 +167,11 @@ class SettingsViewModel(
|
|||||||
_uiState.update { it.copy(wavelogSettings = settings) }
|
_uiState.update { it.copy(wavelogSettings = settings) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsRepo.wavelogUploadSettings.collect { settings ->
|
||||||
|
_uiState.update { it.copy(wavelogUploadSettings = settings) }
|
||||||
|
}
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepo.lotwSettings.collect { settings ->
|
settingsRepo.lotwSettings.collect { settings ->
|
||||||
_uiState.update { it.copy(lotwSettings = settings) }
|
_uiState.update { it.copy(lotwSettings = settings) }
|
||||||
@@ -222,6 +241,10 @@ class SettingsViewModel(
|
|||||||
// Wavelog worked grids
|
// Wavelog worked grids
|
||||||
is SettingsAction.UpdateWavelog -> settingsRepo.updateWavelogSettings(action.settings)
|
is SettingsAction.UpdateWavelog -> settingsRepo.updateWavelogSettings(action.settings)
|
||||||
is SettingsAction.SyncWorkedGrids -> syncWorkedGrids(action.settings)
|
is SettingsAction.SyncWorkedGrids -> syncWorkedGrids(action.settings)
|
||||||
|
// Wavelog upload config (the upload itself rides the LoTW logbook flow)
|
||||||
|
is SettingsAction.UpdateWavelogUpload -> settingsRepo.updateWavelogUploadSettings(action.settings)
|
||||||
|
is SettingsAction.FetchWavelogUploadStations -> fetchWavelogUploadStations(action.url, action.apiKey)
|
||||||
|
is SettingsAction.SelectWavelogUploadStation -> selectWavelogUploadStation(action.station)
|
||||||
// LoTW confirmed grids
|
// LoTW confirmed grids
|
||||||
is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
|
is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
|
||||||
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode)
|
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode)
|
||||||
@@ -233,6 +256,12 @@ class SettingsViewModel(
|
|||||||
SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload()
|
SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload()
|
||||||
SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload()
|
SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload()
|
||||||
SettingsAction.DismissLogbookPreview -> dismissLogbookPreview()
|
SettingsAction.DismissLogbookPreview -> dismissLogbookPreview()
|
||||||
|
SettingsAction.IgnoreLogbookPositionWarning -> ignoreLogbookPositionWarning()
|
||||||
|
SettingsAction.AbandonLogbookForGridFix -> abandonLogbookForGridFix()
|
||||||
|
is SettingsAction.StartLogbookSelection -> startLogbookSelection(action.id)
|
||||||
|
is SettingsAction.ToggleLogbookSelection -> toggleLogbookSelection(action.id)
|
||||||
|
SettingsAction.ExitLogbookSelection -> exitLogbookSelection()
|
||||||
|
SettingsAction.ResubmitSelectedLogbook -> resubmitSelectedLogbook()
|
||||||
SettingsAction.ClearLogbookMessage -> clearLogbookMessage()
|
SettingsAction.ClearLogbookMessage -> clearLogbookMessage()
|
||||||
// LoTW upload configuration
|
// LoTW upload configuration
|
||||||
SettingsAction.LoadLoTWUploadStatus -> loadLoTWUploadStatus()
|
SettingsAction.LoadLoTWUploadStatus -> loadLoTWUploadStatus()
|
||||||
@@ -271,6 +300,78 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wavelog upload (separate config block + upload flow)
|
||||||
|
|
||||||
|
private fun fetchWavelogUploadStations(url: String, apiKey: String) {
|
||||||
|
if (_uiState.value.wavelogUploadProbeBusy) return
|
||||||
|
_uiState.update { it.copy(wavelogUploadProbeBusy = true, wavelogUploadMessage = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val probe = wavelogUploadRepository.probe(url.trim(), apiKey.trim())
|
||||||
|
if (probe == null) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
wavelogUploadProbeBusy = false,
|
||||||
|
wavelogUploadMessage = "Wavelog probe failed — check URL/key/network"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
wavelogUploadProbeBusy = false,
|
||||||
|
wavelogUploadStations = probe.stations,
|
||||||
|
wavelogUploadRights = probe.rights
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Single-station setups select themselves; a saved station that the server
|
||||||
|
// still reports stays selected.
|
||||||
|
val current = settingsRepo.wavelogUploadSettings.value
|
||||||
|
val savedStillPresent = current.stationId.isNotBlank() && probe.stations.any { it.id == current.stationId }
|
||||||
|
val auto = if (probe.stations.size == 1 && !savedStillPresent) probe.stations.single() else null
|
||||||
|
if (auto != null) selectWavelogUploadStation(auto)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectWavelogUploadStation(station: WavelogStationInfo) {
|
||||||
|
val current = settingsRepo.wavelogUploadSettings.value
|
||||||
|
settingsRepo.updateWavelogUploadSettings(
|
||||||
|
current.copy(
|
||||||
|
stationId = station.id,
|
||||||
|
stationName = station.name,
|
||||||
|
stationCallsign = station.callsign,
|
||||||
|
stationGrid = station.grid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prepares the Wavelog batch riding the LoTW upload (null when Wavelog is not ready). */
|
||||||
|
private suspend fun prepareWavelogPiggyback(
|
||||||
|
records: List<com.rtbishop.look4sat.core.domain.logbook.QsoRecord>
|
||||||
|
): WavelogUploadPreview? {
|
||||||
|
val settings = settingsRepo.wavelogUploadSettings.value
|
||||||
|
if (!settings.isReady) return null
|
||||||
|
return runCatching {
|
||||||
|
wavelogUploadRepository.prepare(wavelogUploadCandidates(records), settings)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads one prepared Wavelog batch (piggyback) and marks what the server
|
||||||
|
* accepted. Returns the operator-facing message segment 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)
|
||||||
|
}
|
||||||
|
wavelogConfirmedSegment(outcome, preview)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
wavelogTransportFailureSegment()
|
||||||
|
}
|
||||||
|
|
||||||
private fun syncLoTWGrids(
|
private fun syncLoTWGrids(
|
||||||
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
||||||
mode: LoTWSyncMode
|
mode: LoTWSyncMode
|
||||||
@@ -380,6 +481,9 @@ class SettingsViewModel(
|
|||||||
// LoTW-imported confirmations are the feedback side.
|
// LoTW-imported confirmations are the feedback side.
|
||||||
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE }
|
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE }
|
||||||
val audit = lotwUploadRepository.audit(pending)
|
val audit = lotwUploadRepository.audit(pending)
|
||||||
|
// Wavelog piggyback (option A): prepare its batch while we are here, so
|
||||||
|
// the logbook preview can show the count and confirm can post it directly.
|
||||||
|
val wavelogPreview = prepareWavelogPiggyback(all)
|
||||||
if (audit.pending == 0) {
|
if (audit.pending == 0) {
|
||||||
val msg = when {
|
val msg = when {
|
||||||
audit.unavailable > 0 -> com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary(
|
audit.unavailable > 0 -> com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary(
|
||||||
@@ -389,14 +493,31 @@ class SettingsViewModel(
|
|||||||
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
|
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
|
||||||
else -> "No pending QSOs to upload"
|
else -> "No pending QSOs to upload"
|
||||||
}
|
}
|
||||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = msg) }
|
// No LoTW work to do — still flush the Wavelog backlog (catch-up): a
|
||||||
|
// freshly configured Wavelog syncs without waiting for new QSOs.
|
||||||
|
val wavelogMsg = wavelogPreview?.let {
|
||||||
|
if (it.count > 0) uploadWavelogNow(it, settingsRepo.wavelogUploadSettings.value) else wavelogIdleSegment(it)
|
||||||
|
}
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
logbookUploadBusy = false,
|
||||||
|
logbookUploadMessage = listOfNotNull(msg, wavelogMsg).joinToString("\n")
|
||||||
|
)
|
||||||
|
}
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val preview = lotwUploadRepository.prepare(pending, false)
|
val preview = lotwUploadRepository.prepare(pending, false)
|
||||||
// Only the records that actually made it into the TQ8 may be
|
// Only the records that actually made it into the TQ8 may be
|
||||||
// marked uploaded later — never the whole candidate list.
|
// marked uploaded later — never the whole candidate list.
|
||||||
lastLogbookUploadIds = preview.submittedIds
|
lastLogbookUploadIds = preview.submittedIds
|
||||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookPreview = preview) }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
logbookUploadBusy = false,
|
||||||
|
logbookPreview = preview,
|
||||||
|
wavelogPending = wavelogPreview,
|
||||||
|
logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) {
|
} catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) {
|
||||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload unavailable: ${e.reason}") }
|
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload unavailable: ${e.reason}") }
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
@@ -410,8 +531,9 @@ class SettingsViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(logbookUploadBusy = true) }
|
_uiState.update { it.copy(logbookUploadBusy = true) }
|
||||||
val result = lotwUploadRepository.upload(preview.id)
|
val result = lotwUploadRepository.upload(preview.id)
|
||||||
if (result is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted && lastLogbookUploadIds.isNotEmpty()) {
|
val accepted = result is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted
|
||||||
qsoRepository.markUploaded(lastLogbookUploadIds)
|
if (accepted && lastLogbookUploadIds.isNotEmpty()) {
|
||||||
|
qsoRepository.markUploaded(lastLogbookUploadIds, preview.grids)
|
||||||
lastLogbookUploadIds = emptyList()
|
lastLogbookUploadIds = emptyList()
|
||||||
}
|
}
|
||||||
val msg = when (result) {
|
val msg = when (result) {
|
||||||
@@ -420,11 +542,98 @@ class SettingsViewModel(
|
|||||||
com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry"
|
com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry"
|
||||||
com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.ExpiredPreview -> "Preview expired — tap upload again"
|
com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.ExpiredPreview -> "Preview expired — tap upload again"
|
||||||
}
|
}
|
||||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookPreview = null, logbookUploadMessage = msg) }
|
// Wavelog piggyback: rides the same confirmation, independent of the LoTW
|
||||||
|
// outcome — a Wavelog failure leaves records unmarked for the next upload.
|
||||||
|
val wavelogPending = _uiState.value.wavelogPending
|
||||||
|
val wavelogMsg = wavelogPending?.let { wl ->
|
||||||
|
if (wl.count > 0) uploadWavelogNow(wl, settingsRepo.wavelogUploadSettings.value)
|
||||||
|
else wavelogIdleSegment(wl)
|
||||||
|
}
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
logbookUploadBusy = false,
|
||||||
|
logbookPreview = null,
|
||||||
|
wavelogPending = null,
|
||||||
|
logbookUploadMessage = listOfNotNull(msg, wavelogMsg).joinToString("\n"),
|
||||||
|
// An accepted resubmit is done — drop the checks; a failed one keeps
|
||||||
|
// them so retrying stays one tap away.
|
||||||
|
logbookSelectionMode = if (accepted) false else it.logbookSelectionMode,
|
||||||
|
logbookSelectedIds = if (accepted) emptySet() else it.logbookSelectedIds
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null) }
|
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null, wavelogPending = null) }
|
||||||
|
|
||||||
|
/** Operator chose "ignore" on the position check: keep the prepared preview. */
|
||||||
|
private fun ignoreLogbookPositionWarning() = _uiState.update { it.copy(logbookPositionWarning = null) }
|
||||||
|
|
||||||
|
/** Operator chose to fix the station grid first: drop the prepared preview and leave. */
|
||||||
|
private fun abandonLogbookForGridFix() {
|
||||||
|
lotwUploadRepository.discardPreview()
|
||||||
|
lastLogbookUploadIds = emptyList()
|
||||||
|
_uiState.update { it.copy(logbookPositionWarning = null, logbookPreview = null, wavelogPending = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Long-press entry: selection mode with the pressed record checked. */
|
||||||
|
private fun startLogbookSelection(id: Long) = _uiState.update {
|
||||||
|
it.copy(logbookSelectionMode = true, logbookSelectedIds = setOf(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toggleLogbookSelection(id: Long) = _uiState.update {
|
||||||
|
val selection = it.logbookSelectedIds.toMutableSet().apply { if (!add(id)) remove(id) }
|
||||||
|
it.copy(logbookSelectedIds = selection)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exitLogbookSelection() = _uiState.update {
|
||||||
|
it.copy(logbookSelectionMode = false, logbookSelectedIds = emptySet())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-upload the checked records — already-uploaded and confirmed rows included — so a
|
||||||
|
* corrected station location reaches LoTW and the server updates the existing contacts.
|
||||||
|
* The rest is the normal prepare → preview → upload cycle, resubmit flag included.
|
||||||
|
*/
|
||||||
|
private fun resubmitSelectedLogbook() {
|
||||||
|
if (_uiState.value.logbookUploadBusy) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") }
|
||||||
|
try {
|
||||||
|
val all = qsoRepository.records.first()
|
||||||
|
val selected = resubmitCandidates(all, _uiState.value.logbookSelectedIds)
|
||||||
|
if (selected.isEmpty()) {
|
||||||
|
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No complete QSOs in the selection") }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val preview = lotwUploadRepository.prepare(selected, true)
|
||||||
|
if (preview.count == 0) {
|
||||||
|
// Every checked record proved un-signable — say why instead of opening
|
||||||
|
// a dead preview whose POST could only expire.
|
||||||
|
val message = unavailableUploadSummary(
|
||||||
|
preview.unavailableSkipped, preview.unavailableReasons, duplicates = preview.duplicateSkipped
|
||||||
|
).ifBlank { "No QSOs to resubmit" }
|
||||||
|
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = message) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
lastLogbookUploadIds = preview.submittedIds
|
||||||
|
// Wavelog piggyback: the resubmit confirmation syncs the backlog too.
|
||||||
|
val wavelogPending = prepareWavelogPiggyback(all)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
logbookUploadBusy = false,
|
||||||
|
logbookPreview = preview,
|
||||||
|
wavelogPending = wavelogPending,
|
||||||
|
logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) {
|
||||||
|
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload unavailable: ${e.reason}") }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload failed") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun clearLogbookMessage() = _uiState.update { it.copy(logbookUploadMessage = "") }
|
private fun clearLogbookMessage() = _uiState.update { it.copy(logbookUploadMessage = "") }
|
||||||
|
|
||||||
@@ -613,6 +822,7 @@ class SettingsViewModel(
|
|||||||
settingsRepo = container.settingsRepo,
|
settingsRepo = container.settingsRepo,
|
||||||
updateRepo = container.updateRepo,
|
updateRepo = container.updateRepo,
|
||||||
wavelogRepo = container.wavelogRepo,
|
wavelogRepo = container.wavelogRepo,
|
||||||
|
wavelogUploadRepository = container.wavelogUploadRepository,
|
||||||
lotwRepo = container.lotwRepo,
|
lotwRepo = container.lotwRepo,
|
||||||
qsoRepository = container.qsoRepository,
|
qsoRepository = container.qsoRepository,
|
||||||
lotwUploadRepository = container.lotwUploadRepository,
|
lotwUploadRepository = container.lotwUploadRepository,
|
||||||
|
|||||||
+6
-1
@@ -81,7 +81,10 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
|||||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
|
||||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||||
|
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
|
||||||
|
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@@ -591,7 +594,9 @@ private fun AmSatUploadConfirmDialog(
|
|||||||
val grid = upload.gridSquare.ifBlank { stringResource(R.string.amsat_upload_grid_none) }
|
val grid = upload.gridSquare.ifBlank { stringResource(R.string.amsat_upload_grid_none) }
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text(stringResource(R.string.amsat_upload_confirm_title)) },
|
shape = sheetDialogShape(),
|
||||||
|
containerColor = sheetDialogContainerColor(),
|
||||||
|
title = { SheetDialogTitle(stringResource(R.string.amsat_upload_confirm_title)) },
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(
|
text = stringResource(
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "549"
|
appVersionCode = "567"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.4.8-ba7opf.2.4"
|
appVersionName = "4.4.8-ba7opf.9.2"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
compileSdk = "37"
|
compileSdk = "37"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
|
|||||||
Reference in new issue
Block a user