merge: 合入 fork 9.2 发布线(AMSAT 冲突修复并入;版本保持 9.2/567)

# Conflicts:
#	gradle/libs.versions.toml
This commit is contained in:
atsunatsu committed 2026-10-05 03:56:52 +08:00
commit 1fa7dd2d88
36 files changed
+1586 -32

No files matched your search

@@ -47,4 +47,7 @@ interface QsoDao {
@Query("UPDATE qso_records SET lotwUploaded = 1 WHERE id IN (:ids)")
suspend fun markUploaded(ids: List<Long>)
@Query("UPDATE qso_records SET wavelogUploaded = 1 WHERE id IN (:ids)")
suspend fun markWavelogUploaded(ids: List<Long>)
}
@@ -20,7 +20,7 @@ import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
* upload receipts). Kept apart from [Look4SatDb] so existing installations
* never need a migration of the satellite database.
*/
@Database(entities = [QsoEntity::class], version = 3, exportSchema = false)
@Database(entities = [QsoEntity::class], version = 4, exportSchema = false)
abstract class QsoDatabase : RoomDatabase() {
abstract fun qsoDao(): QsoDao
}
@@ -38,3 +38,10 @@ val MIGRATION_QSO_2_3 = object : Migration(2, 3) {
db.execSQL("ALTER TABLE qso_records ADD COLUMN theirVuccGrids TEXT NOT NULL DEFAULT ''")
}
}
/** v3 → v4: track whether a QSO was uploaded to Wavelog (independent of the LoTW state). */
val MIGRATION_QSO_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE qso_records ADD COLUMN wavelogUploaded INTEGER NOT NULL DEFAULT 0")
}
}
@@ -44,6 +44,7 @@ data class QsoEntity(
@ColumnInfo(defaultValue = "''") val propagationMode: String = "",
@ColumnInfo(defaultValue = "0") val lotwConfirmed: Boolean = false,
@ColumnInfo(defaultValue = "0") val lotwUploaded: Boolean = false,
@ColumnInfo(defaultValue = "0") val wavelogUploaded: Boolean = false,
@ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false,
@ColumnInfo(defaultValue = "''") val lotwQslDate: String = "",
@ColumnInfo(defaultValue = "''") val vuccGrids: String = "",
@@ -31,6 +31,7 @@ import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_1_2
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_2_3
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_3_4
import com.rtbishop.look4sat.core.data.database.QsoDatabase
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
@@ -49,6 +50,7 @@ import com.rtbishop.look4sat.core.data.repository.UpdateRepository
import com.rtbishop.look4sat.core.data.repository.LoTWRepository
import com.rtbishop.look4sat.core.data.repository.LocationRepo
import com.rtbishop.look4sat.core.data.repository.WavelogRepository
import com.rtbishop.look4sat.core.data.repository.WavelogUploadRepository
import com.rtbishop.look4sat.core.data.source.LocalSource
import com.rtbishop.look4sat.core.data.source.RemoteSource
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
@@ -70,6 +72,7 @@ import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
@@ -101,10 +104,11 @@ class MainContainer(private val context: Context) : IMainContainer {
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
override val updateRepo by lazy { UpdateRepository(remoteSource) }
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
override val wavelogUploadRepository: IWavelogUploadRepository by lazy { WavelogUploadRepository() }
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
override val qsoRepository: IQsoRepository by lazy {
val database = Room.databaseBuilder(context, QsoDatabase::class.java, "Look4SatQsoDB")
.addMigrations(MIGRATION_QSO_1_2, MIGRATION_QSO_2_3)
.addMigrations(MIGRATION_QSO_1_2, MIGRATION_QSO_2_3, MIGRATION_QSO_3_4)
.build()
QsoRepository(database.qsoDao(), Dispatchers.IO)
}
@@ -51,12 +51,14 @@ class QsoRepository(
// place: that contact already exists on LoTW under the old values. The original row
// keeps its upload/confirmation state, and the edited content is saved as a NEW
// record (without any LoTW state, so it can be uploaded again).
if (previous != null && (previous.lotwUploaded || previous.lotwConfirmed) &&
if (previous != null &&
(previous.lotwUploaded || previous.lotwConfirmed || previous.wavelogUploaded) &&
!record.sameEditableContentAs(previous)
) {
dao.save(record.copy(
id = 0L,
lotwUploaded = false, lotwConfirmed = false, lotwReceived = false,
wavelogUploaded = false,
lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = emptyList(),
dxcc = null, country = "", cqZone = null, region = ""
).toEntity())
@@ -93,6 +95,11 @@ class QsoRepository(
if (stamped.isNotEmpty()) dao.saveBatch(stamped.map { it.toEntity() })
}
override suspend fun markWavelogUploaded(ids: List<Long>) = withContext(dispatcher) {
if (ids.isEmpty()) return@withContext
importMutex.withLock { dao.markWavelogUploaded(ids) }
}
override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
val records = dao.getAll()
.asSequence()
@@ -286,6 +293,7 @@ private fun QsoEntity.toDomain() = QsoRecord(
propagationMode = propagationMode,
lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded,
wavelogUploaded = wavelogUploaded,
lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
@@ -323,6 +331,7 @@ private fun QsoRecord.toEntity() = QsoEntity(
propagationMode = propagationMode.ifBlank { if (satelliteName.isNotBlank()) "SAT" else "" },
lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded,
wavelogUploaded = wavelogUploaded,
lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.joinToString(","),
@@ -31,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.positionToQth
@@ -386,6 +387,40 @@ class SettingsRepo(
preferences.edit { putString(keyLastLotwSyncCallsign, callsign.trim().uppercase()) }
//endregion
//region # Wavelog upload settings
private val keyWavelogUploadUrl = "wavelogUploadUrl"
private val keyWavelogUploadApiKey = "wavelogUploadApiKey"
private val keyWavelogUploadStationId = "wavelogUploadStationId"
private val keyWavelogUploadStationName = "wavelogUploadStationName"
private val keyWavelogUploadStationCallsign = "wavelogUploadStationCallsign"
private val keyWavelogUploadStationGrid = "wavelogUploadStationGrid"
private val _wavelogUploadSettings = MutableStateFlow(getWavelogUploadSettings())
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> = _wavelogUploadSettings
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) {
val trimmed = settings.copy(url = settings.url.trim(), apiKey = settings.apiKey.trim())
preferences.edit {
putString(keyWavelogUploadUrl, trimmed.url)
putString(keyWavelogUploadApiKey, trimmed.apiKey)
putString(keyWavelogUploadStationId, trimmed.stationId)
putString(keyWavelogUploadStationName, trimmed.stationName)
putString(keyWavelogUploadStationCallsign, trimmed.stationCallsign)
putString(keyWavelogUploadStationGrid, trimmed.stationGrid)
}
_wavelogUploadSettings.value = trimmed
}
private fun getWavelogUploadSettings(): WavelogUploadSettings = WavelogUploadSettings(
url = preferences.getString(keyWavelogUploadUrl, null).orEmpty(),
apiKey = preferences.getString(keyWavelogUploadApiKey, null).orEmpty(),
stationId = preferences.getString(keyWavelogUploadStationId, null).orEmpty(),
stationName = preferences.getString(keyWavelogUploadStationName, null).orEmpty(),
stationCallsign = preferences.getString(keyWavelogUploadStationCallsign, null).orEmpty(),
stationGrid = preferences.getString(keyWavelogUploadStationGrid, null).orEmpty()
)
//endregion
//region # Transceivers settings
private val _passesSettings = MutableStateFlow(getPassesSettings())
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
@@ -0,0 +1,327 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.logbook.AdifCodec
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.logbook.displayMode
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
import com.rtbishop.look4sat.core.domain.repository.WavelogProblem
import com.rtbishop.look4sat.core.domain.repository.WavelogProbe
import com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.TimeUnit
/**
* Uploads local QSO records to a self-hosted Wavelog via the v1 API.
*
* The server contract (verified against Wavelog 3.0.2, `Api.php` / `Logbook_model.php`):
* - `POST {base}/index.php/api/qso` with `{"key","station_profile_id","type":"adif","string"}`.
* - A clean import answers HTTP 201; skipped records (duplicates, differing locator/callsign)
* answer HTTP 400 with one message line per record while the remaining records ARE inserted.
* Duplicates count as "already in Wavelog" and are marked; `SKIPPED` records are not.
* - The export omits `STATION_CALLSIGN` so the server fills it from the chosen station profile
* (a present-but-different callsign would make the server skip the record).
*/
class WavelogUploadRepository internal constructor(
client: OkHttpClient = OkHttpClient()
) : IWavelogUploadRepository {
private val client = client.newBuilder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.callTimeout(120, TimeUnit.SECONDS)
.retryOnConnectionFailure(false)
.build()
private val jsonType = "application/json; charset=utf-8".toMediaType()
private val mutex = Mutex()
@Volatile private var pending: Pending? = null
private data class Pending(
val submittedIds: List<Long>,
val callsById: Map<Long, String>,
val adif: ByteArray,
val created: Long
)
override suspend fun probe(url: String, apiKey: String): WavelogProbe? = withContext(Dispatchers.IO) {
val base = url.trim().trimEnd('/')
if (base.isBlank() || apiKey.isBlank()) return@withContext null
try {
val authBody = get("$base/index.php/api/check_auth/$apiKey") ?: return@withContext null
val rights = JSONObject(authBody).optString("rights")
val stationsBody = get("$base/index.php/api/station_info/$apiKey") ?: return@withContext null
WavelogProbe(rights, parseStations(stationsBody))
} catch (_: Exception) {
null
}
}
override suspend fun prepare(records: List<QsoRecord>, settings: WavelogUploadSettings): WavelogUploadPreview =
withContext(Dispatchers.IO) {
mutex.withLock {
discardPending()
if (settings.url.isBlank() || settings.apiKey.isBlank()) {
return@withLock blocked(settings, WavelogProblem.NOT_CONFIGURED)
}
if (settings.stationId.isBlank()) return@withLock blocked(settings, WavelogProblem.NO_STATION)
var skipped = 0
val reasons = mutableMapOf<WavelogProblem, Int>()
val unique = hashSetOf<String>()
val submitted = records.sortedBy { it.startUtcMillis }.filter { record ->
when {
record.status != QsoStatus.COMPLETE -> {
skipped++
false
}
!gridCompatible(record.myGrid, settings.stationGrid) -> {
skipped++
reasons[WavelogProblem.GRID_MISMATCH] = (reasons[WavelogProblem.GRID_MISMATCH] ?: 0) + 1
false
}
!unique.add(stableQsoKey(record)) -> {
skipped++
false
}
else -> true
}
}
val preview = WavelogUploadPreview(
count = submitted.size,
skipped = skipped,
reasons = reasons,
stationLabel = stationLabel(settings),
firstUtc = submitted.firstOrNull()?.let { utcText(it.startUtcMillis) }.orEmpty(),
lastUtc = submitted.lastOrNull()?.let { utcText(it.startUtcMillis) }.orEmpty(),
contacts = submitted.map { record ->
"${shortText(record.startUtcMillis)} ${record.theirCallsign} ${record.displayMode} " +
record.satelliteName.substringBefore('(').trim()
},
submittedIds = submitted.map { it.id }
)
if (submitted.isNotEmpty()) {
pending = Pending(
submittedIds = submitted.map { it.id },
callsById = submitted.associate { it.id to it.theirCallsign.trim().uppercase(Locale.US) },
adif = AdifCodec.encode(submitted, includeStationCallsign = false).toByteArray(Charsets.UTF_8),
created = System.currentTimeMillis()
)
}
preview
}
}
override suspend fun upload(preview: WavelogUploadPreview, settings: WavelogUploadSettings): WavelogUploadOutcome =
withContext(Dispatchers.IO) {
mutex.withLock {
val payload = pending
pending = null // a preview is a one-shot authorization, including across failures
val now = System.currentTimeMillis()
if (payload == null || payload.submittedIds != preview.submittedIds || now - payload.created !in 0..900_000) {
return@withLock WavelogUploadOutcome.Expired
}
try {
val base = settings.url.trim().trimEnd('/')
val body = JSONObject().apply {
put("key", settings.apiKey.trim())
put("station_profile_id", settings.stationId)
put("type", "adif")
put("string", String(payload.adif, Charsets.UTF_8))
}.toString()
val request = Request.Builder()
.url("$base/index.php/api/qso")
.post(body.toRequestBody(jsonType))
.build()
client.newCall(request).execute().use { response ->
val text = response.body.string().take(64 * 1024)
classifyUploadResponse(response.code, text, payload.callsById)
}
} catch (_: Exception) {
WavelogUploadOutcome.Failed("Upload failed — check server address/network (Tailscale on?)")
} finally {
payload.adif.fill(0)
}
}
}
private fun discardPending() {
pending?.adif?.fill(0)
pending = null
}
private fun blocked(settings: WavelogUploadSettings, reason: WavelogProblem) = WavelogUploadPreview(
count = 0,
skipped = 0,
reasons = emptyMap(),
stationLabel = stationLabel(settings),
firstUtc = "",
lastUtc = "",
contacts = emptyList(),
submittedIds = emptyList(),
blockedBy = reason
)
private fun stationLabel(settings: WavelogUploadSettings): String = listOf(
settings.stationName, settings.stationCallsign, settings.stationGrid
).map(String::trim).filter(String::isNotBlank).joinToString(" · ")
private fun get(url: String): String? {
val request = Request.Builder().url(url).get().build()
return client.newCall(request).execute().use { response ->
if (!response.isSuccessful) null else response.body.string().take(256 * 1024)
}
}
}
/**
* Whether a record's logged grid is consistent with the Wavelog station profile grid.
* Mirror of the server's `adif_grid_check_location` prefix rule; either side blank
* disables the check, exactly like the server.
*/
internal fun gridCompatible(adifGrid: String, stationGrid: String): Boolean {
val adif = adifGrid.trim().uppercase(Locale.US)
val station = stationGrid.trim().uppercase(Locale.US)
if (adif.isEmpty() || station.isEmpty()) return true
val adifParts = adif.split(',').map(String::trim).filter(String::isNotEmpty)
val stationParts = station.split(',').map(String::trim).filter(String::isNotEmpty)
if (adifParts.isEmpty() || stationParts.isEmpty()) return true
val adifMargin = adifParts.size > 1
val stationMargin = stationParts.size > 1
return when {
adifMargin && stationMargin -> adifParts.sorted() == stationParts.sorted()
adifMargin -> false
stationMargin -> stationParts.any { adif.startsWith(it) }
else -> adif.startsWith(station) || station.startsWith(adif)
}
}
/**
* Classifies Wavelog's upload response.
*
* 2xx = everything imported. 400 = per-record errors: duplicates count as "already in Wavelog"
* (they are marked, so a re-run converges), `SKIPPED` records are excluded from marking, and
* any other message is surfaced verbatim with nothing marked.
*/
internal fun classifyUploadResponse(code: Int, body: String, callsById: Map<Long, String>): WavelogUploadOutcome = when {
code in 200..299 -> WavelogUploadOutcome.Imported(
imported = callsById.size, duplicates = 0, skipped = 0, markIds = callsById.keys.toList()
)
code == 401 -> WavelogUploadOutcome.Failed("Wavelog rejected the API key or station (HTTP 401)")
code == 403 -> WavelogUploadOutcome.Failed(
"API key has no write permission — create a read & write key in Wavelog (Settings → API)"
)
else -> classifyAbortMessages(body, callsById)
}
private val skipCallRegex = Regex("importing QSO with\\s*<b>([^<]+)</b>", RegexOption.IGNORE_CASE)
private fun classifyAbortMessages(body: String, callsById: Map<Long, String>): WavelogUploadOutcome {
val raw = rawMessages(body)
val lines = stripTags(raw).split('\n').map(String::trim).filter(String::isNotEmpty)
if (lines.isEmpty()) {
return WavelogUploadOutcome.Failed("Wavelog did not accept the upload — check the server log")
}
val duplicates = lines.count { it.contains("Duplicate for", ignoreCase = true) }
val skipLines = lines.filter { it.contains("SKIPPED", ignoreCase = true) }
val others = lines.filter {
!it.contains("Duplicate for", ignoreCase = true) && !it.contains("SKIPPED", ignoreCase = true)
}
if (others.isNotEmpty()) {
return WavelogUploadOutcome.Rejected("Wavelog reported: ${others.first().take(200)}")
}
val submittedCalls = callsById.values.toSet()
val skipCalls = skipCallRegex.findAll(raw)
.map { it.groupValues[1].trim().uppercase(Locale.US) }.toSet()
if (skipLines.isNotEmpty() && (skipCalls.isEmpty() || skipCalls.any { it !in submittedCalls })) {
// A skip we cannot map back to a submitted record: stay conservative, mark nothing.
return WavelogUploadOutcome.Rejected("Some records were skipped by Wavelog — nothing was marked; review and retry")
}
val markIds = callsById.filterValues { it !in skipCalls }.keys.toList()
val skipped = callsById.size - markIds.size
val imported = (callsById.size - duplicates - skipped).coerceAtLeast(0)
return WavelogUploadOutcome.Imported(imported, duplicates, skipped, markIds)
}
private fun rawMessages(body: String): String {
val json = runCatching { JSONObject(body) }.getOrNull()
return if (json != null) {
val messages = json.optJSONArray("messages")
if (messages != null) (0 until messages.length()).joinToString("") { messages.optString(it) }
else json.optString("reason")
} else {
body
}
}
private fun stripTags(raw: String): String = raw
.replace(Regex("(?i)<br\\s*/?>"), "\n")
.replace(Regex("<[^>]*>"), " ")
.replace("&quot;", "\"").replace("&#39;", "'").replace("&amp;", "&")
.replace(Regex("[ \\t]+"), " ")
.lines().joinToString("\n") { it.trim() }
/** Parses the v1 station_info array (only the fields the app uses). */
internal fun parseStations(body: String): List<WavelogStationInfo> {
val array = JSONArray(body)
return (0 until array.length()).mapNotNull { index ->
val item = array.optJSONObject(index) ?: return@mapNotNull null
val id = item.optString("station_id")
if (id.isBlank()) return@mapNotNull null
WavelogStationInfo(
id = id,
name = item.optString("station_profile_name"),
callsign = item.optString("station_callsign"),
grid = item.optString("station_gridsquare"),
active = item.optString("station_active") == "1"
)
}
}
private fun utcText(millis: Long): String = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(millis))
private fun shortText(millis: Long): String = SimpleDateFormat("MM-dd HH:mm", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(millis))
@@ -458,6 +458,11 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
override val wavelogUploadSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings())
override fun updateWavelogUploadSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings) = Unit
override fun getWorkedGrids(): Set<String> = emptySet()
private val workedGrids = MutableStateFlow(emptySet<String>())
@@ -295,6 +295,27 @@ class QsoRepositoryTest {
assertEquals(1, dao.getAll().size)
}
@Test
fun markWavelogUploaded_setsOnlyTheWavelogFlag() = runBlocking {
val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).toEntity())
repository.markWavelogUploaded(listOf(id))
val row = dao.getAll().single()
assertTrue(row.wavelogUploaded)
assertFalse(row.lotwUploaded)
}
@Test
fun save_editingAWavelogUploadedRecordCreatesANewRecord() = runBlocking {
val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).copy(wavelogUploaded = true).toEntity())
repository.save(loggedInApp("SO-50", lotwUploaded = false).copy(id = id, theirCallsign = "BG5JVM"))
assertEquals(2, dao.getAll().size)
assertEquals(1, dao.getAll().count { it.wavelogUploaded })
}
/** A record as the log page creates it: tracker name, repeater pair, uplink band. */
private fun loggedInApp(satelliteName: String, lotwUploaded: Boolean) = QsoRecord(
startUtcMillis = qsoStart,
@@ -355,6 +376,7 @@ class QsoRepositoryTest {
propagationMode = propagationMode,
lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded,
wavelogUploaded = wavelogUploaded,
lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.joinToString(","),
@@ -393,5 +415,9 @@ class QsoRepositoryTest {
override suspend fun markUploaded(ids: List<Long>) {
ids.forEach { id -> rows[id]?.let { rows[id] = it.copy(lotwUploaded = true) } }
}
override suspend fun markWavelogUploaded(ids: List<Long>) {
ids.forEach { id -> rows[id]?.let { rows[id] = it.copy(wavelogUploaded = true) } }
}
}
}
@@ -346,6 +346,9 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
)
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
override val wavelogUploadSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings())
override fun updateWavelogUploadSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings) = Unit
override val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
@@ -0,0 +1,197 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
import com.rtbishop.look4sat.core.domain.repository.WavelogProblem
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class WavelogUploadRepositoryTest {
// region # gridCompatible (mirror of the server's adif_grid_check_location prefix rule)
@Test
fun grid_sameGridsPass() {
assertTrue(gridCompatible("OL62TI", "OL62TI"))
}
@Test
fun grid_longerAdifGridPasses() {
assertTrue(gridCompatible("OL62TI", "OL62"))
}
@Test
fun grid_longerStationGridPasses() {
assertTrue(gridCompatible("OL62", "OL62TI"))
}
@Test
fun grid_differentSquaresFail() {
assertFalse(gridCompatible("PM01", "OL62"))
}
@Test
fun grid_blankSidesDisableTheCheck() {
assertTrue(gridCompatible("", "OL62"))
assertTrue(gridCompatible("OL62", ""))
}
@Test
fun grid_stationMarginAcceptsAPrefixPart() {
assertTrue(gridCompatible("OL63AA", "OL62,OL63"))
assertFalse(gridCompatible("PM01AA", "OL62,OL63"))
}
@Test
fun grid_caseInsensitive() {
assertTrue(gridCompatible("ol62ti", "OL62"))
}
// endregion
// region # classifyUploadResponse
private val calls = mapOf(1L to "BG5JVM", 2L to "BH6RJD", 3L to "BI3SIR")
@Test
fun createdMarksEverything() {
val outcome = classifyUploadResponse(
201, """{"status":"created","adif_count":3,"adif_errors":0,"messages":[""]}""", calls
)
assertEquals(WavelogUploadOutcome.Imported(3, 0, 0, listOf(1L, 2L, 3L)), outcome)
}
@Test
fun duplicatesAreCountedAndStillMarked() {
val body = """{"status":"abort","messages":["Date/Time: 2026-10-04 12:34:00 Callsign: BG5JVM Band: 70CM Duplicate for BA7OPF<br>"]}"""
val outcome = classifyUploadResponse(400, body, calls)
assertEquals(WavelogUploadOutcome.Imported(2, 1, 0, listOf(1L, 2L, 3L)), outcome)
}
@Test
fun skippedRecordIsNotMarked() {
val body = """{"status":"abort","messages":["Differing locator <b>PM01</b> while importing QSO with <b>BH6RJD</b> for station locator <b>OL62TI</b>: SKIPPED<br>"]}"""
val outcome = classifyUploadResponse(400, body, calls)
assertEquals(WavelogUploadOutcome.Imported(2, 0, 1, listOf(1L, 3L)), outcome)
}
@Test
fun duplicateAndSkipTogether() {
val body = """{"status":"abort","messages":["Differing locator <b>PM01</b> while importing QSO with <b>BH6RJD</b> for station locator <b>OL62TI</b>: SKIPPED<br>Date/Time: 2026-10-04 12:34:00 Callsign: BG5JVM Band: 70CM Duplicate for BA7OPF<br>"]}"""
val outcome = classifyUploadResponse(400, body, calls)
assertEquals(WavelogUploadOutcome.Imported(1, 1, 1, listOf(1L, 3L)), outcome)
}
@Test
fun unmappedSkipMarksNothing() {
val body = """{"status":"abort","messages":["Differing locator <b>PM01</b> while importing QSO with <b>N0CALL</b> for station locator <b>OL62TI</b>: SKIPPED<br>"]}"""
val outcome = classifyUploadResponse(400, body, calls)
assertTrue(outcome is WavelogUploadOutcome.Rejected)
}
@Test
fun otherMessagesAreRejected() {
val body = """{"status":"abort","messages":["QSO on 2026-10-04: You tried to import a QSO without any given CALL. This QSO wasn't imported. It's invalid.<br>"]}"""
val outcome = classifyUploadResponse(400, body, calls)
assertTrue(outcome is WavelogUploadOutcome.Rejected)
}
@Test
fun authFailuresAreActionable() {
assertTrue(classifyUploadResponse(401, "", calls) is WavelogUploadOutcome.Failed)
val forbidden = classifyUploadResponse(403, """{"reason":"API key does not have write permissions"}""", calls)
assertTrue(forbidden is WavelogUploadOutcome.Failed)
assertTrue((forbidden as WavelogUploadOutcome.Failed).message.contains("write"))
}
@Test
fun htmlGarbageOrEmptyBodyMarksNothing() {
assertTrue(classifyUploadResponse(400, "<html>oops</html>", calls) is WavelogUploadOutcome.Rejected)
assertTrue(classifyUploadResponse(400, "", calls) is WavelogUploadOutcome.Failed)
}
// endregion
// region # parseStations + prepare
@Test
fun stationsParse() {
val body = """[{"station_id":"1","station_profile_name":"Shenzhen","station_gridsquare":"OL62TI","station_callsign":"BA7OPF","station_active":"1"}]"""
val stations = parseStations(body)
assertEquals(1, stations.size)
assertEquals("1", stations.single().id)
assertEquals("Shenzhen", stations.single().name)
assertEquals("BA7OPF", stations.single().callsign)
assertEquals("OL62TI", stations.single().grid)
assertTrue(stations.single().active)
}
@Test
fun prepareWithoutConfigIsBlocked() = runBlocking {
val repo = WavelogUploadRepository()
val preview = repo.prepare(listOf(sampleRecord(start = 1_700_000_000_000L)), WavelogUploadSettings())
assertEquals(WavelogProblem.NOT_CONFIGURED, preview.blockedBy)
assertEquals(0, preview.count)
}
@Test
fun prepareWithoutStationIsBlocked() = runBlocking {
val repo = WavelogUploadRepository()
val settings = WavelogUploadSettings(url = "http://example.org", apiKey = "k")
val preview = repo.prepare(listOf(sampleRecord(start = 1_700_000_000_000L)), settings)
assertEquals(WavelogProblem.NO_STATION, preview.blockedBy)
}
@Test
fun prepareSkipsGridMismatchAndDeduplicates() = runBlocking {
val repo = WavelogUploadRepository()
val settings = WavelogUploadSettings(
url = "http://example.org", apiKey = "k", stationId = "1",
stationName = "Shenzhen", stationCallsign = "BA7OPF", stationGrid = "OL62TI"
)
val records = listOf(
sampleRecord(start = 1_700_000_000_000L, myGrid = "PM01AA"),
sampleRecord(start = 1_700_000_120_000L, myGrid = "OL62TI"),
sampleRecord(start = 1_700_000_120_000L, myGrid = "OL62TI") // batch duplicate of the previous
)
val preview = repo.prepare(records, settings)
assertEquals(1, preview.count)
assertEquals(2, preview.skipped)
assertEquals(1, preview.reasons[WavelogProblem.GRID_MISMATCH])
assertEquals(1, preview.submittedIds.size)
assertEquals("Shenzhen · BA7OPF · OL62TI", preview.stationLabel)
}
// endregion
private fun sampleRecord(start: Long, myGrid: String = "OL62TI") = QsoRecord(
id = start,
startUtcMillis = start,
theirCallsign = "BG5JVM",
myCallsign = "BA7OPF",
myGrid = myGrid,
txFrequencyHz = 145_850_000L,
rxFrequencyHz = 436_795_000L,
band = "2M",
rxBand = "70CM",
mode = "FM",
satelliteName = "SO-50",
propagationMode = "SAT",
status = QsoStatus.COMPLETE
)
}