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
+}