From 9e5b9b6ac6d6477b38ab880640d359561b0c9b68 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Mon, 5 Oct 2026 01:22:48 +0800 Subject: [PATCH] feat(wavelog): v1 upload repository (probe/prepare/classify) + DI wiring --- .../core/data/injection/MainContainer.kt | 6 +- .../repository/WavelogUploadRepository.kt | 327 ++++++++++++++++++ .../repository/WavelogUploadRepositoryTest.kt | 197 +++++++++++ .../core/domain/repository/IMainContainer.kt | 1 + .../repository/IWavelogUploadRepository.kt | 79 +++++ 5 files changed, 609 insertions(+), 1 deletion(-) create mode 100644 core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/WavelogUploadRepository.kt create mode 100644 core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/WavelogUploadRepositoryTest.kt create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IWavelogUploadRepository.kt diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt index 850a037e..3d7f089f 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt @@ -31,6 +31,7 @@ import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2 import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3 import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_1_2 import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_2_3 +import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_3_4 import com.rtbishop.look4sat.core.data.database.QsoDatabase import com.rtbishop.look4sat.core.data.framework.BluetoothReporter import com.rtbishop.look4sat.core.data.framework.Ft817Controller @@ -49,6 +50,7 @@ import com.rtbishop.look4sat.core.data.repository.UpdateRepository import com.rtbishop.look4sat.core.data.repository.LoTWRepository import com.rtbishop.look4sat.core.data.repository.LocationRepo import com.rtbishop.look4sat.core.data.repository.WavelogRepository +import com.rtbishop.look4sat.core.data.repository.WavelogUploadRepository import com.rtbishop.look4sat.core.data.source.LocalSource import com.rtbishop.look4sat.core.data.source.RemoteSource import com.rtbishop.look4sat.core.data.usecase.AddToCalendar @@ -70,6 +72,7 @@ import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository +import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository import com.rtbishop.look4sat.core.domain.repository.MutualPassData import com.rtbishop.look4sat.core.domain.source.ILocalSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource @@ -101,10 +104,11 @@ class MainContainer(private val context: Context) : IMainContainer { override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) } override val updateRepo by lazy { UpdateRepository(remoteSource) } override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() } + override val wavelogUploadRepository: IWavelogUploadRepository by lazy { WavelogUploadRepository() } override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() } override val qsoRepository: IQsoRepository by lazy { val database = Room.databaseBuilder(context, QsoDatabase::class.java, "Look4SatQsoDB") - .addMigrations(MIGRATION_QSO_1_2, MIGRATION_QSO_2_3) + .addMigrations(MIGRATION_QSO_1_2, MIGRATION_QSO_2_3, MIGRATION_QSO_3_4) .build() QsoRepository(database.qsoDao(), Dispatchers.IO) } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/WavelogUploadRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/WavelogUploadRepository.kt new file mode 100644 index 00000000..99432fc1 --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/WavelogUploadRepository.kt @@ -0,0 +1,327 @@ +/* + * Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2026 BA7OPF. + * Based on Look4Sat by Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.data.repository + +import com.rtbishop.look4sat.core.domain.logbook.AdifCodec +import com.rtbishop.look4sat.core.domain.logbook.QsoRecord +import com.rtbishop.look4sat.core.domain.logbook.QsoStatus +import com.rtbishop.look4sat.core.domain.logbook.displayMode +import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings +import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository +import com.rtbishop.look4sat.core.domain.repository.WavelogProblem +import com.rtbishop.look4sat.core.domain.repository.WavelogProbe +import com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo +import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome +import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.concurrent.TimeUnit + +/** + * Uploads local QSO records to a self-hosted Wavelog via the v1 API. + * + * The server contract (verified against Wavelog 3.0.2, `Api.php` / `Logbook_model.php`): + * - `POST {base}/index.php/api/qso` with `{"key","station_profile_id","type":"adif","string"}`. + * - A clean import answers HTTP 201; skipped records (duplicates, differing locator/callsign) + * answer HTTP 400 with one message line per record while the remaining records ARE inserted. + * Duplicates count as "already in Wavelog" and are marked; `SKIPPED` records are not. + * - The export omits `STATION_CALLSIGN` so the server fills it from the chosen station profile + * (a present-but-different callsign would make the server skip the record). + */ +class WavelogUploadRepository internal constructor( + client: OkHttpClient = OkHttpClient() +) : IWavelogUploadRepository { + + private val client = client.newBuilder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .callTimeout(120, TimeUnit.SECONDS) + .retryOnConnectionFailure(false) + .build() + + private val jsonType = "application/json; charset=utf-8".toMediaType() + + private val mutex = Mutex() + @Volatile private var pending: Pending? = null + + private data class Pending( + val submittedIds: List, + val callsById: Map, + val adif: ByteArray, + val created: Long + ) + + override suspend fun probe(url: String, apiKey: String): WavelogProbe? = withContext(Dispatchers.IO) { + val base = url.trim().trimEnd('/') + if (base.isBlank() || apiKey.isBlank()) return@withContext null + try { + val authBody = get("$base/index.php/api/check_auth/$apiKey") ?: return@withContext null + val rights = JSONObject(authBody).optString("rights") + val stationsBody = get("$base/index.php/api/station_info/$apiKey") ?: return@withContext null + WavelogProbe(rights, parseStations(stationsBody)) + } catch (_: Exception) { + null + } + } + + override suspend fun prepare(records: List, settings: WavelogUploadSettings): WavelogUploadPreview = + withContext(Dispatchers.IO) { + mutex.withLock { + discardPending() + if (settings.url.isBlank() || settings.apiKey.isBlank()) { + return@withLock blocked(settings, WavelogProblem.NOT_CONFIGURED) + } + if (settings.stationId.isBlank()) return@withLock blocked(settings, WavelogProblem.NO_STATION) + var skipped = 0 + val reasons = mutableMapOf() + val unique = hashSetOf() + val submitted = records.sortedBy { it.startUtcMillis }.filter { record -> + when { + record.status != QsoStatus.COMPLETE -> { + skipped++ + false + } + + !gridCompatible(record.myGrid, settings.stationGrid) -> { + skipped++ + reasons[WavelogProblem.GRID_MISMATCH] = (reasons[WavelogProblem.GRID_MISMATCH] ?: 0) + 1 + false + } + + !unique.add(stableQsoKey(record)) -> { + skipped++ + false + } + + else -> true + } + } + val preview = WavelogUploadPreview( + count = submitted.size, + skipped = skipped, + reasons = reasons, + stationLabel = stationLabel(settings), + firstUtc = submitted.firstOrNull()?.let { utcText(it.startUtcMillis) }.orEmpty(), + lastUtc = submitted.lastOrNull()?.let { utcText(it.startUtcMillis) }.orEmpty(), + contacts = submitted.map { record -> + "${shortText(record.startUtcMillis)} ${record.theirCallsign} ${record.displayMode} " + + record.satelliteName.substringBefore('(').trim() + }, + submittedIds = submitted.map { it.id } + ) + if (submitted.isNotEmpty()) { + pending = Pending( + submittedIds = submitted.map { it.id }, + callsById = submitted.associate { it.id to it.theirCallsign.trim().uppercase(Locale.US) }, + adif = AdifCodec.encode(submitted, includeStationCallsign = false).toByteArray(Charsets.UTF_8), + created = System.currentTimeMillis() + ) + } + preview + } + } + + override suspend fun upload(preview: WavelogUploadPreview, settings: WavelogUploadSettings): WavelogUploadOutcome = + withContext(Dispatchers.IO) { + mutex.withLock { + val payload = pending + pending = null // a preview is a one-shot authorization, including across failures + val now = System.currentTimeMillis() + if (payload == null || payload.submittedIds != preview.submittedIds || now - payload.created !in 0..900_000) { + return@withLock WavelogUploadOutcome.Expired + } + try { + val base = settings.url.trim().trimEnd('/') + val body = JSONObject().apply { + put("key", settings.apiKey.trim()) + put("station_profile_id", settings.stationId) + put("type", "adif") + put("string", String(payload.adif, Charsets.UTF_8)) + }.toString() + val request = Request.Builder() + .url("$base/index.php/api/qso") + .post(body.toRequestBody(jsonType)) + .build() + client.newCall(request).execute().use { response -> + val text = response.body.string().take(64 * 1024) + classifyUploadResponse(response.code, text, payload.callsById) + } + } catch (_: Exception) { + WavelogUploadOutcome.Failed("Upload failed — check server address/network (Tailscale on?)") + } finally { + payload.adif.fill(0) + } + } + } + + private fun discardPending() { + pending?.adif?.fill(0) + pending = null + } + + private fun blocked(settings: WavelogUploadSettings, reason: WavelogProblem) = WavelogUploadPreview( + count = 0, + skipped = 0, + reasons = emptyMap(), + stationLabel = stationLabel(settings), + firstUtc = "", + lastUtc = "", + contacts = emptyList(), + submittedIds = emptyList(), + blockedBy = reason + ) + + private fun stationLabel(settings: WavelogUploadSettings): String = listOf( + settings.stationName, settings.stationCallsign, settings.stationGrid + ).map(String::trim).filter(String::isNotBlank).joinToString(" · ") + + private fun get(url: String): String? { + val request = Request.Builder().url(url).get().build() + return client.newCall(request).execute().use { response -> + if (!response.isSuccessful) null else response.body.string().take(256 * 1024) + } + } +} + +/** + * Whether a record's logged grid is consistent with the Wavelog station profile grid. + * Mirror of the server's `adif_grid_check_location` prefix rule; either side blank + * disables the check, exactly like the server. + */ +internal fun gridCompatible(adifGrid: String, stationGrid: String): Boolean { + val adif = adifGrid.trim().uppercase(Locale.US) + val station = stationGrid.trim().uppercase(Locale.US) + if (adif.isEmpty() || station.isEmpty()) return true + val adifParts = adif.split(',').map(String::trim).filter(String::isNotEmpty) + val stationParts = station.split(',').map(String::trim).filter(String::isNotEmpty) + if (adifParts.isEmpty() || stationParts.isEmpty()) return true + val adifMargin = adifParts.size > 1 + val stationMargin = stationParts.size > 1 + return when { + adifMargin && stationMargin -> adifParts.sorted() == stationParts.sorted() + adifMargin -> false + stationMargin -> stationParts.any { adif.startsWith(it) } + else -> adif.startsWith(station) || station.startsWith(adif) + } +} + +/** + * Classifies Wavelog's upload response. + * + * 2xx = everything imported. 400 = per-record errors: duplicates count as "already in Wavelog" + * (they are marked, so a re-run converges), `SKIPPED` records are excluded from marking, and + * any other message is surfaced verbatim with nothing marked. + */ +internal fun classifyUploadResponse(code: Int, body: String, callsById: Map): WavelogUploadOutcome = when { + code in 200..299 -> WavelogUploadOutcome.Imported( + imported = callsById.size, duplicates = 0, skipped = 0, markIds = callsById.keys.toList() + ) + + code == 401 -> WavelogUploadOutcome.Failed("Wavelog rejected the API key or station (HTTP 401)") + + code == 403 -> WavelogUploadOutcome.Failed( + "API key has no write permission — create a read & write key in Wavelog (Settings → API)" + ) + + else -> classifyAbortMessages(body, callsById) +} + +private val skipCallRegex = Regex("importing QSO with\\s*([^<]+)", RegexOption.IGNORE_CASE) + +private fun classifyAbortMessages(body: String, callsById: Map): WavelogUploadOutcome { + val raw = rawMessages(body) + val lines = stripTags(raw).split('\n').map(String::trim).filter(String::isNotEmpty) + if (lines.isEmpty()) { + return WavelogUploadOutcome.Failed("Wavelog did not accept the upload — check the server log") + } + val duplicates = lines.count { it.contains("Duplicate for", ignoreCase = true) } + val skipLines = lines.filter { it.contains("SKIPPED", ignoreCase = true) } + val others = lines.filter { + !it.contains("Duplicate for", ignoreCase = true) && !it.contains("SKIPPED", ignoreCase = true) + } + if (others.isNotEmpty()) { + return WavelogUploadOutcome.Rejected("Wavelog reported: ${others.first().take(200)}") + } + val submittedCalls = callsById.values.toSet() + val skipCalls = skipCallRegex.findAll(raw) + .map { it.groupValues[1].trim().uppercase(Locale.US) }.toSet() + if (skipLines.isNotEmpty() && (skipCalls.isEmpty() || skipCalls.any { it !in submittedCalls })) { + // A skip we cannot map back to a submitted record: stay conservative, mark nothing. + return WavelogUploadOutcome.Rejected("Some records were skipped by Wavelog — nothing was marked; review and retry") + } + val markIds = callsById.filterValues { it !in skipCalls }.keys.toList() + val skipped = callsById.size - markIds.size + val imported = (callsById.size - duplicates - skipped).coerceAtLeast(0) + return WavelogUploadOutcome.Imported(imported, duplicates, skipped, markIds) +} + +private fun rawMessages(body: String): String { + val json = runCatching { JSONObject(body) }.getOrNull() + return if (json != null) { + val messages = json.optJSONArray("messages") + if (messages != null) (0 until messages.length()).joinToString("") { messages.optString(it) } + else json.optString("reason") + } else { + body + } +} + +private fun stripTags(raw: String): String = raw + .replace(Regex("(?i)"), "\n") + .replace(Regex("<[^>]*>"), " ") + .replace(""", "\"").replace("'", "'").replace("&", "&") + .replace(Regex("[ \\t]+"), " ") + .lines().joinToString("\n") { it.trim() } + +/** Parses the v1 station_info array (only the fields the app uses). */ +internal fun parseStations(body: String): List { + val array = JSONArray(body) + return (0 until array.length()).mapNotNull { index -> + val item = array.optJSONObject(index) ?: return@mapNotNull null + val id = item.optString("station_id") + if (id.isBlank()) return@mapNotNull null + WavelogStationInfo( + id = id, + name = item.optString("station_profile_name"), + callsign = item.optString("station_callsign"), + grid = item.optString("station_gridsquare"), + active = item.optString("station_active") == "1" + ) + } +} + +private fun utcText(millis: Long): String = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") +}.format(Date(millis)) + +private fun shortText(millis: Long): String = SimpleDateFormat("MM-dd HH:mm", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") +}.format(Date(millis)) diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/WavelogUploadRepositoryTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/WavelogUploadRepositoryTest.kt new file mode 100644 index 00000000..b477289b --- /dev/null +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/WavelogUploadRepositoryTest.kt @@ -0,0 +1,197 @@ +/* + * Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2026 BA7OPF. + * Based on Look4Sat by Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.rtbishop.look4sat.core.data.repository + +import com.rtbishop.look4sat.core.domain.logbook.QsoRecord +import com.rtbishop.look4sat.core.domain.logbook.QsoStatus +import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings +import com.rtbishop.look4sat.core.domain.repository.WavelogProblem +import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WavelogUploadRepositoryTest { + + // region # gridCompatible (mirror of the server's adif_grid_check_location prefix rule) + + @Test + fun grid_sameGridsPass() { + assertTrue(gridCompatible("OL62TI", "OL62TI")) + } + + @Test + fun grid_longerAdifGridPasses() { + assertTrue(gridCompatible("OL62TI", "OL62")) + } + + @Test + fun grid_longerStationGridPasses() { + assertTrue(gridCompatible("OL62", "OL62TI")) + } + + @Test + fun grid_differentSquaresFail() { + assertFalse(gridCompatible("PM01", "OL62")) + } + + @Test + fun grid_blankSidesDisableTheCheck() { + assertTrue(gridCompatible("", "OL62")) + assertTrue(gridCompatible("OL62", "")) + } + + @Test + fun grid_stationMarginAcceptsAPrefixPart() { + assertTrue(gridCompatible("OL63AA", "OL62,OL63")) + assertFalse(gridCompatible("PM01AA", "OL62,OL63")) + } + + @Test + fun grid_caseInsensitive() { + assertTrue(gridCompatible("ol62ti", "OL62")) + } + + // endregion + + // region # classifyUploadResponse + + private val calls = mapOf(1L to "BG5JVM", 2L to "BH6RJD", 3L to "BI3SIR") + + @Test + fun createdMarksEverything() { + val outcome = classifyUploadResponse( + 201, """{"status":"created","adif_count":3,"adif_errors":0,"messages":[""]}""", calls + ) + assertEquals(WavelogUploadOutcome.Imported(3, 0, 0, listOf(1L, 2L, 3L)), outcome) + } + + @Test + fun duplicatesAreCountedAndStillMarked() { + val body = """{"status":"abort","messages":["Date/Time: 2026-10-04 12:34:00 Callsign: BG5JVM Band: 70CM Duplicate for BA7OPF
"]}""" + val outcome = classifyUploadResponse(400, body, calls) + assertEquals(WavelogUploadOutcome.Imported(2, 1, 0, listOf(1L, 2L, 3L)), outcome) + } + + @Test + fun skippedRecordIsNotMarked() { + val body = """{"status":"abort","messages":["Differing locator PM01 while importing QSO with BH6RJD for station locator OL62TI: SKIPPED
"]}""" + val outcome = classifyUploadResponse(400, body, calls) + assertEquals(WavelogUploadOutcome.Imported(2, 0, 1, listOf(1L, 3L)), outcome) + } + + @Test + fun duplicateAndSkipTogether() { + val body = """{"status":"abort","messages":["Differing locator PM01 while importing QSO with BH6RJD for station locator OL62TI: SKIPPED
Date/Time: 2026-10-04 12:34:00 Callsign: BG5JVM Band: 70CM Duplicate for BA7OPF
"]}""" + val outcome = classifyUploadResponse(400, body, calls) + assertEquals(WavelogUploadOutcome.Imported(1, 1, 1, listOf(1L, 3L)), outcome) + } + + @Test + fun unmappedSkipMarksNothing() { + val body = """{"status":"abort","messages":["Differing locator PM01 while importing QSO with N0CALL for station locator OL62TI: SKIPPED
"]}""" + val outcome = classifyUploadResponse(400, body, calls) + assertTrue(outcome is WavelogUploadOutcome.Rejected) + } + + @Test + fun otherMessagesAreRejected() { + val body = """{"status":"abort","messages":["QSO on 2026-10-04: You tried to import a QSO without any given CALL. This QSO wasn't imported. It's invalid.
"]}""" + val outcome = classifyUploadResponse(400, body, calls) + assertTrue(outcome is WavelogUploadOutcome.Rejected) + } + + @Test + fun authFailuresAreActionable() { + assertTrue(classifyUploadResponse(401, "", calls) is WavelogUploadOutcome.Failed) + val forbidden = classifyUploadResponse(403, """{"reason":"API key does not have write permissions"}""", calls) + assertTrue(forbidden is WavelogUploadOutcome.Failed) + assertTrue((forbidden as WavelogUploadOutcome.Failed).message.contains("write")) + } + + @Test + fun htmlGarbageOrEmptyBodyMarksNothing() { + assertTrue(classifyUploadResponse(400, "oops", calls) is WavelogUploadOutcome.Rejected) + assertTrue(classifyUploadResponse(400, "", calls) is WavelogUploadOutcome.Failed) + } + + // endregion + + // region # parseStations + prepare + + @Test + fun stationsParse() { + val body = """[{"station_id":"1","station_profile_name":"Shenzhen","station_gridsquare":"OL62TI","station_callsign":"BA7OPF","station_active":"1"}]""" + val stations = parseStations(body) + assertEquals(1, stations.size) + assertEquals("1", stations.single().id) + assertEquals("Shenzhen", stations.single().name) + assertEquals("BA7OPF", stations.single().callsign) + assertEquals("OL62TI", stations.single().grid) + assertTrue(stations.single().active) + } + + @Test + fun prepareWithoutConfigIsBlocked() = runBlocking { + val repo = WavelogUploadRepository() + val preview = repo.prepare(listOf(sampleRecord(start = 1_700_000_000_000L)), WavelogUploadSettings()) + assertEquals(WavelogProblem.NOT_CONFIGURED, preview.blockedBy) + assertEquals(0, preview.count) + } + + @Test + fun prepareWithoutStationIsBlocked() = runBlocking { + val repo = WavelogUploadRepository() + val settings = WavelogUploadSettings(url = "http://example.org", apiKey = "k") + val preview = repo.prepare(listOf(sampleRecord(start = 1_700_000_000_000L)), settings) + assertEquals(WavelogProblem.NO_STATION, preview.blockedBy) + } + + @Test + fun prepareSkipsGridMismatchAndDeduplicates() = runBlocking { + val repo = WavelogUploadRepository() + val settings = WavelogUploadSettings( + url = "http://example.org", apiKey = "k", stationId = "1", + stationName = "Shenzhen", stationCallsign = "BA7OPF", stationGrid = "OL62TI" + ) + val records = listOf( + sampleRecord(start = 1_700_000_000_000L, myGrid = "PM01AA"), + sampleRecord(start = 1_700_000_120_000L, myGrid = "OL62TI"), + sampleRecord(start = 1_700_000_120_000L, myGrid = "OL62TI") // batch duplicate of the previous + ) + val preview = repo.prepare(records, settings) + assertEquals(1, preview.count) + assertEquals(2, preview.skipped) + assertEquals(1, preview.reasons[WavelogProblem.GRID_MISMATCH]) + assertEquals(1, preview.submittedIds.size) + assertEquals("Shenzhen · BA7OPF · OL62TI", preview.stationLabel) + } + + // endregion + + private fun sampleRecord(start: Long, myGrid: String = "OL62TI") = QsoRecord( + id = start, + startUtcMillis = start, + theirCallsign = "BG5JVM", + myCallsign = "BA7OPF", + myGrid = myGrid, + txFrequencyHz = 145_850_000L, + rxFrequencyHz = 436_795_000L, + band = "2M", + rxBand = "70CM", + mode = "FM", + satelliteName = "SO-50", + propagationMode = "SAT", + status = QsoStatus.COMPLETE + ) +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt index c45aab0e..75e7e3d7 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt @@ -34,6 +34,7 @@ interface IMainContainer { val amSatRepo: IAmSatRepository val updateRepo: IUpdateRepository val wavelogRepo: IWavelogRepository + val wavelogUploadRepository: IWavelogUploadRepository val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository val qsoRepository: IQsoRepository val lotwUploadRepository: ILoTWUploadRepository diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IWavelogUploadRepository.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IWavelogUploadRepository.kt new file mode 100644 index 00000000..c00a8e37 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IWavelogUploadRepository.kt @@ -0,0 +1,79 @@ +/* + * Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2026 BA7OPF. + * Based on Look4Sat by Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.domain.repository + +import com.rtbishop.look4sat.core.domain.logbook.QsoRecord +import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings + +/** One station profile as returned by the v1 station_info endpoint. */ +data class WavelogStationInfo( + val id: String, + val name: String, + val callsign: String, + val grid: String, + val active: Boolean +) + +/** Result of probing a Wavelog server (check_auth + station_info). */ +data class WavelogProbe(val rights: String, val stations: List) + +/** Why a record (or the whole upload) cannot be submitted. */ +enum class WavelogProblem { NOT_CONFIGURED, NO_STATION, GRID_MISMATCH } + +/** Records skipped before the POST (with reasons), plus the records that will be submitted. */ +data class WavelogUploadPreview( + val count: Int, + val skipped: Int, + val reasons: Map, + val stationLabel: String, + val firstUtc: String, + val lastUtc: String, + val contacts: List, + val submittedIds: List, + val blockedBy: WavelogProblem? = null +) + +sealed interface WavelogUploadOutcome { + /** [markIds] can be smaller than the submission when Wavelog reported skipped records. */ + data class Imported( + val imported: Int, + val duplicates: Int, + val skipped: Int, + val markIds: List + ) : WavelogUploadOutcome + + data class Rejected(val message: String) : WavelogUploadOutcome + data class Failed(val message: String) : WavelogUploadOutcome + data object Expired : WavelogUploadOutcome +} + +/** + * Uploads local QSO records to a self-hosted Wavelog via the v1 API + * (`POST {base}/index.php/api/qso` with a batch ADIF string). + */ +interface IWavelogUploadRepository { + /** check_auth + station_info; null on any transport/parse failure. */ + suspend fun probe(url: String, apiKey: String): WavelogProbe? + + /** Filters + encodes the batch and keeps a one-shot pending payload for [upload]. */ + suspend fun prepare(records: List, settings: WavelogUploadSettings): WavelogUploadPreview + + /** POSTs the prepared batch and classifies Wavelog's response. */ + suspend fun upload(preview: WavelogUploadPreview, settings: WavelogUploadSettings): WavelogUploadOutcome +}