feat(wavelog): 二选一互斥模式 + p12 空密码导入修复(4.4.8-ba7opf.9.4, 569)

- LoTW/Wavelog 互斥:isReady 即 Wavelog 模式,LoTW 卡置灰+说明,上传按模式分流(雷达页/日志本),自动 LoTW 同步门控;配置切换弹警告、可清除配置
- 同步重做:get_contacts_adif 每台址游标,增量/全量双按钮,只增不删,URL 变更增量降级全量;同步刷新日志本+地图 workedGrids/gridQsos/roamedGrids
- 日志本台址选择器(全部/各台址)+ Wavelog 上传预览弹窗;Qso 增 wavelogStation,DB v4→v5
- 删旧 WavelogRepository/wavelogSettings 链路,换 WavelogSyncRepository + WavelogSync 纯函数
- p12 修复:导入按钮放开空密码(isEnabled=!busy)+ 密码框留空提示;Pkcs12ReaderTest +4 用例
- 单测六模块全绿;版本 4.4.8-ba7opf.9.4 (569)
This commit is contained in:
atsunatsu committed 2026-10-05 14:57:46 +08:00
1 parent 1fa7dd2d88
commit 09539ff13a
40 files changed
+1715 -470

No files matched your search

@@ -48,6 +48,6 @@ 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>)
@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<Long>, stationId: String)
}
@@ -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 ''")
}
}
@@ -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 = "",
@@ -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)
}
@@ -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<Long>) = withContext(dispatcher) {
override suspend fun markWavelogUploaded(ids: List<Long>, 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<Long>?, 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<QsoRecord>): 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<QsoRecord>): 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<String, Int>(working.size)
working.indices.forEach { index -> indexByKey.putIfAbsent(stableQsoKey(working[index]), index) }
val changes = linkedMapOf<Int, QsoRecord>().apply { putAll(renames) }
var imported = 0
var updated = renames.size
var skipped = 0
val seenKeys = hashSetOf<String>()
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(","),
@@ -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> = _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<String> {
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<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> {
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<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) {
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<String, Long> {
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<String, Long>) {
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> = _passesSettings
@@ -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 <https://www.gnu.org/licenses/>.
*/
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": "<api key>", "logbook_id": "<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 "<api_key>:<logbook_id>" 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<String>? = 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<String>? {
return try {
val array = when {
body.trimStart().startsWith("[") -> JSONArray(body)
else -> {
// Tolerate {"grids": [...]} wrappers too
JSONObject(body).optJSONArray("grids") ?: return null
}
}
val result = mutableSetOf<String>()
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
}
}
}
@@ -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 <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.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": <last id>, "limit"}`.
* - Responds `{"status":"successful","lastfetchedid":<id>,"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<WavelogStationInfo>,
cursors: Map<String, Long>,
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<QsoRecord>()
val newCursors = mutableMapOf<String, Long>()
val failed = mutableListOf<String>()
// 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<QsoRecord>, 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
}
}
@@ -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()))
}
}
@@ -453,10 +453,9 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun setAmSatCallsign(callsign: String) = Unit
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
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())
@@ -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<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList()
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) = Unit
override fun getWavelogSyncCursors(): Map<String, Long> = emptyMap()
override fun setWavelogSyncCursors(cursors: Map<String, Long>) = 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<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
@@ -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<Long>) {
ids.forEach { id -> rows[id]?.let { rows[id] = it.copy(wavelogUploaded = true) } }
override suspend fun markWavelogUploaded(ids: List<Long>, stationId: String) {
ids.forEach { id ->
rows[id]?.let {
rows[id] = it.copy(
wavelogUploaded = true,
wavelogStation = if (stationId.isBlank()) it.wavelogStation else stationId
)
}
}
}
}
}
@@ -344,8 +344,7 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
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
@@ -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<String> = emptySet()
override fun setWorkedGrids(grids: Set<String>) = Unit
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = 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<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList()
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) = Unit
override fun getWavelogSyncCursors(): Map<String, Long> = emptyMap()
override fun setWavelogSyncCursors(cursors: Map<String, Long>) = Unit
override fun getWavelogSyncUrl(): String = ""
override fun setWavelogSyncUrl(url: String) = Unit
override fun getLastWavelogSyncEpochMs(): Long = 0L
override fun setLastWavelogSyncEpochMs(value: Long) = Unit
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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 = "<ADIF_VER:5>3.1.7\n<EOH>\n" +
"<QSO_DATE:8>20260916<TIME_ON:4>0745<CALL:6>BG5JSB<GRIDSQUARE:4>EN52" +
"<MODE:2>FM<PROP_MODE:3>SAT<SAT_NAME:5>SO-50<EOR>\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())
}
}
Binary file not shown.
@@ -24,14 +24,26 @@ interface IQsoRepository {
*/
suspend fun markUploaded(ids: List<Long>, grids: List<String> = emptyList())
/** Marks the batch as uploaded to Wavelog (independent of the LoTW upload state). */
suspend fun markWavelogUploaded(ids: List<Long>)
/**
* 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<Long>, stationId: String = "")
suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
suspend fun importAdi(content: String): AdifImportResult
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
suspend fun mergeLoTW(records: List<QsoRecord>): 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<QsoRecord>): 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]).
@@ -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<String> = emptyList(),
@@ -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 = "",
@@ -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
@@ -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<WavelogSettings>
fun updateWavelogSettings(settings: WavelogSettings)
//region # Worked-grid data (written by the LoTW / Wavelog syncs)
fun getWorkedGrids(): Set<String>
fun setWorkedGrids(grids: Set<String>)
/** 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<WavelogStationInfo>
fun setWavelogStations(stations: List<WavelogStationInfo>)
/** stationId -> lastfetchedid cursors of the last successful sync (per station profile). */
fun getWavelogSyncCursors(): Map<String, Long>
fun setWavelogSyncCursors(cursors: Map<String, Long>)
/** 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<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings)
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String>?
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<QsoRecord>,
/** stationId -> lastfetchedid to resume from on the next incremental run. */
val cursors: Map<String, Long>,
/** Station ids that could not be fetched (transport/parse); empty when all succeeded. */
val failedStations: List<String>
)
/**
* 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<WavelogStationInfo>,
cursors: Map<String, Long>,
full: Boolean
): WavelogSyncFetch?
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String>,
val gridQsos: Map<String, List<GridQso>>,
val roamedGrids: Set<String>
)
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<QsoRecord>): WavelogGridData {
val grids = linkedSetOf<String>()
val roamed = linkedSetOf<String>()
val gridQsos = linkedMapOf<String, MutableList<GridQso>>()
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<QsoRecord>,
cursors: Map<String, Long>,
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
}
@@ -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<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> = MutableStateFlow(WavelogUploadSettings())
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
@@ -272,6 +272,19 @@ class LoTWGridSyncTest {
override fun getLastLotwSyncCallsign(): String = lastSyncCallsign
override fun setLastLotwSyncCallsign(callsign: String) { lastSyncCallsign = callsign }
private var wavelogStations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList()
private var wavelogSyncCursors: Map<String, Long> = emptyMap()
private var wavelogSyncUrl: String = ""
private var lastWavelogSyncEpochMs: Long = 0L
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = wavelogStations
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) { wavelogStations = stations }
override fun getWavelogSyncCursors(): Map<String, Long> = wavelogSyncCursors
override fun setWavelogSyncCursors(cursors: Map<String, Long>) { 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<Int>) = TODO()
override fun setSelectedTypes(types: List<String>) = 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()
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String> = 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<String> = emptySet()
private var workedGridQsos: Map<String, List<GridQso>> = emptyMap()
private var roamedGrids: Set<String> = emptySet()
private var cursors: Map<String, Long> = emptyMap()
private var syncUrl: String = ""
private var lastSync: Long = 0L
override fun getWorkedGrids(): Set<String> = workedGrids
override fun setWorkedGrids(grids: Set<String>) { workedGrids = grids }
override fun getWorkedGridQsos(): Map<String, List<GridQso>> = workedGridQsos
override fun setWorkedGridQsos(qsos: Map<String, List<GridQso>>) { workedGridQsos = qsos }
override fun getRoamedGrids(): Set<String> = roamedGrids
override fun setRoamedGrids(grids: Set<String>) { roamedGrids = grids }
override fun getWavelogSyncCursors(): Map<String, Long> = cursors
override fun setWavelogSyncCursors(cursors: Map<String, Long>) { 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<List<Int>> get() = TODO()
override val selectedTypes: StateFlow<List<String>> get() = TODO()
override fun setSelectedIds(ids: List<Int>) = TODO()
override fun setSelectedTypes(types: List<String>) = TODO()
override val passesSettings: StateFlow<PassesSettings> get() = TODO()
override fun setPassesSettings(settings: PassesSettings) = TODO()
override val stationPosition: StateFlow<GeoPos> 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<DatabaseState> get() = TODO()
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
override fun updateDatabaseState(state: DatabaseState) = TODO()
override val rcSettings: StateFlow<RCSettings> get() = TODO()
override fun updateRCSettings(settings: RCSettings) = TODO()
override val otherSettings: StateFlow<OtherSettings> get() = TODO()
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
override val dataSourcesSettings: StateFlow<DataSourcesSettings> get() = TODO()
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
override val dataSourcesStatus: StateFlow<Map<String, Int>> get() = TODO()
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
override val radioControlSettings: StateFlow<RadioControlSettings> 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<WavelogUploadSettings> get() = TODO()
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) = TODO()
override fun getWavelogStations(): List<WavelogStationInfo> = emptyList()
override fun setWavelogStations(stations: List<WavelogStationInfo>) = TODO()
override fun getMarkedGridStations(): Map<String, List<MarkedStation>> = TODO()
override fun setMarkedGridStations(stations: Map<String, List<MarkedStation>>) = TODO()
override val lotwSettings: StateFlow<LoTWSettings> 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()
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)) }
}
)
}
@@ -157,16 +157,20 @@
<string name="prefs_station_lat_text">输入站点的纬度</string>
<string name="prefs_station_lon_text">输入站点的经度</string>
<string name="prefs_locator_title">QTH 网格</string>
<string name="prefs_wavelog_title">Wavelog 已通联网格</string>
<string name="prefs_wavelog_configure">配置</string>
<string name="prefs_wavelog_configured">已从 Wavelog 同步 — 通联 %1$d 个网格</string>
<string name="prefs_wavelog_not_configured">未配置 — 连接你的 Wavelog 日志以在地图上高亮已通联网格</string>
<string name="prefs_wavelog_url">Wavelog 地址</string>
<string name="prefs_wavelog_token">API key :日志 ID</string>
<string name="prefs_wavelog_hint">已同步网格数:%1$d。格式:API key:日志ID。填好直接点同步,会自动保存并拉取。</string>
<string name="prefs_wavelog_sync">立即同步</string>
<string name="prefs_wavelog_syncing">同步中…</string>
<string name="prefs_wavelog_upload_title">Wavelog 日志上传</string>
<string name="prefs_wavelog_upload_title">Wavelog 配置</string>
<string name="prefs_wavelog_exclusive">与 LoTW 二选一配置,两者不兼容。</string>
<string name="prefs_wavelog_sync_incremental">增量同步</string>
<string name="prefs_wavelog_sync_full">全量同步</string>
<string name="prefs_wavelog_clear">清除配置</string>
<string name="prefs_wavelog_clear_title">清除 Wavelog 配置?</string>
<string name="prefs_wavelog_clear_message">清除后将切回 LoTW 模式,LoTW 功能恢复可用。</string>
<string name="prefs_wavelog_switch_title">配置提醒</string>
<string name="prefs_wavelog_switch_message">已配置 LoTW。配置 Wavelog 后,App 内 LoTW 的上传与同步将不可用(两者二选一,不兼容)。继续?</string>
<string name="prefs_wavelog_switch_continue">继续</string>
<string name="prefs_lotw_disabled_wavelog">Wavelog 模式中不可用(二选一)</string>
<string name="prefs_wavelog_upload_not_configured">未配置 — 把本机通联记录上传到 Wavelog</string>
<string name="prefs_wavelog_upload_configured">站点 %1$s — 可以上传</string>
<string name="prefs_wavelog_upload_apikey">API key(读写权限)</string>
@@ -211,7 +215,9 @@
<string name="prefs_logbook_selected">已选 %1$d 条</string>
<string name="prefs_logbook_select_hint">长按记录行可多选重传</string>
<string name="prefs_logbook_resubmit_note">已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。</string>
<string name="prefs_logbook_wavelog_sync">同时同步 Wavelog:%1$d 条</string>
<string name="prefs_logbook_station_label">台址</string>
<string name="prefs_logbook_station_all">全部</string>
<string name="prefs_logbook_upload_wavelog_title">上传到 Wavelog</string>
<string name="lotw_upload_grid_title">上传前核对</string>
<string name="lotw_upload_grid_fix">修改台址</string>
<string name="lotw_upload_grid_ignore">忽略</string>
@@ -223,6 +229,7 @@
<string name="prefs_lotw_upload_busy">处理中…</string>
<string name="prefs_lotw_upload_cert_hint">导入你的 TrustedQSL .p12 证书(与 TQSL 同款)用于签名上传通联。密码仅加密保存在手机本地。</string>
<string name="prefs_lotw_upload_password">证书密码</string>
<string name="prefs_lotw_upload_password_hint">证书无密码时留空即可</string>
<string name="prefs_lotw_upload_import">选择 .p12 文件</string>
<string name="prefs_lotw_upload_import_confirm">导入</string>
<string name="prefs_lotw_upload_error_password">证书密码错误,请检查 TQSL 导出 .p12 时设置的密码</string>
@@ -186,16 +186,20 @@
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
<string name="prefs_locator_title">QTH locator settings</string>
<string name="prefs_wavelog_title">Wavelog worked grids</string>
<string name="prefs_wavelog_configure">Configure</string>
<string name="prefs_wavelog_configured">Synced from Wavelog — %1$d grids worked</string>
<string name="prefs_wavelog_not_configured">Not configured — connect your Wavelog logbook to highlight worked grids on the map</string>
<string name="prefs_wavelog_url">Wavelog URL</string>
<string name="prefs_wavelog_token">API key : logbook ID</string>
<string name="prefs_wavelog_hint">Worked grids synced: %1$d. Format: API key, colon, logbook ID. Just tap Sync — credentials are saved automatically.</string>
<string name="prefs_wavelog_sync">Sync now</string>
<string name="prefs_wavelog_syncing">Syncing…</string>
<string name="prefs_wavelog_upload_title">Wavelog log upload</string>
<string name="prefs_wavelog_upload_title">Wavelog configuration</string>
<string name="prefs_wavelog_exclusive">Configure either LoTW or Wavelog — the two cannot be used together.</string>
<string name="prefs_wavelog_sync_incremental">Incremental sync</string>
<string name="prefs_wavelog_sync_full">Full sync</string>
<string name="prefs_wavelog_clear">Clear configuration</string>
<string name="prefs_wavelog_clear_title">Clear the Wavelog configuration?</string>
<string name="prefs_wavelog_clear_message">LoTW features become available again.</string>
<string name="prefs_wavelog_switch_title">Notice</string>
<string name="prefs_wavelog_switch_message">LoTW is already configured. With Wavelog configured, LoTW upload and sync in this app become unavailable (the two are mutually exclusive). Continue?</string>
<string name="prefs_wavelog_switch_continue">Continue</string>
<string name="prefs_lotw_disabled_wavelog">Unavailable while Wavelog is configured</string>
<string name="prefs_wavelog_upload_not_configured">Not configured — upload your local QSO records to Wavelog</string>
<string name="prefs_wavelog_upload_configured">Station %1$s — ready to upload</string>
<string name="prefs_wavelog_upload_apikey">API key (read &amp; write)</string>
@@ -240,7 +244,9 @@
<string name="prefs_logbook_selected">%1$d selected</string>
<string name="prefs_logbook_select_hint">Long-press a row to re-upload corrected records</string>
<string name="prefs_logbook_resubmit_note">Existing contacts will be updated in place — a grid already used for an award may be rejected.</string>
<string name="prefs_logbook_wavelog_sync">Also syncs to Wavelog: %1$d QSO(s)</string>
<string name="prefs_logbook_station_label">Station</string>
<string name="prefs_logbook_station_all">All</string>
<string name="prefs_logbook_upload_wavelog_title">Upload to Wavelog</string>
<string name="lotw_upload_grid_title">Upload check</string>
<string name="lotw_upload_grid_fix">Fix station location</string>
<string name="lotw_upload_grid_ignore">Ignore</string>
@@ -252,6 +258,7 @@
<string name="prefs_lotw_upload_busy">Working…</string>
<string name="prefs_lotw_upload_cert_hint">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.</string>
<string name="prefs_lotw_upload_password">Certificate password</string>
<string name="prefs_lotw_upload_password_hint">Leave blank if the certificate has no password</string>
<string name="prefs_lotw_upload_import">Choose .p12 file</string>
<string name="prefs_lotw_upload_import_confirm">Import</string>
<string name="prefs_lotw_upload_error_password">Incorrect certificate password. Check the password you set when exporting the .p12 from TQSL.</string>