mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-05 03:57:24 +00:00
merge: 合入 fork 9.2 发布线(AMSAT 冲突修复并入;版本保持 9.2/567)
# Conflicts: # gradle/libs.versions.toml
This commit is contained in:
commit
1fa7dd2d88
36 files changed
+1586
-32
No files matched your search
@@ -47,4 +47,7 @@ interface QsoDao {
|
||||
|
||||
@Query("UPDATE qso_records SET lotwUploaded = 1 WHERE id IN (:ids)")
|
||||
suspend fun markUploaded(ids: List<Long>)
|
||||
|
||||
@Query("UPDATE qso_records SET wavelogUploaded = 1 WHERE id IN (:ids)")
|
||||
suspend fun markWavelogUploaded(ids: List<Long>)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
|
||||
* upload receipts). Kept apart from [Look4SatDb] so existing installations
|
||||
* never need a migration of the satellite database.
|
||||
*/
|
||||
@Database(entities = [QsoEntity::class], version = 3, exportSchema = false)
|
||||
@Database(entities = [QsoEntity::class], version = 4, exportSchema = false)
|
||||
abstract class QsoDatabase : RoomDatabase() {
|
||||
abstract fun qsoDao(): QsoDao
|
||||
}
|
||||
@@ -38,3 +38,10 @@ val MIGRATION_QSO_2_3 = object : Migration(2, 3) {
|
||||
db.execSQL("ALTER TABLE qso_records ADD COLUMN theirVuccGrids TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
|
||||
/** v3 → v4: track whether a QSO was uploaded to Wavelog (independent of the LoTW state). */
|
||||
val MIGRATION_QSO_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE qso_records ADD COLUMN wavelogUploaded INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ data class QsoEntity(
|
||||
@ColumnInfo(defaultValue = "''") val propagationMode: String = "",
|
||||
@ColumnInfo(defaultValue = "0") val lotwConfirmed: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "0") val lotwUploaded: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "0") val wavelogUploaded: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "''") val lotwQslDate: String = "",
|
||||
@ColumnInfo(defaultValue = "''") val vuccGrids: String = "",
|
||||
|
||||
@@ -31,6 +31,7 @@ import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_1_2
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_2_3
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_3_4
|
||||
import com.rtbishop.look4sat.core.data.database.QsoDatabase
|
||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||
@@ -49,6 +50,7 @@ import com.rtbishop.look4sat.core.data.repository.UpdateRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.LoTWRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.LocationRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.WavelogRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.WavelogUploadRepository
|
||||
import com.rtbishop.look4sat.core.data.source.LocalSource
|
||||
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
||||
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
||||
@@ -70,6 +72,7 @@ import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
@@ -101,10 +104,11 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
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 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)
|
||||
.addMigrations(MIGRATION_QSO_1_2, MIGRATION_QSO_2_3, MIGRATION_QSO_3_4)
|
||||
.build()
|
||||
QsoRepository(database.qsoDao(), Dispatchers.IO)
|
||||
}
|
||||
|
||||
+10
-1
@@ -51,12 +51,14 @@ class QsoRepository(
|
||||
// place: that contact already exists on LoTW under the old values. The original row
|
||||
// keeps its upload/confirmation state, and the edited content is saved as a NEW
|
||||
// record (without any LoTW state, so it can be uploaded again).
|
||||
if (previous != null && (previous.lotwUploaded || previous.lotwConfirmed) &&
|
||||
if (previous != null &&
|
||||
(previous.lotwUploaded || previous.lotwConfirmed || previous.wavelogUploaded) &&
|
||||
!record.sameEditableContentAs(previous)
|
||||
) {
|
||||
dao.save(record.copy(
|
||||
id = 0L,
|
||||
lotwUploaded = false, lotwConfirmed = false, lotwReceived = false,
|
||||
wavelogUploaded = false,
|
||||
lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = emptyList(),
|
||||
dxcc = null, country = "", cqZone = null, region = ""
|
||||
).toEntity())
|
||||
@@ -93,6 +95,11 @@ class QsoRepository(
|
||||
if (stamped.isNotEmpty()) dao.saveBatch(stamped.map { it.toEntity() })
|
||||
}
|
||||
|
||||
override suspend fun markWavelogUploaded(ids: List<Long>) = withContext(dispatcher) {
|
||||
if (ids.isEmpty()) return@withContext
|
||||
importMutex.withLock { dao.markWavelogUploaded(ids) }
|
||||
}
|
||||
|
||||
override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
|
||||
val records = dao.getAll()
|
||||
.asSequence()
|
||||
@@ -286,6 +293,7 @@ private fun QsoEntity.toDomain() = QsoRecord(
|
||||
propagationMode = propagationMode,
|
||||
lotwConfirmed = lotwConfirmed,
|
||||
lotwUploaded = lotwUploaded,
|
||||
wavelogUploaded = wavelogUploaded,
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
|
||||
@@ -323,6 +331,7 @@ private fun QsoRecord.toEntity() = QsoEntity(
|
||||
propagationMode = propagationMode.ifBlank { if (satelliteName.isNotBlank()) "SAT" else "" },
|
||||
lotwConfirmed = lotwConfirmed,
|
||||
lotwUploaded = lotwUploaded,
|
||||
wavelogUploaded = wavelogUploaded,
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.joinToString(","),
|
||||
|
||||
@@ -31,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
@@ -386,6 +387,40 @@ class SettingsRepo(
|
||||
preferences.edit { putString(keyLastLotwSyncCallsign, callsign.trim().uppercase()) }
|
||||
//endregion
|
||||
|
||||
//region # Wavelog upload settings
|
||||
private val keyWavelogUploadUrl = "wavelogUploadUrl"
|
||||
private val keyWavelogUploadApiKey = "wavelogUploadApiKey"
|
||||
private val keyWavelogUploadStationId = "wavelogUploadStationId"
|
||||
private val keyWavelogUploadStationName = "wavelogUploadStationName"
|
||||
private val keyWavelogUploadStationCallsign = "wavelogUploadStationCallsign"
|
||||
private val keyWavelogUploadStationGrid = "wavelogUploadStationGrid"
|
||||
|
||||
private val _wavelogUploadSettings = MutableStateFlow(getWavelogUploadSettings())
|
||||
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> = _wavelogUploadSettings
|
||||
|
||||
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) {
|
||||
val trimmed = settings.copy(url = settings.url.trim(), apiKey = settings.apiKey.trim())
|
||||
preferences.edit {
|
||||
putString(keyWavelogUploadUrl, trimmed.url)
|
||||
putString(keyWavelogUploadApiKey, trimmed.apiKey)
|
||||
putString(keyWavelogUploadStationId, trimmed.stationId)
|
||||
putString(keyWavelogUploadStationName, trimmed.stationName)
|
||||
putString(keyWavelogUploadStationCallsign, trimmed.stationCallsign)
|
||||
putString(keyWavelogUploadStationGrid, trimmed.stationGrid)
|
||||
}
|
||||
_wavelogUploadSettings.value = trimmed
|
||||
}
|
||||
|
||||
private fun getWavelogUploadSettings(): WavelogUploadSettings = WavelogUploadSettings(
|
||||
url = preferences.getString(keyWavelogUploadUrl, null).orEmpty(),
|
||||
apiKey = preferences.getString(keyWavelogUploadApiKey, null).orEmpty(),
|
||||
stationId = preferences.getString(keyWavelogUploadStationId, null).orEmpty(),
|
||||
stationName = preferences.getString(keyWavelogUploadStationName, null).orEmpty(),
|
||||
stationCallsign = preferences.getString(keyWavelogUploadStationCallsign, null).orEmpty(),
|
||||
stationGrid = preferences.getString(keyWavelogUploadStationGrid, null).orEmpty()
|
||||
)
|
||||
//endregion
|
||||
|
||||
//region # Transceivers settings
|
||||
private val _passesSettings = MutableStateFlow(getPassesSettings())
|
||||
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
|
||||
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.AdifCodec
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogProblem
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogProbe
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Uploads local QSO records to a self-hosted Wavelog via the v1 API.
|
||||
*
|
||||
* The server contract (verified against Wavelog 3.0.2, `Api.php` / `Logbook_model.php`):
|
||||
* - `POST {base}/index.php/api/qso` with `{"key","station_profile_id","type":"adif","string"}`.
|
||||
* - A clean import answers HTTP 201; skipped records (duplicates, differing locator/callsign)
|
||||
* answer HTTP 400 with one message line per record while the remaining records ARE inserted.
|
||||
* Duplicates count as "already in Wavelog" and are marked; `SKIPPED` records are not.
|
||||
* - The export omits `STATION_CALLSIGN` so the server fills it from the chosen station profile
|
||||
* (a present-but-different callsign would make the server skip the record).
|
||||
*/
|
||||
class WavelogUploadRepository internal constructor(
|
||||
client: OkHttpClient = OkHttpClient()
|
||||
) : IWavelogUploadRepository {
|
||||
|
||||
private val client = client.newBuilder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.callTimeout(120, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(false)
|
||||
.build()
|
||||
|
||||
private val jsonType = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
private val mutex = Mutex()
|
||||
@Volatile private var pending: Pending? = null
|
||||
|
||||
private data class Pending(
|
||||
val submittedIds: List<Long>,
|
||||
val callsById: Map<Long, String>,
|
||||
val adif: ByteArray,
|
||||
val created: Long
|
||||
)
|
||||
|
||||
override suspend fun probe(url: String, apiKey: String): WavelogProbe? = withContext(Dispatchers.IO) {
|
||||
val base = url.trim().trimEnd('/')
|
||||
if (base.isBlank() || apiKey.isBlank()) return@withContext null
|
||||
try {
|
||||
val authBody = get("$base/index.php/api/check_auth/$apiKey") ?: return@withContext null
|
||||
val rights = JSONObject(authBody).optString("rights")
|
||||
val stationsBody = get("$base/index.php/api/station_info/$apiKey") ?: return@withContext null
|
||||
WavelogProbe(rights, parseStations(stationsBody))
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun prepare(records: List<QsoRecord>, settings: WavelogUploadSettings): WavelogUploadPreview =
|
||||
withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
discardPending()
|
||||
if (settings.url.isBlank() || settings.apiKey.isBlank()) {
|
||||
return@withLock blocked(settings, WavelogProblem.NOT_CONFIGURED)
|
||||
}
|
||||
if (settings.stationId.isBlank()) return@withLock blocked(settings, WavelogProblem.NO_STATION)
|
||||
var skipped = 0
|
||||
val reasons = mutableMapOf<WavelogProblem, Int>()
|
||||
val unique = hashSetOf<String>()
|
||||
val submitted = records.sortedBy { it.startUtcMillis }.filter { record ->
|
||||
when {
|
||||
record.status != QsoStatus.COMPLETE -> {
|
||||
skipped++
|
||||
false
|
||||
}
|
||||
|
||||
!gridCompatible(record.myGrid, settings.stationGrid) -> {
|
||||
skipped++
|
||||
reasons[WavelogProblem.GRID_MISMATCH] = (reasons[WavelogProblem.GRID_MISMATCH] ?: 0) + 1
|
||||
false
|
||||
}
|
||||
|
||||
!unique.add(stableQsoKey(record)) -> {
|
||||
skipped++
|
||||
false
|
||||
}
|
||||
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
val preview = WavelogUploadPreview(
|
||||
count = submitted.size,
|
||||
skipped = skipped,
|
||||
reasons = reasons,
|
||||
stationLabel = stationLabel(settings),
|
||||
firstUtc = submitted.firstOrNull()?.let { utcText(it.startUtcMillis) }.orEmpty(),
|
||||
lastUtc = submitted.lastOrNull()?.let { utcText(it.startUtcMillis) }.orEmpty(),
|
||||
contacts = submitted.map { record ->
|
||||
"${shortText(record.startUtcMillis)} ${record.theirCallsign} ${record.displayMode} " +
|
||||
record.satelliteName.substringBefore('(').trim()
|
||||
},
|
||||
submittedIds = submitted.map { it.id }
|
||||
)
|
||||
if (submitted.isNotEmpty()) {
|
||||
pending = Pending(
|
||||
submittedIds = submitted.map { it.id },
|
||||
callsById = submitted.associate { it.id to it.theirCallsign.trim().uppercase(Locale.US) },
|
||||
adif = AdifCodec.encode(submitted, includeStationCallsign = false).toByteArray(Charsets.UTF_8),
|
||||
created = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
preview
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun upload(preview: WavelogUploadPreview, settings: WavelogUploadSettings): WavelogUploadOutcome =
|
||||
withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
val payload = pending
|
||||
pending = null // a preview is a one-shot authorization, including across failures
|
||||
val now = System.currentTimeMillis()
|
||||
if (payload == null || payload.submittedIds != preview.submittedIds || now - payload.created !in 0..900_000) {
|
||||
return@withLock WavelogUploadOutcome.Expired
|
||||
}
|
||||
try {
|
||||
val base = settings.url.trim().trimEnd('/')
|
||||
val body = JSONObject().apply {
|
||||
put("key", settings.apiKey.trim())
|
||||
put("station_profile_id", settings.stationId)
|
||||
put("type", "adif")
|
||||
put("string", String(payload.adif, Charsets.UTF_8))
|
||||
}.toString()
|
||||
val request = Request.Builder()
|
||||
.url("$base/index.php/api/qso")
|
||||
.post(body.toRequestBody(jsonType))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
val text = response.body.string().take(64 * 1024)
|
||||
classifyUploadResponse(response.code, text, payload.callsById)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
WavelogUploadOutcome.Failed("Upload failed — check server address/network (Tailscale on?)")
|
||||
} finally {
|
||||
payload.adif.fill(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun discardPending() {
|
||||
pending?.adif?.fill(0)
|
||||
pending = null
|
||||
}
|
||||
|
||||
private fun blocked(settings: WavelogUploadSettings, reason: WavelogProblem) = WavelogUploadPreview(
|
||||
count = 0,
|
||||
skipped = 0,
|
||||
reasons = emptyMap(),
|
||||
stationLabel = stationLabel(settings),
|
||||
firstUtc = "",
|
||||
lastUtc = "",
|
||||
contacts = emptyList(),
|
||||
submittedIds = emptyList(),
|
||||
blockedBy = reason
|
||||
)
|
||||
|
||||
private fun stationLabel(settings: WavelogUploadSettings): String = listOf(
|
||||
settings.stationName, settings.stationCallsign, settings.stationGrid
|
||||
).map(String::trim).filter(String::isNotBlank).joinToString(" · ")
|
||||
|
||||
private fun get(url: String): String? {
|
||||
val request = Request.Builder().url(url).get().build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) null else response.body.string().take(256 * 1024)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a record's logged grid is consistent with the Wavelog station profile grid.
|
||||
* Mirror of the server's `adif_grid_check_location` prefix rule; either side blank
|
||||
* disables the check, exactly like the server.
|
||||
*/
|
||||
internal fun gridCompatible(adifGrid: String, stationGrid: String): Boolean {
|
||||
val adif = adifGrid.trim().uppercase(Locale.US)
|
||||
val station = stationGrid.trim().uppercase(Locale.US)
|
||||
if (adif.isEmpty() || station.isEmpty()) return true
|
||||
val adifParts = adif.split(',').map(String::trim).filter(String::isNotEmpty)
|
||||
val stationParts = station.split(',').map(String::trim).filter(String::isNotEmpty)
|
||||
if (adifParts.isEmpty() || stationParts.isEmpty()) return true
|
||||
val adifMargin = adifParts.size > 1
|
||||
val stationMargin = stationParts.size > 1
|
||||
return when {
|
||||
adifMargin && stationMargin -> adifParts.sorted() == stationParts.sorted()
|
||||
adifMargin -> false
|
||||
stationMargin -> stationParts.any { adif.startsWith(it) }
|
||||
else -> adif.startsWith(station) || station.startsWith(adif)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies Wavelog's upload response.
|
||||
*
|
||||
* 2xx = everything imported. 400 = per-record errors: duplicates count as "already in Wavelog"
|
||||
* (they are marked, so a re-run converges), `SKIPPED` records are excluded from marking, and
|
||||
* any other message is surfaced verbatim with nothing marked.
|
||||
*/
|
||||
internal fun classifyUploadResponse(code: Int, body: String, callsById: Map<Long, String>): WavelogUploadOutcome = when {
|
||||
code in 200..299 -> WavelogUploadOutcome.Imported(
|
||||
imported = callsById.size, duplicates = 0, skipped = 0, markIds = callsById.keys.toList()
|
||||
)
|
||||
|
||||
code == 401 -> WavelogUploadOutcome.Failed("Wavelog rejected the API key or station (HTTP 401)")
|
||||
|
||||
code == 403 -> WavelogUploadOutcome.Failed(
|
||||
"API key has no write permission — create a read & write key in Wavelog (Settings → API)"
|
||||
)
|
||||
|
||||
else -> classifyAbortMessages(body, callsById)
|
||||
}
|
||||
|
||||
private val skipCallRegex = Regex("importing QSO with\\s*<b>([^<]+)</b>", RegexOption.IGNORE_CASE)
|
||||
|
||||
private fun classifyAbortMessages(body: String, callsById: Map<Long, String>): WavelogUploadOutcome {
|
||||
val raw = rawMessages(body)
|
||||
val lines = stripTags(raw).split('\n').map(String::trim).filter(String::isNotEmpty)
|
||||
if (lines.isEmpty()) {
|
||||
return WavelogUploadOutcome.Failed("Wavelog did not accept the upload — check the server log")
|
||||
}
|
||||
val duplicates = lines.count { it.contains("Duplicate for", ignoreCase = true) }
|
||||
val skipLines = lines.filter { it.contains("SKIPPED", ignoreCase = true) }
|
||||
val others = lines.filter {
|
||||
!it.contains("Duplicate for", ignoreCase = true) && !it.contains("SKIPPED", ignoreCase = true)
|
||||
}
|
||||
if (others.isNotEmpty()) {
|
||||
return WavelogUploadOutcome.Rejected("Wavelog reported: ${others.first().take(200)}")
|
||||
}
|
||||
val submittedCalls = callsById.values.toSet()
|
||||
val skipCalls = skipCallRegex.findAll(raw)
|
||||
.map { it.groupValues[1].trim().uppercase(Locale.US) }.toSet()
|
||||
if (skipLines.isNotEmpty() && (skipCalls.isEmpty() || skipCalls.any { it !in submittedCalls })) {
|
||||
// A skip we cannot map back to a submitted record: stay conservative, mark nothing.
|
||||
return WavelogUploadOutcome.Rejected("Some records were skipped by Wavelog — nothing was marked; review and retry")
|
||||
}
|
||||
val markIds = callsById.filterValues { it !in skipCalls }.keys.toList()
|
||||
val skipped = callsById.size - markIds.size
|
||||
val imported = (callsById.size - duplicates - skipped).coerceAtLeast(0)
|
||||
return WavelogUploadOutcome.Imported(imported, duplicates, skipped, markIds)
|
||||
}
|
||||
|
||||
private fun rawMessages(body: String): String {
|
||||
val json = runCatching { JSONObject(body) }.getOrNull()
|
||||
return if (json != null) {
|
||||
val messages = json.optJSONArray("messages")
|
||||
if (messages != null) (0 until messages.length()).joinToString("") { messages.optString(it) }
|
||||
else json.optString("reason")
|
||||
} else {
|
||||
body
|
||||
}
|
||||
}
|
||||
|
||||
private fun stripTags(raw: String): String = raw
|
||||
.replace(Regex("(?i)<br\\s*/?>"), "\n")
|
||||
.replace(Regex("<[^>]*>"), " ")
|
||||
.replace(""", "\"").replace("'", "'").replace("&", "&")
|
||||
.replace(Regex("[ \\t]+"), " ")
|
||||
.lines().joinToString("\n") { it.trim() }
|
||||
|
||||
/** Parses the v1 station_info array (only the fields the app uses). */
|
||||
internal fun parseStations(body: String): List<WavelogStationInfo> {
|
||||
val array = JSONArray(body)
|
||||
return (0 until array.length()).mapNotNull { index ->
|
||||
val item = array.optJSONObject(index) ?: return@mapNotNull null
|
||||
val id = item.optString("station_id")
|
||||
if (id.isBlank()) return@mapNotNull null
|
||||
WavelogStationInfo(
|
||||
id = id,
|
||||
name = item.optString("station_profile_name"),
|
||||
callsign = item.optString("station_callsign"),
|
||||
grid = item.optString("station_gridsquare"),
|
||||
active = item.optString("station_active") == "1"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun utcText(millis: Long): String = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}.format(Date(millis))
|
||||
|
||||
private fun shortText(millis: Long): String = SimpleDateFormat("MM-dd HH:mm", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}.format(Date(millis))
|
||||
@@ -458,6 +458,11 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
|
||||
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
|
||||
|
||||
override val wavelogUploadSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings())
|
||||
|
||||
override fun updateWavelogUploadSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings) = Unit
|
||||
|
||||
override fun getWorkedGrids(): Set<String> = emptySet()
|
||||
|
||||
private val workedGrids = MutableStateFlow(emptySet<String>())
|
||||
|
||||
+26
@@ -295,6 +295,27 @@ class QsoRepositoryTest {
|
||||
assertEquals(1, dao.getAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markWavelogUploaded_setsOnlyTheWavelogFlag() = runBlocking {
|
||||
val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).toEntity())
|
||||
|
||||
repository.markWavelogUploaded(listOf(id))
|
||||
|
||||
val row = dao.getAll().single()
|
||||
assertTrue(row.wavelogUploaded)
|
||||
assertFalse(row.lotwUploaded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun save_editingAWavelogUploadedRecordCreatesANewRecord() = runBlocking {
|
||||
val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).copy(wavelogUploaded = true).toEntity())
|
||||
|
||||
repository.save(loggedInApp("SO-50", lotwUploaded = false).copy(id = id, theirCallsign = "BG5JVM"))
|
||||
|
||||
assertEquals(2, dao.getAll().size)
|
||||
assertEquals(1, dao.getAll().count { it.wavelogUploaded })
|
||||
}
|
||||
|
||||
/** A record as the log page creates it: tracker name, repeater pair, uplink band. */
|
||||
private fun loggedInApp(satelliteName: String, lotwUploaded: Boolean) = QsoRecord(
|
||||
startUtcMillis = qsoStart,
|
||||
@@ -355,6 +376,7 @@ class QsoRepositoryTest {
|
||||
propagationMode = propagationMode,
|
||||
lotwConfirmed = lotwConfirmed,
|
||||
lotwUploaded = lotwUploaded,
|
||||
wavelogUploaded = wavelogUploaded,
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.joinToString(","),
|
||||
@@ -393,5 +415,9 @@ class QsoRepositoryTest {
|
||||
override suspend fun markUploaded(ids: List<Long>) {
|
||||
ids.forEach { id -> rows[id]?.let { rows[id] = it.copy(lotwUploaded = true) } }
|
||||
}
|
||||
|
||||
override suspend fun markWavelogUploaded(ids: List<Long>) {
|
||||
ids.forEach { id -> rows[id]?.let { rows[id] = it.copy(wavelogUploaded = true) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -346,6 +346,9 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
|
||||
)
|
||||
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
|
||||
override val wavelogUploadSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings())
|
||||
override fun updateWavelogUploadSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings) = Unit
|
||||
override val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
|
||||
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogProblem
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class WavelogUploadRepositoryTest {
|
||||
|
||||
// region # gridCompatible (mirror of the server's adif_grid_check_location prefix rule)
|
||||
|
||||
@Test
|
||||
fun grid_sameGridsPass() {
|
||||
assertTrue(gridCompatible("OL62TI", "OL62TI"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun grid_longerAdifGridPasses() {
|
||||
assertTrue(gridCompatible("OL62TI", "OL62"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun grid_longerStationGridPasses() {
|
||||
assertTrue(gridCompatible("OL62", "OL62TI"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun grid_differentSquaresFail() {
|
||||
assertFalse(gridCompatible("PM01", "OL62"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun grid_blankSidesDisableTheCheck() {
|
||||
assertTrue(gridCompatible("", "OL62"))
|
||||
assertTrue(gridCompatible("OL62", ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun grid_stationMarginAcceptsAPrefixPart() {
|
||||
assertTrue(gridCompatible("OL63AA", "OL62,OL63"))
|
||||
assertFalse(gridCompatible("PM01AA", "OL62,OL63"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun grid_caseInsensitive() {
|
||||
assertTrue(gridCompatible("ol62ti", "OL62"))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region # classifyUploadResponse
|
||||
|
||||
private val calls = mapOf(1L to "BG5JVM", 2L to "BH6RJD", 3L to "BI3SIR")
|
||||
|
||||
@Test
|
||||
fun createdMarksEverything() {
|
||||
val outcome = classifyUploadResponse(
|
||||
201, """{"status":"created","adif_count":3,"adif_errors":0,"messages":[""]}""", calls
|
||||
)
|
||||
assertEquals(WavelogUploadOutcome.Imported(3, 0, 0, listOf(1L, 2L, 3L)), outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicatesAreCountedAndStillMarked() {
|
||||
val body = """{"status":"abort","messages":["Date/Time: 2026-10-04 12:34:00 Callsign: BG5JVM Band: 70CM Duplicate for BA7OPF<br>"]}"""
|
||||
val outcome = classifyUploadResponse(400, body, calls)
|
||||
assertEquals(WavelogUploadOutcome.Imported(2, 1, 0, listOf(1L, 2L, 3L)), outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skippedRecordIsNotMarked() {
|
||||
val body = """{"status":"abort","messages":["Differing locator <b>PM01</b> while importing QSO with <b>BH6RJD</b> for station locator <b>OL62TI</b>: SKIPPED<br>"]}"""
|
||||
val outcome = classifyUploadResponse(400, body, calls)
|
||||
assertEquals(WavelogUploadOutcome.Imported(2, 0, 1, listOf(1L, 3L)), outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicateAndSkipTogether() {
|
||||
val body = """{"status":"abort","messages":["Differing locator <b>PM01</b> while importing QSO with <b>BH6RJD</b> for station locator <b>OL62TI</b>: SKIPPED<br>Date/Time: 2026-10-04 12:34:00 Callsign: BG5JVM Band: 70CM Duplicate for BA7OPF<br>"]}"""
|
||||
val outcome = classifyUploadResponse(400, body, calls)
|
||||
assertEquals(WavelogUploadOutcome.Imported(1, 1, 1, listOf(1L, 3L)), outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unmappedSkipMarksNothing() {
|
||||
val body = """{"status":"abort","messages":["Differing locator <b>PM01</b> while importing QSO with <b>N0CALL</b> for station locator <b>OL62TI</b>: SKIPPED<br>"]}"""
|
||||
val outcome = classifyUploadResponse(400, body, calls)
|
||||
assertTrue(outcome is WavelogUploadOutcome.Rejected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun otherMessagesAreRejected() {
|
||||
val body = """{"status":"abort","messages":["QSO on 2026-10-04: You tried to import a QSO without any given CALL. This QSO wasn't imported. It's invalid.<br>"]}"""
|
||||
val outcome = classifyUploadResponse(400, body, calls)
|
||||
assertTrue(outcome is WavelogUploadOutcome.Rejected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authFailuresAreActionable() {
|
||||
assertTrue(classifyUploadResponse(401, "", calls) is WavelogUploadOutcome.Failed)
|
||||
val forbidden = classifyUploadResponse(403, """{"reason":"API key does not have write permissions"}""", calls)
|
||||
assertTrue(forbidden is WavelogUploadOutcome.Failed)
|
||||
assertTrue((forbidden as WavelogUploadOutcome.Failed).message.contains("write"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun htmlGarbageOrEmptyBodyMarksNothing() {
|
||||
assertTrue(classifyUploadResponse(400, "<html>oops</html>", calls) is WavelogUploadOutcome.Rejected)
|
||||
assertTrue(classifyUploadResponse(400, "", calls) is WavelogUploadOutcome.Failed)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region # parseStations + prepare
|
||||
|
||||
@Test
|
||||
fun stationsParse() {
|
||||
val body = """[{"station_id":"1","station_profile_name":"Shenzhen","station_gridsquare":"OL62TI","station_callsign":"BA7OPF","station_active":"1"}]"""
|
||||
val stations = parseStations(body)
|
||||
assertEquals(1, stations.size)
|
||||
assertEquals("1", stations.single().id)
|
||||
assertEquals("Shenzhen", stations.single().name)
|
||||
assertEquals("BA7OPF", stations.single().callsign)
|
||||
assertEquals("OL62TI", stations.single().grid)
|
||||
assertTrue(stations.single().active)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prepareWithoutConfigIsBlocked() = runBlocking {
|
||||
val repo = WavelogUploadRepository()
|
||||
val preview = repo.prepare(listOf(sampleRecord(start = 1_700_000_000_000L)), WavelogUploadSettings())
|
||||
assertEquals(WavelogProblem.NOT_CONFIGURED, preview.blockedBy)
|
||||
assertEquals(0, preview.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prepareWithoutStationIsBlocked() = runBlocking {
|
||||
val repo = WavelogUploadRepository()
|
||||
val settings = WavelogUploadSettings(url = "http://example.org", apiKey = "k")
|
||||
val preview = repo.prepare(listOf(sampleRecord(start = 1_700_000_000_000L)), settings)
|
||||
assertEquals(WavelogProblem.NO_STATION, preview.blockedBy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prepareSkipsGridMismatchAndDeduplicates() = runBlocking {
|
||||
val repo = WavelogUploadRepository()
|
||||
val settings = WavelogUploadSettings(
|
||||
url = "http://example.org", apiKey = "k", stationId = "1",
|
||||
stationName = "Shenzhen", stationCallsign = "BA7OPF", stationGrid = "OL62TI"
|
||||
)
|
||||
val records = listOf(
|
||||
sampleRecord(start = 1_700_000_000_000L, myGrid = "PM01AA"),
|
||||
sampleRecord(start = 1_700_000_120_000L, myGrid = "OL62TI"),
|
||||
sampleRecord(start = 1_700_000_120_000L, myGrid = "OL62TI") // batch duplicate of the previous
|
||||
)
|
||||
val preview = repo.prepare(records, settings)
|
||||
assertEquals(1, preview.count)
|
||||
assertEquals(2, preview.skipped)
|
||||
assertEquals(1, preview.reasons[WavelogProblem.GRID_MISMATCH])
|
||||
assertEquals(1, preview.submittedIds.size)
|
||||
assertEquals("Shenzhen · BA7OPF · OL62TI", preview.stationLabel)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
private fun sampleRecord(start: Long, myGrid: String = "OL62TI") = QsoRecord(
|
||||
id = start,
|
||||
startUtcMillis = start,
|
||||
theirCallsign = "BG5JVM",
|
||||
myCallsign = "BA7OPF",
|
||||
myGrid = myGrid,
|
||||
txFrequencyHz = 145_850_000L,
|
||||
rxFrequencyHz = 436_795_000L,
|
||||
band = "2M",
|
||||
rxBand = "70CM",
|
||||
mode = "FM",
|
||||
satelliteName = "SO-50",
|
||||
propagationMode = "SAT",
|
||||
status = QsoStatus.COMPLETE
|
||||
)
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import java.util.TimeZone
|
||||
object AdifCodec {
|
||||
private val utc = TimeZone.getTimeZone("UTC")
|
||||
|
||||
fun encode(records: List<QsoRecord>): String = buildString {
|
||||
fun encode(records: List<QsoRecord>, includeStationCallsign: Boolean = true): String = buildString {
|
||||
append(field("ADIF_VER", "3.1.7"))
|
||||
append(field("PROGRAMID", "Look4Sat"))
|
||||
append("<EOH>\r\n")
|
||||
@@ -32,7 +32,7 @@ object AdifCodec {
|
||||
append(field("TIME_OFF", format(it, "HHmmss")))
|
||||
}
|
||||
append(field("CALL", record.theirCallsign))
|
||||
append(field("STATION_CALLSIGN", record.myCallsign))
|
||||
if (includeStationCallsign) append(field("STATION_CALLSIGN", record.myCallsign))
|
||||
appendOptional("MODE", record.mode)
|
||||
appendOptional("SUBMODE", record.submode)
|
||||
appendOptional("GRIDSQUARE", record.theirGrid)
|
||||
|
||||
@@ -23,6 +23,10 @@ interface IQsoRepository {
|
||||
* logbook row can show which grids the QSO went out under.
|
||||
*/
|
||||
suspend fun markUploaded(ids: List<Long>, grids: List<String> = emptyList())
|
||||
|
||||
/** Marks the batch as uploaded to Wavelog (independent of the LoTW upload state). */
|
||||
suspend fun markWavelogUploaded(ids: List<Long>)
|
||||
|
||||
suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
|
||||
suspend fun importAdi(content: String): AdifImportResult
|
||||
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
|
||||
|
||||
@@ -40,6 +40,8 @@ data class QsoRecord(
|
||||
val propagationMode: String = if (satelliteName.isNotBlank()) "SAT" else "",
|
||||
val lotwConfirmed: Boolean = false,
|
||||
val lotwUploaded: Boolean = false,
|
||||
/** Internal: set after a successful Wavelog upload; keeps the pending filter small. Not shown in any list. */
|
||||
val wavelogUploaded: Boolean = false,
|
||||
val lotwReceived: Boolean = false,
|
||||
val lotwQslDate: String = "",
|
||||
val vuccGrids: List<String> = emptyList(),
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogProblem
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
|
||||
|
||||
/**
|
||||
* The records a Wavelog upload may submit: locally recorded contacts only.
|
||||
*
|
||||
* Pure LoTW-imported confirmation rows never carry [QsoRecord.lotwUploaded] (the ADIF decoder
|
||||
* does not set it), while a locally recorded row keeps its value through a confirmation merge —
|
||||
* [withConfirmation] leaves `lotwUploaded` untouched. Confirmed local rows uploaded to LoTW
|
||||
* from this app therefore stay eligible; imported history stays out of the upload.
|
||||
*/
|
||||
fun wavelogUploadCandidates(records: List<QsoRecord>): List<QsoRecord> = records.filter {
|
||||
it.status == QsoStatus.COMPLETE && !it.wavelogUploaded && (!it.lotwConfirmed || it.lotwUploaded)
|
||||
}
|
||||
|
||||
/** Operator-facing wording for why a record cannot enter a Wavelog upload. */
|
||||
fun WavelogProblem.label(): String = when (this) {
|
||||
WavelogProblem.NOT_CONFIGURED -> "Wavelog upload not configured"
|
||||
WavelogProblem.NO_STATION -> "no station profile selected"
|
||||
WavelogProblem.GRID_MISMATCH -> "logged grid outside the Wavelog station grid"
|
||||
}
|
||||
|
||||
/** One-line summary of the records excluded before a Wavelog upload (empty when none). */
|
||||
fun wavelogSkipSummary(preview: WavelogUploadPreview): String {
|
||||
if (preview.skipped <= 0) return ""
|
||||
val detail = preview.reasons.entries.sortedByDescending { it.value }
|
||||
.joinToString(", ") { (problem, count) -> "$count× ${problem.label()}" }
|
||||
return "${preview.skipped} QSO(s) skipped" + if (detail.isNotBlank()) " — $detail" else ""
|
||||
}
|
||||
|
||||
/** One-line result wording for a finished Wavelog upload. */
|
||||
fun wavelogResultMessage(result: WavelogUploadOutcome): String = when (result) {
|
||||
is WavelogUploadOutcome.Imported -> buildString {
|
||||
if (result.imported == 0 && result.duplicates > 0) {
|
||||
append("All ${result.markIds.size} record(s) already in Wavelog")
|
||||
} else {
|
||||
append("Uploaded ${result.imported} QSO(s) to Wavelog")
|
||||
if (result.duplicates > 0) append(" — ${result.duplicates} already there")
|
||||
}
|
||||
if (result.skipped > 0) append(" — ${result.skipped} skipped by Wavelog")
|
||||
}
|
||||
is WavelogUploadOutcome.Rejected -> result.message
|
||||
is WavelogUploadOutcome.Failed -> result.message
|
||||
WavelogUploadOutcome.Expired -> "Preview expired — tap upload again"
|
||||
}
|
||||
|
||||
/**
|
||||
* Message for the piggybacked Wavelog side when nothing can be submitted —
|
||||
* null when there is nothing to say (fully caught up, no held-back records).
|
||||
*/
|
||||
fun wavelogIdleSegment(preview: WavelogUploadPreview): String? = when {
|
||||
preview.blockedBy != null -> "Wavelog: ${preview.blockedBy.label()}"
|
||||
preview.skipped > 0 -> "Wavelog: ${wavelogSkipSummary(preview)}"
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Message for a finished piggybacked Wavelog upload: the server outcome plus
|
||||
* the records held back before the POST (e.g. grids outside the station profile).
|
||||
*/
|
||||
fun wavelogConfirmedSegment(outcome: WavelogUploadOutcome, preview: WavelogUploadPreview): String {
|
||||
val base = wavelogResultMessage(outcome)
|
||||
val head = if (outcome is WavelogUploadOutcome.Imported) base else "Wavelog: $base"
|
||||
val skip = wavelogSkipSummary(preview)
|
||||
return if (skip.isNotBlank()) "$head\n$skip" else head
|
||||
}
|
||||
|
||||
/** Message when the piggybacked upload threw outright — records stay unmarked for retry. */
|
||||
fun wavelogTransportFailureSegment(): String = "Wavelog: upload failed — it will retry with your next upload"
|
||||
@@ -104,3 +104,40 @@ data class GridQso(
|
||||
else -> band.trim().uppercase()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every MY-side grid of this QSO: [myGrids] (MY_GRIDSQUARE + MY_VUCC_GRIDS)
|
||||
* with a single-grid fallback to [myGrid] for records synced before
|
||||
* multi-grid support. Empty when the record carries no MY-side grid at all.
|
||||
*/
|
||||
fun GridQso.myStationGrids(): Set<String> =
|
||||
myGrids.ifEmpty { myGrid?.let { setOf(it) }.orEmpty() }
|
||||
|
||||
/**
|
||||
* Station-location (台址) grouping key: the QSO's MY grid set, sorted and
|
||||
* comma-joined ("OL62", "OM60,PM01"). Records covering the same grid set
|
||||
* belong to the same 台址 — they are VUCC-equivalent. Null when the record
|
||||
* carries no MY-side grid. Backs both the map's operated-grid selector and
|
||||
* its per-台址 grid-detail filtering, so the two scope by one identity.
|
||||
*/
|
||||
fun GridQso.stationGridSetKey(): String? =
|
||||
myStationGrids().takeIf { it.isNotEmpty() }?.sorted()?.joinToString(",")
|
||||
|
||||
/**
|
||||
* Scopes a per-grid QSO list to the operated-grid selector's choice:
|
||||
* [stationId] == null ("All") keeps the list as-is, otherwise only records
|
||||
* whose [stationGridSetKey] matches stay. Records without a MY-side grid
|
||||
* belong to no 台址 and are dropped under any specific scope.
|
||||
*/
|
||||
fun List<GridQso>.scopedToStation(stationId: String?): List<GridQso> =
|
||||
if (stationId == null) this else filter { it.stationGridSetKey() == stationId }
|
||||
|
||||
/**
|
||||
* For every grid in the store, the callsign of the earliest QSO — scoped to the
|
||||
* operated-grid selector's choice first, so a specific 台址 labels each worked
|
||||
* cell with ITS first contact; null ("All") keeps the global first call.
|
||||
*/
|
||||
fun Map<String, List<GridQso>>.firstCallsByGrid(stationId: String?): Map<String, String> =
|
||||
mapNotNull { (grid, qsos) ->
|
||||
qsos.scopedToStation(stationId).minByOrNull { it.epochMs }?.let { grid to it.call }
|
||||
}.toMap()
|
||||
@@ -109,6 +109,19 @@ data class WavelogSettings(
|
||||
val isConfigured: Boolean get() = url.isNotBlank() && token.isNotBlank()
|
||||
}
|
||||
|
||||
/** Wavelog QSO-upload configuration (a separate block from the worked-grids sync). */
|
||||
data class WavelogUploadSettings(
|
||||
val url: String = "",
|
||||
val apiKey: String = "",
|
||||
val stationId: String = "",
|
||||
val stationName: String = "",
|
||||
val stationCallsign: String = "",
|
||||
val stationGrid: String = ""
|
||||
) {
|
||||
val isConfigured: Boolean get() = url.isNotBlank() && apiKey.isNotBlank()
|
||||
val isReady: Boolean get() = isConfigured && stationId.isNotBlank()
|
||||
}
|
||||
|
||||
data class LoTWSettings(
|
||||
val callsign: String = "",
|
||||
val password: String = ""
|
||||
|
||||
+1
@@ -34,6 +34,7 @@ interface IMainContainer {
|
||||
val amSatRepo: IAmSatRepository
|
||||
val updateRepo: IUpdateRepository
|
||||
val wavelogRepo: IWavelogRepository
|
||||
val wavelogUploadRepository: IWavelogUploadRepository
|
||||
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||
val qsoRepository: IQsoRepository
|
||||
val lotwUploadRepository: ILoTWUploadRepository
|
||||
|
||||
+6
@@ -24,6 +24,7 @@ import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.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
|
||||
|
||||
@@ -124,6 +125,11 @@ interface ISettingsRepo {
|
||||
fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>)
|
||||
//endregion
|
||||
|
||||
//region # Wavelog upload settings
|
||||
val wavelogUploadSettings: StateFlow<WavelogUploadSettings>
|
||||
fun updateWavelogUploadSettings(settings: WavelogUploadSettings)
|
||||
//endregion
|
||||
|
||||
//region # LoTW confirmed-grids settings
|
||||
val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
|
||||
fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings)
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
|
||||
/** One station profile as returned by the v1 station_info endpoint. */
|
||||
data class WavelogStationInfo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val callsign: String,
|
||||
val grid: String,
|
||||
val active: Boolean
|
||||
)
|
||||
|
||||
/** Result of probing a Wavelog server (check_auth + station_info). */
|
||||
data class WavelogProbe(val rights: String, val stations: List<WavelogStationInfo>)
|
||||
|
||||
/** Why a record (or the whole upload) cannot be submitted. */
|
||||
enum class WavelogProblem { NOT_CONFIGURED, NO_STATION, GRID_MISMATCH }
|
||||
|
||||
/** Records skipped before the POST (with reasons), plus the records that will be submitted. */
|
||||
data class WavelogUploadPreview(
|
||||
val count: Int,
|
||||
val skipped: Int,
|
||||
val reasons: Map<WavelogProblem, Int>,
|
||||
val stationLabel: String,
|
||||
val firstUtc: String,
|
||||
val lastUtc: String,
|
||||
val contacts: List<String>,
|
||||
val submittedIds: List<Long>,
|
||||
val blockedBy: WavelogProblem? = null
|
||||
)
|
||||
|
||||
sealed interface WavelogUploadOutcome {
|
||||
/** [markIds] can be smaller than the submission when Wavelog reported skipped records. */
|
||||
data class Imported(
|
||||
val imported: Int,
|
||||
val duplicates: Int,
|
||||
val skipped: Int,
|
||||
val markIds: List<Long>
|
||||
) : WavelogUploadOutcome
|
||||
|
||||
data class Rejected(val message: String) : WavelogUploadOutcome
|
||||
data class Failed(val message: String) : WavelogUploadOutcome
|
||||
data object Expired : WavelogUploadOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads local QSO records to a self-hosted Wavelog via the v1 API
|
||||
* (`POST {base}/index.php/api/qso` with a batch ADIF string).
|
||||
*/
|
||||
interface IWavelogUploadRepository {
|
||||
/** check_auth + station_info; null on any transport/parse failure. */
|
||||
suspend fun probe(url: String, apiKey: String): WavelogProbe?
|
||||
|
||||
/** Filters + encodes the batch and keeps a one-shot pending payload for [upload]. */
|
||||
suspend fun prepare(records: List<QsoRecord>, settings: WavelogUploadSettings): WavelogUploadPreview
|
||||
|
||||
/** POSTs the prepared batch and classifies Wavelog's response. */
|
||||
suspend fun upload(preview: WavelogUploadPreview, settings: WavelogUploadSettings): WavelogUploadOutcome
|
||||
}
|
||||
@@ -83,4 +83,20 @@ class AdifCodecTest {
|
||||
assertTrue(record.lotwConfirmed)
|
||||
assertTrue(record.isSatellite)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodeOmitsStationCallsignForWavelog() {
|
||||
val record = QsoRecord(
|
||||
startUtcMillis = 1_700_000_000_000L,
|
||||
theirCallsign = "N0CALL",
|
||||
myCallsign = "BA7OPF",
|
||||
txFrequencyHz = 145_850_000L,
|
||||
band = "2M",
|
||||
satelliteName = "SO-50"
|
||||
)
|
||||
val adif = AdifCodec.encode(listOf(record), includeStationCallsign = false)
|
||||
assertTrue(!adif.contains("<STATION_CALLSIGN"))
|
||||
assertTrue(adif.contains("<CALL:6>N0CALL"))
|
||||
assertTrue(adif.contains("<SAT_NAME:5>SO-50"))
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogProblem
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class WavelogUploadMessagesTest {
|
||||
|
||||
private fun record(
|
||||
complete: Boolean = true,
|
||||
wavelogUploaded: Boolean = false,
|
||||
lotwConfirmed: Boolean = false,
|
||||
lotwUploaded: Boolean = false
|
||||
) = QsoRecord(
|
||||
startUtcMillis = 1_700_000_000_000L,
|
||||
theirCallsign = "BG5JVM",
|
||||
myCallsign = "BA7OPF",
|
||||
status = if (complete) QsoStatus.COMPLETE else QsoStatus.DRAFT,
|
||||
lotwConfirmed = lotwConfirmed,
|
||||
lotwUploaded = lotwUploaded,
|
||||
wavelogUploaded = wavelogUploaded
|
||||
)
|
||||
|
||||
private fun preview(
|
||||
count: Int = 0,
|
||||
skipped: Int = 0,
|
||||
reasons: Map<WavelogProblem, Int> = emptyMap(),
|
||||
blockedBy: WavelogProblem? = null
|
||||
) = WavelogUploadPreview(
|
||||
count = count,
|
||||
skipped = skipped,
|
||||
reasons = reasons,
|
||||
stationLabel = "OL62TI · BA7OPF",
|
||||
firstUtc = "",
|
||||
lastUtc = "",
|
||||
contacts = emptyList(),
|
||||
submittedIds = List(count) { it.toLong() },
|
||||
blockedBy = blockedBy
|
||||
)
|
||||
|
||||
@Test
|
||||
fun freshLocalRecordIsEligible() {
|
||||
assertTrue(wavelogUploadCandidates(listOf(record())).isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun locallyUploadedToLotwStaysEligible() {
|
||||
assertTrue(wavelogUploadCandidates(listOf(record(lotwUploaded = true))).isNotEmpty())
|
||||
assertTrue(
|
||||
wavelogUploadCandidates(listOf(record(lotwConfirmed = true, lotwUploaded = true))).isNotEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importedConfirmationRowIsExcluded() {
|
||||
assertTrue(wavelogUploadCandidates(listOf(record(lotwConfirmed = true))).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun draftAndAlreadyUploadedRecordsAreExcluded() {
|
||||
assertTrue(wavelogUploadCandidates(listOf(record(complete = false))).isEmpty())
|
||||
assertTrue(wavelogUploadCandidates(listOf(record(wavelogUploaded = true))).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun problemLabelsAreOperatorFacing() {
|
||||
assertEquals("Wavelog upload not configured", WavelogProblem.NOT_CONFIGURED.label())
|
||||
assertEquals("no station profile selected", WavelogProblem.NO_STATION.label())
|
||||
assertEquals("logged grid outside the Wavelog station grid", WavelogProblem.GRID_MISMATCH.label())
|
||||
}
|
||||
|
||||
// --- Piggyback segments (Wavelog rides the LoTW upload flow) ---
|
||||
|
||||
@Test
|
||||
fun `idle segment stays silent when nothing was held back`() {
|
||||
assertNull(wavelogIdleSegment(preview()))
|
||||
assertNull(wavelogIdleSegment(preview(count = 0, skipped = 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `idle segment explains a blocked profile`() {
|
||||
assertEquals(
|
||||
"Wavelog: no station profile selected",
|
||||
wavelogIdleSegment(preview(blockedBy = WavelogProblem.NO_STATION))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `idle segment lists held-back records with reasons`() {
|
||||
assertEquals(
|
||||
"Wavelog: 3 QSO(s) skipped — 3× logged grid outside the Wavelog station grid",
|
||||
wavelogIdleSegment(preview(skipped = 3, reasons = mapOf(WavelogProblem.GRID_MISMATCH to 3)))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirmed segment reports the outcome and pre-POST skips`() {
|
||||
val outcome = WavelogUploadOutcome.Imported(imported = 2, duplicates = 0, skipped = 0, markIds = listOf(1, 2))
|
||||
assertEquals("Uploaded 2 QSO(s) to Wavelog", wavelogConfirmedSegment(outcome, preview()))
|
||||
assertEquals(
|
||||
"Uploaded 2 QSO(s) to Wavelog\n1 QSO(s) skipped — 1× logged grid outside the Wavelog station grid",
|
||||
wavelogConfirmedSegment(
|
||||
outcome,
|
||||
preview(skipped = 1, reasons = mapOf(WavelogProblem.GRID_MISMATCH to 1))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirmed segment keeps the all-duplicates wording unprefixed`() {
|
||||
val outcome = WavelogUploadOutcome.Imported(imported = 0, duplicates = 2, skipped = 0, markIds = listOf(1, 2))
|
||||
assertEquals("All 2 record(s) already in Wavelog", wavelogConfirmedSegment(outcome, preview()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirmed segment prefixes failures with Wavelog`() {
|
||||
assertEquals("Wavelog: boom", wavelogConfirmedSegment(WavelogUploadOutcome.Failed("boom"), preview()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transport failure segment asks for a retry on the next upload`() {
|
||||
assertEquals(
|
||||
"Wavelog: upload failed — it will retry with your next upload",
|
||||
wavelogTransportFailureSegment()
|
||||
)
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The map's VUCC view scopes QSO data to the operated-grid selector's choice
|
||||
* (台址 = station location): [stationGridSetKey] identifies a record's 台址 and
|
||||
* [scopedToStation] applies the choice — the grid-detail dialog relies on both.
|
||||
*/
|
||||
class GridQsoStationScopeTest {
|
||||
|
||||
private fun qso(
|
||||
call: String = "BG7XYZ",
|
||||
myGrid: String? = null,
|
||||
myGrids: Set<String> = emptySet()
|
||||
) = GridQso(
|
||||
call = call, epochMs = 0L, satName = "SO-50", mode = "FM",
|
||||
bandUp = "70CM", bandDown = "2M", myGrid = myGrid, myGrids = myGrids
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `multi-grid record keys by its full sorted grid set`() {
|
||||
assertEquals("OL62,PM01", qso(myGrids = setOf("PM01", "OL62")).stationGridSetKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy single-grid record falls back to myGrid`() {
|
||||
assertEquals("OL62", qso(myGrid = "OL62").stationGridSetKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `myGrids wins over myGrid when both are present`() {
|
||||
val q = qso(myGrid = "OL62", myGrids = setOf("OM60", "PM01"))
|
||||
assertEquals("OM60,PM01", q.stationGridSetKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `record without any MY grid carries no key`() {
|
||||
assertNull(qso().stationGridSetKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `key does not depend on the grid set's iteration order`() {
|
||||
assertEquals(
|
||||
qso(myGrids = setOf("PM01", "OL62")).stationGridSetKey(),
|
||||
qso(myGrids = setOf("OL62", "PM01")).stationGridSetKey()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null scope keeps every record`() {
|
||||
val a = qso("A", myGrid = "OL62")
|
||||
val b = qso("B", myGrid = "PM01")
|
||||
assertEquals(listOf(a, b), listOf(a, b).scopedToStation(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `specific scope keeps only the matching station's records`() {
|
||||
val multi = qso("A", myGrids = setOf("OL62", "PM01"))
|
||||
val single = qso("B", myGrid = "OL62")
|
||||
val list = listOf(multi, single)
|
||||
assertEquals(listOf(multi), list.scopedToStation("OL62,PM01"))
|
||||
assertEquals(listOf(single), list.scopedToStation("OL62"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `records without a MY grid belong to no station scope`() {
|
||||
val orphan = qso("A")
|
||||
val mine = qso("B", myGrid = "OL62")
|
||||
assertEquals(listOf(mine), listOf(orphan, mine).scopedToStation("OL62"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scoping to a station absent from the list yields empty`() {
|
||||
val list = listOf(qso("A", myGrid = "OL62"))
|
||||
assertEquals(emptyList<GridQso>(), list.scopedToStation("PM01"))
|
||||
}
|
||||
|
||||
// --- firstCallsByGrid ---
|
||||
|
||||
@Test
|
||||
fun `first call per grid picks the earliest of the scoped records`() {
|
||||
val store = mapOf(
|
||||
"OL62" to listOf(
|
||||
qso("OTHER", myGrid = "PM01").copy(epochMs = 10),
|
||||
qso("MINE", myGrid = "OL62").copy(epochMs = 20)
|
||||
),
|
||||
"PM95" to listOf(qso("MINE2", myGrid = "OL62").copy(epochMs = 30))
|
||||
)
|
||||
assertEquals(mapOf("OL62" to "MINE", "PM95" to "MINE2"), store.firstCallsByGrid("OL62"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first call per grid with All uses every record`() {
|
||||
val store = mapOf(
|
||||
"OL62" to listOf(
|
||||
qso("OTHER", myGrid = "PM01").copy(epochMs = 10),
|
||||
qso("MINE", myGrid = "OL62").copy(epochMs = 20)
|
||||
)
|
||||
)
|
||||
assertEquals(mapOf("OL62" to "OTHER"), store.firstCallsByGrid(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `grids without scoped records get no first call`() {
|
||||
val store = mapOf("OL62" to listOf(qso("A", myGrid = "PM01")))
|
||||
assertEquals(emptyMap<String, String>(), store.firstCallsByGrid("OL62"))
|
||||
}
|
||||
}
|
||||
+3
@@ -27,6 +27,7 @@ import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -248,6 +249,7 @@ class LoTWGridSyncTest {
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
|
||||
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> = MutableStateFlow(WavelogUploadSettings())
|
||||
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
|
||||
|
||||
private var workedGrids: Set<String> = emptySet()
|
||||
@@ -292,6 +294,7 @@ class LoTWGridSyncTest {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,16 @@
|
||||
<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_not_configured">未配置 — 把本机通联记录上传到 Wavelog</string>
|
||||
<string name="prefs_wavelog_upload_configured">站点 %1$s — 可以上传</string>
|
||||
<string name="prefs_wavelog_upload_apikey">API key(读写权限)</string>
|
||||
<string name="prefs_wavelog_upload_fetch">获取站点</string>
|
||||
<string name="prefs_wavelog_upload_probing">检测中…</string>
|
||||
<string name="prefs_wavelog_upload_station_none">未选择站点 — 点「获取站点」</string>
|
||||
<string name="prefs_wavelog_upload_rights_rw">密钥:读写 — 可上传</string>
|
||||
<string name="prefs_wavelog_upload_rights_r">密钥只读 — 请在 Wavelog → API 生成「读写」密钥</string>
|
||||
<string name="prefs_wavelog_upload_hint">点「获取站点」选择上传使用的台站档案;密钥需要「读写」权限。</string>
|
||||
<string name="prefs_lotw_title">LoTW 已确认网格</string>
|
||||
<string name="prefs_lotw_callsign">LoTW 呼号</string>
|
||||
<string name="prefs_lotw_password">LoTW 密码</string>
|
||||
@@ -201,6 +211,7 @@
|
||||
<string name="prefs_logbook_selected">已选 %1$d 条</string>
|
||||
<string name="prefs_logbook_select_hint">长按记录行可多选重传</string>
|
||||
<string name="prefs_logbook_resubmit_note">已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。</string>
|
||||
<string name="prefs_logbook_wavelog_sync">同时同步 Wavelog:%1$d 条</string>
|
||||
<string name="lotw_upload_grid_title">上传前核对</string>
|
||||
<string name="lotw_upload_grid_fix">修改台址</string>
|
||||
<string name="lotw_upload_grid_ignore">忽略</string>
|
||||
|
||||
@@ -195,6 +195,16 @@
|
||||
<string name="prefs_wavelog_hint">Worked grids synced: %1$d. Format: API key, colon, logbook ID. Just tap Sync — credentials are saved automatically.</string>
|
||||
<string name="prefs_wavelog_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_not_configured">Not configured — upload your local QSO records to Wavelog</string>
|
||||
<string name="prefs_wavelog_upload_configured">Station %1$s — ready to upload</string>
|
||||
<string name="prefs_wavelog_upload_apikey">API key (read & write)</string>
|
||||
<string name="prefs_wavelog_upload_fetch">Fetch stations</string>
|
||||
<string name="prefs_wavelog_upload_probing">Checking…</string>
|
||||
<string name="prefs_wavelog_upload_station_none">No station selected — tap Fetch stations</string>
|
||||
<string name="prefs_wavelog_upload_rights_rw">Key: read & write — uploads enabled</string>
|
||||
<string name="prefs_wavelog_upload_rights_r">Key is read-only — create a read & write key in Wavelog → API</string>
|
||||
<string name="prefs_wavelog_upload_hint">Tap Fetch stations to pick the station profile uploads go through. The key needs read & write permission.</string>
|
||||
<string name="prefs_lotw_title">LoTW confirmed grids</string>
|
||||
<string name="prefs_lotw_callsign">LoTW callsign</string>
|
||||
<string name="prefs_lotw_password">LoTW password</string>
|
||||
@@ -230,6 +240,7 @@
|
||||
<string name="prefs_logbook_selected">%1$d selected</string>
|
||||
<string name="prefs_logbook_select_hint">Long-press a row to re-upload corrected records</string>
|
||||
<string name="prefs_logbook_resubmit_note">Existing contacts will be updated in place — a grid already used for an award may be rejected.</string>
|
||||
<string name="prefs_logbook_wavelog_sync">Also syncs to Wavelog: %1$d QSO(s)</string>
|
||||
<string name="lotw_upload_grid_title">Upload check</string>
|
||||
<string name="lotw_upload_grid_fix">Fix station location</string>
|
||||
<string name="lotw_upload_grid_ignore">Ignore</string>
|
||||
|
||||
@@ -85,7 +85,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.model.AwardCalculator
|
||||
import com.rtbishop.look4sat.core.domain.model.AwardProgress
|
||||
import com.rtbishop.look4sat.core.domain.model.AwardType
|
||||
import com.rtbishop.look4sat.core.domain.model.firstCallsByGrid
|
||||
import com.rtbishop.look4sat.core.domain.model.MapSource
|
||||
import com.rtbishop.look4sat.core.domain.model.myStationGrids
|
||||
import com.rtbishop.look4sat.core.domain.model.scopedToStation
|
||||
import com.rtbishop.look4sat.core.domain.model.stationGridSetKey
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
@@ -303,13 +307,6 @@ private fun MapScreen(
|
||||
// Selected award filter. Lives in an Activity-scoped ViewModel so it
|
||||
// survives page switches; defaults to VUCC only once per process (cold start).
|
||||
var selectedAward by mapFilterViewModel.selectedAward
|
||||
// First callsign worked in each grid (earliest QSO by time), used by the
|
||||
// "首通呼号" label mode — derived from the same QSO store as the worked fills.
|
||||
val firstCallsByGrid: Map<String, String> = remember(uiState.workedGridQsos) {
|
||||
uiState.workedGridQsos.mapNotNull { (grid, qsos) ->
|
||||
qsos.minByOrNull { it.epochMs }?.let { grid to it.call }
|
||||
}.toMap()
|
||||
}
|
||||
// 台址名单: QSOs grouped by the 台址's GRID SET. A station location (台址)
|
||||
// can span several grids (MY_GRIDSQUARE + MY_VUCC_GRIDS), and QSOs whose
|
||||
// location covers the same grid set belong to the same 台址; groups with an
|
||||
@@ -321,10 +318,8 @@ private fun MapScreen(
|
||||
val byGridSet = LinkedHashMap<String, Pair<Set<String>, MutableSet<String>>>()
|
||||
for ((grid, qsos) in uiState.workedGridQsos) {
|
||||
for (q in qsos) {
|
||||
val gs = q.myGrids.ifEmpty { q.myGrid?.let { setOf(it) }.orEmpty() }
|
||||
if (gs.isEmpty()) continue
|
||||
val key = gs.sorted().joinToString(",")
|
||||
byGridSet.getOrPut(key) { gs to mutableSetOf() }.second.add(grid)
|
||||
val key = q.stationGridSetKey() ?: continue
|
||||
byGridSet.getOrPut(key) { q.myStationGrids() to mutableSetOf() }.second.add(grid)
|
||||
}
|
||||
}
|
||||
byGridSet.map { (key, v) -> StationGroup(id = key, grids = v.first, worked = v.second) }
|
||||
@@ -352,6 +347,13 @@ private fun MapScreen(
|
||||
if (selectedStationId == null) uiState.workedGrids
|
||||
else stationGroups.firstOrNull { it.id == selectedStationId }?.worked ?: emptySet()
|
||||
}
|
||||
// First callsign worked in each grid (earliest QSO by time), used by the
|
||||
// "首通呼号" label mode. Scoped to the selected 台址 like the fills: with a
|
||||
// specific operated grid chosen, the label must show ITS first contact, not
|
||||
// another 台址's (user decision 2026-10-05); "All" = global first call.
|
||||
val firstCallsByGrid: Map<String, String> = remember(uiState.workedGridQsos, selectedStationId) {
|
||||
uiState.workedGridQsos.firstCallsByGrid(selectedStationId)
|
||||
}
|
||||
val isGridMode = uiState.isGridMode
|
||||
// True when this composition restored a saved viewport. Only grid mode
|
||||
// restores the viewport the user left (satellite mode keeps its original
|
||||
@@ -539,7 +541,11 @@ private fun MapScreen(
|
||||
selectedGrid?.let { grid ->
|
||||
WorkedGridQsoDialog(
|
||||
grid = grid,
|
||||
qsos = uiState.workedGridQsos[grid].orEmpty().sortedBy { it.epochMs },
|
||||
// Scope to the selected 台址 (null = All): a specific station location
|
||||
// must not show other locations' QSOs (bug report 2026-10-05).
|
||||
qsos = uiState.workedGridQsos[grid].orEmpty()
|
||||
.scopedToStation(selectedStationId)
|
||||
.sortedBy { it.epochMs },
|
||||
marked = uiState.markedGrids[grid].orEmpty(),
|
||||
isUtc = uiState.isUtc,
|
||||
matchCalculating = matchCalculating,
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
@@ -107,6 +108,8 @@ class FakeSettingsRepo(
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
|
||||
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> = MutableStateFlow(WavelogUploadSettings())
|
||||
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) = Unit
|
||||
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = TODO()
|
||||
|
||||
@@ -260,6 +260,7 @@ fun LogPage(
|
||||
UploadPreviewDialog(
|
||||
preview = preview,
|
||||
busy = logUiState.busy,
|
||||
wavelogCount = logUiState.wavelogPending?.count ?: 0,
|
||||
onConfirm = logViewModel::confirmUpload,
|
||||
onDismiss = logViewModel::dismissPreview
|
||||
)
|
||||
@@ -406,6 +407,7 @@ private fun PostDialog(
|
||||
private fun UploadPreviewDialog(
|
||||
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
|
||||
busy: Boolean,
|
||||
wavelogCount: Int = 0,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
@@ -454,6 +456,13 @@ 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)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -21,13 +21,21 @@ import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
|
||||
import com.rtbishop.look4sat.core.domain.logbook.officialSatelliteName
|
||||
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
|
||||
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogConfirmedSegment
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogIdleSegment
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogTransportFailureSegment
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogUploadCandidates
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
|
||||
import com.rtbishop.look4sat.core.domain.repository.positionWarning
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -78,6 +86,9 @@ 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,
|
||||
/** Out-of-window contact waiting for the operator's confirmation (null when closed). */
|
||||
val outOfWindowLog: OutOfWindowLog? = null
|
||||
)
|
||||
@@ -85,6 +96,7 @@ data class LogUiState(
|
||||
class LogViewModel(
|
||||
private val qsoRepository: IQsoRepository,
|
||||
private val lotwUploadRepository: ILoTWUploadRepository,
|
||||
private val wavelogUploadRepository: IWavelogUploadRepository,
|
||||
private val settingsRepo: ISettingsRepo
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -260,6 +272,16 @@ class LogViewModel(
|
||||
// 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(
|
||||
@@ -269,7 +291,14 @@ class LogViewModel(
|
||||
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
|
||||
else -> "No pending QSOs to upload"
|
||||
}
|
||||
_uiState.update { it.copy(busy = false, message = msg) }
|
||||
// No LoTW work to do — still flush the Wavelog backlog (catch-up): a
|
||||
// freshly configured Wavelog syncs without waiting for new QSOs.
|
||||
val wavelogMsg = wavelogPreview?.let {
|
||||
if (it.count > 0) uploadWavelogNow(it, wavelogSettings) else wavelogIdleSegment(it)
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(busy = false, message = listOfNotNull(msg, wavelogMsg).joinToString("\n"))
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
val preview = lotwUploadRepository.prepare(pending, false)
|
||||
@@ -280,6 +309,7 @@ class LogViewModel(
|
||||
it.copy(
|
||||
busy = false,
|
||||
preview = preview,
|
||||
wavelogPending = wavelogPreview,
|
||||
uploadPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
|
||||
)
|
||||
}
|
||||
@@ -308,11 +338,40 @@ class LogViewModel(
|
||||
LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry"
|
||||
LoTWUploadResult.ExpiredPreview -> "Preview expired — tap upload again"
|
||||
}
|
||||
_uiState.update { it.copy(busy = false, preview = null, message = msg) }
|
||||
// Wavelog piggyback: rides the same confirmation, independent of the LoTW
|
||||
// outcome — a Wavelog failure leaves records unmarked for the next upload.
|
||||
val wavelogPending = _uiState.value.wavelogPending
|
||||
val wavelogMsg = wavelogPending?.let { wl ->
|
||||
if (wl.count > 0) uploadWavelogNow(wl, settingsRepo.wavelogUploadSettings.value)
|
||||
else wavelogIdleSegment(wl)
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
busy = false, preview = null, wavelogPending = null,
|
||||
message = listOfNotNull(msg, wavelogMsg).joinToString("\n")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissPreview() = _uiState.update { it.copy(preview = null) }
|
||||
fun dismissPreview() = _uiState.update { it.copy(preview = null, wavelogPending = null) }
|
||||
|
||||
/**
|
||||
* Uploads one prepared Wavelog batch (piggyback) and marks what the server
|
||||
* accepted. Returns the operator-facing message segment for this upload.
|
||||
*/
|
||||
private suspend fun uploadWavelogNow(
|
||||
preview: WavelogUploadPreview,
|
||||
settings: WavelogUploadSettings
|
||||
): String = try {
|
||||
val outcome = wavelogUploadRepository.upload(preview, settings)
|
||||
if (outcome is WavelogUploadOutcome.Imported && outcome.markIds.isNotEmpty()) {
|
||||
qsoRepository.markWavelogUploaded(outcome.markIds)
|
||||
}
|
||||
wavelogConfirmedSegment(outcome, preview)
|
||||
} catch (_: Exception) {
|
||||
wavelogTransportFailureSegment()
|
||||
}
|
||||
|
||||
/** Operator chose "ignore" on the position check: keep the prepared preview. */
|
||||
fun ignorePositionWarning() = _uiState.update { it.copy(uploadPositionWarning = null) }
|
||||
@@ -321,7 +380,7 @@ class LogViewModel(
|
||||
fun abandonForGridFix() {
|
||||
lotwUploadRepository.discardPreview()
|
||||
lastUploadedIds = emptyList()
|
||||
_uiState.update { it.copy(uploadPositionWarning = null, preview = null) }
|
||||
_uiState.update { it.copy(uploadPositionWarning = null, preview = null, wavelogPending = null) }
|
||||
}
|
||||
|
||||
/** Recompute the log-page roaming hint from the freshest station/position data. */
|
||||
@@ -347,7 +406,10 @@ class LogViewModel(
|
||||
companion object {
|
||||
fun factory(container: IMainContainer) = viewModelFactory {
|
||||
initializer {
|
||||
LogViewModel(container.qsoRepository, container.lotwUploadRepository, container.settingsRepo)
|
||||
LogViewModel(
|
||||
container.qsoRepository, container.lotwUploadRepository,
|
||||
container.wavelogUploadRepository, container.settingsRepo
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -104,7 +104,9 @@ fun LogbookDialog(
|
||||
onStartSelection: (Long) -> Unit = {},
|
||||
onToggleSelection: (Long) -> Unit = {},
|
||||
onExitSelection: () -> Unit = {},
|
||||
onResubmitSelected: () -> Unit = {}
|
||||
onResubmitSelected: () -> Unit = {},
|
||||
/** Submit count of the Wavelog batch riding this upload (0 hides the note). */
|
||||
wavelogCount: Int = 0
|
||||
) {
|
||||
val swipeController = rememberSwipeController()
|
||||
// Entering selection mode closes any row left swiped open — reveals are off there.
|
||||
@@ -181,6 +183,7 @@ fun LogbookDialog(
|
||||
LogbookUploadPreviewDialog(
|
||||
preview = preview,
|
||||
busy = uploadBusy,
|
||||
wavelogCount = wavelogCount,
|
||||
onConfirm = onConfirmUpload,
|
||||
onDismiss = onDismissPreview
|
||||
)
|
||||
@@ -215,6 +218,7 @@ fun LogbookDialog(
|
||||
private fun LogbookUploadPreviewDialog(
|
||||
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
|
||||
busy: Boolean,
|
||||
wavelogCount: Int,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
@@ -255,6 +259,13 @@ 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
@@ -225,6 +225,117 @@ fun WavelogDialog(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WavelogUploadDialog(
|
||||
initialSettings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings,
|
||||
stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>,
|
||||
rights: String,
|
||||
probeBusy: Boolean,
|
||||
message: String?,
|
||||
dismiss: () -> Unit,
|
||||
onSave: (com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings) -> Unit,
|
||||
onFetchStations: (url: String, apiKey: String) -> Unit,
|
||||
onSelectStation: (com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo) -> Unit
|
||||
) {
|
||||
val url = rememberSaveable { mutableStateOf(initialSettings.url) }
|
||||
val apiKey = rememberSaveable { mutableStateOf(initialSettings.apiKey) }
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.prefs_wavelog_upload_title),
|
||||
onCancel = dismiss,
|
||||
onAccept = {
|
||||
onSave(initialSettings.copy(url = url.value, apiKey = apiKey.value))
|
||||
dismiss()
|
||||
}
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = url.value,
|
||||
onValueChange = { url.value = it },
|
||||
label = { Text(text = stringResource(id = R.string.prefs_wavelog_url)) },
|
||||
placeholder = { Text(text = "http://100.x.x.x/wavelog") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = apiKey.value,
|
||||
onValueChange = { apiKey.value = it },
|
||||
label = { Text(text = stringResource(id = R.string.prefs_wavelog_upload_apikey)) },
|
||||
placeholder = { Text(text = "wl…") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
|
||||
) {
|
||||
TextButton(onClick = { onFetchStations(url.value, apiKey.value) }, enabled = !probeBusy) {
|
||||
Text(
|
||||
text = if (probeBusy) stringResource(R.string.prefs_wavelog_upload_probing)
|
||||
else stringResource(R.string.prefs_wavelog_upload_fetch)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (rights.isNotBlank()) {
|
||||
Text(
|
||||
text = when (rights) {
|
||||
"rw" -> stringResource(R.string.prefs_wavelog_upload_rights_rw)
|
||||
else -> stringResource(R.string.prefs_wavelog_upload_rights_r)
|
||||
},
|
||||
color = if (rights == "rw") MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
}
|
||||
if (stations.isEmpty() && initialSettings.stationId.isBlank()) {
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_wavelog_upload_station_none),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
} else {
|
||||
stations.forEach { station ->
|
||||
val selected = station.id == initialSettings.stationId
|
||||
Text(
|
||||
text = (if (selected) "● " else "○ ") + listOf(station.name, station.callsign, station.grid)
|
||||
.filter { it.isNotBlank() }.joinToString(" · "),
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelectStation(station) }
|
||||
.padding(horizontal = LocalSpacing.current.large, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (initialSettings.stationId.isNotBlank()) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.prefs_wavelog_upload_configured,
|
||||
listOf(initialSettings.stationName, initialSettings.stationCallsign, initialSettings.stationGrid)
|
||||
.filter { it.isNotBlank() }.joinToString(" · ")
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
}
|
||||
if (message != null) {
|
||||
Text(
|
||||
text = message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_wavelog_upload_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(0.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoTWDialog(
|
||||
initialSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
||||
|
||||
+60
-2
@@ -318,7 +318,8 @@ private fun SettingsScreen(
|
||||
onStartSelection = { onAction(SettingsAction.StartLogbookSelection(it)) },
|
||||
onToggleSelection = { onAction(SettingsAction.ToggleLogbookSelection(it)) },
|
||||
onExitSelection = { onAction(SettingsAction.ExitLogbookSelection) },
|
||||
onResubmitSelected = { onAction(SettingsAction.ResubmitSelectedLogbook) }
|
||||
onResubmitSelected = { onAction(SettingsAction.ResubmitSelectedLogbook) },
|
||||
wavelogCount = uiState.wavelogPending?.count ?: 0
|
||||
)
|
||||
}
|
||||
if (dialogs.lotwUpload) {
|
||||
@@ -340,6 +341,20 @@ private fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (dialogs.wavelogUpload) {
|
||||
WavelogUploadDialog(
|
||||
initialSettings = uiState.wavelogUploadSettings,
|
||||
stations = uiState.wavelogUploadStations,
|
||||
rights = uiState.wavelogUploadRights,
|
||||
probeBusy = uiState.wavelogUploadProbeBusy,
|
||||
message = uiState.wavelogUploadMessage,
|
||||
dismiss = { dialogs.wavelogUpload = false },
|
||||
onSave = { onAction(SettingsAction.UpdateWavelogUpload(it)) },
|
||||
onFetchStations = { url, apiKey -> onAction(SettingsAction.FetchWavelogUploadStations(url, apiKey)) },
|
||||
onSelectStation = { onAction(SettingsAction.SelectWavelogUploadStation(it)) }
|
||||
)
|
||||
}
|
||||
|
||||
// URLs for top bar
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val upstreamTitle = stringResource(R.string.prefs_upstream_title)
|
||||
@@ -461,6 +476,12 @@ private fun SettingsScreen(
|
||||
showLoTWDialog = { dialogs.lotw = true }
|
||||
)
|
||||
}
|
||||
item {
|
||||
WavelogUploadCard(
|
||||
settings = uiState.wavelogUploadSettings,
|
||||
showDialog = { dialogs.wavelogUpload = true }
|
||||
)
|
||||
}
|
||||
item {
|
||||
OtherCard(
|
||||
settings = uiState.otherSettings,
|
||||
@@ -883,6 +904,41 @@ private fun LoTWCard(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WavelogUploadCard(
|
||||
settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings,
|
||||
showDialog: () -> Unit
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_wavelog_upload_title),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = if (settings.isReady) {
|
||||
stringResource(
|
||||
R.string.prefs_wavelog_upload_configured,
|
||||
listOf(settings.stationName, settings.stationCallsign, settings.stationGrid)
|
||||
.filter { it.isNotBlank() }.joinToString(" · ")
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.prefs_wavelog_upload_not_configured)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
CardButton(
|
||||
onClick = showDialog,
|
||||
text = stringResource(id = R.string.prefs_wavelog_configure),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun formatUpdateTime(updateTime: Long): String {
|
||||
val timePattern = stringResource(id = R.string.prefs_updated_time)
|
||||
@@ -1029,6 +1085,7 @@ private class DialogVisibility {
|
||||
var compassCalibration by mutableStateOf(false)
|
||||
var logbook by mutableStateOf(false)
|
||||
var lotwUpload by mutableStateOf(false)
|
||||
var wavelogUpload by mutableStateOf(false)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -1036,7 +1093,7 @@ private fun rememberDialogVisibility(): DialogVisibility {
|
||||
return rememberSaveable(saver = run {
|
||||
androidx.compose.runtime.saveable.Saver(
|
||||
save = {
|
||||
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw, it.compassCalibration, it.logbook, it.lotwUpload)
|
||||
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw, it.compassCalibration, it.logbook, it.lotwUpload, it.wavelogUpload)
|
||||
},
|
||||
restore = {
|
||||
DialogVisibility().apply {
|
||||
@@ -1046,6 +1103,7 @@ private fun rememberDialogVisibility(): DialogVisibility {
|
||||
compassCalibration = it.getOrElse(8) { false }
|
||||
logbook = it.getOrElse(9) { false }
|
||||
lotwUpload = it.getOrElse(10) { false }
|
||||
wavelogUpload = it.getOrElse(11) { false }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+16
@@ -23,6 +23,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.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
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
||||
@@ -61,6 +62,16 @@ data class SettingsState(
|
||||
val workedGridsCount: Int = 0,
|
||||
val wavelogSyncing: Boolean = false,
|
||||
val wavelogMessage: String? = null,
|
||||
/** Wavelog upload: its own config block + probe/upload transient state. */
|
||||
val wavelogUploadSettings: WavelogUploadSettings = WavelogUploadSettings(),
|
||||
val wavelogUploadStations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList(),
|
||||
val wavelogUploadRights: String = "",
|
||||
val wavelogUploadProbeBusy: Boolean = false,
|
||||
/** Wavelog batch prepared alongside the logbook LoTW preview; uploaded when the
|
||||
* LoTW upload is confirmed (null when Wavelog is not ready). */
|
||||
val wavelogPending: com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview? = null,
|
||||
/** Config-block feedback (probe failures etc.); also shown inside the config dialog. */
|
||||
val wavelogUploadMessage: String? = null,
|
||||
val lotwSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings = com.rtbishop.look4sat.core.domain.model.LoTWSettings(),
|
||||
val lotwSyncing: Boolean = false,
|
||||
val lotwSyncMode: LoTWSyncMode? = null,
|
||||
@@ -150,6 +161,11 @@ sealed interface SettingsAction {
|
||||
data class UpdateWavelog(val settings: WavelogSettings) : SettingsAction
|
||||
data class SyncWorkedGrids(val settings: WavelogSettings) : SettingsAction
|
||||
|
||||
// Wavelog upload (separate config block + upload flow)
|
||||
data class UpdateWavelogUpload(val settings: WavelogUploadSettings) : SettingsAction
|
||||
data class FetchWavelogUploadStations(val url: String, val apiKey: String) : SettingsAction
|
||||
data class SelectWavelogUploadStation(val station: com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo) : SettingsAction
|
||||
|
||||
// LoTW confirmed grids
|
||||
data class UpdateLoTW(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction
|
||||
data class SyncLoTWGrids(
|
||||
|
||||
+124
-5
@@ -24,6 +24,7 @@ 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
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
@@ -36,11 +37,19 @@ 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.WavelogStationInfo
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
||||
import com.rtbishop.look4sat.core.domain.logbook.resubmitCandidates
|
||||
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogConfirmedSegment
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogIdleSegment
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogTransportFailureSegment
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogUploadCandidates
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -54,6 +63,7 @@ class SettingsViewModel(
|
||||
private val settingsRepo: ISettingsRepo,
|
||||
private val updateRepo: IUpdateRepository,
|
||||
private val wavelogRepo: IWavelogRepository,
|
||||
private val wavelogUploadRepository: IWavelogUploadRepository,
|
||||
private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository,
|
||||
private val qsoRepository: com.rtbishop.look4sat.core.domain.logbook.IQsoRepository,
|
||||
private val lotwUploadRepository: com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository,
|
||||
@@ -79,6 +89,7 @@ class SettingsViewModel(
|
||||
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
|
||||
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
|
||||
wavelogSettings = settingsRepo.wavelogSettings.value,
|
||||
wavelogUploadSettings = settingsRepo.wavelogUploadSettings.value,
|
||||
workedGridsCount = settingsRepo.getWorkedGrids().size,
|
||||
compassAccuracy = sensorsRepo.compassAccuracy.value,
|
||||
compassHeadingDegrees = correctedCompassHeading(),
|
||||
@@ -156,6 +167,11 @@ class SettingsViewModel(
|
||||
_uiState.update { it.copy(wavelogSettings = settings) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.wavelogUploadSettings.collect { settings ->
|
||||
_uiState.update { it.copy(wavelogUploadSettings = settings) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.lotwSettings.collect { settings ->
|
||||
_uiState.update { it.copy(lotwSettings = settings) }
|
||||
@@ -225,6 +241,10 @@ class SettingsViewModel(
|
||||
// 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)
|
||||
is SettingsAction.FetchWavelogUploadStations -> fetchWavelogUploadStations(action.url, action.apiKey)
|
||||
is SettingsAction.SelectWavelogUploadStation -> selectWavelogUploadStation(action.station)
|
||||
// LoTW confirmed grids
|
||||
is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
|
||||
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode)
|
||||
@@ -280,6 +300,78 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// Wavelog upload (separate config block + upload flow)
|
||||
|
||||
private fun fetchWavelogUploadStations(url: String, apiKey: String) {
|
||||
if (_uiState.value.wavelogUploadProbeBusy) return
|
||||
_uiState.update { it.copy(wavelogUploadProbeBusy = true, wavelogUploadMessage = null) }
|
||||
viewModelScope.launch {
|
||||
val probe = wavelogUploadRepository.probe(url.trim(), apiKey.trim())
|
||||
if (probe == null) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
wavelogUploadProbeBusy = false,
|
||||
wavelogUploadMessage = "Wavelog probe failed — check URL/key/network"
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
wavelogUploadProbeBusy = false,
|
||||
wavelogUploadStations = probe.stations,
|
||||
wavelogUploadRights = probe.rights
|
||||
)
|
||||
}
|
||||
// Single-station setups select themselves; a saved station that the server
|
||||
// still reports stays selected.
|
||||
val current = settingsRepo.wavelogUploadSettings.value
|
||||
val savedStillPresent = current.stationId.isNotBlank() && probe.stations.any { it.id == current.stationId }
|
||||
val auto = if (probe.stations.size == 1 && !savedStillPresent) probe.stations.single() else null
|
||||
if (auto != null) selectWavelogUploadStation(auto)
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectWavelogUploadStation(station: WavelogStationInfo) {
|
||||
val current = settingsRepo.wavelogUploadSettings.value
|
||||
settingsRepo.updateWavelogUploadSettings(
|
||||
current.copy(
|
||||
stationId = station.id,
|
||||
stationName = station.name,
|
||||
stationCallsign = station.callsign,
|
||||
stationGrid = station.grid
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Prepares the Wavelog batch riding the LoTW upload (null when Wavelog is not ready). */
|
||||
private suspend fun prepareWavelogPiggyback(
|
||||
records: List<com.rtbishop.look4sat.core.domain.logbook.QsoRecord>
|
||||
): WavelogUploadPreview? {
|
||||
val settings = settingsRepo.wavelogUploadSettings.value
|
||||
if (!settings.isReady) return null
|
||||
return runCatching {
|
||||
wavelogUploadRepository.prepare(wavelogUploadCandidates(records), settings)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads one prepared Wavelog batch (piggyback) and marks what the server
|
||||
* accepted. Returns the operator-facing message segment for this upload.
|
||||
*/
|
||||
private suspend fun uploadWavelogNow(
|
||||
preview: WavelogUploadPreview,
|
||||
settings: WavelogUploadSettings
|
||||
): String = try {
|
||||
val outcome = wavelogUploadRepository.upload(preview, settings)
|
||||
if (outcome is WavelogUploadOutcome.Imported && outcome.markIds.isNotEmpty()) {
|
||||
qsoRepository.markWavelogUploaded(outcome.markIds)
|
||||
}
|
||||
wavelogConfirmedSegment(outcome, preview)
|
||||
} catch (_: Exception) {
|
||||
wavelogTransportFailureSegment()
|
||||
}
|
||||
|
||||
private fun syncLoTWGrids(
|
||||
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
||||
mode: LoTWSyncMode
|
||||
@@ -389,6 +481,9 @@ class SettingsViewModel(
|
||||
// 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(
|
||||
@@ -398,7 +493,17 @@ class SettingsViewModel(
|
||||
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
|
||||
else -> "No pending QSOs to upload"
|
||||
}
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = msg) }
|
||||
// No LoTW work to do — still flush the Wavelog backlog (catch-up): a
|
||||
// freshly configured Wavelog syncs without waiting for new QSOs.
|
||||
val wavelogMsg = wavelogPreview?.let {
|
||||
if (it.count > 0) uploadWavelogNow(it, settingsRepo.wavelogUploadSettings.value) else wavelogIdleSegment(it)
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
logbookUploadBusy = false,
|
||||
logbookUploadMessage = listOfNotNull(msg, wavelogMsg).joinToString("\n")
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
val preview = lotwUploadRepository.prepare(pending, false)
|
||||
@@ -409,6 +514,7 @@ class SettingsViewModel(
|
||||
it.copy(
|
||||
logbookUploadBusy = false,
|
||||
logbookPreview = preview,
|
||||
wavelogPending = wavelogPreview,
|
||||
logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
|
||||
)
|
||||
}
|
||||
@@ -436,11 +542,19 @@ 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,
|
||||
logbookUploadMessage = msg,
|
||||
wavelogPending = null,
|
||||
logbookUploadMessage = listOfNotNull(msg, wavelogMsg).joinToString("\n"),
|
||||
// An accepted resubmit is done — drop the checks; a failed one keeps
|
||||
// them so retrying stays one tap away.
|
||||
logbookSelectionMode = if (accepted) false else it.logbookSelectionMode,
|
||||
@@ -450,7 +564,7 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null) }
|
||||
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null, wavelogPending = null) }
|
||||
|
||||
/** Operator chose "ignore" on the position check: keep the prepared preview. */
|
||||
private fun ignoreLogbookPositionWarning() = _uiState.update { it.copy(logbookPositionWarning = null) }
|
||||
@@ -459,7 +573,7 @@ class SettingsViewModel(
|
||||
private fun abandonLogbookForGridFix() {
|
||||
lotwUploadRepository.discardPreview()
|
||||
lastLogbookUploadIds = emptyList()
|
||||
_uiState.update { it.copy(logbookPositionWarning = null, logbookPreview = null) }
|
||||
_uiState.update { it.copy(logbookPositionWarning = null, logbookPreview = null, wavelogPending = null) }
|
||||
}
|
||||
|
||||
/** Long-press entry: selection mode with the pressed record checked. */
|
||||
@@ -486,7 +600,8 @@ class SettingsViewModel(
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") }
|
||||
try {
|
||||
val selected = resubmitCandidates(qsoRepository.records.first(), _uiState.value.logbookSelectedIds)
|
||||
val all = qsoRepository.records.first()
|
||||
val selected = resubmitCandidates(all, _uiState.value.logbookSelectedIds)
|
||||
if (selected.isEmpty()) {
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No complete QSOs in the selection") }
|
||||
return@launch
|
||||
@@ -502,10 +617,13 @@ 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)
|
||||
)
|
||||
}
|
||||
@@ -704,6 +822,7 @@ class SettingsViewModel(
|
||||
settingsRepo = container.settingsRepo,
|
||||
updateRepo = container.updateRepo,
|
||||
wavelogRepo = container.wavelogRepo,
|
||||
wavelogUploadRepository = container.wavelogUploadRepository,
|
||||
lotwRepo = container.lotwRepo,
|
||||
qsoRepository = container.qsoRepository,
|
||||
lotwUploadRepository = container.lotwUploadRepository,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "565"
|
||||
appVersionCode = "567"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.8-ba7opf.8.2"
|
||||
appVersionName = "4.4.8-ba7opf.9.2"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user