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