mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-06 04:27:29 +00:00
feat(wavelog): 二选一互斥模式 + p12 空密码导入修复(4.4.8-ba7opf.9.4, 569)
- LoTW/Wavelog 互斥:isReady 即 Wavelog 模式,LoTW 卡置灰+说明,上传按模式分流(雷达页/日志本),自动 LoTW 同步门控;配置切换弹警告、可清除配置 - 同步重做:get_contacts_adif 每台址游标,增量/全量双按钮,只增不删,URL 变更增量降级全量;同步刷新日志本+地图 workedGrids/gridQsos/roamedGrids - 日志本台址选择器(全部/各台址)+ Wavelog 上传预览弹窗;Qso 增 wavelogStation,DB v4→v5 - 删旧 WavelogRepository/wavelogSettings 链路,换 WavelogSyncRepository + WavelogSync 纯函数 - p12 修复:导入按钮放开空密码(isEnabled=!busy)+ 密码框留空提示;Pkcs12ReaderTest +4 用例 - 单测六模块全绿;版本 4.4.8-ba7opf.9.4 (569)
This commit is contained in:
1 parent
1fa7dd2d88
commit
09539ff13a
40 files changed
+1715
-470
No files matched your search
@@ -105,6 +105,9 @@ class MainApplication : Application(), IContainerProvider {
|
||||
*/
|
||||
private suspend fun checkLoTWAutoSync(timeNow: Long = System.currentTimeMillis()) {
|
||||
val settingsRepo = container.settingsRepo
|
||||
// One-of-two rule: while Wavelog is configured the app uploads/syncs through
|
||||
// Wavelog only — the automatic LoTW sync stays off.
|
||||
if (settingsRepo.wavelogUploadSettings.value.isReady) return
|
||||
val lotwSettings = settingsRepo.lotwSettings.value
|
||||
if (!shouldAutoSyncLoTW(
|
||||
isConfigured = lotwSettings.isConfigured,
|
||||
|
||||
@@ -48,6 +48,6 @@ interface QsoDao {
|
||||
@Query("UPDATE qso_records SET lotwUploaded = 1 WHERE id IN (:ids)")
|
||||
suspend fun markUploaded(ids: List<Long>)
|
||||
|
||||
@Query("UPDATE qso_records SET wavelogUploaded = 1 WHERE id IN (:ids)")
|
||||
suspend fun markWavelogUploaded(ids: List<Long>)
|
||||
@Query("UPDATE qso_records SET wavelogUploaded = 1, wavelogStation = CASE WHEN :stationId = '' THEN wavelogStation ELSE :stationId END WHERE id IN (:ids)")
|
||||
suspend fun markWavelogUploaded(ids: List<Long>, stationId: String)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
|
||||
* upload receipts). Kept apart from [Look4SatDb] so existing installations
|
||||
* never need a migration of the satellite database.
|
||||
*/
|
||||
@Database(entities = [QsoEntity::class], version = 4, exportSchema = false)
|
||||
@Database(entities = [QsoEntity::class], version = 5, exportSchema = false)
|
||||
abstract class QsoDatabase : RoomDatabase() {
|
||||
abstract fun qsoDao(): QsoDao
|
||||
}
|
||||
@@ -45,3 +45,10 @@ val MIGRATION_QSO_3_4 = object : Migration(3, 4) {
|
||||
db.execSQL("ALTER TABLE qso_records ADD COLUMN wavelogUploaded INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
/** v4 → v5: remember which Wavelog station profile (台址) a QSO belongs to. */
|
||||
val MIGRATION_QSO_4_5 = object : Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE qso_records ADD COLUMN wavelogStation TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ data class QsoEntity(
|
||||
@ColumnInfo(defaultValue = "0") val lotwConfirmed: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "0") val lotwUploaded: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "0") val wavelogUploaded: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "''") val wavelogStation: String = "",
|
||||
@ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "''") val lotwQslDate: String = "",
|
||||
@ColumnInfo(defaultValue = "''") val vuccGrids: String = "",
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_1_2
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_2_3
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_3_4
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_4_5
|
||||
import com.rtbishop.look4sat.core.data.database.QsoDatabase
|
||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||
@@ -49,8 +50,9 @@ import com.rtbishop.look4sat.core.data.repository.SettingsRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.UpdateRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.LoTWRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.LocationRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.WavelogRepository
|
||||
|
||||
import com.rtbishop.look4sat.core.data.repository.WavelogUploadRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.WavelogSyncRepository
|
||||
import com.rtbishop.look4sat.core.data.source.LocalSource
|
||||
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
||||
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
||||
@@ -71,8 +73,9 @@ import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogSyncRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
@@ -103,12 +106,14 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
override val databaseRepo = provideDatabaseRepo()
|
||||
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
|
||||
override val updateRepo by lazy { UpdateRepository(remoteSource) }
|
||||
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
|
||||
|
||||
override val wavelogUploadRepository: IWavelogUploadRepository by lazy { WavelogUploadRepository() }
|
||||
override val wavelogSyncRepository: IWavelogSyncRepository by lazy { WavelogSyncRepository() }
|
||||
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
|
||||
override val qsoRepository: IQsoRepository by lazy {
|
||||
val database = Room.databaseBuilder(context, QsoDatabase::class.java, "Look4SatQsoDB")
|
||||
.addMigrations(MIGRATION_QSO_1_2, MIGRATION_QSO_2_3, MIGRATION_QSO_3_4)
|
||||
.addMigrations(MIGRATION_QSO_4_5)
|
||||
.build()
|
||||
QsoRepository(database.qsoDao(), Dispatchers.IO)
|
||||
}
|
||||
|
||||
+68
-2
@@ -59,6 +59,7 @@ class QsoRepository(
|
||||
id = 0L,
|
||||
lotwUploaded = false, lotwConfirmed = false, lotwReceived = false,
|
||||
wavelogUploaded = false,
|
||||
wavelogStation = "",
|
||||
lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = emptyList(),
|
||||
dxcc = null, country = "", cqZone = null, region = ""
|
||||
).toEntity())
|
||||
@@ -95,9 +96,9 @@ class QsoRepository(
|
||||
if (stamped.isNotEmpty()) dao.saveBatch(stamped.map { it.toEntity() })
|
||||
}
|
||||
|
||||
override suspend fun markWavelogUploaded(ids: List<Long>) = withContext(dispatcher) {
|
||||
override suspend fun markWavelogUploaded(ids: List<Long>, stationId: String) = withContext(dispatcher) {
|
||||
if (ids.isEmpty()) return@withContext
|
||||
importMutex.withLock { dao.markWavelogUploaded(ids) }
|
||||
importMutex.withLock { dao.markWavelogUploaded(ids, stationId) }
|
||||
}
|
||||
|
||||
override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
|
||||
@@ -124,6 +125,19 @@ class QsoRepository(
|
||||
importMutex.withLock { mergeRecords(records, fromLoTW = true) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges QSO records pulled from Wavelog (the sync/download direction).
|
||||
*
|
||||
* Matching uses the same [stableQsoKey] identity as every other import, so a record
|
||||
* already recorded locally (typically uploaded from this app) is not duplicated — it
|
||||
* simply learns its Wavelog station profile and, where the local row is missing them,
|
||||
* the opposite-station fields the server kept. Fresh records are inserted already
|
||||
* marked as uploaded, so a later upload never sends them straight back.
|
||||
*/
|
||||
override suspend fun mergeWavelog(records: List<QsoRecord>): AdifImportResult = withContext(dispatcher) {
|
||||
importMutex.withLock { mergeWavelogRecords(records) }
|
||||
}
|
||||
|
||||
override suspend fun consolidateConfirmations(): Int = withContext(dispatcher) {
|
||||
importMutex.withLock {
|
||||
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
|
||||
@@ -216,6 +230,56 @@ class QsoRepository(
|
||||
return AdifImportResult(imported, skipped, updated)
|
||||
}
|
||||
|
||||
private suspend fun mergeWavelogRecords(records: List<QsoRecord>): AdifImportResult {
|
||||
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
|
||||
// Rows imported under a tracking-source name join the ARRL name first, so the
|
||||
// lookup below sees one identity for both sides of a match.
|
||||
val renames = rewriteOfficialNames(working)
|
||||
val indexByKey = HashMap<String, Int>(working.size)
|
||||
working.indices.forEach { index -> indexByKey.putIfAbsent(stableQsoKey(working[index]), index) }
|
||||
val changes = linkedMapOf<Int, QsoRecord>().apply { putAll(renames) }
|
||||
var imported = 0
|
||||
var updated = renames.size
|
||||
var skipped = 0
|
||||
val seenKeys = hashSetOf<String>()
|
||||
records.forEach { remote ->
|
||||
val key = stableQsoKey(remote)
|
||||
if (!seenKeys.add(key)) {
|
||||
skipped++
|
||||
return@forEach
|
||||
}
|
||||
val index = indexByKey[key]
|
||||
if (index == null) {
|
||||
val added = remote.copy(
|
||||
id = 0L,
|
||||
wavelogUploaded = true,
|
||||
satelliteName = officialSatelliteName(remote.satelliteName)
|
||||
)
|
||||
changes[working.size] = added
|
||||
indexByKey[stableQsoKey(added)] = working.size
|
||||
working += added
|
||||
imported++
|
||||
} else {
|
||||
val previous = working[index]
|
||||
val merged = previous.copy(
|
||||
wavelogUploaded = true,
|
||||
wavelogStation = remote.wavelogStation.ifBlank { previous.wavelogStation },
|
||||
theirGrid = previous.theirGrid.ifBlank { remote.theirGrid },
|
||||
theirVuccGrids = previous.theirVuccGrids.ifEmpty { remote.theirVuccGrids }
|
||||
)
|
||||
if (merged != previous) {
|
||||
working[index] = merged
|
||||
changes[index] = merged
|
||||
updated++
|
||||
} else {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
}
|
||||
dao.saveBatch(changes.values.map { it.toEntity() })
|
||||
return AdifImportResult(imported, skipped, updated)
|
||||
}
|
||||
|
||||
/** Result of folding already-split rows back together. */
|
||||
private data class Consolidation(
|
||||
/** index in the stored list -> the record to save (local row + its confirmation). */
|
||||
@@ -294,6 +358,7 @@ private fun QsoEntity.toDomain() = QsoRecord(
|
||||
lotwConfirmed = lotwConfirmed,
|
||||
lotwUploaded = lotwUploaded,
|
||||
wavelogUploaded = wavelogUploaded,
|
||||
wavelogStation = wavelogStation,
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
|
||||
@@ -332,6 +397,7 @@ private fun QsoRecord.toEntity() = QsoEntity(
|
||||
lotwConfirmed = lotwConfirmed,
|
||||
lotwUploaded = lotwUploaded,
|
||||
wavelogUploaded = wavelogUploaded,
|
||||
wavelogStation = wavelogStation.trim(),
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.joinToString(","),
|
||||
|
||||
+74
-20
@@ -30,7 +30,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
@@ -148,27 +148,9 @@ class SettingsRepo(
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # Wavelog worked-grids settings
|
||||
private val keyWavelogUrl = "wavelogUrl"
|
||||
private val keyWavelogToken = "wavelogToken"
|
||||
//region # Worked-grid data (written by the LoTW / Wavelog syncs)
|
||||
private val keyWorkedGrids = "workedGrids"
|
||||
|
||||
private val _wavelogSettings = MutableStateFlow(getWavelogSettings())
|
||||
override val wavelogSettings: StateFlow<WavelogSettings> = _wavelogSettings
|
||||
|
||||
override fun updateWavelogSettings(settings: WavelogSettings) {
|
||||
preferences.edit {
|
||||
putString(keyWavelogUrl, settings.url.trim())
|
||||
putString(keyWavelogToken, settings.token.trim())
|
||||
}
|
||||
_wavelogSettings.value = settings.copy(url = settings.url.trim(), token = settings.token.trim())
|
||||
}
|
||||
|
||||
private fun getWavelogSettings(): WavelogSettings = WavelogSettings(
|
||||
url = preferences.getString(keyWavelogUrl, null).orEmpty(),
|
||||
token = preferences.getString(keyWavelogToken, null).orEmpty()
|
||||
)
|
||||
|
||||
override fun getWorkedGrids(): Set<String> {
|
||||
val json = preferences.getString(keyWorkedGrids, null).orEmpty()
|
||||
if (json.isBlank()) return emptySet()
|
||||
@@ -421,6 +403,78 @@ class SettingsRepo(
|
||||
)
|
||||
//endregion
|
||||
|
||||
//region # Wavelog sync bookkeeping
|
||||
private val keyWavelogStations = "wavelogStations"
|
||||
private val keyWavelogSyncCursors = "wavelogSyncCursors"
|
||||
private val keyWavelogSyncUrl = "wavelogSyncUrl"
|
||||
private val keyLastWavelogSyncEpochMs = "wavelogLastSyncEpochMs"
|
||||
|
||||
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> {
|
||||
val json = preferences.getString(keyWavelogStations, null).orEmpty()
|
||||
if (json.isBlank()) return emptyList()
|
||||
return try {
|
||||
val array = org.json.JSONArray(json)
|
||||
(0 until array.length()).mapNotNull { i ->
|
||||
val o = array.optJSONObject(i) ?: return@mapNotNull null
|
||||
val id = o.optString("id").ifBlank { return@mapNotNull null }
|
||||
com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo(
|
||||
id = id,
|
||||
name = o.optString("name"),
|
||||
callsign = o.optString("call"),
|
||||
grid = o.optString("grid"),
|
||||
active = o.optBoolean("active", true)
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) {
|
||||
val array = org.json.JSONArray()
|
||||
stations.forEach { station ->
|
||||
array.put(
|
||||
org.json.JSONObject()
|
||||
.put("id", station.id)
|
||||
.put("name", station.name)
|
||||
.put("call", station.callsign)
|
||||
.put("grid", station.grid)
|
||||
.put("active", station.active)
|
||||
)
|
||||
}
|
||||
preferences.edit { putString(keyWavelogStations, array.toString()) }
|
||||
}
|
||||
|
||||
override fun getWavelogSyncCursors(): Map<String, Long> {
|
||||
val json = preferences.getString(keyWavelogSyncCursors, null).orEmpty()
|
||||
if (json.isBlank()) return emptyMap()
|
||||
return try {
|
||||
val root = org.json.JSONObject(json)
|
||||
root.keys().asSequence().associateWith { key -> root.optLong(key, 0L) }
|
||||
} catch (_: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setWavelogSyncCursors(cursors: Map<String, Long>) {
|
||||
val root = org.json.JSONObject()
|
||||
cursors.forEach { (stationId, cursor) -> root.put(stationId, cursor) }
|
||||
preferences.edit { putString(keyWavelogSyncCursors, root.toString()) }
|
||||
}
|
||||
|
||||
override fun getWavelogSyncUrl(): String =
|
||||
preferences.getString(keyWavelogSyncUrl, null).orEmpty()
|
||||
|
||||
override fun setWavelogSyncUrl(url: String) =
|
||||
preferences.edit { putString(keyWavelogSyncUrl, url) }
|
||||
|
||||
override fun getLastWavelogSyncEpochMs(): Long =
|
||||
preferences.getLong(keyLastWavelogSyncEpochMs, 0L)
|
||||
|
||||
override fun setLastWavelogSyncEpochMs(value: Long) =
|
||||
preferences.edit { putLong(keyLastWavelogSyncEpochMs, value) }
|
||||
//endregion
|
||||
|
||||
//region # Transceivers settings
|
||||
private val _passesSettings = MutableStateFlow(getPassesSettings())
|
||||
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
|
||||
|
||||
-97
@@ -1,97 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Fetches the list of worked gridsquares from a self-hosted Wavelog instance.
|
||||
*
|
||||
* Uses the Wavelog v1 API endpoint (verified against Wavelog 3.0.2):
|
||||
* POST {base}/index.php/api/logbook_get_worked_grids
|
||||
* Body: {"key": "<api key>", "logbook_id": "<logbook id>"}
|
||||
* Response 201: JSON array of 4-char gridsquares, e.g. ["JN47","JO30"]
|
||||
* (see Wavelog Api.php logbook_get_worked_grids / Api_model::get_grids_worked_in_logbook)
|
||||
*
|
||||
* The token field carries "<api_key>:<logbook_id>" so one credential line
|
||||
* configures both values (the dialog explains this to the user).
|
||||
*/
|
||||
class WavelogRepository : IWavelogRepository {
|
||||
|
||||
override suspend fun fetchWorkedGrids(url: String, token: String): Set<String>? = withContext(Dispatchers.IO) {
|
||||
val parts = token.trim().split(":")
|
||||
val apiKey = parts.getOrNull(0)?.trim().orEmpty()
|
||||
val logbookId = parts.getOrNull(1)?.trim().orEmpty()
|
||||
if (url.isBlank() || apiKey.isBlank() || logbookId.isBlank()) return@withContext null
|
||||
val base = url.trim().trimEnd('/')
|
||||
val requestUrl = "$base/index.php/api/logbook_get_worked_grids"
|
||||
try {
|
||||
val connection = java.net.URL(requestUrl).openConnection() as java.net.HttpURLConnection
|
||||
connection.connectTimeout = 10_000
|
||||
connection.readTimeout = 15_000
|
||||
connection.requestMethod = "POST"
|
||||
connection.doOutput = true
|
||||
connection.setRequestProperty("Content-Type", "application/json")
|
||||
val body = JSONObject().apply {
|
||||
put("key", apiKey)
|
||||
put("logbook_id", logbookId)
|
||||
}.toString().toByteArray()
|
||||
connection.outputStream.use { it.write(body) }
|
||||
val code = connection.responseCode
|
||||
if (code !in 200..299) {
|
||||
connection.disconnect()
|
||||
return@withContext null
|
||||
}
|
||||
val response = connection.inputStream.bufferedReader().use { it.readText() }
|
||||
connection.disconnect()
|
||||
parseWorkedGrids(response)
|
||||
} catch (e: Exception) {
|
||||
println("WavelogRepository fetch failure: $e")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parseWorkedGrids(body: String): Set<String>? {
|
||||
return try {
|
||||
val array = when {
|
||||
body.trimStart().startsWith("[") -> JSONArray(body)
|
||||
else -> {
|
||||
// Tolerate {"grids": [...]} wrappers too
|
||||
JSONObject(body).optJSONArray("grids") ?: return null
|
||||
}
|
||||
}
|
||||
val result = mutableSetOf<String>()
|
||||
for (i in 0 until array.length()) {
|
||||
val grid = array.optString(i, "").trim().uppercase()
|
||||
// Keep only well-formed 4-character Maidenhead squares (e.g. OL62)
|
||||
if (grid.length == 4 && grid[0] in 'A'..'R' && grid[1] in 'A'..'R'
|
||||
&& grid[2] in '0'..'9' && grid[3] in '0'..'9'
|
||||
) {
|
||||
result.add(grid)
|
||||
}
|
||||
}
|
||||
result
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.AdifCodec
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogSyncRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogSyncFetch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Pulls QSO records from a self-hosted Wavelog via the v1 API.
|
||||
*
|
||||
* Verified against Wavelog 3.0.2 (`Api.php::get_contacts_adif`):
|
||||
* - `POST {base}/index.php/api/get_contacts_adif`
|
||||
* with `{"key", "station_id": [id...], "fetchfromid": <last id>, "limit"}`.
|
||||
* - Responds `{"status":"successful","lastfetchedid":<id>,"exported_qsos":N,"adif":"..."}`;
|
||||
* only rows whose internal id is greater than `fetchfromid` are exported, so the cursor
|
||||
* makes the pull incremental. Each station keeps its own cursor: a station profile added
|
||||
* later starts from 0 and pulls its full history without touching the others'.
|
||||
* - The export is a full ADIF document; decoding reuses [AdifCodec].
|
||||
*/
|
||||
class WavelogSyncRepository internal constructor(
|
||||
client: OkHttpClient = OkHttpClient()
|
||||
) : IWavelogSyncRepository {
|
||||
|
||||
private val client = client.newBuilder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.callTimeout(180, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(false)
|
||||
.build()
|
||||
|
||||
private val jsonType = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
override suspend fun fetchNewRecords(
|
||||
url: String,
|
||||
apiKey: String,
|
||||
stations: List<WavelogStationInfo>,
|
||||
cursors: Map<String, Long>,
|
||||
full: Boolean
|
||||
): WavelogSyncFetch? = withContext(Dispatchers.IO) {
|
||||
val base = url.trim().trimEnd('/')
|
||||
val key = apiKey.trim()
|
||||
if (base.isBlank() || key.isBlank() || stations.isEmpty()) return@withContext null
|
||||
val records = mutableListOf<QsoRecord>()
|
||||
val newCursors = mutableMapOf<String, Long>()
|
||||
val failed = mutableListOf<String>()
|
||||
// Every station profile is fetched (including ones the server marks inactive:
|
||||
// their historical QSOs still belong to the account and the logbook shows them).
|
||||
stations.forEach { station ->
|
||||
var cursor = if (full) 0L else cursors[station.id] ?: 0L
|
||||
var advanced = false
|
||||
var stationFailed = false
|
||||
try {
|
||||
var guard = 0
|
||||
while (guard++ < MAX_CHUNKS_PER_STATION) {
|
||||
val page = fetchPage(base, key, station.id, cursor)
|
||||
if (page == null) {
|
||||
stationFailed = true
|
||||
break
|
||||
}
|
||||
cursor = maxOf(cursor, page.cursor)
|
||||
advanced = true
|
||||
page.records.forEach { records += it.copy(wavelogStation = station.id) }
|
||||
if (page.exported < CHUNK_LIMIT) break
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
stationFailed = true
|
||||
}
|
||||
// A partially fetched station keeps the cursor of its last good page, so the
|
||||
// next sync resumes exactly where this one stopped — nothing is fetched twice
|
||||
// and nothing is skipped.
|
||||
if (advanced) newCursors[station.id] = cursor
|
||||
if (stationFailed) failed += station.id
|
||||
}
|
||||
WavelogSyncFetch(records, newCursors, failed)
|
||||
}
|
||||
|
||||
internal data class Page(val records: List<QsoRecord>, val cursor: Long, val exported: Int)
|
||||
|
||||
private fun fetchPage(base: String, key: String, stationId: String, fromId: Long): Page? {
|
||||
val body = JSONObject().apply {
|
||||
put("key", key)
|
||||
put("station_id", JSONArray().put(stationId))
|
||||
put("fetchfromid", fromId)
|
||||
put("limit", CHUNK_LIMIT)
|
||||
}.toString()
|
||||
val request = Request.Builder()
|
||||
.url("$base/index.php/api/get_contacts_adif")
|
||||
.post(body.toRequestBody(jsonType))
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
parsePage(response.body.string())
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parsePage(text: String): Page? {
|
||||
val json = runCatching { JSONObject(text) }.getOrNull() ?: return null
|
||||
if (json.optString("status") != "successful") return null
|
||||
if (!json.has("lastfetchedid")) return null
|
||||
val cursor = json.optLong("lastfetchedid", 0L)
|
||||
val exported = json.optInt("exported_qsos", 0)
|
||||
val adif = json.optString("adif")
|
||||
if (adif.isBlank()) return Page(emptyList(), cursor, exported)
|
||||
return Page(AdifCodec.decode(adif), cursor, exported)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CHUNK_LIMIT = 5000
|
||||
const val MAX_CHUNKS_PER_STATION = 200
|
||||
}
|
||||
}
|
||||
@@ -131,4 +131,40 @@ class Pkcs12ReaderTest {
|
||||
Pkcs12Reader.read("not a p12 file at all".toByteArray(), "x".toCharArray())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsEmptyPasswordCertpbeNoneLayout() {
|
||||
// Certificates exported without a password must import with an empty char
|
||||
// array — the reader must decrypt, not demand a password.
|
||||
val (key, cert) = Pkcs12Reader.read(fixture("test_pbes2_empty.p12"), charArrayOf())
|
||||
assertEquals("RSA", key.algorithm)
|
||||
assertTrue("key/cert pair", keyMatches(key, cert))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsEmptyPasswordEncryptedCertLayout() {
|
||||
val (key, cert) = Pkcs12Reader.read(fixture("test_pbes2_empty_enc.p12"), charArrayOf())
|
||||
assertEquals("RSA", key.algorithm)
|
||||
assertTrue("key/cert pair", keyMatches(key, cert))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyPasswordOnProtectedFileFailsAsDecrypt() {
|
||||
// Blank input on a password-protected file must fail as a decryption error
|
||||
// (mapped to "password" upstream) — never silently succeed.
|
||||
val e = assertThrows(Exception::class.java) {
|
||||
Pkcs12Reader.read(fixture(), charArrayOf())
|
||||
}
|
||||
assertTrue("BadPadding", e is javax.crypto.BadPaddingException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun platformKeystoreAcceptsEmptyPassword() {
|
||||
// LoTWKeyMaterial first tries the platform PKCS12 KeyStore: verify the
|
||||
// empty-password load/getKey call convention this fix relies on.
|
||||
val ks = java.security.KeyStore.getInstance("PKCS12")
|
||||
ks.load(fixture("test_pbes2_empty.p12").inputStream(), charArrayOf())
|
||||
val alias = ks.aliases().nextElement()
|
||||
assertNotNull(ks.getKey(alias, charArrayOf()))
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -453,10 +453,9 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
|
||||
override fun setAmSatCallsign(callsign: String) = Unit
|
||||
|
||||
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
|
||||
|
||||
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
|
||||
|
||||
|
||||
|
||||
override val wavelogUploadSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings())
|
||||
@@ -479,6 +478,14 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
override fun setLastLotwSyncDate(date: String) = Unit
|
||||
override fun getLastLotwSyncCallsign(): String = ""
|
||||
override fun setLastLotwSyncCallsign(callsign: String) = Unit
|
||||
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList()
|
||||
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) = Unit
|
||||
override fun getWavelogSyncCursors(): Map<String, Long> = emptyMap()
|
||||
override fun setWavelogSyncCursors(cursors: Map<String, Long>) = Unit
|
||||
override fun getWavelogSyncUrl(): String = ""
|
||||
override fun setWavelogSyncUrl(url: String) = Unit
|
||||
override fun getLastWavelogSyncEpochMs(): Long = 0L
|
||||
override fun setLastWavelogSyncEpochMs(value: Long) = Unit
|
||||
|
||||
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
|
||||
|
||||
|
||||
+53
-4
@@ -296,14 +296,15 @@ class QsoRepositoryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markWavelogUploaded_setsOnlyTheWavelogFlag() = runBlocking {
|
||||
fun markWavelogUploaded_setsTheFlagAndTheStationStamp() = runBlocking {
|
||||
val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).toEntity())
|
||||
|
||||
repository.markWavelogUploaded(listOf(id))
|
||||
repository.markWavelogUploaded(listOf(id), "3")
|
||||
|
||||
val row = dao.getAll().single()
|
||||
assertTrue(row.wavelogUploaded)
|
||||
assertFalse(row.lotwUploaded)
|
||||
assertEquals("3", row.wavelogStation)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -316,6 +317,46 @@ class QsoRepositoryTest {
|
||||
assertEquals(1, dao.getAll().count { it.wavelogUploaded })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeWavelog_insertsNewRecordsAlreadyMarkedAndStationTagged() = runBlocking {
|
||||
val result = repository.mergeWavelog(
|
||||
listOf(loggedInApp("SO-50", lotwUploaded = false).copy(wavelogStation = "2"))
|
||||
)
|
||||
|
||||
assertEquals(1, result.imported)
|
||||
val row = dao.getAll().single()
|
||||
assertTrue(row.wavelogUploaded)
|
||||
assertEquals("2", row.wavelogStation)
|
||||
assertFalse(row.lotwUploaded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeWavelog_matchesALocalRowInsteadOfDuplicating() = runBlocking {
|
||||
val id = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = false).copy(wavelogUploaded = true).toEntity())
|
||||
|
||||
repository.mergeWavelog(
|
||||
listOf(loggedInApp("SAUDISAT 1C", lotwUploaded = false).copy(wavelogStation = "1", theirGrid = "EN52"))
|
||||
)
|
||||
|
||||
val rows = dao.getAll()
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals(id, rows.first().id)
|
||||
assertEquals("1", rows.first().wavelogStation)
|
||||
assertEquals("EN52", rows.first().theirGrid)
|
||||
// The row joins the ARRL spelling like every other import path.
|
||||
assertEquals("SO-50", rows.first().satelliteName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeWavelog_isIdempotentAcrossSyncs() = runBlocking {
|
||||
val records = listOf(loggedInApp("SO-50", lotwUploaded = false).copy(wavelogStation = "1"))
|
||||
|
||||
repository.mergeWavelog(records)
|
||||
repository.mergeWavelog(records)
|
||||
|
||||
assertEquals(1, dao.getAll().size)
|
||||
}
|
||||
|
||||
/** A record as the log page creates it: tracker name, repeater pair, uplink band. */
|
||||
private fun loggedInApp(satelliteName: String, lotwUploaded: Boolean) = QsoRecord(
|
||||
startUtcMillis = qsoStart,
|
||||
@@ -377,6 +418,7 @@ class QsoRepositoryTest {
|
||||
lotwConfirmed = lotwConfirmed,
|
||||
lotwUploaded = lotwUploaded,
|
||||
wavelogUploaded = wavelogUploaded,
|
||||
wavelogStation = wavelogStation,
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.joinToString(","),
|
||||
@@ -416,8 +458,15 @@ class QsoRepositoryTest {
|
||||
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) } }
|
||||
override suspend fun markWavelogUploaded(ids: List<Long>, stationId: String) {
|
||||
ids.forEach { id ->
|
||||
rows[id]?.let {
|
||||
rows[id] = it.copy(
|
||||
wavelogUploaded = true,
|
||||
wavelogStation = if (stationId.isBlank()) it.wavelogStation else stationId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -344,8 +344,7 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
|
||||
|
||||
override val wavelogUploadSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings())
|
||||
override fun updateWavelogUploadSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings) = Unit
|
||||
@@ -377,7 +376,7 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
override fun setAmSatCallsign(callsign: String) = Unit
|
||||
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
|
||||
|
||||
override fun getWorkedGrids(): Set<String> = emptySet()
|
||||
override fun setWorkedGrids(grids: Set<String>) = Unit
|
||||
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
|
||||
@@ -391,4 +390,12 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
|
||||
override fun setLastLotwSyncDate(date: String) = Unit
|
||||
override fun getLastLotwSyncCallsign(): String = ""
|
||||
override fun setLastLotwSyncCallsign(callsign: String) = Unit
|
||||
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList()
|
||||
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) = Unit
|
||||
override fun getWavelogSyncCursors(): Map<String, Long> = emptyMap()
|
||||
override fun setWavelogSyncCursors(cursors: Map<String, Long>) = Unit
|
||||
override fun getWavelogSyncUrl(): String = ""
|
||||
override fun setWavelogSyncUrl(url: String) = Unit
|
||||
override fun getLastWavelogSyncEpochMs(): Long = 0L
|
||||
override fun setLastWavelogSyncEpochMs(value: Long) = Unit
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Parsing of the `get_contacts_adif` response envelope (transport-level wiring is exercised on device). */
|
||||
class WavelogSyncRepositoryTest {
|
||||
|
||||
private val repository = WavelogSyncRepository()
|
||||
|
||||
@Test
|
||||
fun successfulPageParsesRecordsAndCursor() {
|
||||
val adif = "<ADIF_VER:5>3.1.7\n<EOH>\n" +
|
||||
"<QSO_DATE:8>20260916<TIME_ON:4>0745<CALL:6>BG5JSB<GRIDSQUARE:4>EN52" +
|
||||
"<MODE:2>FM<PROP_MODE:3>SAT<SAT_NAME:5>SO-50<EOR>\n"
|
||||
val text = JSONObject()
|
||||
.put("status", "successful")
|
||||
.put("lastfetchedid", 42)
|
||||
.put("exported_qsos", 1)
|
||||
.put("adif", adif)
|
||||
.toString()
|
||||
|
||||
val page = repository.parsePage(text)
|
||||
|
||||
assertNotNull(page)
|
||||
assertEquals(42L, page!!.cursor)
|
||||
assertEquals(1, page.exported)
|
||||
assertEquals(1, page.records.size)
|
||||
assertEquals("BG5JSB", page.records.single().theirCallsign)
|
||||
assertEquals("EN52", page.records.single().theirGrid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failedStatusIsRejected() {
|
||||
assertNull(repository.parsePage("""{"status":"failed","reason":"missing api key"}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingCursorIsRejected() {
|
||||
assertNull(repository.parsePage("""{"status":"successful","exported_qsos":0,"adif":null}"""))
|
||||
assertNull(repository.parsePage("not json at all"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyExportKeepsTheCursorAndYieldsNoRecords() {
|
||||
val page = repository.parsePage("""{"status":"successful","lastfetchedid":7,"exported_qsos":0,"adif":null}""")
|
||||
|
||||
assertNotNull(page)
|
||||
assertEquals(7L, page!!.cursor)
|
||||
assertTrue(page.records.isEmpty())
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
+14
-2
@@ -24,14 +24,26 @@ interface IQsoRepository {
|
||||
*/
|
||||
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>)
|
||||
/**
|
||||
* Marks the batch as uploaded to Wavelog (independent of the LoTW upload state) and
|
||||
* stamps the station profile (台址) the batch went out through, so the logbook's
|
||||
* 台址 selector can group the records. An empty [stationId] leaves the stamp as-is.
|
||||
*/
|
||||
suspend fun markWavelogUploaded(ids: List<Long>, stationId: String = "")
|
||||
|
||||
suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
|
||||
suspend fun importAdi(content: String): AdifImportResult
|
||||
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
|
||||
suspend fun mergeLoTW(records: List<QsoRecord>): AdifImportResult
|
||||
|
||||
/**
|
||||
* Merges QSO records pulled from Wavelog by a sync (the download direction):
|
||||
* matches by the same stable identity as every other import, tags matched and
|
||||
* freshly inserted rows with their Wavelog station profile, and marks them as
|
||||
* already uploaded so a later upload never sends them straight back.
|
||||
*/
|
||||
suspend fun mergeWavelog(records: List<QsoRecord>): AdifImportResult
|
||||
|
||||
/**
|
||||
* Folds LoTW confirmations that were imported as their own rows back into the local
|
||||
* record they belong to (see [com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs]).
|
||||
|
||||
@@ -42,6 +42,10 @@ data class QsoRecord(
|
||||
val lotwUploaded: Boolean = false,
|
||||
/** Internal: set after a successful Wavelog upload; keeps the pending filter small. Not shown in any list. */
|
||||
val wavelogUploaded: Boolean = false,
|
||||
/** Wavelog station profile (台址) this QSO belongs to: the profile it was uploaded
|
||||
* through, or the profile a sync pulled it from. Empty for records the app never
|
||||
* involved with Wavelog — the logbook's 台址 selector shows those under "All" only. */
|
||||
val wavelogStation: String = "",
|
||||
val lotwReceived: Boolean = false,
|
||||
val lotwQslDate: String = "",
|
||||
val vuccGrids: List<String> = emptyList(),
|
||||
|
||||
@@ -102,14 +102,7 @@ data class DataSourcesSettings(
|
||||
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
|
||||
}
|
||||
|
||||
data class WavelogSettings(
|
||||
val url: String = "",
|
||||
val token: String = ""
|
||||
) {
|
||||
val isConfigured: Boolean get() = url.isNotBlank() && token.isNotBlank()
|
||||
}
|
||||
|
||||
/** Wavelog QSO-upload configuration (a separate block from the worked-grids sync). */
|
||||
/** Wavelog configuration — QSO upload both ways; one of LoTW or Wavelog (mutually exclusive). */
|
||||
data class WavelogUploadSettings(
|
||||
val url: String = "",
|
||||
val apiKey: String = "",
|
||||
|
||||
+1
-1
@@ -33,8 +33,8 @@ interface IMainContainer {
|
||||
val databaseRepo: IDatabaseRepo
|
||||
val amSatRepo: IAmSatRepository
|
||||
val updateRepo: IUpdateRepository
|
||||
val wavelogRepo: IWavelogRepository
|
||||
val wavelogUploadRepository: IWavelogUploadRepository
|
||||
val wavelogSyncRepository: IWavelogSyncRepository
|
||||
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||
val qsoRepository: IQsoRepository
|
||||
val lotwUploadRepository: ILoTWUploadRepository
|
||||
|
||||
+17
-4
@@ -23,7 +23,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -107,9 +107,7 @@ interface ISettingsRepo {
|
||||
fun setAmSatCallsign(callsign: String)
|
||||
//endregion
|
||||
|
||||
//region # Wavelog worked-grids settings
|
||||
val wavelogSettings: StateFlow<WavelogSettings>
|
||||
fun updateWavelogSettings(settings: WavelogSettings)
|
||||
//region # Worked-grid data (written by the LoTW / Wavelog syncs)
|
||||
fun getWorkedGrids(): Set<String>
|
||||
fun setWorkedGrids(grids: Set<String>)
|
||||
/** Confirmed satellite QSOs grouped by worked 4-char gridsquare (map tap detail). */
|
||||
@@ -130,6 +128,21 @@ interface ISettingsRepo {
|
||||
fun updateWavelogUploadSettings(settings: WavelogUploadSettings)
|
||||
//endregion
|
||||
|
||||
//region # Wavelog sync bookkeeping
|
||||
/** Station profiles as last reported by the server — labels for the 台址 selector and targets for sync. */
|
||||
fun getWavelogStations(): List<WavelogStationInfo>
|
||||
fun setWavelogStations(stations: List<WavelogStationInfo>)
|
||||
/** stationId -> lastfetchedid cursors of the last successful sync (per station profile). */
|
||||
fun getWavelogSyncCursors(): Map<String, Long>
|
||||
fun setWavelogSyncCursors(cursors: Map<String, Long>)
|
||||
/** Wavelog base URL the stored cursors belong to (empty when never synced). */
|
||||
fun getWavelogSyncUrl(): String
|
||||
fun setWavelogSyncUrl(url: String)
|
||||
/** Epoch ms of the last successful Wavelog sync (0 when never synced). */
|
||||
fun getLastWavelogSyncEpochMs(): Long
|
||||
fun setLastWavelogSyncEpochMs(value: Long)
|
||||
//endregion
|
||||
|
||||
//region # LoTW confirmed-grids settings
|
||||
val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
|
||||
fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings)
|
||||
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
/** Fetches worked gridsquares from a self-hosted Wavelog instance (API v2). */
|
||||
interface IWavelogRepository {
|
||||
|
||||
/**
|
||||
* Fetch worked grids for the configured Wavelog URL/token.
|
||||
* Returns the grid set, or null on any failure.
|
||||
*/
|
||||
suspend fun fetchWorkedGrids(url: String, token: String): Set<String>?
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
|
||||
/** What a Wavelog sync pulls: everything, or only records newer than the stored cursors. */
|
||||
enum class WavelogSyncMode { Full, Incremental }
|
||||
|
||||
/** Outcome of one sync fetch. */
|
||||
data class WavelogSyncFetch(
|
||||
/** Records pulled this run, each tagged with the station profile it came from. */
|
||||
val records: List<QsoRecord>,
|
||||
/** stationId -> lastfetchedid to resume from on the next incremental run. */
|
||||
val cursors: Map<String, Long>,
|
||||
/** Station ids that could not be fetched (transport/parse); empty when all succeeded. */
|
||||
val failedStations: List<String>
|
||||
)
|
||||
|
||||
/**
|
||||
* Pulls QSO records from a self-hosted Wavelog (v1 API, `api/get_contacts_adif`).
|
||||
* One request per station profile; the server exports only rows newer than the
|
||||
* `fetchfromid` cursor, so repeated syncs stay incremental per station.
|
||||
*/
|
||||
interface IWavelogSyncRepository {
|
||||
|
||||
/**
|
||||
* Fetches records for every entry of [stations]. [cursors] maps stationId to the
|
||||
* last id already synced (missing -> 0); [full] re-pulls each station from the
|
||||
* beginning, ignoring the cursors. Returns null when the fetch cannot run at all
|
||||
* (blank url/key or no stations); individual station failures land in
|
||||
* [WavelogSyncFetch.failedStations] with the remaining stations still fetched.
|
||||
*/
|
||||
suspend fun fetchNewRecords(
|
||||
url: String,
|
||||
apiKey: String,
|
||||
stations: List<WavelogStationInfo>,
|
||||
cursors: Map<String, Long>,
|
||||
full: Boolean
|
||||
): WavelogSyncFetch?
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
|
||||
/** Trimmed base form of a Wavelog server URL (".../wavelog/" -> ".../wavelog"). */
|
||||
fun normalizeWavelogUrl(url: String): String = url.trim().trimEnd('/')
|
||||
|
||||
/**
|
||||
* Resolves the effective sync mode for a request:
|
||||
* - an explicit [WavelogSyncMode.Full] is always honored;
|
||||
* - an [WavelogSyncMode.Incremental] request is honored only when the stored sync URL
|
||||
* matches the current one — a first sync or a server switch falls back to
|
||||
* [WavelogSyncMode.Full], so cursors from another server never leak in.
|
||||
*/
|
||||
fun resolveWavelogSyncMode(storedUrl: String, currentUrl: String, requested: WavelogSyncMode?): WavelogSyncMode {
|
||||
if (requested == WavelogSyncMode.Full) return WavelogSyncMode.Full
|
||||
val sameServer = storedUrl.isNotBlank() && storedUrl == normalizeWavelogUrl(currentUrl)
|
||||
return if (sameServer) WavelogSyncMode.Incremental else WavelogSyncMode.Full
|
||||
}
|
||||
|
||||
/** Grid data derived from Wavelog-synced records, in the same shape the LoTW sync produces. */
|
||||
data class WavelogGridData(
|
||||
val grids: Set<String>,
|
||||
val gridQsos: Map<String, List<GridQso>>,
|
||||
val roamedGrids: Set<String>
|
||||
)
|
||||
|
||||
internal fun isValidGrid4(grid: String): Boolean =
|
||||
grid.length == 4 && grid[0] in 'A'..'R' && grid[1] in 'A'..'R' &&
|
||||
grid[2] in '0'..'9' && grid[3] in '0'..'9'
|
||||
|
||||
/**
|
||||
* Derives the worked-grid set, the per-grid QSO detail and the roamed (own) grids from
|
||||
* records pulled by a Wavelog sync — mirroring what the LoTW report parser produces, so
|
||||
* both sync sources feed the map's highlight and its 台址 selector the same data shape.
|
||||
*/
|
||||
fun buildWavelogGridData(records: List<QsoRecord>): WavelogGridData {
|
||||
val grids = linkedSetOf<String>()
|
||||
val roamed = linkedSetOf<String>()
|
||||
val gridQsos = linkedMapOf<String, MutableList<GridQso>>()
|
||||
records.forEach { record ->
|
||||
val isSatellite = record.satelliteName.isNotBlank() || record.propagationMode.equals("SAT", true)
|
||||
if (!isSatellite) return@forEach
|
||||
val theirGrids = (record.theirVuccGrids.ifEmpty { listOf(record.theirGrid) })
|
||||
.map { it.trim().uppercase(Locale.US).take(4) }
|
||||
.filter(::isValidGrid4)
|
||||
.distinct()
|
||||
if (theirGrids.isEmpty()) return@forEach
|
||||
val myGrids = (record.vuccGrids.ifEmpty { listOf(record.myGrid) })
|
||||
.map { it.trim().uppercase(Locale.US).take(4) }
|
||||
.filter(::isValidGrid4)
|
||||
.toSet()
|
||||
roamed += myGrids
|
||||
val qso = GridQso(
|
||||
call = record.theirCallsign,
|
||||
epochMs = record.startUtcMillis,
|
||||
satName = record.satelliteName,
|
||||
mode = record.mode,
|
||||
bandUp = record.band,
|
||||
bandDown = record.rxBand,
|
||||
dxcc = record.dxcc,
|
||||
country = record.country.ifBlank { null },
|
||||
cqz = record.cqZone,
|
||||
state = record.region.ifBlank { null },
|
||||
myGrid = myGrids.sorted().firstOrNull(),
|
||||
myGrids = myGrids,
|
||||
myCallsign = record.myCallsign.ifBlank { null },
|
||||
theirGrids = theirGrids
|
||||
)
|
||||
theirGrids.forEach { grid ->
|
||||
grids += grid
|
||||
gridQsos.getOrPut(grid) { mutableListOf() }.add(qso)
|
||||
}
|
||||
}
|
||||
return WavelogGridData(grids, gridQsos, roamed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a successful Wavelog sync to the stored map data and sync bookkeeping.
|
||||
*
|
||||
* Grids, per-grid QSO detail and roamed grids only ever GROW (union): both the
|
||||
* incremental and the full re-pull merge into the stored sets, matching how the LoTW
|
||||
* sync treats stale data — a full re-pull heals missing records but never deletes map
|
||||
* data the app already knows about. Cursors are replaced wholesale ([cursors] covers
|
||||
* every station just fetched), and the sync URL/epoch advance so the next run resumes
|
||||
* incrementally. Persistence runs on the IO dispatcher — building the per-grid QSO
|
||||
* JSON blocks the caller, and for large accounts that froze the main thread after a
|
||||
* manual sync.
|
||||
*
|
||||
* @return the resulting number of worked grids.
|
||||
*/
|
||||
suspend fun applyWavelogSyncResult(
|
||||
settingsRepo: ISettingsRepo,
|
||||
records: List<QsoRecord>,
|
||||
cursors: Map<String, Long>,
|
||||
url: String,
|
||||
now: Long = System.currentTimeMillis()
|
||||
): Int {
|
||||
val data = buildWavelogGridData(records)
|
||||
val existingGrids = settingsRepo.getWorkedGrids()
|
||||
val existingQsos = settingsRepo.getWorkedGridQsos()
|
||||
val existingRoamed = settingsRepo.getRoamedGrids()
|
||||
val mergedGrids = existingGrids + data.grids
|
||||
val mergedQsos = mergeGridQsos(existingQsos, data.gridQsos)
|
||||
val mergedRoamed = existingRoamed + data.roamedGrids
|
||||
settingsRepo.setWavelogSyncCursors(cursors)
|
||||
settingsRepo.setWavelogSyncUrl(normalizeWavelogUrl(url))
|
||||
settingsRepo.setLastWavelogSyncEpochMs(now)
|
||||
withContext(Dispatchers.IO) {
|
||||
settingsRepo.setWorkedGrids(mergedGrids)
|
||||
settingsRepo.setWorkedGridQsos(mergedQsos)
|
||||
settingsRepo.setRoamedGrids(mergedRoamed)
|
||||
}
|
||||
return mergedGrids.size
|
||||
}
|
||||
+16
-3
@@ -26,7 +26,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -248,7 +248,7 @@ class LoTWGridSyncTest {
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
|
||||
|
||||
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> = MutableStateFlow(WavelogUploadSettings())
|
||||
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
|
||||
|
||||
@@ -272,6 +272,19 @@ class LoTWGridSyncTest {
|
||||
override fun getLastLotwSyncCallsign(): String = lastSyncCallsign
|
||||
override fun setLastLotwSyncCallsign(callsign: String) { lastSyncCallsign = callsign }
|
||||
|
||||
private var wavelogStations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList()
|
||||
private var wavelogSyncCursors: Map<String, Long> = emptyMap()
|
||||
private var wavelogSyncUrl: String = ""
|
||||
private var lastWavelogSyncEpochMs: Long = 0L
|
||||
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = wavelogStations
|
||||
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) { wavelogStations = stations }
|
||||
override fun getWavelogSyncCursors(): Map<String, Long> = wavelogSyncCursors
|
||||
override fun setWavelogSyncCursors(cursors: Map<String, Long>) { wavelogSyncCursors = cursors }
|
||||
override fun getWavelogSyncUrl(): String = wavelogSyncUrl
|
||||
override fun setWavelogSyncUrl(url: String) { wavelogSyncUrl = url }
|
||||
override fun getLastWavelogSyncEpochMs(): Long = lastWavelogSyncEpochMs
|
||||
override fun setLastWavelogSyncEpochMs(value: Long) { lastWavelogSyncEpochMs = value }
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = TODO()
|
||||
override fun setSelectedTypes(types: List<String>) = TODO()
|
||||
override fun setPassesSettings(settings: PassesSettings) = TODO()
|
||||
@@ -293,7 +306,7 @@ class LoTWGridSyncTest {
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
override fun setAmSatCallsign(callsign: String) = TODO()
|
||||
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
|
||||
|
||||
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) = TODO()
|
||||
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
|
||||
}
|
||||
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
import com.rtbishop.look4sat.core.domain.model.LoTWSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.MarkedStation
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class WavelogSyncTest {
|
||||
|
||||
// region # resolveWavelogSyncMode
|
||||
|
||||
@Test
|
||||
fun fullRequestIsAlwaysHonored() {
|
||||
assertEquals(WavelogSyncMode.Full, resolveWavelogSyncMode("", "http://a", WavelogSyncMode.Full))
|
||||
assertEquals(WavelogSyncMode.Full, resolveWavelogSyncMode("http://a", "http://a", WavelogSyncMode.Full))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun incrementalIsHonoredOnlyOnTheSameServer() {
|
||||
assertEquals(
|
||||
WavelogSyncMode.Incremental,
|
||||
resolveWavelogSyncMode("http://a", "http://a/", WavelogSyncMode.Incremental)
|
||||
)
|
||||
assertEquals(WavelogSyncMode.Full, resolveWavelogSyncMode("", "http://a", WavelogSyncMode.Incremental))
|
||||
assertEquals(
|
||||
WavelogSyncMode.Full,
|
||||
resolveWavelogSyncMode("http://old", "http://a", WavelogSyncMode.Incremental)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun urlNormalizationTrimsWhitespaceAndSlashes() {
|
||||
assertEquals("http://a/wavelog", normalizeWavelogUrl(" http://a/wavelog/ "))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region # buildWavelogGridData
|
||||
|
||||
@Test
|
||||
fun gridDataCollectsTheirGridsAndRoamedOwnGrids() {
|
||||
val data = buildWavelogGridData(
|
||||
listOf(
|
||||
record(start = 1_000L, theirGrid = "EN52", myGrid = "OL62"),
|
||||
record(start = 2_000L, theirGrid = "EN53XX", myGrid = "OL62TI")
|
||||
)
|
||||
)
|
||||
assertEquals(setOf("EN52", "EN53"), data.grids)
|
||||
assertEquals(setOf("OL62"), data.roamedGrids)
|
||||
assertEquals(1, data.gridQsos.getValue("EN52").size)
|
||||
assertEquals(1, data.gridQsos.getValue("EN53").size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gridDataSpreadsAMultiGridQsoAcrossItsGrids() {
|
||||
val data = buildWavelogGridData(
|
||||
listOf(record(start = 1_000L, theirGrid = "EN52", theirVucc = listOf("EN52", "EN53")))
|
||||
)
|
||||
assertEquals(setOf("EN52", "EN53"), data.grids)
|
||||
assertEquals(1, data.gridQsos.getValue("EN52").size)
|
||||
assertEquals(1, data.gridQsos.getValue("EN53").size)
|
||||
// The same QSO object is shared, so its detail matches from either grid.
|
||||
assertEquals(data.gridQsos.getValue("EN52").single(), data.gridQsos.getValue("EN53").single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gridDataIgnoresNonSatelliteRecordsAndBlankGrids() {
|
||||
val data = buildWavelogGridData(
|
||||
listOf(
|
||||
record(start = 1L, theirGrid = "", sat = ""),
|
||||
record(start = 2L, theirGrid = "XX99", sat = "SO-50")
|
||||
)
|
||||
)
|
||||
assertTrue(data.grids.isEmpty())
|
||||
assertTrue(data.roamedGrids.isEmpty())
|
||||
assertTrue(data.gridQsos.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gridQsoCarriesTheFullRecordDetails() {
|
||||
val data = buildWavelogGridData(listOf(record(start = 1_000L, theirGrid = "EN52", myGrid = "OL62TI")))
|
||||
val qso = data.gridQsos.getValue("EN52").single()
|
||||
assertEquals("BG5JVM", qso.call)
|
||||
assertEquals(1_000L, qso.epochMs)
|
||||
assertEquals("SO-50", qso.satName)
|
||||
assertEquals("FM", qso.mode)
|
||||
assertEquals("2M", qso.bandUp)
|
||||
assertEquals("70CM", qso.bandDown)
|
||||
assertEquals("OL62", qso.myGrid)
|
||||
assertEquals(setOf("OL62"), qso.myGrids)
|
||||
assertEquals(listOf("EN52"), qso.theirGrids)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region # applyWavelogSyncResult
|
||||
|
||||
@Test
|
||||
fun applySyncMergesGridsAndAdvancesBookkeeping() = runBlocking {
|
||||
val settings = FakeSettings()
|
||||
settings.setWorkedGrids(setOf("PM01"))
|
||||
|
||||
val count = applyWavelogSyncResult(
|
||||
settings,
|
||||
listOf(record(start = 1_000L, theirGrid = "EN52", myGrid = "OL62")),
|
||||
mapOf("1" to 500L),
|
||||
"http://a/wavelog/",
|
||||
now = 42L
|
||||
)
|
||||
|
||||
assertEquals(setOf("PM01", "EN52"), settings.getWorkedGrids())
|
||||
assertEquals(setOf("OL62"), settings.getRoamedGrids())
|
||||
assertEquals(setOf("EN52"), settings.getWorkedGridQsos().keys)
|
||||
assertEquals(mapOf("1" to 500L), settings.getWavelogSyncCursors())
|
||||
assertEquals("http://a/wavelog", settings.getWavelogSyncUrl())
|
||||
assertEquals(42L, settings.getLastWavelogSyncEpochMs())
|
||||
assertEquals(2, count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun applySyncNeverShrinksStoredData() = runBlocking {
|
||||
val settings = FakeSettings()
|
||||
settings.setWorkedGrids(setOf("PM01"))
|
||||
settings.setRoamedGrids(setOf("OM60"))
|
||||
|
||||
applyWavelogSyncResult(settings, listOf(record(1_000L, "EN52", myGrid = "OL62")), mapOf("1" to 10L), "http://a")
|
||||
applyWavelogSyncResult(settings, listOf(record(2_000L, "EN53", myGrid = "OL63")), mapOf("1" to 20L), "http://a")
|
||||
|
||||
assertEquals(setOf("PM01", "EN52", "EN53"), settings.getWorkedGrids())
|
||||
assertEquals(setOf("OM60", "OL62", "OL63"), settings.getRoamedGrids())
|
||||
assertEquals(mapOf("1" to 20L), settings.getWavelogSyncCursors())
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
private fun record(
|
||||
start: Long,
|
||||
theirGrid: String = "",
|
||||
theirVucc: List<String> = emptyList(),
|
||||
myGrid: String = "OL62TI",
|
||||
sat: String = "SO-50"
|
||||
) = QsoRecord(
|
||||
startUtcMillis = start,
|
||||
theirCallsign = "BG5JVM",
|
||||
myCallsign = "BA7OPF",
|
||||
theirGrid = theirGrid,
|
||||
theirVuccGrids = theirVucc,
|
||||
myGrid = myGrid,
|
||||
txFrequencyHz = 145_850_000L,
|
||||
rxFrequencyHz = 436_795_000L,
|
||||
band = "2M",
|
||||
rxBand = "70CM",
|
||||
mode = "FM",
|
||||
satelliteName = sat,
|
||||
propagationMode = if (sat.isNotBlank()) "SAT" else "",
|
||||
status = QsoStatus.COMPLETE
|
||||
)
|
||||
|
||||
private class FakeSettings : ISettingsRepo {
|
||||
private var workedGrids: Set<String> = emptySet()
|
||||
private var workedGridQsos: Map<String, List<GridQso>> = emptyMap()
|
||||
private var roamedGrids: Set<String> = emptySet()
|
||||
private var cursors: Map<String, Long> = emptyMap()
|
||||
private var syncUrl: String = ""
|
||||
private var lastSync: Long = 0L
|
||||
|
||||
override fun getWorkedGrids(): Set<String> = workedGrids
|
||||
override fun setWorkedGrids(grids: Set<String>) { workedGrids = grids }
|
||||
override fun getWorkedGridQsos(): Map<String, List<GridQso>> = workedGridQsos
|
||||
override fun setWorkedGridQsos(qsos: Map<String, List<GridQso>>) { workedGridQsos = qsos }
|
||||
override fun getRoamedGrids(): Set<String> = roamedGrids
|
||||
override fun setRoamedGrids(grids: Set<String>) { roamedGrids = grids }
|
||||
override fun getWavelogSyncCursors(): Map<String, Long> = cursors
|
||||
override fun setWavelogSyncCursors(cursors: Map<String, Long>) { this.cursors = cursors }
|
||||
override fun getWavelogSyncUrl(): String = syncUrl
|
||||
override fun setWavelogSyncUrl(url: String) { syncUrl = url }
|
||||
override fun getLastWavelogSyncEpochMs(): Long = lastSync
|
||||
override fun setLastWavelogSyncEpochMs(value: Long) { lastSync = value }
|
||||
|
||||
override val appVersionName: String get() = TODO()
|
||||
override val selectedIds: StateFlow<List<Int>> get() = TODO()
|
||||
override val selectedTypes: StateFlow<List<String>> get() = TODO()
|
||||
override fun setSelectedIds(ids: List<Int>) = TODO()
|
||||
override fun setSelectedTypes(types: List<String>) = TODO()
|
||||
override val passesSettings: StateFlow<PassesSettings> get() = TODO()
|
||||
override fun setPassesSettings(settings: PassesSettings) = TODO()
|
||||
override val stationPosition: StateFlow<GeoPos> get() = TODO()
|
||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
|
||||
override fun setStationPosition(): Boolean = TODO()
|
||||
override fun setStationPosition(locator: String): Boolean = TODO()
|
||||
override fun getCurrentGrid(): String? = TODO()
|
||||
override val databaseState: StateFlow<DatabaseState> get() = TODO()
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
|
||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
|
||||
override fun updateDatabaseState(state: DatabaseState) = TODO()
|
||||
override val rcSettings: StateFlow<RCSettings> get() = TODO()
|
||||
override fun updateRCSettings(settings: RCSettings) = TODO()
|
||||
override val otherSettings: StateFlow<OtherSettings> get() = TODO()
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
|
||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> get() = TODO()
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
|
||||
override val dataSourcesStatus: StateFlow<Map<String, Int>> get() = TODO()
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> get() = TODO()
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
|
||||
override fun getSatelliteOffset(catnum: Int): String = TODO()
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||
override fun getSatelliteMode(catnum: Int): String = TODO()
|
||||
override fun setSatelliteMode(catnum: Int, mode: String) = TODO()
|
||||
override fun getAmSatCallsign(): String = TODO()
|
||||
override fun setAmSatCallsign(callsign: String) = TODO()
|
||||
|
||||
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> get() = TODO()
|
||||
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) = TODO()
|
||||
override fun getWavelogStations(): List<WavelogStationInfo> = emptyList()
|
||||
override fun setWavelogStations(stations: List<WavelogStationInfo>) = TODO()
|
||||
override fun getMarkedGridStations(): Map<String, List<MarkedStation>> = TODO()
|
||||
override fun setMarkedGridStations(stations: Map<String, List<MarkedStation>>) = TODO()
|
||||
override val lotwSettings: StateFlow<LoTWSettings> get() = TODO()
|
||||
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
|
||||
override fun getLastLotwSyncDate(): String = TODO()
|
||||
override fun setLastLotwSyncDate(date: String) = TODO()
|
||||
override fun getLastLotwSyncCallsign(): String = TODO()
|
||||
override fun setLastLotwSyncCallsign(callsign: String) = TODO()
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogSkipSummary
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
|
||||
|
||||
/**
|
||||
* Confirmation sheet for a Wavelog-mode upload (radar log page and settings logbook):
|
||||
* shows the station profile, the batch size and the records held back before the POST,
|
||||
* so the operator commits the batch after seeing what goes out (the Wavelog analog of
|
||||
* the LoTW preview flow).
|
||||
*/
|
||||
@Composable
|
||||
fun WavelogUploadPreviewDialog(
|
||||
preview: WavelogUploadPreview,
|
||||
busy: Boolean,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle(stringResource(R.string.prefs_logbook_upload_wavelog_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (preview.stationLabel.isNotBlank()) {
|
||||
Text(preview.stationLabel, fontSize = 13.sp)
|
||||
}
|
||||
Text("${preview.count} QSO(s) · ${preview.firstUtc} – ${preview.lastUtc}", fontSize = 13.sp)
|
||||
val skip = wavelogSkipSummary(preview)
|
||||
if (skip.isNotBlank()) {
|
||||
Text(skip, fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm, enabled = !busy) {
|
||||
Text(stringResource(R.string.prefs_logbook_upload_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -157,16 +157,20 @@
|
||||
<string name="prefs_station_lat_text">输入站点的纬度</string>
|
||||
<string name="prefs_station_lon_text">输入站点的经度</string>
|
||||
<string name="prefs_locator_title">QTH 网格</string>
|
||||
<string name="prefs_wavelog_title">Wavelog 已通联网格</string>
|
||||
<string name="prefs_wavelog_configure">配置</string>
|
||||
<string name="prefs_wavelog_configured">已从 Wavelog 同步 — 通联 %1$d 个网格</string>
|
||||
<string name="prefs_wavelog_not_configured">未配置 — 连接你的 Wavelog 日志以在地图上高亮已通联网格</string>
|
||||
<string name="prefs_wavelog_url">Wavelog 地址</string>
|
||||
<string name="prefs_wavelog_token">API key :日志 ID</string>
|
||||
<string name="prefs_wavelog_hint">已同步网格数:%1$d。格式:API key:日志ID。填好直接点同步,会自动保存并拉取。</string>
|
||||
<string name="prefs_wavelog_sync">立即同步</string>
|
||||
<string name="prefs_wavelog_syncing">同步中…</string>
|
||||
<string name="prefs_wavelog_upload_title">Wavelog 日志上传</string>
|
||||
<string name="prefs_wavelog_upload_title">Wavelog 配置</string>
|
||||
<string name="prefs_wavelog_exclusive">与 LoTW 二选一配置,两者不兼容。</string>
|
||||
<string name="prefs_wavelog_sync_incremental">增量同步</string>
|
||||
<string name="prefs_wavelog_sync_full">全量同步</string>
|
||||
<string name="prefs_wavelog_clear">清除配置</string>
|
||||
<string name="prefs_wavelog_clear_title">清除 Wavelog 配置?</string>
|
||||
<string name="prefs_wavelog_clear_message">清除后将切回 LoTW 模式,LoTW 功能恢复可用。</string>
|
||||
<string name="prefs_wavelog_switch_title">配置提醒</string>
|
||||
<string name="prefs_wavelog_switch_message">已配置 LoTW。配置 Wavelog 后,App 内 LoTW 的上传与同步将不可用(两者二选一,不兼容)。继续?</string>
|
||||
<string name="prefs_wavelog_switch_continue">继续</string>
|
||||
<string name="prefs_lotw_disabled_wavelog">Wavelog 模式中不可用(二选一)</string>
|
||||
<string name="prefs_wavelog_upload_not_configured">未配置 — 把本机通联记录上传到 Wavelog</string>
|
||||
<string name="prefs_wavelog_upload_configured">站点 %1$s — 可以上传</string>
|
||||
<string name="prefs_wavelog_upload_apikey">API key(读写权限)</string>
|
||||
@@ -211,7 +215,9 @@
|
||||
<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="prefs_logbook_station_label">台址</string>
|
||||
<string name="prefs_logbook_station_all">全部</string>
|
||||
<string name="prefs_logbook_upload_wavelog_title">上传到 Wavelog</string>
|
||||
<string name="lotw_upload_grid_title">上传前核对</string>
|
||||
<string name="lotw_upload_grid_fix">修改台址</string>
|
||||
<string name="lotw_upload_grid_ignore">忽略</string>
|
||||
@@ -223,6 +229,7 @@
|
||||
<string name="prefs_lotw_upload_busy">处理中…</string>
|
||||
<string name="prefs_lotw_upload_cert_hint">导入你的 TrustedQSL .p12 证书(与 TQSL 同款)用于签名上传通联。密码仅加密保存在手机本地。</string>
|
||||
<string name="prefs_lotw_upload_password">证书密码</string>
|
||||
<string name="prefs_lotw_upload_password_hint">证书无密码时留空即可</string>
|
||||
<string name="prefs_lotw_upload_import">选择 .p12 文件</string>
|
||||
<string name="prefs_lotw_upload_import_confirm">导入</string>
|
||||
<string name="prefs_lotw_upload_error_password">证书密码错误,请检查 TQSL 导出 .p12 时设置的密码</string>
|
||||
|
||||
@@ -186,16 +186,20 @@
|
||||
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
|
||||
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
|
||||
<string name="prefs_locator_title">QTH locator settings</string>
|
||||
<string name="prefs_wavelog_title">Wavelog worked grids</string>
|
||||
<string name="prefs_wavelog_configure">Configure</string>
|
||||
<string name="prefs_wavelog_configured">Synced from Wavelog — %1$d grids worked</string>
|
||||
<string name="prefs_wavelog_not_configured">Not configured — connect your Wavelog logbook to highlight worked grids on the map</string>
|
||||
<string name="prefs_wavelog_url">Wavelog URL</string>
|
||||
<string name="prefs_wavelog_token">API key : logbook ID</string>
|
||||
<string name="prefs_wavelog_hint">Worked grids synced: %1$d. Format: API key, colon, logbook ID. Just tap Sync — credentials are saved automatically.</string>
|
||||
<string name="prefs_wavelog_sync">Sync now</string>
|
||||
<string name="prefs_wavelog_syncing">Syncing…</string>
|
||||
<string name="prefs_wavelog_upload_title">Wavelog log upload</string>
|
||||
<string name="prefs_wavelog_upload_title">Wavelog configuration</string>
|
||||
<string name="prefs_wavelog_exclusive">Configure either LoTW or Wavelog — the two cannot be used together.</string>
|
||||
<string name="prefs_wavelog_sync_incremental">Incremental sync</string>
|
||||
<string name="prefs_wavelog_sync_full">Full sync</string>
|
||||
<string name="prefs_wavelog_clear">Clear configuration</string>
|
||||
<string name="prefs_wavelog_clear_title">Clear the Wavelog configuration?</string>
|
||||
<string name="prefs_wavelog_clear_message">LoTW features become available again.</string>
|
||||
<string name="prefs_wavelog_switch_title">Notice</string>
|
||||
<string name="prefs_wavelog_switch_message">LoTW is already configured. With Wavelog configured, LoTW upload and sync in this app become unavailable (the two are mutually exclusive). Continue?</string>
|
||||
<string name="prefs_wavelog_switch_continue">Continue</string>
|
||||
<string name="prefs_lotw_disabled_wavelog">Unavailable while Wavelog is configured</string>
|
||||
<string name="prefs_wavelog_upload_not_configured">Not configured — upload your local QSO records to Wavelog</string>
|
||||
<string name="prefs_wavelog_upload_configured">Station %1$s — ready to upload</string>
|
||||
<string name="prefs_wavelog_upload_apikey">API key (read & write)</string>
|
||||
@@ -240,7 +244,9 @@
|
||||
<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="prefs_logbook_station_label">Station</string>
|
||||
<string name="prefs_logbook_station_all">All</string>
|
||||
<string name="prefs_logbook_upload_wavelog_title">Upload to Wavelog</string>
|
||||
<string name="lotw_upload_grid_title">Upload check</string>
|
||||
<string name="lotw_upload_grid_fix">Fix station location</string>
|
||||
<string name="lotw_upload_grid_ignore">Ignore</string>
|
||||
@@ -252,6 +258,7 @@
|
||||
<string name="prefs_lotw_upload_busy">Working…</string>
|
||||
<string name="prefs_lotw_upload_cert_hint">Import your TrustedQSL .p12 certificate (same one you use in TQSL) to sign and upload QSOs. The password is stored encrypted on this device only.</string>
|
||||
<string name="prefs_lotw_upload_password">Certificate password</string>
|
||||
<string name="prefs_lotw_upload_password_hint">Leave blank if the certificate has no password</string>
|
||||
<string name="prefs_lotw_upload_import">Choose .p12 file</string>
|
||||
<string name="prefs_lotw_upload_import_confirm">Import</string>
|
||||
<string name="prefs_lotw_upload_error_password">Incorrect certificate password. Check the password you set when exporting the .p12 from TQSL.</string>
|
||||
|
||||
@@ -94,22 +94,11 @@ class MapViewModel(
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.wavelogSettings.collectLatest { _ ->
|
||||
val workedGrids = settingsRepo.getWorkedGrids()
|
||||
val markedGrids = settingsRepo.getMarkedGridStations()
|
||||
// Marked stations auto-clear once their grid becomes worked:
|
||||
// the reminder has served its purpose (user req: 网格变绿后标记自动清除).
|
||||
val stale = markedGrids.keys.filter { it in workedGrids }
|
||||
val activeMarks = if (stale.isEmpty()) markedGrids else markedGrids - stale.toSet()
|
||||
if (stale.isNotEmpty()) settingsRepo.setMarkedGridStations(activeMarks)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
workedGrids = workedGrids,
|
||||
workedGridQsos = settingsRepo.getWorkedGridQsos(),
|
||||
roamedGrids = settingsRepo.getRoamedGrids(),
|
||||
markedGrids = activeMarks
|
||||
)
|
||||
}
|
||||
// Grid + mark data reloads whenever the screen becomes visible (and once at
|
||||
// init): the LoTW/Wavelog syncs run from Settings or the app start, so
|
||||
// re-entering the map picks their results up.
|
||||
isScreenVisible.collect { visible ->
|
||||
if (visible) refreshMapGridData()
|
||||
}
|
||||
}
|
||||
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
|
||||
@@ -117,6 +106,25 @@ class MapViewModel(
|
||||
getStationPosition()
|
||||
}
|
||||
|
||||
/** Loads the worked-grid / mark data into the map state (see the visibility collector). */
|
||||
private fun refreshMapGridData() {
|
||||
val workedGrids = settingsRepo.getWorkedGrids()
|
||||
val markedGrids = settingsRepo.getMarkedGridStations()
|
||||
// Marked stations auto-clear once their grid becomes worked:
|
||||
// the reminder has served its purpose (user req: 网格变绿后标记自动清除).
|
||||
val stale = markedGrids.keys.filter { it in workedGrids }
|
||||
val activeMarks = if (stale.isEmpty()) markedGrids else markedGrids - stale.toSet()
|
||||
if (stale.isNotEmpty()) settingsRepo.setMarkedGridStations(activeMarks)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
workedGrids = workedGrids,
|
||||
workedGridQsos = settingsRepo.getWorkedGridQsos(),
|
||||
roamedGrids = settingsRepo.getRoamedGrids(),
|
||||
markedGrids = activeMarks
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onAction(action: MapAction) {
|
||||
when (action) {
|
||||
MapAction.SelectPrev -> scrollSelection(true)
|
||||
|
||||
@@ -27,7 +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.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
@@ -107,7 +107,7 @@ class FakeSettingsRepo(
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
|
||||
|
||||
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> = MutableStateFlow(WavelogUploadSettings())
|
||||
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) = Unit
|
||||
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
|
||||
@@ -137,7 +137,7 @@ class FakeSettingsRepo(
|
||||
override fun setSatelliteMode(catnum: Int, mode: String) = TODO()
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
override fun setAmSatCallsign(callsign: String) = TODO()
|
||||
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
|
||||
|
||||
override fun getWorkedGrids(): Set<String> = TODO()
|
||||
override fun setWorkedGrids(grids: Set<String>) = TODO()
|
||||
override fun getWorkedGridQsos(): Map<String, List<GridQso>> = TODO()
|
||||
@@ -151,4 +151,12 @@ class FakeSettingsRepo(
|
||||
override fun setLastLotwSyncDate(date: String) = Unit
|
||||
override fun getLastLotwSyncCallsign(): String = ""
|
||||
override fun setLastLotwSyncCallsign(callsign: String) = Unit
|
||||
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList()
|
||||
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) = Unit
|
||||
override fun getWavelogSyncCursors(): Map<String, Long> = emptyMap()
|
||||
override fun setWavelogSyncCursors(cursors: Map<String, Long>) = Unit
|
||||
override fun getWavelogSyncUrl(): String = ""
|
||||
override fun setWavelogSyncUrl(url: String) = Unit
|
||||
override fun getLastWavelogSyncEpochMs(): Long = 0L
|
||||
override fun setLastWavelogSyncEpochMs(value: Long) = Unit
|
||||
}
|
||||
@@ -68,6 +68,7 @@ import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
|
||||
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
|
||||
import com.rtbishop.look4sat.core.presentation.gridsLabel
|
||||
import com.rtbishop.look4sat.core.presentation.LoTWPositionWarningDialog
|
||||
import com.rtbishop.look4sat.core.presentation.WavelogUploadPreviewDialog
|
||||
|
||||
@Composable
|
||||
fun LogPage(
|
||||
@@ -212,7 +213,7 @@ fun LogPage(
|
||||
onClick = logViewModel::prepareUpload,
|
||||
enabled = !logUiState.busy,
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text("上传 LoTW", fontSize = 13.sp) }
|
||||
) { Text(if (logUiState.wavelogMode) "上传 Wavelog" else "上传 LoTW", fontSize = 13.sp) }
|
||||
}
|
||||
if (logUiState.busy) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -260,19 +261,27 @@ fun LogPage(
|
||||
UploadPreviewDialog(
|
||||
preview = preview,
|
||||
busy = logUiState.busy,
|
||||
wavelogCount = logUiState.wavelogPending?.count ?: 0,
|
||||
onConfirm = logViewModel::confirmUpload,
|
||||
onDismiss = logViewModel::dismissPreview
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
logUiState.wavelogPreview?.let { preview ->
|
||||
WavelogUploadPreviewDialog(
|
||||
preview = preview,
|
||||
busy = logUiState.busy,
|
||||
onConfirm = logViewModel::confirmWavelogUpload,
|
||||
onDismiss = logViewModel::dismissWavelogPreview
|
||||
)
|
||||
}
|
||||
|
||||
if (logUiState.message.isNotBlank()) {
|
||||
AlertDialog(
|
||||
onDismissRequest = logViewModel::clearMessage,
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle("LoTW Upload") },
|
||||
title = { SheetDialogTitle(if (logUiState.wavelogMode) "Wavelog Upload" else "LoTW Upload") },
|
||||
text = { Text(logUiState.message) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = logViewModel::clearMessage) { Text("OK") }
|
||||
@@ -407,7 +416,6 @@ private fun PostDialog(
|
||||
private fun UploadPreviewDialog(
|
||||
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
|
||||
busy: Boolean,
|
||||
wavelogCount: Int = 0,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
@@ -456,13 +464,6 @@ private fun UploadPreviewDialog(
|
||||
}
|
||||
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)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -86,9 +86,10 @@ data class LogUiState(
|
||||
val message: String = "",
|
||||
val postText: String? = null,
|
||||
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,
|
||||
/** True when uploads route through Wavelog (Wavelog configured — LoTW paused). */
|
||||
val wavelogMode: Boolean = false,
|
||||
/** Wavelog mode: the prepared batch awaiting the operator's confirmation. */
|
||||
val wavelogPreview: WavelogUploadPreview? = null,
|
||||
/** Out-of-window contact waiting for the operator's confirmation (null when closed). */
|
||||
val outOfWindowLog: OutOfWindowLog? = null
|
||||
)
|
||||
@@ -124,6 +125,13 @@ class LogViewModel(
|
||||
)
|
||||
}
|
||||
}
|
||||
// Upload routing follows the Wavelog configuration: once it is ready the log
|
||||
// page uploads through Wavelog and skips LoTW entirely (the one-of-two rule).
|
||||
viewModelScope.launch {
|
||||
settingsRepo.wavelogUploadSettings.collect { settings ->
|
||||
_uiState.update { it.copy(wavelogMode = settings.isReady) }
|
||||
}
|
||||
}
|
||||
// The roaming hint tracks the freshest position data: refresh on init and every fix.
|
||||
viewModelScope.launch {
|
||||
settingsRepo.stationPosition.collect { refreshPositionHintNow() }
|
||||
@@ -268,20 +276,16 @@ class LogViewModel(
|
||||
_uiState.update { it.copy(busy = true, message = "") }
|
||||
try {
|
||||
val all = qsoRepository.records.first()
|
||||
val wavelogSettings = settingsRepo.wavelogUploadSettings.value
|
||||
if (wavelogSettings.isReady) {
|
||||
// Wavelog mode: the upload goes to Wavelog only (LoTW stays out).
|
||||
prepareWavelogUpload(all, wavelogSettings)
|
||||
return@launch
|
||||
}
|
||||
// Only local (non-confirmed) records are candidates for upload;
|
||||
// LoTW-imported confirmations are the feedback side.
|
||||
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == QsoStatus.COMPLETE }
|
||||
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) {
|
||||
val msg = when {
|
||||
audit.unavailable > 0 -> unavailableUploadSummary(
|
||||
@@ -291,14 +295,7 @@ class LogViewModel(
|
||||
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
|
||||
else -> "No pending QSOs to upload"
|
||||
}
|
||||
// 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"))
|
||||
}
|
||||
_uiState.update { it.copy(busy = false, message = msg) }
|
||||
return@launch
|
||||
}
|
||||
val preview = lotwUploadRepository.prepare(pending, false)
|
||||
@@ -309,7 +306,6 @@ class LogViewModel(
|
||||
it.copy(
|
||||
busy = false,
|
||||
preview = preview,
|
||||
wavelogPending = wavelogPreview,
|
||||
uploadPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
|
||||
)
|
||||
}
|
||||
@@ -321,6 +317,24 @@ class LogViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Wavelog-mode branch of [prepareUpload]: prepare the batch and ask for confirmation. */
|
||||
private suspend fun prepareWavelogUpload(all: List<QsoRecord>, settings: WavelogUploadSettings) {
|
||||
val preview = runCatching {
|
||||
wavelogUploadRepository.prepare(wavelogUploadCandidates(all), settings)
|
||||
}.getOrNull()
|
||||
if (preview == null) {
|
||||
_uiState.update { it.copy(busy = false, message = "Wavelog upload failed to prepare — check the configuration") }
|
||||
return
|
||||
}
|
||||
if (preview.count == 0) {
|
||||
_uiState.update {
|
||||
it.copy(busy = false, message = wavelogIdleSegment(preview) ?: "No pending QSOs to upload to Wavelog")
|
||||
}
|
||||
return
|
||||
}
|
||||
_uiState.update { it.copy(busy = false, wavelogPreview = preview) }
|
||||
}
|
||||
|
||||
fun confirmUpload() {
|
||||
val preview = _uiState.value.preview ?: return
|
||||
viewModelScope.launch {
|
||||
@@ -338,27 +352,28 @@ class LogViewModel(
|
||||
LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry"
|
||||
LoTWUploadResult.ExpiredPreview -> "Preview expired — tap upload again"
|
||||
}
|
||||
// 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")
|
||||
)
|
||||
}
|
||||
_uiState.update { it.copy(busy = false, preview = null, message = msg) }
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissPreview() = _uiState.update { it.copy(preview = null, wavelogPending = null) }
|
||||
/** Confirms the Wavelog-mode batch prepared by [prepareUpload]. */
|
||||
fun confirmWavelogUpload() {
|
||||
val preview = _uiState.value.wavelogPreview ?: return
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(busy = true) }
|
||||
val msg = uploadWavelogNow(preview, settingsRepo.wavelogUploadSettings.value)
|
||||
_uiState.update { it.copy(busy = false, wavelogPreview = null, message = msg) }
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissPreview() = _uiState.update { it.copy(preview = null) }
|
||||
|
||||
fun dismissWavelogPreview() = _uiState.update { it.copy(wavelogPreview = null) }
|
||||
|
||||
/**
|
||||
* Uploads one prepared Wavelog batch (piggyback) and marks what the server
|
||||
* accepted. Returns the operator-facing message segment for this upload.
|
||||
* Uploads one prepared Wavelog batch and marks what the server accepted (stamped
|
||||
* with the station profile the batch went out through). Returns the operator-facing
|
||||
* message for this upload.
|
||||
*/
|
||||
private suspend fun uploadWavelogNow(
|
||||
preview: WavelogUploadPreview,
|
||||
@@ -366,7 +381,7 @@ class LogViewModel(
|
||||
): String = try {
|
||||
val outcome = wavelogUploadRepository.upload(preview, settings)
|
||||
if (outcome is WavelogUploadOutcome.Imported && outcome.markIds.isNotEmpty()) {
|
||||
qsoRepository.markWavelogUploaded(outcome.markIds)
|
||||
qsoRepository.markWavelogUploaded(outcome.markIds, settings.stationId)
|
||||
}
|
||||
wavelogConfirmedSegment(outcome, preview)
|
||||
} catch (_: Exception) {
|
||||
@@ -380,7 +395,7 @@ class LogViewModel(
|
||||
fun abandonForGridFix() {
|
||||
lotwUploadRepository.discardPreview()
|
||||
lastUploadedIds = emptyList()
|
||||
_uiState.update { it.copy(uploadPositionWarning = null, preview = null, wavelogPending = null) }
|
||||
_uiState.update { it.copy(uploadPositionWarning = null, preview = null) }
|
||||
}
|
||||
|
||||
/** Recompute the log-page roaming hint from the freshest station/position data. */
|
||||
|
||||
+25
-3
@@ -44,6 +44,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
@@ -63,10 +64,15 @@ import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
fun LoTWUploadCard(
|
||||
hasCertificate: Boolean,
|
||||
stationGrid: String,
|
||||
enabled: Boolean = true,
|
||||
showUploadConfigDialog: () -> Unit
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth().clickable { showUploadConfigDialog() }) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth().clickable(enabled = enabled) { showUploadConfigDialog() }) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.alpha(if (enabled) 1f else 0.5f)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_lotw_upload_title),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
@@ -81,6 +87,15 @@ fun LoTWUploadCard(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2
|
||||
)
|
||||
if (!enabled) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_lotw_disabled_wavelog),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,6 +215,11 @@ fun LoTWUploadConfigDialog(
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_lotw_upload_password_hint),
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
CardButton(
|
||||
onClick = {
|
||||
val bytes = runCatching {
|
||||
@@ -212,7 +232,9 @@ fun LoTWUploadConfigDialog(
|
||||
}
|
||||
},
|
||||
text = stringResource(R.string.prefs_lotw_upload_import_confirm),
|
||||
isEnabled = password.isNotBlank() && !busy,
|
||||
// An empty field is a legitimate attempt: certificates exported
|
||||
// without a password import with a blank password.
|
||||
isEnabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
+65
-12
@@ -13,6 +13,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -23,6 +24,8 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -54,6 +57,7 @@ import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
|
||||
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
|
||||
import com.rtbishop.look4sat.core.presentation.gridsLabel
|
||||
import com.rtbishop.look4sat.core.presentation.LoTWPositionWarningDialog
|
||||
import com.rtbishop.look4sat.core.presentation.WavelogUploadPreviewDialog
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import com.rtbishop.look4sat.core.presentation.SwipeController
|
||||
@@ -105,8 +109,14 @@ fun LogbookDialog(
|
||||
onToggleSelection: (Long) -> Unit = {},
|
||||
onExitSelection: () -> Unit = {},
|
||||
onResubmitSelected: () -> Unit = {},
|
||||
/** Submit count of the Wavelog batch riding this upload (0 hides the note). */
|
||||
wavelogCount: Int = 0
|
||||
/** Wavelog mode: the prepared batch awaiting confirmation (null otherwise). */
|
||||
wavelogPreview: com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview? = null,
|
||||
/** True when uploads route through Wavelog — titles and previews follow the mode. */
|
||||
wavelogMode: Boolean = false,
|
||||
/** Wavelog station profiles for the 台址 selector (empty hides the selector). */
|
||||
stationOptions: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList(),
|
||||
stationFilter: String? = null,
|
||||
onStationFilterChange: (String?) -> Unit = {}
|
||||
) {
|
||||
val swipeController = rememberSwipeController()
|
||||
// Entering selection mode closes any row left swiped open — reveals are off there.
|
||||
@@ -147,6 +157,51 @@ fun LogbookDialog(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (stationOptions.isNotEmpty()) {
|
||||
// 台址 selector: filters the list to one Wavelog station profile
|
||||
// (its QSOs) — "All" keeps everything. The menu scrolls natively.
|
||||
var stationMenuOpen by remember { mutableStateOf(false) }
|
||||
val currentLabel = stationOptions.firstOrNull { it.id == stationFilter }?.let { station ->
|
||||
listOf(station.name, station.callsign).filter { it.isNotBlank() }.joinToString(" · ")
|
||||
} ?: stringResource(R.string.prefs_logbook_station_all)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.prefs_logbook_station_label), fontSize = 12.sp)
|
||||
Box {
|
||||
TextButton(onClick = { stationMenuOpen = true }) {
|
||||
Text(currentLabel, fontSize = 12.sp)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = stationMenuOpen,
|
||||
onDismissRequest = { stationMenuOpen = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.prefs_logbook_station_all)) },
|
||||
onClick = {
|
||||
onStationFilterChange(null)
|
||||
stationMenuOpen = false
|
||||
}
|
||||
)
|
||||
stationOptions.forEach { station ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
listOf(station.name, station.callsign, station.grid)
|
||||
.filter { it.isNotBlank() }.joinToString(" · ")
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
onStationFilterChange(station.id)
|
||||
stationMenuOpen = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
@@ -179,11 +234,17 @@ fun LogbookDialog(
|
||||
onFixStation = { onFixGrid(listOf(positionWarning.currentGrid)) },
|
||||
onIgnore = onIgnorePositionWarning
|
||||
)
|
||||
} else if (wavelogPreview != null) {
|
||||
WavelogUploadPreviewDialog(
|
||||
preview = wavelogPreview,
|
||||
busy = uploadBusy,
|
||||
onConfirm = onConfirmUpload,
|
||||
onDismiss = onDismissPreview
|
||||
)
|
||||
} else if (preview != null) {
|
||||
LogbookUploadPreviewDialog(
|
||||
preview = preview,
|
||||
busy = uploadBusy,
|
||||
wavelogCount = wavelogCount,
|
||||
onConfirm = onConfirmUpload,
|
||||
onDismiss = onDismissPreview
|
||||
)
|
||||
@@ -193,7 +254,7 @@ fun LogbookDialog(
|
||||
onDismissRequest = onDismissMessage,
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle("LoTW Upload") },
|
||||
title = { SheetDialogTitle(if (wavelogMode) "Wavelog Upload" else "LoTW Upload") },
|
||||
text = { Text(uploadMessage) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismissMessage) { Text("OK") }
|
||||
@@ -218,7 +279,6 @@ fun LogbookDialog(
|
||||
private fun LogbookUploadPreviewDialog(
|
||||
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
|
||||
busy: Boolean,
|
||||
wavelogCount: Int,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
@@ -259,13 +319,6 @@ private fun LogbookUploadPreviewDialog(
|
||||
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)
|
||||
}
|
||||
},
|
||||
|
||||
+111
-79
@@ -28,6 +28,8 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -39,6 +41,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -46,6 +49,7 @@ import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -69,6 +73,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -158,72 +163,6 @@ fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Uni
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WavelogDialog(
|
||||
initialSettings: com.rtbishop.look4sat.core.domain.model.WavelogSettings,
|
||||
workedGridsCount: Int,
|
||||
isSyncing: Boolean,
|
||||
message: String?,
|
||||
dismiss: () -> Unit,
|
||||
onSave: (com.rtbishop.look4sat.core.domain.model.WavelogSettings) -> Unit,
|
||||
onSync: (com.rtbishop.look4sat.core.domain.model.WavelogSettings) -> Unit
|
||||
) {
|
||||
val url = rememberSaveable { mutableStateOf(initialSettings.url) }
|
||||
val token = rememberSaveable { mutableStateOf(initialSettings.token) }
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.prefs_wavelog_title),
|
||||
onCancel = dismiss,
|
||||
onAccept = {
|
||||
onSave(com.rtbishop.look4sat.core.domain.model.WavelogSettings(url.value, token.value))
|
||||
dismiss()
|
||||
}
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = url.value,
|
||||
onValueChange = { url.value = it },
|
||||
label = { Text(text = stringResource(id = R.string.prefs_wavelog_url)) },
|
||||
placeholder = { Text(text = "http://192.168.1.10") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = token.value,
|
||||
onValueChange = { token.value = it },
|
||||
label = { Text(text = stringResource(id = R.string.prefs_wavelog_token)) },
|
||||
placeholder = { Text(text = "abcdef123456:1") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_wavelog_hint, workedGridsCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
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)
|
||||
)
|
||||
}
|
||||
// Sync uses the values typed in the fields directly — saving and syncing
|
||||
// happen in one step, no need to close and reopen the dialog.
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { onSync(com.rtbishop.look4sat.core.domain.model.WavelogSettings(url.value, token.value)) },
|
||||
enabled = !isSyncing
|
||||
) {
|
||||
Text(text = if (isSyncing) stringResource(R.string.prefs_wavelog_syncing)
|
||||
else stringResource(R.string.prefs_wavelog_sync))
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(0.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WavelogUploadDialog(
|
||||
@@ -231,14 +170,20 @@ fun WavelogUploadDialog(
|
||||
stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>,
|
||||
rights: String,
|
||||
probeBusy: Boolean,
|
||||
isSyncing: Boolean,
|
||||
lastSyncEpochMs: Long,
|
||||
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
|
||||
onSelectStation: (com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo) -> Unit,
|
||||
onSyncIncremental: () -> Unit,
|
||||
onSyncFull: () -> Unit,
|
||||
onClear: () -> Unit
|
||||
) {
|
||||
val url = rememberSaveable { mutableStateOf(initialSettings.url) }
|
||||
val apiKey = rememberSaveable { mutableStateOf(initialSettings.apiKey) }
|
||||
var showClearConfirm by remember { mutableStateOf(false) }
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.prefs_wavelog_upload_title),
|
||||
onCancel = dismiss,
|
||||
@@ -293,18 +238,27 @@ fun WavelogUploadDialog(
|
||||
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)
|
||||
)
|
||||
// Many station profiles must stay reachable: the list scrolls inside a
|
||||
// bounded height instead of stretching (or clipping) the dialog.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 240.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
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()) {
|
||||
@@ -319,6 +273,43 @@ fun WavelogUploadDialog(
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
}
|
||||
if (lastSyncEpochMs != 0L) {
|
||||
val timePattern = stringResource(R.string.prefs_updated_time)
|
||||
val lastSyncText = remember(lastSyncEpochMs) {
|
||||
java.text.SimpleDateFormat(timePattern, java.util.Locale.getDefault())
|
||||
.format(java.util.Date(lastSyncEpochMs))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_updated_title, lastSyncText),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
}
|
||||
// Sync pulls the server's QSOs (and the grid data the map highlights) — the
|
||||
// incremental button resumes from the stored cursor, the full one re-pulls.
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
|
||||
) {
|
||||
CardButton(
|
||||
onClick = onSyncFull,
|
||||
text = stringResource(R.string.prefs_wavelog_sync_full),
|
||||
isEnabled = !isSyncing && !probeBusy
|
||||
)
|
||||
CardButton(
|
||||
onClick = onSyncIncremental,
|
||||
text = stringResource(R.string.prefs_wavelog_sync_incremental),
|
||||
isEnabled = !isSyncing && !probeBusy
|
||||
)
|
||||
}
|
||||
if (isSyncing) {
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_wavelog_syncing),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
}
|
||||
if (message != null) {
|
||||
Text(
|
||||
text = message,
|
||||
@@ -332,8 +323,49 @@ fun WavelogUploadDialog(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
TextButton(
|
||||
onClick = { showClearConfirm = true },
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
) {
|
||||
Text(text = stringResource(R.string.prefs_wavelog_clear), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(0.dp))
|
||||
}
|
||||
if (showClearConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showClearConfirm = false },
|
||||
title = { Text(stringResource(R.string.prefs_wavelog_clear_title)) },
|
||||
text = { Text(stringResource(R.string.prefs_wavelog_clear_message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showClearConfirm = false
|
||||
onClear()
|
||||
}) { Text(stringResource(R.string.btn_accept)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showClearConfirm = false }) { Text(stringResource(R.string.btn_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** LoTW→Wavelog hand-over guard: shown when completing the Wavelog config would switch the mode. */
|
||||
@Composable
|
||||
fun WavelogSwitchWarningDialog(
|
||||
onConfirm: () -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text(stringResource(R.string.prefs_wavelog_switch_title)) },
|
||||
text = { Text(stringResource(R.string.prefs_wavelog_switch_message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) { Text(stringResource(R.string.prefs_wavelog_switch_continue)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onCancel) { Text(stringResource(R.string.btn_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+64
-6
@@ -54,6 +54,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -69,11 +70,12 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogSyncMode
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
@@ -286,8 +288,9 @@ private fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
if (dialogs.logbook) {
|
||||
val stationFilter = uiState.logbookStationFilter
|
||||
LogbookDialog(
|
||||
records = uiState.logbookRecords,
|
||||
records = uiState.logbookRecords.filter { stationFilter == null || it.wavelogStation == stationFilter },
|
||||
satelliteCandidates = uiState.satelliteCatalog,
|
||||
uploadBusy = uiState.logbookUploadBusy,
|
||||
uploadMessage = uiState.logbookUploadMessage,
|
||||
@@ -319,7 +322,11 @@ private fun SettingsScreen(
|
||||
onToggleSelection = { onAction(SettingsAction.ToggleLogbookSelection(it)) },
|
||||
onExitSelection = { onAction(SettingsAction.ExitLogbookSelection) },
|
||||
onResubmitSelected = { onAction(SettingsAction.ResubmitSelectedLogbook) },
|
||||
wavelogCount = uiState.wavelogPending?.count ?: 0
|
||||
wavelogPreview = uiState.logbookWavelogPreview,
|
||||
wavelogMode = uiState.wavelogUploadSettings.isReady,
|
||||
stationOptions = if (uiState.wavelogUploadSettings.isReady) uiState.wavelogUploadStations else emptyList(),
|
||||
stationFilter = uiState.logbookStationFilter,
|
||||
onStationFilterChange = { onAction(SettingsAction.SetLogbookStationFilter(it)) }
|
||||
)
|
||||
}
|
||||
if (dialogs.lotwUpload) {
|
||||
@@ -347,11 +354,22 @@ private fun SettingsScreen(
|
||||
stations = uiState.wavelogUploadStations,
|
||||
rights = uiState.wavelogUploadRights,
|
||||
probeBusy = uiState.wavelogUploadProbeBusy,
|
||||
isSyncing = uiState.wavelogSyncing,
|
||||
lastSyncEpochMs = uiState.wavelogLastSyncEpochMs,
|
||||
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)) }
|
||||
onSelectStation = { onAction(SettingsAction.SelectWavelogUploadStation(it)) },
|
||||
onSyncIncremental = { onAction(SettingsAction.SyncWavelog(WavelogSyncMode.Incremental)) },
|
||||
onSyncFull = { onAction(SettingsAction.SyncWavelog(WavelogSyncMode.Full)) },
|
||||
onClear = { onAction(SettingsAction.ClearWavelogConfig) }
|
||||
)
|
||||
}
|
||||
if (uiState.wavelogSwitchWarning) {
|
||||
WavelogSwitchWarningDialog(
|
||||
onConfirm = { onAction(SettingsAction.ConfirmWavelogSwitch) },
|
||||
onCancel = { onAction(SettingsAction.CancelWavelogSwitch) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -423,6 +441,7 @@ private fun SettingsScreen(
|
||||
}
|
||||
) { _ ->
|
||||
val isVerticalLayout = isVerticalLayout()
|
||||
val wavelogMode = uiState.wavelogUploadSettings.isReady
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(if (isVerticalLayout) 1 else 2),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
@@ -465,6 +484,7 @@ private fun SettingsScreen(
|
||||
LoTWUploadCard(
|
||||
hasCertificate = uiState.lotwCertificate != null,
|
||||
stationGrid = uiState.lotwStation?.grid.orEmpty(),
|
||||
enabled = !wavelogMode,
|
||||
showUploadConfigDialog = { onAction(SettingsAction.LoadLoTWUploadStatus); dialogs.lotwUpload = true }
|
||||
)
|
||||
}
|
||||
@@ -473,12 +493,14 @@ private fun SettingsScreen(
|
||||
settings = uiState.lotwSettings,
|
||||
workedGridsCount = uiState.workedGridsCount,
|
||||
lastSyncEpochMs = uiState.lotwLastSyncEpochMs,
|
||||
enabled = !wavelogMode,
|
||||
showLoTWDialog = { dialogs.lotw = true }
|
||||
)
|
||||
}
|
||||
item {
|
||||
WavelogUploadCard(
|
||||
settings = uiState.wavelogUploadSettings,
|
||||
lastSyncEpochMs = uiState.wavelogLastSyncEpochMs,
|
||||
showDialog = { dialogs.wavelogUpload = true }
|
||||
)
|
||||
}
|
||||
@@ -486,6 +508,7 @@ private fun SettingsScreen(
|
||||
OtherCard(
|
||||
settings = uiState.otherSettings,
|
||||
onCompassCalibration = { dialogs.compassCalibration = true },
|
||||
loTWEnabled = !wavelogMode,
|
||||
onAction = onAction
|
||||
)
|
||||
}
|
||||
@@ -710,6 +733,7 @@ private fun MapSettingsCard(onClick: () -> Unit) {
|
||||
private fun OtherCard(
|
||||
settings: OtherSettings,
|
||||
onCompassCalibration: () -> Unit,
|
||||
loTWEnabled: Boolean = true,
|
||||
onAction: (SettingsAction) -> Unit
|
||||
) {
|
||||
ElevatedCard(
|
||||
@@ -728,7 +752,11 @@ private fun OtherCard(
|
||||
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
|
||||
onAction(SettingsAction.ToggleUpdate(it))
|
||||
}
|
||||
SwitchRow(R.string.prefs_other_switch_lotw_sync, settings.stateOfAutoLotwSync) {
|
||||
SwitchRow(
|
||||
R.string.prefs_other_switch_lotw_sync,
|
||||
settings.stateOfAutoLotwSync,
|
||||
enabled = loTWEnabled
|
||||
) {
|
||||
onAction(SettingsAction.ToggleAutoLotwSync(it))
|
||||
}
|
||||
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
|
||||
@@ -868,10 +896,15 @@ private fun LoTWCard(
|
||||
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
||||
workedGridsCount: Int,
|
||||
lastSyncEpochMs: Long,
|
||||
enabled: Boolean = true,
|
||||
showLoTWDialog: () -> Unit
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.alpha(if (enabled) 1f else 0.5f)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_lotw_title),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
@@ -894,10 +927,20 @@ private fun LoTWCard(
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
if (!enabled) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_lotw_disabled_wavelog),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
CardButton(
|
||||
onClick = showLoTWDialog,
|
||||
text = stringResource(id = R.string.prefs_wavelog_configure),
|
||||
isEnabled = enabled,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
@@ -907,6 +950,7 @@ private fun LoTWCard(
|
||||
@Composable
|
||||
private fun WavelogUploadCard(
|
||||
settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings,
|
||||
lastSyncEpochMs: Long,
|
||||
showDialog: () -> Unit
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -916,6 +960,13 @@ private fun WavelogUploadCard(
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_wavelog_exclusive),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = if (settings.isReady) {
|
||||
stringResource(
|
||||
@@ -929,6 +980,13 @@ private fun WavelogUploadCard(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2
|
||||
)
|
||||
if (lastSyncEpochMs != 0L) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = formatUpdateTime(updateTime = lastSyncEpochMs),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
CardButton(
|
||||
onClick = showDialog,
|
||||
|
||||
+24
-14
@@ -22,7 +22,6 @@ import com.rtbishop.look4sat.core.domain.model.LatestRelease
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
|
||||
@@ -58,20 +57,25 @@ data class SettingsState(
|
||||
val radioControlSettings: RadioControlSettings,
|
||||
val dataSourcesSettings: DataSourcesSettings,
|
||||
val dataSourcesStatus: Map<String, Int> = emptyMap(),
|
||||
val wavelogSettings: WavelogSettings = WavelogSettings(),
|
||||
val workedGridsCount: Int = 0,
|
||||
val wavelogSyncing: Boolean = false,
|
||||
val wavelogMessage: String? = null,
|
||||
/** Wavelog upload: its own config block + probe/upload transient state. */
|
||||
/** Wavelog configuration (one block; LoTW and Wavelog are mutually exclusive). */
|
||||
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. */
|
||||
/** Config-block feedback (probe failures, sync results); also shown inside the config dialog. */
|
||||
val wavelogUploadMessage: String? = null,
|
||||
/** Wavelog sync (download of QSOs + grids). */
|
||||
val wavelogSyncing: Boolean = false,
|
||||
/** Epoch ms of the last successful Wavelog sync (0 = never). */
|
||||
val wavelogLastSyncEpochMs: Long = 0L,
|
||||
/** Mode-switch guard: a change that would hand the logbook over to Wavelog is
|
||||
* waiting for the operator's confirmation (LoTW is configured). */
|
||||
val wavelogSwitchWarning: Boolean = false,
|
||||
/** Logbook 台址 selector: selected Wavelog station id (null = 全部). */
|
||||
val logbookStationFilter: String? = null,
|
||||
/** Wavelog-mode logbook upload: prepared batch awaiting confirmation. */
|
||||
val logbookWavelogPreview: com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview? = null,
|
||||
val lotwSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings = com.rtbishop.look4sat.core.domain.model.LoTWSettings(),
|
||||
val lotwSyncing: Boolean = false,
|
||||
val lotwSyncMode: LoTWSyncMode? = null,
|
||||
@@ -157,14 +161,20 @@ sealed interface SettingsAction {
|
||||
// Data sources
|
||||
data class UpdateDataSources(val settings: DataSourcesSettings) : SettingsAction
|
||||
|
||||
// Wavelog worked grids
|
||||
data class UpdateWavelog(val settings: WavelogSettings) : SettingsAction
|
||||
data class SyncWorkedGrids(val settings: WavelogSettings) : SettingsAction
|
||||
|
||||
// Wavelog upload (separate config block + upload flow)
|
||||
// Wavelog configuration (single block; LoTW and Wavelog are mutually exclusive)
|
||||
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
|
||||
/** Download QSOs (+ grids) from Wavelog. */
|
||||
data class SyncWavelog(val mode: com.rtbishop.look4sat.core.domain.repository.WavelogSyncMode) : SettingsAction
|
||||
/** Confirmed the LoTW→Wavelog mode switch warning. */
|
||||
data object ConfirmWavelogSwitch : SettingsAction
|
||||
/** Declined the switch warning — nothing changes. */
|
||||
data object CancelWavelogSwitch : SettingsAction
|
||||
/** Wipe the Wavelog config (back to LoTW mode). */
|
||||
data object ClearWavelogConfig : SettingsAction
|
||||
/** Logbook 台址 selector choice (null = 全部). */
|
||||
data class SetLogbookStationFilter(val stationId: String?) : SettingsAction
|
||||
|
||||
// LoTW confirmed grids
|
||||
data class UpdateLoTW(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction
|
||||
|
||||
+223
-87
@@ -23,7 +23,6 @@ import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||
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.ISensorsRepo
|
||||
@@ -36,8 +35,12 @@ import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
|
||||
import com.rtbishop.look4sat.core.domain.repository.lotwCursorEpochMs
|
||||
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.IWavelogUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogSyncRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogSyncFetch
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.applyWavelogSyncResult
|
||||
import com.rtbishop.look4sat.core.domain.repository.resolveWavelogSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
|
||||
@@ -62,7 +65,7 @@ class SettingsViewModel(
|
||||
private val databaseRepo: IDatabaseRepo,
|
||||
private val settingsRepo: ISettingsRepo,
|
||||
private val updateRepo: IUpdateRepository,
|
||||
private val wavelogRepo: IWavelogRepository,
|
||||
private val wavelogSyncRepository: IWavelogSyncRepository,
|
||||
private val wavelogUploadRepository: IWavelogUploadRepository,
|
||||
private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository,
|
||||
private val qsoRepository: com.rtbishop.look4sat.core.domain.logbook.IQsoRepository,
|
||||
@@ -88,8 +91,9 @@ class SettingsViewModel(
|
||||
radioControlSettings = settingsRepo.radioControlSettings.value,
|
||||
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
|
||||
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
|
||||
wavelogSettings = settingsRepo.wavelogSettings.value,
|
||||
wavelogUploadSettings = settingsRepo.wavelogUploadSettings.value,
|
||||
wavelogUploadStations = settingsRepo.getWavelogStations(),
|
||||
wavelogLastSyncEpochMs = settingsRepo.getLastWavelogSyncEpochMs(),
|
||||
workedGridsCount = settingsRepo.getWorkedGrids().size,
|
||||
compassAccuracy = sensorsRepo.compassAccuracy.value,
|
||||
compassHeadingDegrees = correctedCompassHeading(),
|
||||
@@ -162,11 +166,6 @@ class SettingsViewModel(
|
||||
_uiState.update { it.copy(radioControlSettings = settings) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.wavelogSettings.collect { settings ->
|
||||
_uiState.update { it.copy(wavelogSettings = settings) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.wavelogUploadSettings.collect { settings ->
|
||||
_uiState.update { it.copy(wavelogUploadSettings = settings) }
|
||||
@@ -238,13 +237,15 @@ class SettingsViewModel(
|
||||
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
||||
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
||||
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
|
||||
// Wavelog worked grids
|
||||
is SettingsAction.UpdateWavelog -> settingsRepo.updateWavelogSettings(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)
|
||||
// Wavelog configuration (single block; LoTW and Wavelog are mutually exclusive)
|
||||
is SettingsAction.UpdateWavelogUpload -> applyWavelogUploadSettings(action.settings)
|
||||
is SettingsAction.FetchWavelogUploadStations -> fetchWavelogUploadStations(action.url, action.apiKey)
|
||||
is SettingsAction.SelectWavelogUploadStation -> selectWavelogUploadStation(action.station)
|
||||
is SettingsAction.SelectWavelogUploadStation -> applyWavelogUploadStation(action.station)
|
||||
is SettingsAction.SyncWavelog -> syncWavelog(action.mode)
|
||||
SettingsAction.ConfirmWavelogSwitch -> confirmWavelogSwitch()
|
||||
SettingsAction.CancelWavelogSwitch -> cancelWavelogSwitch()
|
||||
SettingsAction.ClearWavelogConfig -> clearWavelogConfig()
|
||||
is SettingsAction.SetLogbookStationFilter -> _uiState.update { it.copy(logbookStationFilter = action.stationId) }
|
||||
// LoTW confirmed grids
|
||||
is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
|
||||
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode)
|
||||
@@ -277,30 +278,62 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// region Wavelog worked grids
|
||||
// Wavelog configuration + sync
|
||||
|
||||
private fun syncWorkedGrids(settings: WavelogSettings) {
|
||||
if (!settings.isConfigured) {
|
||||
_uiState.update { it.copy(wavelogMessage = "Wavelog URL/token not configured") }
|
||||
/** A switch that needs the LoTW→Wavelog hand-over confirmation before it applies. */
|
||||
private var pendingWavelogSwitch: (() -> Unit)? = null
|
||||
|
||||
private fun currentLoTWConfigured(): Boolean =
|
||||
settingsRepo.lotwSettings.value.isConfigured || _uiState.value.lotwCertificate != null
|
||||
|
||||
/**
|
||||
* Applies a Wavelog config change. Completing the configuration (the block turning
|
||||
* ready) while LoTW is configured pauses for the operator's confirmation: from then
|
||||
* on uploads and syncs go through Wavelog only and the LoTW sides stay unavailable
|
||||
* (the one-of-two rule). A half-filled config changes nothing about the routing.
|
||||
*/
|
||||
private fun applyWavelogUploadSettings(settings: WavelogUploadSettings) {
|
||||
val current = settingsRepo.wavelogUploadSettings.value
|
||||
if (!current.isReady && settings.isReady && currentLoTWConfigured()) {
|
||||
pendingWavelogSwitch = { settingsRepo.updateWavelogUploadSettings(settings) }
|
||||
_uiState.update { it.copy(wavelogSwitchWarning = true) }
|
||||
return
|
||||
}
|
||||
// Persist the credentials first, then sync with the freshly-typed values.
|
||||
settingsRepo.updateWavelogSettings(settings)
|
||||
_uiState.update { it.copy(wavelogSyncing = true, wavelogMessage = null) }
|
||||
viewModelScope.launch {
|
||||
val grids = wavelogRepo.fetchWorkedGrids(settings.url, settings.token)
|
||||
_uiState.update {
|
||||
if (grids != null) {
|
||||
settingsRepo.setWorkedGrids(grids)
|
||||
it.copy(wavelogSyncing = false, workedGridsCount = grids.size, wavelogMessage = null)
|
||||
} else {
|
||||
it.copy(wavelogSyncing = false, wavelogMessage = "Sync failed — check URL/token/network")
|
||||
}
|
||||
}
|
||||
}
|
||||
settingsRepo.updateWavelogUploadSettings(settings)
|
||||
}
|
||||
|
||||
// Wavelog upload (separate config block + upload flow)
|
||||
private fun applyWavelogUploadStation(station: WavelogStationInfo) {
|
||||
val current = settingsRepo.wavelogUploadSettings.value
|
||||
val updated = current.copy(
|
||||
stationId = station.id,
|
||||
stationName = station.name,
|
||||
stationCallsign = station.callsign,
|
||||
stationGrid = station.grid
|
||||
)
|
||||
if (!current.isReady && updated.isReady && currentLoTWConfigured()) {
|
||||
pendingWavelogSwitch = { settingsRepo.updateWavelogUploadSettings(updated) }
|
||||
_uiState.update { it.copy(wavelogSwitchWarning = true) }
|
||||
return
|
||||
}
|
||||
settingsRepo.updateWavelogUploadSettings(updated)
|
||||
}
|
||||
|
||||
private fun confirmWavelogSwitch() {
|
||||
pendingWavelogSwitch?.invoke()
|
||||
pendingWavelogSwitch = null
|
||||
_uiState.update { it.copy(wavelogSwitchWarning = false) }
|
||||
}
|
||||
|
||||
private fun cancelWavelogSwitch() {
|
||||
pendingWavelogSwitch = null
|
||||
_uiState.update { it.copy(wavelogSwitchWarning = false) }
|
||||
}
|
||||
|
||||
/** Wipe the configuration — routing falls back to LoTW on the next check. */
|
||||
private fun clearWavelogConfig() {
|
||||
settingsRepo.updateWavelogUploadSettings(WavelogUploadSettings())
|
||||
_uiState.update { it.copy(logbookStationFilter = null, logbookWavelogPreview = null) }
|
||||
}
|
||||
|
||||
private fun fetchWavelogUploadStations(url: String, apiKey: String) {
|
||||
if (_uiState.value.wavelogUploadProbeBusy) return
|
||||
@@ -316,6 +349,9 @@ class SettingsViewModel(
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
// Persist the full list: the logbook's 台址 selector and the sync use it,
|
||||
// not just the dialog that fetched it.
|
||||
settingsRepo.setWavelogStations(probe.stations)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
wavelogUploadProbeBusy = false,
|
||||
@@ -328,36 +364,87 @@ class SettingsViewModel(
|
||||
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)
|
||||
if (auto != null) applyWavelogUploadStation(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
|
||||
)
|
||||
)
|
||||
/**
|
||||
* Downloads QSOs from Wavelog and folds them into the logbook + map grid data.
|
||||
* Incremental resumes from the per-station cursors; the full mode re-pulls each
|
||||
* station from the start (healing anything a cursor missed) — neither ever deletes
|
||||
* data the app already has.
|
||||
*/
|
||||
private fun syncWavelog(requested: WavelogSyncMode) {
|
||||
if (_uiState.value.wavelogSyncing) return
|
||||
val settings = settingsRepo.wavelogUploadSettings.value
|
||||
if (!settings.isConfigured) {
|
||||
_uiState.update { it.copy(wavelogUploadMessage = "Wavelog URL/key not configured") }
|
||||
return
|
||||
}
|
||||
val stations = settingsRepo.getWavelogStations()
|
||||
if (stations.isEmpty()) {
|
||||
_uiState.update { it.copy(wavelogUploadMessage = "Fetch stations first — no station profile known") }
|
||||
return
|
||||
}
|
||||
val mode = resolveWavelogSyncMode(settingsRepo.getWavelogSyncUrl(), settings.url, requested)
|
||||
_uiState.update { it.copy(wavelogSyncing = true, wavelogUploadMessage = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val fetch = wavelogSyncRepository.fetchNewRecords(
|
||||
settings.url,
|
||||
settings.apiKey,
|
||||
stations,
|
||||
settingsRepo.getWavelogSyncCursors(),
|
||||
full = mode == WavelogSyncMode.Full
|
||||
)
|
||||
if (fetch == null) {
|
||||
_uiState.update {
|
||||
it.copy(wavelogSyncing = false, wavelogUploadMessage = "Wavelog sync failed — check URL/key/network")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
val merge = if (fetch.records.isEmpty()) {
|
||||
com.rtbishop.look4sat.core.domain.logbook.AdifImportResult(0, 0, 0)
|
||||
} else {
|
||||
qsoRepository.mergeWavelog(fetch.records)
|
||||
}
|
||||
// Stations that failed keep their previous cursor; fetched ones advance.
|
||||
val cursors = settingsRepo.getWavelogSyncCursors() + fetch.cursors
|
||||
applyWavelogSyncResult(settingsRepo, fetch.records, cursors, settings.url)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
wavelogSyncing = false,
|
||||
workedGridsCount = settingsRepo.getWorkedGrids().size,
|
||||
wavelogLastSyncEpochMs = settingsRepo.getLastWavelogSyncEpochMs(),
|
||||
wavelogUploadMessage = wavelogSyncSummary(mode, fetch, merge, stations.size)
|
||||
)
|
||||
}
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(wavelogSyncing = false, wavelogUploadMessage = "Wavelog sync failed — check URL/key/network")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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()
|
||||
private fun wavelogSyncSummary(
|
||||
mode: WavelogSyncMode,
|
||||
fetch: WavelogSyncFetch,
|
||||
merge: com.rtbishop.look4sat.core.domain.logbook.AdifImportResult,
|
||||
totalStations: Int
|
||||
): String {
|
||||
val head = if (mode == WavelogSyncMode.Full) "Full Wavelog sync" else "Incremental Wavelog sync"
|
||||
val base = "$head — ${merge.imported} new, ${merge.skipped} already there"
|
||||
return if (fetch.failedStations.isEmpty()) base
|
||||
else "$base · ${fetch.failedStations.size}/$totalStations station(s) failed"
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads one prepared Wavelog batch (piggyback) and marks what the server
|
||||
* accepted. Returns the operator-facing message segment for this upload.
|
||||
* Uploads one prepared Wavelog batch and marks what the server accepted (stamped
|
||||
* with the station profile the batch went out through). Returns the operator-facing
|
||||
* message for this upload.
|
||||
*/
|
||||
private suspend fun uploadWavelogNow(
|
||||
preview: WavelogUploadPreview,
|
||||
@@ -365,7 +452,7 @@ class SettingsViewModel(
|
||||
): String = try {
|
||||
val outcome = wavelogUploadRepository.upload(preview, settings)
|
||||
if (outcome is WavelogUploadOutcome.Imported && outcome.markIds.isNotEmpty()) {
|
||||
qsoRepository.markWavelogUploaded(outcome.markIds)
|
||||
qsoRepository.markWavelogUploaded(outcome.markIds, settings.stationId)
|
||||
}
|
||||
wavelogConfirmedSegment(outcome, preview)
|
||||
} catch (_: Exception) {
|
||||
@@ -477,13 +564,16 @@ class SettingsViewModel(
|
||||
_uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") }
|
||||
try {
|
||||
val all = qsoRepository.records.first()
|
||||
val wavelogSettings = settingsRepo.wavelogUploadSettings.value
|
||||
if (wavelogSettings.isReady) {
|
||||
// Wavelog mode: the logbook upload goes to Wavelog only.
|
||||
prepareLogbookWavelogUpload(all, wavelogSettings)
|
||||
return@launch
|
||||
}
|
||||
// Only local (non-confirmed) records are candidates for upload;
|
||||
// LoTW-imported confirmations are the feedback side.
|
||||
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE }
|
||||
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) {
|
||||
val msg = when {
|
||||
audit.unavailable > 0 -> com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary(
|
||||
@@ -493,17 +583,7 @@ class SettingsViewModel(
|
||||
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
|
||||
else -> "No pending QSOs to upload"
|
||||
}
|
||||
// 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")
|
||||
)
|
||||
}
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = msg) }
|
||||
return@launch
|
||||
}
|
||||
val preview = lotwUploadRepository.prepare(pending, false)
|
||||
@@ -514,7 +594,6 @@ class SettingsViewModel(
|
||||
it.copy(
|
||||
logbookUploadBusy = false,
|
||||
logbookPreview = preview,
|
||||
wavelogPending = wavelogPreview,
|
||||
logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
|
||||
)
|
||||
}
|
||||
@@ -526,7 +605,42 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Wavelog-mode branch of [prepareLogbookUpload]: prepare the batch, then confirm. */
|
||||
private suspend fun prepareLogbookWavelogUpload(
|
||||
all: List<com.rtbishop.look4sat.core.domain.logbook.QsoRecord>,
|
||||
settings: WavelogUploadSettings
|
||||
) {
|
||||
val preview = runCatching {
|
||||
wavelogUploadRepository.prepare(wavelogUploadCandidates(all), settings)
|
||||
}.getOrNull()
|
||||
if (preview == null) {
|
||||
_uiState.update {
|
||||
it.copy(logbookUploadBusy = false, logbookUploadMessage = "Wavelog upload failed to prepare — check the configuration")
|
||||
}
|
||||
return
|
||||
}
|
||||
if (preview.count == 0) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
logbookUploadBusy = false,
|
||||
logbookUploadMessage = wavelogIdleSegment(preview) ?: "No pending QSOs to upload to Wavelog"
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookWavelogPreview = preview) }
|
||||
}
|
||||
|
||||
private fun confirmLogbookUpload() {
|
||||
val wavelogPreview = _uiState.value.logbookWavelogPreview
|
||||
if (wavelogPreview != null) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(logbookUploadBusy = true) }
|
||||
val msg = uploadWavelogNow(wavelogPreview, settingsRepo.wavelogUploadSettings.value)
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookWavelogPreview = null, logbookUploadMessage = msg) }
|
||||
}
|
||||
return
|
||||
}
|
||||
val preview = _uiState.value.logbookPreview ?: return
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(logbookUploadBusy = true) }
|
||||
@@ -542,19 +656,11 @@ class SettingsViewModel(
|
||||
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"
|
||||
}
|
||||
// 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"),
|
||||
logbookUploadMessage = msg,
|
||||
// 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,
|
||||
@@ -564,7 +670,7 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null, wavelogPending = null) }
|
||||
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null, logbookWavelogPreview = null) }
|
||||
|
||||
/** Operator chose "ignore" on the position check: keep the prepared preview. */
|
||||
private fun ignoreLogbookPositionWarning() = _uiState.update { it.copy(logbookPositionWarning = null) }
|
||||
@@ -573,7 +679,7 @@ class SettingsViewModel(
|
||||
private fun abandonLogbookForGridFix() {
|
||||
lotwUploadRepository.discardPreview()
|
||||
lastLogbookUploadIds = emptyList()
|
||||
_uiState.update { it.copy(logbookPositionWarning = null, logbookPreview = null, wavelogPending = null) }
|
||||
_uiState.update { it.copy(logbookPositionWarning = null, logbookPreview = null) }
|
||||
}
|
||||
|
||||
/** Long-press entry: selection mode with the pressed record checked. */
|
||||
@@ -601,6 +707,39 @@ class SettingsViewModel(
|
||||
_uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") }
|
||||
try {
|
||||
val all = qsoRepository.records.first()
|
||||
val wavelogSettings = settingsRepo.wavelogUploadSettings.value
|
||||
if (wavelogSettings.isReady) {
|
||||
// Wavelog mode: re-send the checked records through Wavelog (the
|
||||
// server dedupes what is already there).
|
||||
val selected = all.filter {
|
||||
it.id in _uiState.value.logbookSelectedIds &&
|
||||
it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE
|
||||
}
|
||||
if (selected.isEmpty()) {
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No complete QSOs in the selection") }
|
||||
return@launch
|
||||
}
|
||||
val preview = runCatching {
|
||||
wavelogUploadRepository.prepare(selected, wavelogSettings)
|
||||
}.getOrNull()
|
||||
if (preview == null) {
|
||||
_uiState.update {
|
||||
it.copy(logbookUploadBusy = false, logbookUploadMessage = "Wavelog upload failed to prepare — check the configuration")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
if (preview.count == 0) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
logbookUploadBusy = false,
|
||||
logbookUploadMessage = wavelogIdleSegment(preview) ?: "No QSOs to resubmit"
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookWavelogPreview = preview) }
|
||||
return@launch
|
||||
}
|
||||
val selected = resubmitCandidates(all, _uiState.value.logbookSelectedIds)
|
||||
if (selected.isEmpty()) {
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No complete QSOs in the selection") }
|
||||
@@ -617,13 +756,10 @@ class SettingsViewModel(
|
||||
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)
|
||||
)
|
||||
}
|
||||
@@ -821,7 +957,7 @@ class SettingsViewModel(
|
||||
databaseRepo = container.databaseRepo,
|
||||
settingsRepo = container.settingsRepo,
|
||||
updateRepo = container.updateRepo,
|
||||
wavelogRepo = container.wavelogRepo,
|
||||
wavelogSyncRepository = container.wavelogSyncRepository,
|
||||
wavelogUploadRepository = container.wavelogUploadRepository,
|
||||
lotwRepo = container.lotwRepo,
|
||||
qsoRepository = container.qsoRepository,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "567"
|
||||
appVersionCode = "569"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.8-ba7opf.9.2"
|
||||
appVersionName = "4.4.8-ba7opf.9.4"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user