diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDao.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDao.kt index d747afe5..b1c96a14 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDao.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDao.kt @@ -47,4 +47,7 @@ interface QsoDao { @Query("UPDATE qso_records SET lotwUploaded = 1 WHERE id IN (:ids)") suspend fun markUploaded(ids: List) + + @Query("UPDATE qso_records SET wavelogUploaded = 1 WHERE id IN (:ids)") + suspend fun markWavelogUploaded(ids: List) } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDatabase.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDatabase.kt index 0cd8a961..ef716c0a 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDatabase.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDatabase.kt @@ -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") + } +} diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/QsoEntity.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/QsoEntity.kt index 574304c7..e34c188f 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/QsoEntity.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/QsoEntity.kt @@ -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 = "", diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt index 850a037e..3d7f089f 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt @@ -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) } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/QsoRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/QsoRepository.kt index fff4357f..7380f575 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/QsoRepository.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/QsoRepository.kt @@ -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) = withContext(dispatcher) { + if (ids.isEmpty()) return@withContext + importMutex.withLock { dao.markWavelogUploaded(ids) } + } + override suspend fun exportAdi(ids: Set?, 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(","), diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt index afa8b5e3..3cce3445 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt @@ -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 + + 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 diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/WavelogUploadRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/WavelogUploadRepository.kt new file mode 100644 index 00000000..99432fc1 --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/WavelogUploadRepository.kt @@ -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 . + */ +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, + val callsById: Map, + 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, 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() + val unique = hashSetOf() + 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): 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*([^<]+)", RegexOption.IGNORE_CASE) + +private fun classifyAbortMessages(body: String, callsById: Map): 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)"), "\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 { + 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)) diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt index a5ed56cc..8d0ee8d8 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt @@ -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 = + MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings()) + + override fun updateWavelogUploadSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings) = Unit + override fun getWorkedGrids(): Set = emptySet() private val workedGrids = MutableStateFlow(emptySet()) diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/QsoRepositoryTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/QsoRepositoryTest.kt index eda9c4ce..2e48600e 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/QsoRepositoryTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/QsoRepositoryTest.kt @@ -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) { ids.forEach { id -> rows[id]?.let { rows[id] = it.copy(lotwUploaded = true) } } } + + override suspend fun markWavelogUploaded(ids: List) { + ids.forEach { id -> rows[id]?.let { rows[id] = it.copy(wavelogUploaded = true) } } + } } } diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt index 5ad6d52f..7f583154 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt @@ -346,6 +346,9 @@ private class FakeSettingsRepoForSearch : ISettingsRepo { ) override val wavelogSettings: StateFlow = MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings()) + override val wavelogUploadSettings: StateFlow = + MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings()) + override fun updateWavelogUploadSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings) = Unit override val lotwSettings: StateFlow = MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings()) diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/WavelogUploadRepositoryTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/WavelogUploadRepositoryTest.kt new file mode 100644 index 00000000..b477289b --- /dev/null +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/WavelogUploadRepositoryTest.kt @@ -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
"]}""" + 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 PM01 while importing QSO with BH6RJD for station locator OL62TI: SKIPPED
"]}""" + 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 PM01 while importing QSO with BH6RJD for station locator OL62TI: SKIPPED
Date/Time: 2026-10-04 12:34:00 Callsign: BG5JVM Band: 70CM Duplicate for BA7OPF
"]}""" + 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 PM01 while importing QSO with N0CALL for station locator OL62TI: SKIPPED
"]}""" + 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.
"]}""" + 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, "oops", 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 + ) +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodec.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodec.kt index 4c1620f5..e48ff990 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodec.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodec.kt @@ -18,7 +18,7 @@ import java.util.TimeZone object AdifCodec { private val utc = TimeZone.getTimeZone("UTC") - fun encode(records: List): String = buildString { + fun encode(records: List, includeStationCallsign: Boolean = true): String = buildString { append(field("ADIF_VER", "3.1.7")) append(field("PROGRAMID", "Look4Sat")) append("\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) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/IQsoRepository.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/IQsoRepository.kt index 6e6e7672..a45db5ce 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/IQsoRepository.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/IQsoRepository.kt @@ -23,6 +23,10 @@ interface IQsoRepository { * logbook row can show which grids the QSO went out under. */ suspend fun markUploaded(ids: List, grids: List = emptyList()) + + /** Marks the batch as uploaded to Wavelog (independent of the LoTW upload state). */ + suspend fun markWavelogUploaded(ids: List) + suspend fun exportAdi(ids: Set? = null, includeIncomplete: Boolean = false): String suspend fun importAdi(content: String): AdifImportResult suspend fun mergeConfirmed(records: List): AdifImportResult diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt index 19c0da09..fce1d117 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt @@ -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 = emptyList(), diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/WavelogUploadMessages.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/WavelogUploadMessages.kt new file mode 100644 index 00000000..02e1a215 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/WavelogUploadMessages.kt @@ -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 . + */ +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): List = 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" diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/GridQso.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/GridQso.kt index a57d3eeb..4f5bc310 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/GridQso.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/GridQso.kt @@ -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 = + 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.scopedToStation(stationId: String?): List = + 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>.firstCallsByGrid(stationId: String?): Map = + mapNotNull { (grid, qsos) -> + qsos.scopedToStation(stationId).minByOrNull { it.epochMs }?.let { grid to it.call } + }.toMap() diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt index e98d648e..76682190 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt @@ -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 = "" diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt index c45aab0e..75e7e3d7 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt @@ -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 diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt index c06824eb..29c6e000 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt @@ -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>) //endregion + //region # Wavelog upload settings + val wavelogUploadSettings: StateFlow + fun updateWavelogUploadSettings(settings: WavelogUploadSettings) + //endregion + //region # LoTW confirmed-grids settings val lotwSettings: StateFlow fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IWavelogUploadRepository.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IWavelogUploadRepository.kt new file mode 100644 index 00000000..c00a8e37 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IWavelogUploadRepository.kt @@ -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 . + */ +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) + +/** 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, + val stationLabel: String, + val firstUtc: String, + val lastUtc: String, + val contacts: List, + val submittedIds: List, + 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 + ) : 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, settings: WavelogUploadSettings): WavelogUploadPreview + + /** POSTs the prepared batch and classifies Wavelog's response. */ + suspend fun upload(preview: WavelogUploadPreview, settings: WavelogUploadSettings): WavelogUploadOutcome +} diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodecTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodecTest.kt index 05a824b7..9a72fa72 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodecTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodecTest.kt @@ -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("N0CALL")) + assertTrue(adif.contains("SO-50")) + } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/WavelogUploadMessagesTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/WavelogUploadMessagesTest.kt new file mode 100644 index 00000000..acdf0f99 --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/WavelogUploadMessagesTest.kt @@ -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 = 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() + ) + } +} diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/model/GridQsoStationScopeTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/model/GridQsoStationScopeTest.kt new file mode 100644 index 00000000..433e1ec2 --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/model/GridQsoStationScopeTest.kt @@ -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 . + */ +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 = 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(), 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(), store.firstCallsByGrid("OL62")) + } +} diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt index 5eb7c672..195060bb 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt @@ -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 = MutableStateFlow(WavelogSettings()) + override val wavelogUploadSettings: StateFlow = MutableStateFlow(WavelogUploadSettings()) override val lotwSettings: StateFlow = MutableStateFlow(LoTWSettings()) private var workedGrids: Set = 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() } } diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 95f56501..dff78e8c 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -166,6 +166,16 @@ 已同步网格数:%1$d。格式:API key:日志ID。填好直接点同步,会自动保存并拉取。 立即同步 同步中… + Wavelog 日志上传 + 未配置 — 把本机通联记录上传到 Wavelog + 站点 %1$s — 可以上传 + API key(读写权限) + 获取站点 + 检测中… + 未选择站点 — 点「获取站点」 + 密钥:读写 — 可上传 + 密钥只读 — 请在 Wavelog → API 生成「读写」密钥 + 点「获取站点」选择上传使用的台站档案;密钥需要「读写」权限。 LoTW 已确认网格 LoTW 呼号 LoTW 密码 @@ -201,6 +211,7 @@ 已选 %1$d 条 长按记录行可多选重传 已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。 + 同时同步 Wavelog:%1$d 条 上传前核对 修改台址 忽略 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index a5e0dbbf..0efd9b14 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -195,6 +195,16 @@ Worked grids synced: %1$d. Format: API key, colon, logbook ID. Just tap Sync — credentials are saved automatically. Sync now Syncing… + Wavelog log upload + Not configured — upload your local QSO records to Wavelog + Station %1$s — ready to upload + API key (read & write) + Fetch stations + Checking… + No station selected — tap Fetch stations + Key: read & write — uploads enabled + Key is read-only — create a read & write key in Wavelog → API + Tap Fetch stations to pick the station profile uploads go through. The key needs read & write permission. LoTW confirmed grids LoTW callsign LoTW password @@ -230,6 +240,7 @@ %1$d selected Long-press a row to re-upload corrected records Existing contacts will be updated in place — a grid already used for an award may be rejected. + Also syncs to Wavelog: %1$d QSO(s) Upload check Fix station location Ignore diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt index d893a721..5eac2468 100644 --- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt +++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt @@ -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 = 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, MutableSet>>() 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 = 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, diff --git a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt index 7171525b..feac6b28 100644 --- a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt +++ b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt @@ -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 = MutableStateFlow(WavelogSettings()) + override val wavelogUploadSettings: StateFlow = MutableStateFlow(WavelogUploadSettings()) + override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) = Unit override val lotwSettings: StateFlow = MutableStateFlow(LoTWSettings()) override fun setSelectedIds(ids: List) = TODO() diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt index 20b43933..cddff9ee 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt @@ -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) } }, diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt index 5bd230a1..7f48f2cd 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt @@ -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 + ) } } } diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt index de35353d..f5baa4f6 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt @@ -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) } }, diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt index 3d5f8d03..b75d808b 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt @@ -225,6 +225,117 @@ fun WavelogDialog( } } +@Composable +fun WavelogUploadDialog( + initialSettings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings, + stations: List, + 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, diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt index 5c2d2300..98053f69 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt @@ -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 } } } ) diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt index b206d4ba..7cb4d33c 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt @@ -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 = 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( diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt index 035d3152..33470d07 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt @@ -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 + ): 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, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index df6f2b06..02c64e47 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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