mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-06 12:37:26 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
917788041c | ||
|
|
58bc96c441 | ||
|
|
7a7d742b54 | ||
|
|
146d47455f | ||
|
|
a7d99862d9 | ||
|
|
a24acc2bfd | ||
|
|
ad970eec77 | ||
|
|
5c12d64824 | ||
|
|
881ca442ff | ||
|
|
f35d78186c | ||
|
|
09539ff13a | ||
|
|
1fa7dd2d88 | ||
|
|
f0fc788ebe | ||
|
|
6f83740983 | ||
|
|
e7c0df0954 | ||
|
|
5a8c4281a3 | ||
|
|
a459cfb046 | ||
|
|
7589f3f37d | ||
|
|
6a263d8422 | ||
|
|
1558919482 | ||
|
|
9e5b9b6ac6 | ||
|
|
c050e7bb9b | ||
|
|
73ac41ff85 | ||
|
|
733f660d78 |
No files matched your search
@@ -44,3 +44,27 @@ SOFTWARE.
|
||||
- License: Apache-2.0
|
||||
- Used for: `core/presentation/src/main/res/drawable/ic_grid_finder.xml`
|
||||
(crosshairs-gps icon). Attribution is also noted in the icon file header.
|
||||
|
||||
## OpenStreetMap (award boundary geometry)
|
||||
|
||||
- Source: https://www.openstreetmap.org — relations `Ural Mountains` (21368163),
|
||||
`England` (58447), `Scotland` (58446), `Wales` (58437),
|
||||
`Northern Ireland` (156393)
|
||||
- License: Open Database License (ODbL) 1.0 —
|
||||
https://opendatacommons.org/licenses/odbl/1-0/
|
||||
- Used for: the Europe/Asia split line of European/Asiatic Russia and the
|
||||
England/Scotland/Wales/Northern Ireland partitions in
|
||||
`feature/map/src/main/assets/awards/dxcc.json` (derived geometry).
|
||||
Map tiles displayed in the app are © OpenStreetMap contributors and carry
|
||||
their own in-app attribution.
|
||||
|
||||
## Natural Earth
|
||||
|
||||
- Source: https://www.naturalearthdata.com (1:110m countries, 1:10m admin-0
|
||||
map units, 1:10m rivers)
|
||||
- License: Public domain — https://www.naturalearthdata.com/about/terms-of-use/
|
||||
- Used for: the country/region polygons of the award boundary assets under
|
||||
`feature/map/src/main/assets/awards/` (including the island / microstate
|
||||
DXCC entities — Singapore, Malta, Canary Is., ... — and the Balearic/Canary
|
||||
rings split out of Spain's 1:10m multipolygon), and the Ural river centerline
|
||||
used for the European/Asiatic Russia split.
|
||||
@@ -105,6 +105,9 @@ class MainApplication : Application(), IContainerProvider {
|
||||
*/
|
||||
private suspend fun checkLoTWAutoSync(timeNow: Long = System.currentTimeMillis()) {
|
||||
val settingsRepo = container.settingsRepo
|
||||
// One-of-two rule: while Wavelog is configured the app uploads/syncs through
|
||||
// Wavelog only — the automatic LoTW sync stays off.
|
||||
if (settingsRepo.wavelogUploadSettings.value.isReady) return
|
||||
val lotwSettings = settingsRepo.lotwSettings.value
|
||||
if (!shouldAutoSyncLoTW(
|
||||
isConfigured = lotwSettings.isConfigured,
|
||||
|
||||
@@ -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, 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 = 3, exportSchema = false)
|
||||
@Database(entities = [QsoEntity::class], version = 5, exportSchema = false)
|
||||
abstract class QsoDatabase : RoomDatabase() {
|
||||
abstract fun qsoDao(): QsoDao
|
||||
}
|
||||
@@ -38,3 +38,17 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 ''")
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,8 @@ 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 = "''") val wavelogStation: String = "",
|
||||
@ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "''") val lotwQslDate: String = "",
|
||||
@ColumnInfo(defaultValue = "''") val vuccGrids: String = "",
|
||||
|
||||
@@ -31,6 +31,8 @@ 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.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
|
||||
@@ -48,7 +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
|
||||
@@ -69,7 +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
|
||||
@@ -100,11 +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)
|
||||
.addMigrations(MIGRATION_QSO_1_2, MIGRATION_QSO_2_3, MIGRATION_QSO_3_4)
|
||||
.addMigrations(MIGRATION_QSO_4_5)
|
||||
.build()
|
||||
QsoRepository(database.qsoDao(), Dispatchers.IO)
|
||||
}
|
||||
|
||||
@@ -114,6 +114,19 @@ class LoTWUploadRepository internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun previewCertificate(data: ByteArray, password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
try {
|
||||
if (data.isEmpty() || data.size > MAX_CERTIFICATE_BYTES) fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||
LoTWKeyMaterial.read(data, password, now()).info
|
||||
} finally {
|
||||
// Never keep the operator's password; the p12 bytes stay the caller's
|
||||
// own copy (the dialog re-reads the file for every preview attempt).
|
||||
password.fill('\u0000')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
|
||||
|
||||
+34
-11
@@ -52,6 +52,10 @@ private data class ApiReport(
|
||||
val reportedTimeUtcSec: Long
|
||||
)
|
||||
|
||||
/** One slot's verdict: ARGB colour, the count the page prints, and whether the slot
|
||||
* has no strict majority (= the page's "Conflicting reports" state). */
|
||||
private data class SlotStatus(val color: Long, val count: Int, val conflicted: Boolean)
|
||||
|
||||
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
|
||||
class AmSatRepository(
|
||||
private val remoteSource: IRemoteSource,
|
||||
@@ -214,7 +218,8 @@ class AmSatRepository(
|
||||
}
|
||||
}
|
||||
|
||||
/** Build one SatStatus (3 days x 12 slots) per catalog satellite, slotting reports by age. */
|
||||
/** Build one SatStatus (3 days x 12 slots) per catalog satellite, slotting reports by age.
|
||||
* Each slot's colour/count follows the AMSAT status page rule — see [slotStatusOf]. */
|
||||
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||
val byName = reports.groupBy { it.name }
|
||||
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
@@ -230,11 +235,12 @@ class AmSatRepository(
|
||||
if (inSlot.isEmpty()) {
|
||||
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
|
||||
} else {
|
||||
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
|
||||
val status = slotStatusOf(inSlot)
|
||||
SatSlot(
|
||||
statusColor = statusColorOf(newest.report),
|
||||
count = inSlot.size,
|
||||
reportIds = inSlot.map { it.id }
|
||||
statusColor = status.color,
|
||||
count = status.count,
|
||||
reportIds = inSlot.map { it.id },
|
||||
isConflicted = status.conflicted
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -287,12 +293,28 @@ class AmSatRepository(
|
||||
)
|
||||
}
|
||||
|
||||
/** Map status text to color value (for UI rendering). */
|
||||
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
|
||||
"heard", "crew active" -> ACTIVE_BLUE
|
||||
"telemetry only" -> TLM_ORANGE
|
||||
"not heard" -> NOT_HEARD_PINK
|
||||
else -> CONFLICT_DEEP_ORANGE
|
||||
/**
|
||||
* The AMSAT status page's per-block rule, mirrored from the official page source
|
||||
* (gitlab.amsat.org/open-source/satellite-status, index.php):
|
||||
* - any Crew Active report wins: purple, count = crew + heard;
|
||||
* - otherwise one status must be a STRICT majority to take its colour, and the
|
||||
* count shown is the majority group's count (not the block total);
|
||||
* - any tie of the remaining statuses is the "Conflicting reports" state;
|
||||
* - a block whose reports all carry an unknown status stays blank.
|
||||
*/
|
||||
private fun slotStatusOf(inSlot: List<ApiReport>): SlotStatus {
|
||||
val heard = inSlot.count { it.report.equals("Heard", ignoreCase = true) }
|
||||
val notHeard = inSlot.count { it.report.equals("Not Heard", ignoreCase = true) }
|
||||
val telemetry = inSlot.count { it.report.equals("Telemetry Only", ignoreCase = true) }
|
||||
val crew = inSlot.count { it.report.equals("Crew Active", ignoreCase = true) }
|
||||
return when {
|
||||
crew > 0 -> SlotStatus(CREW_PURPLE, crew + heard, conflicted = false)
|
||||
heard > notHeard && heard > telemetry -> SlotStatus(ACTIVE_BLUE, heard, conflicted = false)
|
||||
notHeard > heard && notHeard > telemetry -> SlotStatus(NOT_HEARD_PINK, notHeard, conflicted = false)
|
||||
telemetry > notHeard && telemetry > heard -> SlotStatus(TLM_ORANGE, telemetry, conflicted = false)
|
||||
telemetry + notHeard + heard >= 1 -> SlotStatus(CONFLICT_DEEP_ORANGE, inSlot.size, conflicted = true)
|
||||
else -> SlotStatus(NO_REPORT_GRAY, inSlot.size, conflicted = false)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -301,6 +323,7 @@ class AmSatRepository(
|
||||
private const val TLM_ORANGE = 0xFFFFB000
|
||||
private const val NOT_HEARD_PINK = 0xFFDC267F
|
||||
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
|
||||
private const val CREW_PURPLE = 0xFF785EF0
|
||||
private const val NO_REPORT_GRAY = 0xFFC0C0C0
|
||||
}
|
||||
}
|
||||
+76
-1
@@ -51,12 +51,15 @@ 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,
|
||||
wavelogStation = "",
|
||||
lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = emptyList(),
|
||||
dxcc = null, country = "", cqZone = null, region = ""
|
||||
).toEntity())
|
||||
@@ -93,6 +96,11 @@ class QsoRepository(
|
||||
if (stamped.isNotEmpty()) dao.saveBatch(stamped.map { it.toEntity() })
|
||||
}
|
||||
|
||||
override suspend fun markWavelogUploaded(ids: List<Long>, stationId: String) = withContext(dispatcher) {
|
||||
if (ids.isEmpty()) return@withContext
|
||||
importMutex.withLock { dao.markWavelogUploaded(ids, stationId) }
|
||||
}
|
||||
|
||||
override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
|
||||
val records = dao.getAll()
|
||||
.asSequence()
|
||||
@@ -117,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()
|
||||
@@ -209,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). */
|
||||
@@ -286,6 +357,8 @@ private fun QsoEntity.toDomain() = QsoRecord(
|
||||
propagationMode = propagationMode,
|
||||
lotwConfirmed = lotwConfirmed,
|
||||
lotwUploaded = lotwUploaded,
|
||||
wavelogUploaded = wavelogUploaded,
|
||||
wavelogStation = wavelogStation,
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
|
||||
@@ -323,6 +396,8 @@ private fun QsoRecord.toEntity() = QsoEntity(
|
||||
propagationMode = propagationMode.ifBlank { if (satelliteName.isNotBlank()) "SAT" else "" },
|
||||
lotwConfirmed = lotwConfirmed,
|
||||
lotwUploaded = lotwUploaded,
|
||||
wavelogUploaded = wavelogUploaded,
|
||||
wavelogStation = wavelogStation.trim(),
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.joinToString(","),
|
||||
|
||||
+109
-20
@@ -30,7 +30,8 @@ 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
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
@@ -147,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()
|
||||
@@ -386,6 +369,112 @@ 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 # 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
|
||||
|
||||
-97
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+142
@@ -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
|
||||
}
|
||||
}
|
||||
+327
@@ -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(""", "\"").replace("'", "'").replace("&", "&")
|
||||
.replace(Regex("[ \\t]+"), " ")
|
||||
.lines().joinToString("\n") { it.trim() }
|
||||
|
||||
/** Parses the v1 station_info array (only the fields the app uses). */
|
||||
internal fun parseStations(body: String): List<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))
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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.lotw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* previewCertificate parses a .p12 without touching storage — just enough to
|
||||
* learn the certificate's DXCC entity, so the settings dialog can offer the
|
||||
* entity's Province/State dropdown before the operator confirms the import.
|
||||
*
|
||||
* Fixture test_tqsl_empty.p12 mirrors a modern TQSL export: PBES2/AES-256,
|
||||
* empty password, TQSL subject attribute 1.3.6.1.4.1.12348.1.1 = BA7OPF and
|
||||
* extensions .2/.3/.4 = first/last QSO date, DXCC 318 (China).
|
||||
*/
|
||||
class LoTWUploadRepositoryPreviewTest {
|
||||
|
||||
private class MemStorage : LoTWStorage {
|
||||
val files = mutableMapOf<String, ByteArray>()
|
||||
override fun read(name: String): ByteArray? = files[name]
|
||||
override fun write(name: String, data: ByteArray) { files[name] = data.copyOf() }
|
||||
override fun delete(name: String) { files.remove(name) }
|
||||
}
|
||||
|
||||
private fun fixture(name: String): ByteArray =
|
||||
javaClass.classLoader!!.getResourceAsStream(name)!!.use { it.readBytes() }
|
||||
|
||||
private fun repository(storage: MemStorage) = LoTWUploadRepository(
|
||||
storage, { error("station config not needed") }, now = { System.currentTimeMillis() }
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `preview parses TQSL-style certificate without persisting it`() {
|
||||
runBlocking {
|
||||
val storage = MemStorage()
|
||||
val repo = repository(storage)
|
||||
val info = repo.previewCertificate(fixture("test_tqsl_empty.p12"), charArrayOf())
|
||||
assertEquals("BA7OPF", info.callsign)
|
||||
assertEquals(318, info.dxcc)
|
||||
assertEquals("2026-01-01", info.firstQsoDate)
|
||||
assertTrue("nothing written", storage.files.isEmpty())
|
||||
assertNull("no certificate stored", repo.certificate())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wrong password fails as certificate password and wipes the attempt`() {
|
||||
runBlocking {
|
||||
val storage = MemStorage()
|
||||
val repo = repository(storage)
|
||||
val password = "definitely-wrong".toCharArray()
|
||||
val error = assertThrows(LoTWOperationException::class.java) {
|
||||
runBlocking { repo.previewCertificate(fixture("test_pbes2.p12"), password) }
|
||||
}
|
||||
assertEquals(LoTWProblem.CERTIFICATE_PASSWORD, error.reason)
|
||||
assertTrue("password wiped", password.all { it == '\u0000' })
|
||||
assertTrue("nothing written", storage.files.isEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
+118
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
@@ -143,6 +144,123 @@ class AmSatRepositoryTest {
|
||||
val page = repository.fetchStatus()
|
||||
assertEquals(4, page?.statuses?.single()?.days?.get(0)?.streakCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildStatusesStrictMajorityTakesItsColorAndCount() = runTest {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
|
||||
val heardMost = firstSlotStatus(
|
||||
report("h1", "AO-7", "Heard", nowSec - 3600),
|
||||
report("h2", "AO-7", "Heard", nowSec - 4500),
|
||||
report("t1", "AO-7", "Telemetry Only", nowSec - 5400)
|
||||
)
|
||||
assertEquals(0xFF648FFFL, heardMost?.statusColor) // blue
|
||||
assertEquals(2, heardMost?.count) // majority count, not the block total
|
||||
|
||||
val telemetryMost = firstSlotStatus(
|
||||
report("t1", "AO-7", "Telemetry Only", nowSec - 3600),
|
||||
report("t2", "AO-7", "Telemetry Only", nowSec - 4500),
|
||||
report("h1", "AO-7", "Heard", nowSec - 5400)
|
||||
)
|
||||
assertEquals(0xFFFFB000L, telemetryMost?.statusColor) // amber
|
||||
assertEquals(2, telemetryMost?.count)
|
||||
|
||||
val notHeardMost = firstSlotStatus(
|
||||
report("n1", "AO-7", "Not Heard", nowSec - 3600),
|
||||
report("n2", "AO-7", "Not Heard", nowSec - 4500),
|
||||
report("n3", "AO-7", "Not Heard", nowSec - 5400),
|
||||
report("t1", "AO-7", "Telemetry Only", nowSec - 6000)
|
||||
)
|
||||
assertEquals(0xFFDC267FL, notHeardMost?.statusColor) // pink
|
||||
assertEquals(3, notHeardMost?.count)
|
||||
|
||||
val single = firstSlotStatus(report("h1", "AO-7", "Heard", nowSec - 3600))
|
||||
assertEquals(0xFF648FFFL, single?.statusColor)
|
||||
assertEquals(1, single?.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildStatusesShowsConflictWhenNoStrictMajority() = runTest {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
|
||||
val oneToOne = firstSlotStatus(
|
||||
report("h1", "AO-7", "Heard", nowSec - 3600),
|
||||
report("n1", "AO-7", "Not Heard", nowSec - 4500)
|
||||
)
|
||||
assertEquals(0xFFFE6100L, oneToOne?.statusColor) // conflicting
|
||||
assertEquals(2, oneToOne?.count)
|
||||
|
||||
val twoToTwo = firstSlotStatus(
|
||||
report("h1", "AO-7", "Heard", nowSec - 3600),
|
||||
report("h2", "AO-7", "Heard", nowSec - 4500),
|
||||
report("t1", "AO-7", "Telemetry Only", nowSec - 5400),
|
||||
report("t2", "AO-7", "Telemetry Only", nowSec - 6000)
|
||||
)
|
||||
assertEquals(0xFFFE6100L, twoToTwo?.statusColor)
|
||||
|
||||
val threeWay = firstSlotStatus(
|
||||
report("h1", "AO-7", "Heard", nowSec - 3600),
|
||||
report("n1", "AO-7", "Not Heard", nowSec - 4500),
|
||||
report("t1", "AO-7", "Telemetry Only", nowSec - 5400)
|
||||
)
|
||||
assertEquals(0xFFFE6100L, threeWay?.statusColor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildStatusesCrewActiveWinsWithCrewPlusHeardCount() = runTest {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
|
||||
val crewWithHeard = firstSlotStatus(
|
||||
report("c1", "AO-7", "Crew Active", nowSec - 3600),
|
||||
report("h1", "AO-7", "Heard", nowSec - 4500),
|
||||
report("h2", "AO-7", "Heard", nowSec - 5400),
|
||||
report("h3", "AO-7", "Heard", nowSec - 6000)
|
||||
)
|
||||
assertEquals(0xFF785EF0L, crewWithHeard?.statusColor) // purple
|
||||
assertEquals(4, crewWithHeard?.count) // crew + heard
|
||||
|
||||
val crewAlone = firstSlotStatus(
|
||||
report("c1", "AO-7", "Crew Active", nowSec - 3600),
|
||||
report("n1", "AO-7", "Not Heard", nowSec - 4500),
|
||||
report("n2", "AO-7", "Not Heard", nowSec - 5400)
|
||||
)
|
||||
assertEquals(0xFF785EF0L, crewAlone?.statusColor)
|
||||
assertEquals(1, crewAlone?.count) // crew + heard (0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildStatusesMarksOnlyNoStrictMajoritySlotsAsConflicted() = runTest {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
|
||||
val majority = firstSlotStatus(
|
||||
report("h1", "AO-7", "Heard", nowSec - 3600),
|
||||
report("h2", "AO-7", "Heard", nowSec - 4500),
|
||||
report("t1", "AO-7", "Telemetry Only", nowSec - 5400)
|
||||
)
|
||||
assertEquals(false, majority?.isConflicted)
|
||||
|
||||
val tie = firstSlotStatus(
|
||||
report("h1", "AO-7", "Heard", nowSec - 3600),
|
||||
report("t1", "AO-7", "Telemetry Only", nowSec - 4500)
|
||||
)
|
||||
assertEquals(true, tie?.isConflicted)
|
||||
|
||||
val crew = firstSlotStatus(
|
||||
report("c1", "AO-7", "Crew Active", nowSec - 3600),
|
||||
report("n1", "AO-7", "Not Heard", nowSec - 4500)
|
||||
)
|
||||
assertEquals(false, crew?.isConflicted)
|
||||
|
||||
val empty = firstSlotStatus()
|
||||
assertEquals(false, empty?.isConflicted)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun firstSlotStatus(vararg reports: String): SatSlot? {
|
||||
val page = AmSatRepository(
|
||||
FakeAmSatRemoteSource(reportsJson = amSatReportsJson(*reports))
|
||||
).fetchStatus()
|
||||
return page?.statuses?.single()?.days?.get(0)?.slots?.get(0)
|
||||
}
|
||||
|
||||
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
|
||||
|
||||
+15
-3
@@ -453,10 +453,14 @@ 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())
|
||||
|
||||
override fun updateWavelogUploadSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings) = Unit
|
||||
|
||||
override fun getWorkedGrids(): Set<String> = emptySet()
|
||||
|
||||
@@ -474,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()
|
||||
|
||||
|
||||
+75
@@ -295,6 +295,68 @@ class QsoRepositoryTest {
|
||||
assertEquals(1, dao.getAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markWavelogUploaded_setsTheFlagAndTheStationStamp() = runBlocking {
|
||||
val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).toEntity())
|
||||
|
||||
repository.markWavelogUploaded(listOf(id), "3")
|
||||
|
||||
val row = dao.getAll().single()
|
||||
assertTrue(row.wavelogUploaded)
|
||||
assertFalse(row.lotwUploaded)
|
||||
assertEquals("3", row.wavelogStation)
|
||||
}
|
||||
|
||||
@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 })
|
||||
}
|
||||
|
||||
@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,
|
||||
@@ -355,6 +417,8 @@ class QsoRepositoryTest {
|
||||
propagationMode = propagationMode,
|
||||
lotwConfirmed = lotwConfirmed,
|
||||
lotwUploaded = lotwUploaded,
|
||||
wavelogUploaded = wavelogUploaded,
|
||||
wavelogStation = wavelogStation,
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.joinToString(","),
|
||||
@@ -393,5 +457,16 @@ 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>, stationId: String) {
|
||||
ids.forEach { id ->
|
||||
rows[id]?.let {
|
||||
rows[id] = it.copy(
|
||||
wavelogUploaded = true,
|
||||
wavelogStation = if (stationId.isBlank()) it.wavelogStation else stationId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-3
@@ -344,8 +344,10 @@ 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
|
||||
override val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
|
||||
|
||||
@@ -374,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()
|
||||
@@ -388,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
|
||||
}
|
||||
+74
@@ -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())
|
||||
}
|
||||
}
|
||||
+197
@@ -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
|
||||
)
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -18,7 +18,7 @@ import java.util.TimeZone
|
||||
object AdifCodec {
|
||||
private val utc = TimeZone.getTimeZone("UTC")
|
||||
|
||||
fun encode(records: List<QsoRecord>): String = buildString {
|
||||
fun encode(records: List<QsoRecord>, includeStationCallsign: Boolean = true): String = buildString {
|
||||
append(field("ADIF_VER", "3.1.7"))
|
||||
append(field("PROGRAMID", "Look4Sat"))
|
||||
append("<EOH>\r\n")
|
||||
@@ -32,7 +32,7 @@ object AdifCodec {
|
||||
append(field("TIME_OFF", format(it, "HHmmss")))
|
||||
}
|
||||
append(field("CALL", record.theirCallsign))
|
||||
append(field("STATION_CALLSIGN", record.myCallsign))
|
||||
if (includeStationCallsign) append(field("STATION_CALLSIGN", record.myCallsign))
|
||||
appendOptional("MODE", record.mode)
|
||||
appendOptional("SUBMODE", record.submode)
|
||||
appendOptional("GRIDSQUARE", record.theirGrid)
|
||||
|
||||
@@ -23,11 +23,27 @@ interface IQsoRepository {
|
||||
* logbook row can show which grids the QSO went out under.
|
||||
*/
|
||||
suspend fun markUploaded(ids: List<Long>, grids: List<String> = emptyList())
|
||||
|
||||
/**
|
||||
* 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]).
|
||||
|
||||
@@ -40,6 +40,12 @@ data class QsoRecord(
|
||||
val propagationMode: String = if (satelliteName.isNotBlank()) "SAT" else "",
|
||||
val lotwConfirmed: Boolean = false,
|
||||
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(),
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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.logbook
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogProblem
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
|
||||
|
||||
/**
|
||||
* The records a Wavelog upload may submit: locally recorded contacts only.
|
||||
*
|
||||
* Pure LoTW-imported confirmation rows never carry [QsoRecord.lotwUploaded] (the ADIF decoder
|
||||
* does not set it), while a locally recorded row keeps its value through a confirmation merge —
|
||||
* [withConfirmation] leaves `lotwUploaded` untouched. Confirmed local rows uploaded to LoTW
|
||||
* from this app therefore stay eligible; imported history stays out of the upload.
|
||||
*/
|
||||
fun wavelogUploadCandidates(records: List<QsoRecord>): List<QsoRecord> = records.filter {
|
||||
it.status == QsoStatus.COMPLETE && !it.wavelogUploaded && (!it.lotwConfirmed || it.lotwUploaded)
|
||||
}
|
||||
|
||||
/** Operator-facing wording for why a record cannot enter a Wavelog upload. */
|
||||
fun WavelogProblem.label(): String = when (this) {
|
||||
WavelogProblem.NOT_CONFIGURED -> "Wavelog upload not configured"
|
||||
WavelogProblem.NO_STATION -> "no station profile selected"
|
||||
WavelogProblem.GRID_MISMATCH -> "logged grid outside the Wavelog station grid"
|
||||
}
|
||||
|
||||
/** One-line summary of the records excluded before a Wavelog upload (empty when none). */
|
||||
fun wavelogSkipSummary(preview: WavelogUploadPreview): String {
|
||||
if (preview.skipped <= 0) return ""
|
||||
val detail = preview.reasons.entries.sortedByDescending { it.value }
|
||||
.joinToString(", ") { (problem, count) -> "$count× ${problem.label()}" }
|
||||
return "${preview.skipped} QSO(s) skipped" + if (detail.isNotBlank()) " — $detail" else ""
|
||||
}
|
||||
|
||||
/** One-line result wording for a finished Wavelog upload. */
|
||||
fun wavelogResultMessage(result: WavelogUploadOutcome): String = when (result) {
|
||||
is WavelogUploadOutcome.Imported -> buildString {
|
||||
if (result.imported == 0 && result.duplicates > 0) {
|
||||
append("All ${result.markIds.size} record(s) already in Wavelog")
|
||||
} else {
|
||||
append("Uploaded ${result.imported} QSO(s) to Wavelog")
|
||||
if (result.duplicates > 0) append(" — ${result.duplicates} already there")
|
||||
}
|
||||
if (result.skipped > 0) append(" — ${result.skipped} skipped by Wavelog")
|
||||
}
|
||||
is WavelogUploadOutcome.Rejected -> result.message
|
||||
is WavelogUploadOutcome.Failed -> result.message
|
||||
WavelogUploadOutcome.Expired -> "Preview expired — tap upload again"
|
||||
}
|
||||
|
||||
/**
|
||||
* Message for the piggybacked Wavelog side when nothing can be submitted —
|
||||
* null when there is nothing to say (fully caught up, no held-back records).
|
||||
*/
|
||||
fun wavelogIdleSegment(preview: WavelogUploadPreview): String? = when {
|
||||
preview.blockedBy != null -> "Wavelog: ${preview.blockedBy.label()}"
|
||||
preview.skipped > 0 -> "Wavelog: ${wavelogSkipSummary(preview)}"
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Message for a finished piggybacked Wavelog upload: the server outcome plus
|
||||
* the records held back before the POST (e.g. grids outside the station profile).
|
||||
*/
|
||||
fun wavelogConfirmedSegment(outcome: WavelogUploadOutcome, preview: WavelogUploadPreview): String {
|
||||
val base = wavelogResultMessage(outcome)
|
||||
val head = if (outcome is WavelogUploadOutcome.Imported) base else "Wavelog: $base"
|
||||
val skip = wavelogSkipSummary(preview)
|
||||
return if (skip.isNotBlank()) "$head\n$skip" else head
|
||||
}
|
||||
|
||||
/** Message when the piggybacked upload threw outright — records stay unmarked for retry. */
|
||||
fun wavelogTransportFailureSegment(): String = "Wavelog: upload failed — it will retry with your next upload"
|
||||
+56
-29
@@ -81,52 +81,79 @@ object AwardCalculator {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The award unit one QSO counts toward for [type] — the match key of the
|
||||
* award boundary assets' region "code" (WAPC -> "GD"/"HK", WAJA -> "34",
|
||||
* WAZ -> "24", WAS -> "CA", DXCC -> "318"). Null when the QSO does not
|
||||
* count toward that award (wrong entity or missing field). VUCC has no
|
||||
* per-QSO key — its units are the store's grid keys — and returns null.
|
||||
*
|
||||
* Single source of truth shared by the progress counters above and the
|
||||
* map's region-detail dialogs, so a tapped region lists exactly the QSOs
|
||||
* that light it up.
|
||||
*/
|
||||
fun awardKey(type: AwardType, q: GridQso): String? = when (type) {
|
||||
AwardType.DXCC -> (q.dxcc ?: prefixEntityCode(q.call))?.toString()
|
||||
AwardType.WAPC -> wapcKey(q)
|
||||
AwardType.WAJA -> if (entityOf(q) == Entity.JAPAN) {
|
||||
q.state?.trim()?.takeIf { it.length == 2 && it.all(Char::isDigit) }
|
||||
} else null
|
||||
|
||||
AwardType.WAZ -> q.cqz?.takeIf { it in 1..40 }?.toString()
|
||||
AwardType.WAS -> if (entityOf(q) == Entity.USA) {
|
||||
q.state?.trim()?.uppercase()?.takeIf { it in US_STATES }
|
||||
} else null
|
||||
|
||||
AwardType.VUCC -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every confirmed QSO counted for award region [code] of [type], oldest
|
||||
* first. The store keeps one entry per WORKED GRID, so a QSO spanning
|
||||
* several grids arrives as several copies — [distinct] collapses them.
|
||||
* Not scoped by 台址: the non-VUCC awards count (and fill the map)
|
||||
* globally, so their detail lists do too.
|
||||
*/
|
||||
fun regionQsos(
|
||||
qsosByGrid: Map<String, List<GridQso>>,
|
||||
type: AwardType,
|
||||
code: String
|
||||
): List<GridQso> =
|
||||
qsosByGrid.values.flatten()
|
||||
.filter { awardKey(type, it) == code }
|
||||
.distinct()
|
||||
.sortedBy { it.epochMs }
|
||||
|
||||
private fun wapcKey(q: GridQso): String? = when (entityOf(q)) {
|
||||
Entity.CHINA -> q.state?.trim()?.uppercase()?.takeIf { it in CN_STATES }
|
||||
Entity.HONG_KONG -> HK_CODE
|
||||
Entity.MACAO -> MO_CODE
|
||||
Entity.TAIWAN -> TW_CODE
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun calculateDxcc(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
val code = q.dxcc ?: prefixEntityCode(q.call)
|
||||
if (code != null) keys.add(code.toString())
|
||||
}
|
||||
val keys = all.mapNotNull { awardKey(AwardType.DXCC, it) }.toSet()
|
||||
return AwardProgress(AwardType.DXCC, keys, keys.size, AwardTargets.DXCC)
|
||||
}
|
||||
|
||||
private fun calculateWapc(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
when (entityOf(q)) {
|
||||
Entity.CHINA -> q.state?.trim()?.uppercase()?.takeIf { it in CN_STATES }?.let { keys.add(it) }
|
||||
Entity.HONG_KONG -> keys.add(HK_CODE)
|
||||
Entity.MACAO -> keys.add(MO_CODE)
|
||||
Entity.TAIWAN -> keys.add(TW_CODE)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
val keys = all.mapNotNull { awardKey(AwardType.WAPC, it) }.toSet()
|
||||
return AwardProgress(AwardType.WAPC, keys, keys.size, AwardTargets.WAPC)
|
||||
}
|
||||
|
||||
private fun calculateWaja(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
if (entityOf(q) != Entity.JAPAN) continue
|
||||
q.state?.trim()?.takeIf { it.length == 2 && it.all(Char::isDigit) }?.let { keys.add(it) }
|
||||
}
|
||||
val keys = all.mapNotNull { awardKey(AwardType.WAJA, it) }.toSet()
|
||||
return AwardProgress(AwardType.WAJA, keys, keys.size, AwardTargets.WAJA)
|
||||
}
|
||||
|
||||
private fun calculateWaz(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
q.cqz?.takeIf { it in 1..40 }?.let { keys.add(it.toString()) }
|
||||
}
|
||||
val keys = all.mapNotNull { awardKey(AwardType.WAZ, it) }.toSet()
|
||||
return AwardProgress(AwardType.WAZ, keys, keys.size, AwardTargets.WAZ)
|
||||
}
|
||||
|
||||
private fun calculateWas(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
if (entityOf(q) != Entity.USA) continue
|
||||
q.state?.trim()?.uppercase()?.takeIf { it in US_STATES }?.let { keys.add(it) }
|
||||
}
|
||||
val keys = all.mapNotNull { awardKey(AwardType.WAS, it) }.toSet()
|
||||
return AwardProgress(AwardType.WAS, keys, keys.size, AwardTargets.WAS)
|
||||
}
|
||||
|
||||
|
||||
@@ -104,3 +104,50 @@ data class GridQso(
|
||||
else -> band.trim().uppercase()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every MY-side grid of this QSO: [myGrids] (MY_GRIDSQUARE + MY_VUCC_GRIDS)
|
||||
* with a single-grid fallback to [myGrid] for records synced before
|
||||
* multi-grid support. Empty when the record carries no MY-side grid at all.
|
||||
*/
|
||||
fun GridQso.myStationGrids(): Set<String> =
|
||||
myGrids.ifEmpty { myGrid?.let { setOf(it) }.orEmpty() }
|
||||
|
||||
/**
|
||||
* Station-location (台址) grouping key: the QSO's MY grid set, sorted and
|
||||
* comma-joined ("OL62", "OM60,PM01"). Records covering the same grid set
|
||||
* belong to the same 台址 — they are VUCC-equivalent. Null when the record
|
||||
* carries no MY-side grid. Backs both the map's operated-grid selector and
|
||||
* its per-台址 grid-detail filtering, so the two scope by one identity.
|
||||
*/
|
||||
fun GridQso.stationGridSetKey(): String? =
|
||||
myStationGrids().takeIf { it.isNotEmpty() }?.sorted()?.joinToString(",")
|
||||
|
||||
/**
|
||||
* Scopes a per-grid QSO list to the operated-grid selector's choice with
|
||||
* COVERING semantics (2026-10-05): [stationId] is a grid-set key such as
|
||||
* "OL62" or "OL62,OL63", and a record stays when its own [myStationGrids]
|
||||
* COVER that key (record grid set ⊇ key set). So a multi-grid 台址 that
|
||||
* covers a single-grid one (OL62/63 covers OL62) contributes its records to
|
||||
* the single-grid scope's statistics — map fills, dialog lists and first-call
|
||||
* labels all derive from this one rule. The reverse does NOT hold: records
|
||||
* under a narrower 台址 never leak into a wider scope (single OL62 records
|
||||
* stay out of the OL62/63 scope). Records without a MY-side grid belong to
|
||||
* no 台址 and are dropped under any specific scope.
|
||||
*/
|
||||
fun List<GridQso>.scopedToStation(stationId: String?): List<GridQso> =
|
||||
if (stationId == null) this
|
||||
else {
|
||||
val scope = stationId.split(',').toSet()
|
||||
filter { it.myStationGrids().containsAll(scope) }
|
||||
}
|
||||
|
||||
/**
|
||||
* For every grid in the store, the callsign of the earliest QSO — scoped to the
|
||||
* operated-grid selector's choice first, so a specific 台址 labels each worked
|
||||
* cell with ITS first contact; null ("All") keeps the global first call.
|
||||
*/
|
||||
fun Map<String, List<GridQso>>.firstCallsByGrid(stationId: String?): Map<String, String> =
|
||||
mapNotNull { (grid, qsos) ->
|
||||
qsos.scopedToStation(stationId).minByOrNull { it.epochMs }?.let { grid to it.call }
|
||||
}.toMap()
|
||||
@@ -30,9 +30,10 @@ data class SatReport(
|
||||
|
||||
/** State of one 2-hour slot */
|
||||
data class SatSlot(
|
||||
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
|
||||
val count: Int, // 报告数量(0 = 无)
|
||||
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
|
||||
val statusColor: Long, // ARGB 状态色(灰=无报告;判定见 AmSatRepository.slotStatusOf)
|
||||
val count: Int, // 官网页口径:多数方计数(冲突槽/异常槽=总条数;0 = 无)
|
||||
val reportIds: List<String> = emptyList(), // 该槽报告 ID 列表
|
||||
val isConflicted: Boolean = false // 官网页"Conflicting reports":该槽无严格多数(弹窗据此标注)
|
||||
)
|
||||
|
||||
/** One satellite day (12 two-hour slots) */
|
||||
|
||||
@@ -102,11 +102,17 @@ data class DataSourcesSettings(
|
||||
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
|
||||
}
|
||||
|
||||
data class WavelogSettings(
|
||||
/** Wavelog configuration — QSO upload both ways; one of LoTW or Wavelog (mutually exclusive). */
|
||||
data class WavelogUploadSettings(
|
||||
val url: String = "",
|
||||
val token: String = ""
|
||||
val apiKey: String = "",
|
||||
val stationId: String = "",
|
||||
val stationName: String = "",
|
||||
val stationCallsign: String = "",
|
||||
val stationGrid: String = ""
|
||||
) {
|
||||
val isConfigured: Boolean get() = url.isNotBlank() && token.isNotBlank()
|
||||
val isConfigured: Boolean get() = url.isNotBlank() && apiKey.isNotBlank()
|
||||
val isReady: Boolean get() = isConfigured && stationId.isNotBlank()
|
||||
}
|
||||
|
||||
data class LoTWSettings(
|
||||
|
||||
+3
@@ -25,6 +25,9 @@ interface ILoTWUploadRepository {
|
||||
suspend fun certificate(): LoTWCertificate?
|
||||
suspend fun station(): LoTWStation?
|
||||
suspend fun importCertificate(data: ByteArray, password: CharArray): LoTWCertificate
|
||||
/** Parse a .p12 without persisting it — enough to learn the certificate's DXCC
|
||||
* entity so the station form can offer its region field before the import. */
|
||||
suspend fun previewCertificate(data: ByteArray, password: CharArray): LoTWCertificate
|
||||
suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate
|
||||
suspend fun saveStation(station: LoTWStation): LoTWStation
|
||||
suspend fun removeCertificate()
|
||||
|
||||
+2
-1
@@ -33,7 +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
-4
@@ -23,7 +23,8 @@ 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
|
||||
|
||||
@@ -106,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). */
|
||||
@@ -124,6 +123,26 @@ interface ISettingsRepo {
|
||||
fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>)
|
||||
//endregion
|
||||
|
||||
//region # Wavelog upload settings
|
||||
val wavelogUploadSettings: StateFlow<WavelogUploadSettings>
|
||||
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)
|
||||
|
||||
-28
@@ -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>?
|
||||
}
|
||||
+57
@@ -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?
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <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.WavelogUploadSettings
|
||||
|
||||
/** One station profile as returned by the v1 station_info endpoint. */
|
||||
data class WavelogStationInfo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val callsign: String,
|
||||
val grid: String,
|
||||
val active: Boolean
|
||||
)
|
||||
|
||||
/** Result of probing a Wavelog server (check_auth + station_info). */
|
||||
data class WavelogProbe(val rights: String, val stations: List<WavelogStationInfo>)
|
||||
|
||||
/** Why a record (or the whole upload) cannot be submitted. */
|
||||
enum class WavelogProblem { NOT_CONFIGURED, NO_STATION, GRID_MISMATCH }
|
||||
|
||||
/** Records skipped before the POST (with reasons), plus the records that will be submitted. */
|
||||
data class WavelogUploadPreview(
|
||||
val count: Int,
|
||||
val skipped: Int,
|
||||
val reasons: Map<WavelogProblem, Int>,
|
||||
val stationLabel: String,
|
||||
val firstUtc: String,
|
||||
val lastUtc: String,
|
||||
val contacts: List<String>,
|
||||
val submittedIds: List<Long>,
|
||||
val blockedBy: WavelogProblem? = null
|
||||
)
|
||||
|
||||
sealed interface WavelogUploadOutcome {
|
||||
/** [markIds] can be smaller than the submission when Wavelog reported skipped records. */
|
||||
data class Imported(
|
||||
val imported: Int,
|
||||
val duplicates: Int,
|
||||
val skipped: Int,
|
||||
val markIds: List<Long>
|
||||
) : WavelogUploadOutcome
|
||||
|
||||
data class Rejected(val message: String) : WavelogUploadOutcome
|
||||
data class Failed(val message: String) : WavelogUploadOutcome
|
||||
data object Expired : WavelogUploadOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads local QSO records to a self-hosted Wavelog via the v1 API
|
||||
* (`POST {base}/index.php/api/qso` with a batch ADIF string).
|
||||
*/
|
||||
interface IWavelogUploadRepository {
|
||||
/** check_auth + station_info; null on any transport/parse failure. */
|
||||
suspend fun probe(url: String, apiKey: String): WavelogProbe?
|
||||
|
||||
/** Filters + encodes the batch and keeps a one-shot pending payload for [upload]. */
|
||||
suspend fun prepare(records: List<QsoRecord>, settings: WavelogUploadSettings): WavelogUploadPreview
|
||||
|
||||
/** POSTs the prepared batch and classifies Wavelog's response. */
|
||||
suspend fun upload(preview: WavelogUploadPreview, settings: WavelogUploadSettings): WavelogUploadOutcome
|
||||
}
|
||||
+137
@@ -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
|
||||
}
|
||||
@@ -83,4 +83,20 @@ class AdifCodecTest {
|
||||
assertTrue(record.lotwConfirmed)
|
||||
assertTrue(record.isSatellite)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodeOmitsStationCallsignForWavelog() {
|
||||
val record = QsoRecord(
|
||||
startUtcMillis = 1_700_000_000_000L,
|
||||
theirCallsign = "N0CALL",
|
||||
myCallsign = "BA7OPF",
|
||||
txFrequencyHz = 145_850_000L,
|
||||
band = "2M",
|
||||
satelliteName = "SO-50"
|
||||
)
|
||||
val adif = AdifCodec.encode(listOf(record), includeStationCallsign = false)
|
||||
assertTrue(!adif.contains("<STATION_CALLSIGN"))
|
||||
assertTrue(adif.contains("<CALL:6>N0CALL"))
|
||||
assertTrue(adif.contains("<SAT_NAME:5>SO-50"))
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* 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.domain.logbook
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogProblem
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class WavelogUploadMessagesTest {
|
||||
|
||||
private fun record(
|
||||
complete: Boolean = true,
|
||||
wavelogUploaded: Boolean = false,
|
||||
lotwConfirmed: Boolean = false,
|
||||
lotwUploaded: Boolean = false
|
||||
) = QsoRecord(
|
||||
startUtcMillis = 1_700_000_000_000L,
|
||||
theirCallsign = "BG5JVM",
|
||||
myCallsign = "BA7OPF",
|
||||
status = if (complete) QsoStatus.COMPLETE else QsoStatus.DRAFT,
|
||||
lotwConfirmed = lotwConfirmed,
|
||||
lotwUploaded = lotwUploaded,
|
||||
wavelogUploaded = wavelogUploaded
|
||||
)
|
||||
|
||||
private fun preview(
|
||||
count: Int = 0,
|
||||
skipped: Int = 0,
|
||||
reasons: Map<WavelogProblem, Int> = emptyMap(),
|
||||
blockedBy: WavelogProblem? = null
|
||||
) = WavelogUploadPreview(
|
||||
count = count,
|
||||
skipped = skipped,
|
||||
reasons = reasons,
|
||||
stationLabel = "OL62TI · BA7OPF",
|
||||
firstUtc = "",
|
||||
lastUtc = "",
|
||||
contacts = emptyList(),
|
||||
submittedIds = List(count) { it.toLong() },
|
||||
blockedBy = blockedBy
|
||||
)
|
||||
|
||||
@Test
|
||||
fun freshLocalRecordIsEligible() {
|
||||
assertTrue(wavelogUploadCandidates(listOf(record())).isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun locallyUploadedToLotwStaysEligible() {
|
||||
assertTrue(wavelogUploadCandidates(listOf(record(lotwUploaded = true))).isNotEmpty())
|
||||
assertTrue(
|
||||
wavelogUploadCandidates(listOf(record(lotwConfirmed = true, lotwUploaded = true))).isNotEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importedConfirmationRowIsExcluded() {
|
||||
assertTrue(wavelogUploadCandidates(listOf(record(lotwConfirmed = true))).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun draftAndAlreadyUploadedRecordsAreExcluded() {
|
||||
assertTrue(wavelogUploadCandidates(listOf(record(complete = false))).isEmpty())
|
||||
assertTrue(wavelogUploadCandidates(listOf(record(wavelogUploaded = true))).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun problemLabelsAreOperatorFacing() {
|
||||
assertEquals("Wavelog upload not configured", WavelogProblem.NOT_CONFIGURED.label())
|
||||
assertEquals("no station profile selected", WavelogProblem.NO_STATION.label())
|
||||
assertEquals("logged grid outside the Wavelog station grid", WavelogProblem.GRID_MISMATCH.label())
|
||||
}
|
||||
|
||||
// --- Piggyback segments (Wavelog rides the LoTW upload flow) ---
|
||||
|
||||
@Test
|
||||
fun `idle segment stays silent when nothing was held back`() {
|
||||
assertNull(wavelogIdleSegment(preview()))
|
||||
assertNull(wavelogIdleSegment(preview(count = 0, skipped = 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `idle segment explains a blocked profile`() {
|
||||
assertEquals(
|
||||
"Wavelog: no station profile selected",
|
||||
wavelogIdleSegment(preview(blockedBy = WavelogProblem.NO_STATION))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `idle segment lists held-back records with reasons`() {
|
||||
assertEquals(
|
||||
"Wavelog: 3 QSO(s) skipped — 3× logged grid outside the Wavelog station grid",
|
||||
wavelogIdleSegment(preview(skipped = 3, reasons = mapOf(WavelogProblem.GRID_MISMATCH to 3)))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirmed segment reports the outcome and pre-POST skips`() {
|
||||
val outcome = WavelogUploadOutcome.Imported(imported = 2, duplicates = 0, skipped = 0, markIds = listOf(1, 2))
|
||||
assertEquals("Uploaded 2 QSO(s) to Wavelog", wavelogConfirmedSegment(outcome, preview()))
|
||||
assertEquals(
|
||||
"Uploaded 2 QSO(s) to Wavelog\n1 QSO(s) skipped — 1× logged grid outside the Wavelog station grid",
|
||||
wavelogConfirmedSegment(
|
||||
outcome,
|
||||
preview(skipped = 1, reasons = mapOf(WavelogProblem.GRID_MISMATCH to 1))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirmed segment keeps the all-duplicates wording unprefixed`() {
|
||||
val outcome = WavelogUploadOutcome.Imported(imported = 0, duplicates = 2, skipped = 0, markIds = listOf(1, 2))
|
||||
assertEquals("All 2 record(s) already in Wavelog", wavelogConfirmedSegment(outcome, preview()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirmed segment prefixes failures with Wavelog`() {
|
||||
assertEquals("Wavelog: boom", wavelogConfirmedSegment(WavelogUploadOutcome.Failed("boom"), preview()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transport failure segment asks for a retry on the next upload`() {
|
||||
assertEquals(
|
||||
"Wavelog: upload failed — it will retry with your next upload",
|
||||
wavelogTransportFailureSegment()
|
||||
)
|
||||
}
|
||||
}
|
||||
+45
@@ -152,4 +152,49 @@ class AwardCalculatorTest {
|
||||
assertEquals(40, progress.first { it.type == AwardType.WAZ }.target)
|
||||
assertEquals(50, progress.first { it.type == AwardType.WAS }.target)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `award key matches the region codes for every award type`() {
|
||||
val cn = qso("BG7XYZ", dxcc = 318, cqz = 24, state = "GD")
|
||||
assertEquals("318", AwardCalculator.awardKey(AwardType.DXCC, cn))
|
||||
assertEquals("GD", AwardCalculator.awardKey(AwardType.WAPC, cn))
|
||||
assertEquals("24", AwardCalculator.awardKey(AwardType.WAZ, cn))
|
||||
// DXCC code falls back to a prefix guess when LoTW omitted the field
|
||||
assertEquals("152", AwardCalculator.awardKey(AwardType.DXCC, qso("XX9A")))
|
||||
assertEquals(null, AwardCalculator.awardKey(AwardType.DXCC, qso("ZS1ABC")))
|
||||
// WAPC special entities
|
||||
assertEquals("HK", AwardCalculator.awardKey(AwardType.WAPC, qso("VR2X", dxcc = 321)))
|
||||
assertEquals("MO", AwardCalculator.awardKey(AwardType.WAPC, qso("XX9A", dxcc = 152)))
|
||||
assertEquals("TW", AwardCalculator.awardKey(AwardType.WAPC, qso("BV2A", dxcc = 386)))
|
||||
// WAJA: only Japanese prefecture numbers count
|
||||
assertEquals("34", AwardCalculator.awardKey(AwardType.WAJA, qso("JH0ABC", dxcc = 339, state = "34")))
|
||||
assertEquals(null, AwardCalculator.awardKey(AwardType.WAJA, qso("BG7XYZ", dxcc = 318, state = "34")))
|
||||
assertEquals(null, AwardCalculator.awardKey(AwardType.WAJA, qso("JH0ABC", dxcc = 339, state = "GD")))
|
||||
// WAS: only US states count
|
||||
assertEquals("CA", AwardCalculator.awardKey(AwardType.WAS, qso("W1AW", dxcc = 291, state = "CA")))
|
||||
assertEquals(null, AwardCalculator.awardKey(AwardType.WAS, qso("BG7XYZ", dxcc = 318, state = "CA")))
|
||||
// out-of-range CQ zone and VUCC have no key
|
||||
assertEquals(null, AwardCalculator.awardKey(AwardType.WAZ, qso("BG7XYZ", cqz = 41)))
|
||||
assertEquals(null, AwardCalculator.awardKey(AwardType.VUCC, cn))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `region qsos dedupe multi-grid copies and sort oldest first`() {
|
||||
val older = GridQso(
|
||||
call = "BA7AAA", epochMs = 100L, satName = "SO-50", mode = "FM",
|
||||
bandUp = "2M", bandDown = "70CM", dxcc = 318
|
||||
)
|
||||
val newer = GridQso(
|
||||
call = "BA7BBB", epochMs = 200L, satName = "SO-50", mode = "FM",
|
||||
bandUp = "2M", bandDown = "70CM", dxcc = 318
|
||||
)
|
||||
val store = mapOf(
|
||||
"OL62" to listOf(newer, older),
|
||||
"PM95" to listOf(older), // same QSO recorded under a second worked grid
|
||||
"OL62,PM01" to listOf(qso("W1AW", dxcc = 291))
|
||||
)
|
||||
val result = AwardCalculator.regionQsos(store, AwardType.DXCC, "318")
|
||||
assertEquals(listOf(older, newer), result)
|
||||
assertEquals(emptyList<GridQso>(), AwardCalculator.regionQsos(store, AwardType.DXCC, "292"))
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* 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.model
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The map's VUCC view scopes QSO data to the operated-grid selector's choice
|
||||
* (台址 = station location): [stationGridSetKey] identifies a record's 台址 and
|
||||
* [scopedToStation] applies the choice — the grid-detail dialog relies on both.
|
||||
*/
|
||||
class GridQsoStationScopeTest {
|
||||
|
||||
private fun qso(
|
||||
call: String = "BG7XYZ",
|
||||
myGrid: String? = null,
|
||||
myGrids: Set<String> = emptySet()
|
||||
) = GridQso(
|
||||
call = call, epochMs = 0L, satName = "SO-50", mode = "FM",
|
||||
bandUp = "70CM", bandDown = "2M", myGrid = myGrid, myGrids = myGrids
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `multi-grid record keys by its full sorted grid set`() {
|
||||
assertEquals("OL62,PM01", qso(myGrids = setOf("PM01", "OL62")).stationGridSetKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy single-grid record falls back to myGrid`() {
|
||||
assertEquals("OL62", qso(myGrid = "OL62").stationGridSetKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `myGrids wins over myGrid when both are present`() {
|
||||
val q = qso(myGrid = "OL62", myGrids = setOf("OM60", "PM01"))
|
||||
assertEquals("OM60,PM01", q.stationGridSetKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `record without any MY grid carries no key`() {
|
||||
assertNull(qso().stationGridSetKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `key does not depend on the grid set's iteration order`() {
|
||||
assertEquals(
|
||||
qso(myGrids = setOf("PM01", "OL62")).stationGridSetKey(),
|
||||
qso(myGrids = setOf("OL62", "PM01")).stationGridSetKey()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null scope keeps every record`() {
|
||||
val a = qso("A", myGrid = "OL62")
|
||||
val b = qso("B", myGrid = "PM01")
|
||||
assertEquals(listOf(a, b), listOf(a, b).scopedToStation(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `specific scope keeps covering station's records too`() {
|
||||
// 方案 A 覆盖语义: 选中单格台址 OL62 时,覆盖它的多格台址 (OL62,PM01)
|
||||
// 的通联也计入;反向不泄漏。
|
||||
val multi = qso("A", myGrids = setOf("OL62", "PM01"))
|
||||
val single = qso("B", myGrid = "OL62")
|
||||
val list = listOf(multi, single)
|
||||
assertEquals(listOf(multi), list.scopedToStation("OL62,PM01"))
|
||||
assertEquals(listOf(multi, single), list.scopedToStation("OL62"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `narrower station never leaks into a wider scope`() {
|
||||
val single = qso("B", myGrid = "OL62")
|
||||
val pair = qso("A", myGrids = setOf("OL62", "PM01"))
|
||||
// 宽台址 (OL62,PM01) 选中时,单格 OL62 的记录不出现
|
||||
assertEquals(emptyList<GridQso>(), listOf(single, pair).scopedToStation("OL62,PM01").filter { it === single })
|
||||
assertEquals(listOf(pair), listOf(single, pair).scopedToStation("OL62,PM01"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `covering is per-cell set containment not string equality`() {
|
||||
// 多格台址的 key 是排序拼接,覆盖判定必须按网格集合而非字符串
|
||||
val quad = qso("A", myGrids = setOf("OL62", "OL63", "OL64", "OL65"))
|
||||
assertEquals(listOf(quad), listOf(quad).scopedToStation("OL63"))
|
||||
assertEquals(listOf(quad), listOf(quad).scopedToStation("OL62,OL65"))
|
||||
assertEquals(emptyList<GridQso>(), listOf(quad).scopedToStation("OL62,PM01"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `records without a MY grid belong to no station scope`() {
|
||||
val orphan = qso("A")
|
||||
val mine = qso("B", myGrid = "OL62")
|
||||
assertEquals(listOf(mine), listOf(orphan, mine).scopedToStation("OL62"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scoping to a station absent from the list yields empty`() {
|
||||
val list = listOf(qso("A", myGrid = "OL62"))
|
||||
assertEquals(emptyList<GridQso>(), list.scopedToStation("PM01"))
|
||||
}
|
||||
|
||||
// --- firstCallsByGrid ---
|
||||
|
||||
@Test
|
||||
fun `first call per grid picks the earliest of the scoped records`() {
|
||||
val store = mapOf(
|
||||
"OL62" to listOf(
|
||||
qso("OTHER", myGrid = "PM01").copy(epochMs = 10),
|
||||
qso("MINE", myGrid = "OL62").copy(epochMs = 20)
|
||||
),
|
||||
"PM95" to listOf(qso("MINE2", myGrid = "OL62").copy(epochMs = 30))
|
||||
)
|
||||
assertEquals(mapOf("OL62" to "MINE", "PM95" to "MINE2"), store.firstCallsByGrid("OL62"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first call per grid with All uses every record`() {
|
||||
val store = mapOf(
|
||||
"OL62" to listOf(
|
||||
qso("OTHER", myGrid = "PM01").copy(epochMs = 10),
|
||||
qso("MINE", myGrid = "OL62").copy(epochMs = 20)
|
||||
)
|
||||
)
|
||||
assertEquals(mapOf("OL62" to "OTHER"), store.firstCallsByGrid(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `grids without scoped records get no first call`() {
|
||||
val store = mapOf("OL62" to listOf(qso("A", myGrid = "PM01")))
|
||||
assertEquals(emptyMap<String, String>(), store.firstCallsByGrid("OL62"))
|
||||
}
|
||||
}
|
||||
+19
-3
@@ -26,7 +26,8 @@ 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
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -247,7 +248,8 @@ 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())
|
||||
|
||||
private var workedGrids: Set<String> = emptySet()
|
||||
@@ -270,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()
|
||||
@@ -291,7 +306,8 @@ 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()
|
||||
}
|
||||
}
|
||||
+259
@@ -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()
|
||||
}
|
||||
}
|
||||
+74
@@ -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)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -125,6 +125,7 @@
|
||||
<string name="map_visibility">能见度:%s</string>
|
||||
<!-- Worked-grid QSO dialog -->
|
||||
<string name="grid_qso_calls_count" formatted="false">%d 个呼号 · %d 次通联</string>
|
||||
<string name="region_qso_empty">该区域暂无已确认通联</string>
|
||||
<string name="grid_qso_count_one">%d QSO</string>
|
||||
<string name="grid_qso_count_many">%d QSOs</string>
|
||||
<string name="grid_qso_first">首通</string>
|
||||
@@ -157,15 +158,29 @@
|
||||
<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_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>
|
||||
<string name="prefs_wavelog_upload_fetch">获取站点</string>
|
||||
<string name="prefs_wavelog_upload_probing">检测中…</string>
|
||||
<string name="prefs_wavelog_upload_station_none">未选择站点 — 点「获取站点」</string>
|
||||
<string name="prefs_wavelog_upload_rights_rw">密钥:读写 — 可上传</string>
|
||||
<string name="prefs_wavelog_upload_rights_r">密钥只读 — 请在 Wavelog → API 生成「读写」密钥</string>
|
||||
<string name="prefs_wavelog_upload_hint">点「获取站点」选择上传使用的台站档案;密钥需要「读写」权限。</string>
|
||||
<string name="prefs_lotw_title">LoTW 已确认网格</string>
|
||||
<string name="prefs_lotw_callsign">LoTW 呼号</string>
|
||||
<string name="prefs_lotw_password">LoTW 密码</string>
|
||||
@@ -201,6 +216,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_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>
|
||||
@@ -212,6 +230,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>
|
||||
@@ -224,6 +243,8 @@
|
||||
<string name="prefs_lotw_upload_remove">移除证书</string>
|
||||
<string name="prefs_lotw_upload_station_title">台址</string>
|
||||
<string name="prefs_lotw_upload_region_hint">未选择</string>
|
||||
<string name="prefs_lotw_upload_region_label">省份</string>
|
||||
<string name="prefs_lotw_upload_region_pending">导入证书后可选</string>
|
||||
<string name="prefs_lotw_upload_grid">网格(逗号分隔,可多格)</string>
|
||||
<string name="prefs_lotw_upload_save">保存</string>
|
||||
<string name="prefs_lotw_upload_close">关闭</string>
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
<string name="map_visibility">Visibility: %s</string>
|
||||
<!-- Worked-grid QSO dialog -->
|
||||
<string name="grid_qso_calls_count" formatted="false">%d callsigns · %d QSOs</string>
|
||||
<string name="region_qso_empty">No confirmed QSOs in this region yet</string>
|
||||
<string name="grid_qso_count_one">%d QSO</string>
|
||||
<string name="grid_qso_count_many">%d QSOs</string>
|
||||
<string name="grid_qso_first">First</string>
|
||||
@@ -186,15 +187,29 @@
|
||||
<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 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 & write)</string>
|
||||
<string name="prefs_wavelog_upload_fetch">Fetch stations</string>
|
||||
<string name="prefs_wavelog_upload_probing">Checking…</string>
|
||||
<string name="prefs_wavelog_upload_station_none">No station selected — tap Fetch stations</string>
|
||||
<string name="prefs_wavelog_upload_rights_rw">Key: read & write — uploads enabled</string>
|
||||
<string name="prefs_wavelog_upload_rights_r">Key is read-only — create a read & write key in Wavelog → API</string>
|
||||
<string name="prefs_wavelog_upload_hint">Tap Fetch stations to pick the station profile uploads go through. The key needs read & write permission.</string>
|
||||
<string name="prefs_lotw_title">LoTW confirmed grids</string>
|
||||
<string name="prefs_lotw_callsign">LoTW callsign</string>
|
||||
<string name="prefs_lotw_password">LoTW password</string>
|
||||
@@ -230,6 +245,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_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>
|
||||
@@ -241,6 +259,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>
|
||||
@@ -253,6 +272,8 @@
|
||||
<string name="prefs_lotw_upload_remove">Remove certificate</string>
|
||||
<string name="prefs_lotw_upload_station_title">Station location</string>
|
||||
<string name="prefs_lotw_upload_region_hint">Not selected</string>
|
||||
<string name="prefs_lotw_upload_region_label">Region</string>
|
||||
<string name="prefs_lotw_upload_region_pending">Import certificate to select</string>
|
||||
<string name="prefs_lotw_upload_grid">Grid(s), comma-separated</string>
|
||||
<string name="prefs_lotw_upload_save">Save</string>
|
||||
<string name="prefs_lotw_upload_close">Close</string>
|
||||
|
||||
@@ -4,8 +4,22 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.rtbishop.look4sat.feature.map"
|
||||
testOptions {
|
||||
unitTests {
|
||||
// Robolectric needs real Android resources for Compose UI tests.
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.other.osmdroid)
|
||||
// Real org.json for unit tests (android.jar stub throws "not mocked")
|
||||
testImplementation(libs.test.json)
|
||||
testImplementation(libs.test.junit4)
|
||||
testImplementation(libs.test.coroutines)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.androidx.test.core)
|
||||
testImplementation(libs.compose.ui.test.junit4)
|
||||
debugImplementation(libs.compose.debug.manifest)
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"type":"dxcc","regions":[{"id":0,"code":176,"name":"Fiji","label_lon":177.9759,"label_lat":-17.9376,"rings":[[[180.0,-16.0671],[180.0,-16.5552],[179.3641,-16.8014],[178.7251,-17.012],[178.5968,-16.6392],[179.0966,-16.434],[179.4135,-16.3791],[180.0,-16.0671]],[[178.1256,-17.5048],[178.3736,-17.3399],[178.7181,-17.6285],[178.5527,-18.1506],[177.9327,-18.288],[177.3815,-18.1643],[177.285,-17.7247],[177.6709,-17.3811]],[[-179.7933,-16.0209],[-179.9174,-16.5018],[-180.0,-16.5552],[-180.0,-16.0671]]]},{"id":1,"code":470,"name":"Tanzania","label_lon":34.1421,"label_lat":-6.2078,"rings":[[[33.9037,-0.95],[34.0726,-1.0598],[37.6987,-3.097],[37.7669,-3.6771],[39.2022,-4.6768],[38.7405,-5.9089],[38.7998,-6.4757],[39.44,-6.84],[39.47,-7.1],[39.1947,-7.7039],[39.252,-8.0078],[39.1865,-8.4855],[39.5357,-9.1124],[39.9496,-10.0984],[40.3166,-10.3171],[40.3166,-10.3171],[39.521,-10.8969],[38.4276,-11.2852],[37.8276,-11.2688],[37.4713,-11.5688],[36.7752,-11.5945],[36.5141,-11.7209],[35.3124,-11.4391],[34.56,-11.52],[34.28,-10.16],[33.9408,-9.6937],[33.7397,-9.4171],[32.7594,-9.2306],[32.1919,-8.9304],[31.5563,-8.762],[31.1578,-8.5946],[30.74,-8.34],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.7535,-4.4524],[30.1163,-4.0901],[30.5055,-3.5686],[30.7522,-3.3593],[30.743,-3.0343],[30.5277,-2.8076],[30.4697,-2.4139],[30.4697,-2.4138],[30.7583,-2.2872],[30.8161,-1.6989],[30.4191,-1.1347],[30.7699,-1.0146],[31.8662,-1.0274]]]},{"id":2,"code":302,"name":"W. Sahara","label_lon":-12.572,"label_lat":24.2306,"rings":[[[-8.6656,27.6564],[-8.6651,27.5895],[-8.6844,27.3957],[-8.6873,25.8811],[-11.9694,25.9334],[-11.9372,23.3746],[-12.8742,23.2848],[-13.1188,22.7712],[-12.9291,21.3271],[-16.8452,21.3333],[-17.0634,20.9998],[-17.0204,21.4223],[-17.003,21.4207],[-14.751,21.5006],[-14.6308,21.8609],[-14.2212,22.3102],[-13.8911,23.691],[-12.501,24.7701],[-12.0308,26.0309],[-11.7182,26.1041],[-11.3926,26.8834],[-10.5513,26.9908],[-10.1894,26.8609],[-9.7353,26.8609],[-9.413,27.0885],[-8.7949,27.1207],[-8.8178,27.6564]]]},{"id":3,"code":1,"name":"Canada","label_lon":-110.2438,"label_lat":56.7019,"rings":[[[-122.84,49.0],[-122.9742,49.0025],[-124.9102,49.9846],[-125.6246,50.4166],[-127.4356,50.8306],[-127.9928,51.7158],[-127.8503,52.3296],[-129.1298,52.7554],[-129.3052,53.5616],[-130.515,54.2876],[-130.5361,54.8028],[-130.5361,54.8028],[-129.98,55.285],[-130.0078,55.9158],[-131.7078,56.5521],[-132.7304,57.6929],[-133.3556,58.4103],[-134.2711,58.8611],[-134.945,59.2706],[-135.4758,59.7878],[-136.4797,59.4639],[-137.4525,58.905],[-138.3409,59.5621],[-139.039,60.0],[-140.013,60.2768],[-140.9978,60.3064],[-140.9925,66.0],[-140.986,69.712],[-140.986,69.712],[-139.1205,69.471],[-137.5464,68.99],[-136.5036,68.898],[-135.6258,69.3151],[-134.4146,69.6274],[-132.9292,69.5053],[-131.4314,69.9445],[-129.7947,70.1937],[-129.1077,69.7793],[-128.3616,70.0129],[-128.1382,70.4838],[-127.4471,70.3772],[-125.7563,69.4806],[-124.4248,70.1584],[-124.2897,69.3997],[-123.0611,69.5637],[-122.6835,69.8555],[-121.4723,69.7978],[-119.9429,69.3779],[-117.6027,69.0113],[-116.2264,68.8415],[-115.2469,68.9059],[-113.8979,68.3989],[-115.3049,67.9026],[-113.4973,67.6881],[-110.798,67.8061],[-109.9462,67.981],[-108.8802,67.3814],[-107.7924,67.8874],[-108.813,68.3116],[-108.1672,68.6539],[-106.95,68.7],[-106.15,68.8],[-105.3428,68.5612],[-104.3379,68.018],[-103.2211,68.0978],[-101.4543,67.6469],[-99.9019,67.8057],[-98.4432,67.7816],[-98.5586,68.4039],[-97.6695,68.5786],[-96.1199,68.2394],[-96.1259,67.2934],[-95.4894,68.0907],[-94.685,68.0638],[-94.2328,69.069],[-95.3041,69.6857],[-96.4713,70.0898],[-96.3911,71.1948],[-95.2088,71.9205],[-93.89,71.7601],[-92.8782,71.3187],[-91.5196,70.1913],[-92.4069,69.7],[-90.5471,69.4977],[-90.5515,68.475],[-89.2151,69.2587],[-88.0197,68.6151],[-88.3175,67.8734],[-87.3502,67.1987],[-86.3061,67.9215],[-85.5766,68.7846],[-85.522,69.8821],[-84.1008,69.8054],[-82.6226,69.6583],[-81.2804,69.162],[-81.2202,68.6657],[-81.9644,68.1325],[-81.2593,67.5972],[-81.3865,67.1108],[-83.3446,66.4115],[-84.7354,66.2573],[-85.7694,66.5583],[-86.0676,66.0563],[-87.0314,65.213],[-87.3232,64.7756],[-88.483,64.099],[-89.9144,64.0327],[-90.704,63.6102],[-90.77,62.9602],[-91.9334,62.8351],[-93.157,62.0247],[-94.2415,60.8987],[-94.6293,60.1102],[-94.6846,58.9488],[-93.215,58.7821],[-92.7646,57.8457],[-92.297,57.0871],[-90.8977,57.2847],[-89.0395,56.8517],[-88.0398,56.4716],[-87.3242,55.9991],[-86.0712,55.7238],[-85.0118,55.3026],[-83.3606,55.2449],[-82.2729,55.1483],[-82.4362,54.2823],[-82.125,53.277],[-81.4008,52.1579],[-79.9129,51.2084],[-79.143,51.5339],[-78.6019,52.5621],[-79.1242,54.1414],[-79.8296,54.6677],[-78.2287,55.1365],[-77.0956,55.8374],[-76.5414,56.5342],[-76.6232,57.2026],[-77.3023,58.0521],[-78.5169,58.8046],[-77.3368,59.8526],[-77.7727,60.7579],[-78.1069,62.3196],[-77.4107,62.5505],[-75.6962,62.2784],[-74.6682,62.1811],[-73.8399,62.4438],[-72.9085,62.1051],[-71.6771,61.5254],[-71.3737,61.1372],[-69.5904,61.0614],[Line truncated
|
||||
{"type":"dxcc","regions":[{"id":0,"code":176,"name":"Fiji","label_lon":177.9759,"label_lat":-17.9376,"rings":[[[180.0,-16.0671],[180.0,-16.5552],[179.3641,-16.8014],[178.7251,-17.012],[178.5968,-16.6392],[179.0966,-16.434],[179.4135,-16.3791],[180.0,-16.0671]],[[178.1256,-17.5048],[178.3736,-17.3399],[178.7181,-17.6285],[178.5527,-18.1506],[177.9327,-18.288],[177.3815,-18.1643],[177.285,-17.7247],[177.6709,-17.3811]],[[-179.7933,-16.0209],[-179.9174,-16.5018],[-180.0,-16.5552],[-180.0,-16.0671]]]},{"id":1,"code":470,"name":"Tanzania","label_lon":34.1421,"label_lat":-6.2078,"rings":[[[33.9037,-0.95],[34.0726,-1.0598],[37.6987,-3.097],[37.7669,-3.6771],[39.2022,-4.6768],[38.7405,-5.9089],[38.7998,-6.4757],[39.44,-6.84],[39.47,-7.1],[39.1947,-7.7039],[39.252,-8.0078],[39.1865,-8.4855],[39.5357,-9.1124],[39.9496,-10.0984],[40.3166,-10.3171],[40.3166,-10.3171],[39.521,-10.8969],[38.4276,-11.2852],[37.8276,-11.2688],[37.4713,-11.5688],[36.7752,-11.5945],[36.5141,-11.7209],[35.3124,-11.4391],[34.56,-11.52],[34.28,-10.16],[33.9408,-9.6937],[33.7397,-9.4171],[32.7594,-9.2306],[32.1919,-8.9304],[31.5563,-8.762],[31.1578,-8.5946],[30.74,-8.34],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.7535,-4.4524],[30.1163,-4.0901],[30.5055,-3.5686],[30.7522,-3.3593],[30.743,-3.0343],[30.5277,-2.8076],[30.4697,-2.4139],[30.4697,-2.4138],[30.7583,-2.2872],[30.8161,-1.6989],[30.4191,-1.1347],[30.7699,-1.0146],[31.8662,-1.0274]]]},{"id":2,"code":302,"name":"W. Sahara","label_lon":-12.572,"label_lat":24.2306,"rings":[[[-8.6656,27.6564],[-8.6651,27.5895],[-8.6844,27.3957],[-8.6873,25.8811],[-11.9694,25.9334],[-11.9372,23.3746],[-12.8742,23.2848],[-13.1188,22.7712],[-12.9291,21.3271],[-16.8452,21.3333],[-17.0634,20.9998],[-17.0204,21.4223],[-17.003,21.4207],[-14.751,21.5006],[-14.6308,21.8609],[-14.2212,22.3102],[-13.8911,23.691],[-12.501,24.7701],[-12.0308,26.0309],[-11.7182,26.1041],[-11.3926,26.8834],[-10.5513,26.9908],[-10.1894,26.8609],[-9.7353,26.8609],[-9.413,27.0885],[-8.7949,27.1207],[-8.8178,27.6564]]]},{"id":3,"code":1,"name":"Canada","label_lon":-110.2438,"label_lat":56.7019,"rings":[[[-122.84,49.0],[-122.9742,49.0025],[-124.9102,49.9846],[-125.6246,50.4166],[-127.4356,50.8306],[-127.9928,51.7158],[-127.8503,52.3296],[-129.1298,52.7554],[-129.3052,53.5616],[-130.515,54.2876],[-130.5361,54.8028],[-130.5361,54.8028],[-129.98,55.285],[-130.0078,55.9158],[-131.7078,56.5521],[-132.7304,57.6929],[-133.3556,58.4103],[-134.2711,58.8611],[-134.945,59.2706],[-135.4758,59.7878],[-136.4797,59.4639],[-137.4525,58.905],[-138.3409,59.5621],[-139.039,60.0],[-140.013,60.2768],[-140.9978,60.3064],[-140.9925,66.0],[-140.986,69.712],[-140.986,69.712],[-139.1205,69.471],[-137.5464,68.99],[-136.5036,68.898],[-135.6258,69.3151],[-134.4146,69.6274],[-132.9292,69.5053],[-131.4314,69.9445],[-129.7947,70.1937],[-129.1077,69.7793],[-128.3616,70.0129],[-128.1382,70.4838],[-127.4471,70.3772],[-125.7563,69.4806],[-124.4248,70.1584],[-124.2897,69.3997],[-123.0611,69.5637],[-122.6835,69.8555],[-121.4723,69.7978],[-119.9429,69.3779],[-117.6027,69.0113],[-116.2264,68.8415],[-115.2469,68.9059],[-113.8979,68.3989],[-115.3049,67.9026],[-113.4973,67.6881],[-110.798,67.8061],[-109.9462,67.981],[-108.8802,67.3814],[-107.7924,67.8874],[-108.813,68.3116],[-108.1672,68.6539],[-106.95,68.7],[-106.15,68.8],[-105.3428,68.5612],[-104.3379,68.018],[-103.2211,68.0978],[-101.4543,67.6469],[-99.9019,67.8057],[-98.4432,67.7816],[-98.5586,68.4039],[-97.6695,68.5786],[-96.1199,68.2394],[-96.1259,67.2934],[-95.4894,68.0907],[-94.685,68.0638],[-94.2328,69.069],[-95.3041,69.6857],[-96.4713,70.0898],[-96.3911,71.1948],[-95.2088,71.9205],[-93.89,71.7601],[-92.8782,71.3187],[-91.5196,70.1913],[-92.4069,69.7],[-90.5471,69.4977],[-90.5515,68.475],[-89.2151,69.2587],[-88.0197,68.6151],[-88.3175,67.8734],[-87.3502,67.1987],[-86.3061,67.9215],[-85.5766,68.7846],[-85.522,69.8821],[-84.1008,69.8054],[-82.6226,69.6583],[-81.2804,69.162],[-81.2202,68.6657],[-81.9644,68.1325],[-81.2593,67.5972],[-81.3865,67.1108],[-83.3446,66.4115],[-84.7354,66.2573],[-85.7694,66.5583],[-86.0676,66.0563],[-87.0314,65.213],[-87.3232,64.7756],[-88.483,64.099],[-89.9144,64.0327],[-90.704,63.6102],[-90.77,62.9602],[-91.9334,62.8351],[-93.157,62.0247],[-94.2415,60.8987],[-94.6293,60.1102],[-94.6846,58.9488],[-93.215,58.7821],[-92.7646,57.8457],[-92.297,57.0871],[-90.8977,57.2847],[-89.0395,56.8517],[-88.0398,56.4716],[-87.3242,55.9991],[-86.0712,55.7238],[-85.0118,55.3026],[-83.3606,55.2449],[-82.2729,55.1483],[-82.4362,54.2823],[-82.125,53.277],[-81.4008,52.1579],[-79.9129,51.2084],[-79.143,51.5339],[-78.6019,52.5621],[-79.1242,54.1414],[-79.8296,54.6677],[-78.2287,55.1365],[-77.0956,55.8374],[-76.5414,56.5342],[-76.6232,57.2026],[-77.3023,58.0521],[-78.5169,58.8046],[-77.3368,59.8526],[-77.7727,60.7579],[-78.1069,62.3196],[-77.4107,62.5505],[-75.6962,62.2784],[-74.6682,62.1811],[-73.8399,62.4438],[-72.9085,62.1051],[-71.6771,61.5254],[-71.3737,61.1372],[-69.5904,61.0614],[Line truncated
|
||||
+95
-17
@@ -22,6 +22,7 @@ import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.Projection
|
||||
@@ -48,6 +49,12 @@ data class AwardRegion(
|
||||
val labelLon: Double,
|
||||
val labelLat: Double,
|
||||
val rings: List<List<DoubleArray>>,
|
||||
/** Optional fill geometry with separate child entities knocked out as
|
||||
* even-odd holes (e.g. Lesotho inside South Africa, Hong Kong inside
|
||||
* China). Used only for the worked green fill so a worked parent never
|
||||
* greens a different DXCC entity's territory; [rings] stay the stroke /
|
||||
* hit-test source. Empty means "fill = rings". */
|
||||
val fillRings: List<List<DoubleArray>> = emptyList(),
|
||||
val forceLabel: Boolean = false
|
||||
)
|
||||
|
||||
@@ -61,6 +68,20 @@ object AwardBoundaryData {
|
||||
|
||||
private val cache = mutableMapOf<AwardAsset, List<AwardRegion>>()
|
||||
|
||||
private fun parseRings(array: JSONArray): List<List<DoubleArray>> {
|
||||
val rings = mutableListOf<List<DoubleArray>>()
|
||||
for (r in 0 until array.length()) {
|
||||
val ring = array.optJSONArray(r) ?: continue
|
||||
val points = mutableListOf<DoubleArray>()
|
||||
for (p in 0 until ring.length()) {
|
||||
val pt = ring.optJSONArray(p) ?: continue
|
||||
if (pt.length() >= 2) points.add(doubleArrayOf(pt.getDouble(0), pt.getDouble(1)))
|
||||
}
|
||||
if (points.size >= 3) rings.add(points)
|
||||
}
|
||||
return rings
|
||||
}
|
||||
|
||||
enum class AwardAsset(val fileName: String) {
|
||||
WAPC("wapc.json"),
|
||||
WAJA("waja.json"),
|
||||
@@ -80,16 +101,7 @@ object AwardBoundaryData {
|
||||
for (i in 0 until array.length()) {
|
||||
val o = array.optJSONObject(i) ?: continue
|
||||
val ringsArray = o.optJSONArray("rings") ?: continue
|
||||
val rings = mutableListOf<List<DoubleArray>>()
|
||||
for (r in 0 until ringsArray.length()) {
|
||||
val ring = ringsArray.optJSONArray(r) ?: continue
|
||||
val points = mutableListOf<DoubleArray>()
|
||||
for (p in 0 until ring.length()) {
|
||||
val pt = ring.optJSONArray(p) ?: continue
|
||||
if (pt.length() >= 2) points.add(doubleArrayOf(pt.getDouble(0), pt.getDouble(1)))
|
||||
}
|
||||
if (points.size >= 3) rings.add(points)
|
||||
}
|
||||
val rings = parseRings(ringsArray)
|
||||
if (rings.isEmpty()) continue
|
||||
list.add(
|
||||
AwardRegion(
|
||||
@@ -99,6 +111,7 @@ object AwardBoundaryData {
|
||||
labelLon = o.optDouble("label_lon", 0.0),
|
||||
labelLat = o.optDouble("label_lat", 0.0),
|
||||
rings = rings,
|
||||
fillRings = o.optJSONArray("fill_rings")?.let { parseRings(it) } ?: emptyList(),
|
||||
forceLabel = o.optBoolean("force_label", false)
|
||||
)
|
||||
)
|
||||
@@ -138,6 +151,14 @@ class AwardBoundaryOverlay : Overlay() {
|
||||
style = Paint.Style.FILL
|
||||
color = Color.argb(90, 76, 217, 100)
|
||||
}
|
||||
private val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.FILL
|
||||
color = Color.argb(230, 64, 68, 76)
|
||||
}
|
||||
private val workedDotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.FILL
|
||||
color = Color.argb(230, 76, 217, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recolor the amber boundary/label paints for the active map theme (dark amber + white halo on
|
||||
@@ -172,6 +193,15 @@ class AwardBoundaryOverlay : Overlay() {
|
||||
/** Codes of worked regions -> green fill. */
|
||||
var workedCodes: Set<String> = emptySet()
|
||||
|
||||
/** Code of the tapped region whose detail dialog is open -> thicker outline. */
|
||||
var selectedCode: String? = null
|
||||
|
||||
/** The region under a tap at the given geo point on the map at [zoom],
|
||||
* or null when the tap lands on empty space. Delegates to [AwardHitTest]
|
||||
* so the tap tolerance matches the tiny-region dots drawn in [draw]. */
|
||||
fun regionAt(latitude: Double, longitude: Double, zoom: Double): AwardRegion? =
|
||||
AwardHitTest.hitRegion(regions, regionBounds, latitude, longitude, zoom)
|
||||
|
||||
/** Bounding boxes (computed on region assignment) for cheap culling. */
|
||||
private val regionBounds = mutableListOf<DoubleArray>() // [minLon,minLat,maxLon,maxLat]
|
||||
|
||||
@@ -211,8 +241,24 @@ class AwardBoundaryOverlay : Overlay() {
|
||||
if (b[1] > topLat || b[3] < bottomLat) continue
|
||||
if (b[0] > rightLon || b[2] < leftLon) continue
|
||||
val worked = region.code in workedCodes
|
||||
val selected = region.code == selectedCode
|
||||
|
||||
// Projected bbox size: decides between the region's own shape and
|
||||
// the minimum-size dot below, and gates the label.
|
||||
val regionW = abs((b[2] - b[0]) / 360.0 * worldWidthPx).toFloat()
|
||||
val regionH =
|
||||
abs((projectionToY(projection, b[3]) ?: 0f) - (projectionToY(projection, b[1]) ?: 0f))
|
||||
val tiny = max(regionW, regionH) <= AwardHitTest.MIN_SHAPE_PX
|
||||
|
||||
val path = Path()
|
||||
// The worked fill uses fillRings when present: the award asset stores
|
||||
// fill geometry with separate child entities (Lesotho in South Africa,
|
||||
// Hong Kong in China, ...) knocked out as even-odd holes, so working a
|
||||
// parent never greens a different DXCC entity's territory. The stroke
|
||||
// keeps using the plain rings — hole borders coincide with the child
|
||||
// entity's own outline and must not be drawn twice.
|
||||
val fillRings = region.fillRings.ifEmpty { region.rings }
|
||||
val fillPath = if (worked) Path().apply { fillType = Path.FillType.EVEN_ODD } else null
|
||||
var pathHasPoints = false
|
||||
for (ring in region.rings) {
|
||||
// Split each ring into consecutive segments at the antimeridian
|
||||
@@ -237,10 +283,40 @@ class AwardBoundaryOverlay : Overlay() {
|
||||
pathHasPoints = traceSegment(path, segment, projection, centerLon, worldWidthPx, closeRing) || pathHasPoints
|
||||
}
|
||||
}
|
||||
if (!pathHasPoints) continue
|
||||
fillPath?.let { fp ->
|
||||
for (ring in fillRings) {
|
||||
val segments = splitRingAtAntimeridian(ring)
|
||||
val closeRing = segments.size == 1
|
||||
for (segment in segments) {
|
||||
if (segment.isEmpty()) continue
|
||||
traceSegment(fp, segment, projection, centerLon, worldWidthPx, closeRing)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!pathHasPoints && !tiny) continue
|
||||
|
||||
if (worked) canvas.drawPath(path, workedPaint)
|
||||
if (worked) fillPath?.let { canvas.drawPath(it, workedPaint) }
|
||||
linePaint.strokeWidth = if (selected) LINE_WIDTH_SELECTED else LINE_WIDTH
|
||||
canvas.drawPath(path, linePaint)
|
||||
linePaint.strokeWidth = LINE_WIDTH
|
||||
|
||||
// Tiny-region fallback (Macao, island DXCCs at low zoom): the own
|
||||
// shape is sub-pixel and invisible, so draw a minimum-size dot at
|
||||
// the bbox centre — green when worked — and let it carry the
|
||||
// region. AwardHitTest gives small regions (dot-sized included) an
|
||||
// inflated MIN_TAP_TARGET_PX square hit area at the same centre,
|
||||
// so the dot is reliably tappable at any zoom (Tianditu tiles have
|
||||
// no zoom cap, so "just zoom in" is not a workable answer for
|
||||
// finding these).
|
||||
if (tiny) {
|
||||
val cx = projectionToX(projection, normalizeLon((b[0] + b[2]) / 2.0), centerLon, worldWidthPx)
|
||||
val cy = projectionToY(projection, (b[1] + b[3]) / 2.0)
|
||||
if (cx != null && cy != null) {
|
||||
val r = if (selected) DOT_RADIUS_PX + 3f else DOT_RADIUS_PX
|
||||
canvas.drawCircle(cx, cy, r, if (worked) workedDotPaint else dotPaint)
|
||||
canvas.drawCircle(cx, cy, r, linePaint)
|
||||
}
|
||||
}
|
||||
|
||||
// Label: draw when its anchor is on screen and the region is big
|
||||
// enough on screen to hold the text (avoid clutter at low zoom).
|
||||
@@ -250,11 +326,10 @@ class AwardBoundaryOverlay : Overlay() {
|
||||
val lx = projectionToX(projection, labelLonNorm, centerLon, worldWidthPx) ?: continue
|
||||
val ly = projectionToY(projection, region.labelLat) ?: continue
|
||||
// Screen-size gate: the label only when the region spans enough px.
|
||||
// force_label regions (HK / Macau) always draw — their tiny land
|
||||
// bbox would otherwise stay below the gate forever, and their label
|
||||
// anchor sits out on the sea where there is room for the text.
|
||||
val regionH = (projectionToY(projection, b[3]) ?: 0f) - (projectionToY(projection, b[1]) ?: 0f)
|
||||
if (abs(regionH) < MIN_LABEL_REGION_PX && !region.forceLabel) continue
|
||||
// force_label regions (HK / Macau) and tiny regions always draw —
|
||||
// their bbox would otherwise stay below the gate forever, and they
|
||||
// are exactly the ones that would otherwise be unfindable.
|
||||
if (abs(regionH) < MIN_LABEL_REGION_PX && !region.forceLabel && !tiny) continue
|
||||
val label = if (useEnglishLabels) region.nameEn ?: region.name else region.name
|
||||
canvas.drawText(label, lx, ly - textHalfHeight, labelPaint)
|
||||
}
|
||||
@@ -424,5 +499,8 @@ class AwardBoundaryOverlay : Overlay() {
|
||||
private companion object {
|
||||
const val MIN_LABEL_REGION_PX = 42f
|
||||
const val MAX_MERCATOR_LAT = 85.05113 // Web Mercator latitude limit
|
||||
const val LINE_WIDTH = 1.5f
|
||||
const val LINE_WIDTH_SELECTED = 3f
|
||||
const val DOT_RADIUS_PX = 6f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* 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.feature.map
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.max
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* Tap hit-testing for the award boundary overlay: which region sits under the
|
||||
* user's finger in DXCC/WAPC/WAJA/WAZ/WAS view. Pure geometry (no osmdroid
|
||||
* types) so it is unit-testable; the overlay delegates to [hitRegion].
|
||||
*
|
||||
* Three hit shapes, mirroring how the overlay draws:
|
||||
* - a small region (projects to <= [MIN_TAP_TARGET_PX] at the current zoom —
|
||||
* including the dot-rendered ones) is hit by an inflated square target of
|
||||
* [MIN_TAP_TARGET_PX] around its bbox centre, so finger taps land even on
|
||||
* dot-sized islands (user request 2026-10-05);
|
||||
* - a normal region is hit by an even-odd point-in-polygon test on its rings.
|
||||
* Small targets win over polygon hits (Macao sits inside Guangdong's outline),
|
||||
* and among polygon hits the smallest region wins (Hong Kong over China).
|
||||
*
|
||||
* - a scattered sea archipelago (>= 2 disjoint island rings, every island
|
||||
* small — see [ARCHIPELAGO_MAX_RING_DEG]) is additionally hit anywhere in
|
||||
* its overall envelope, so tapping open sea between the islands lands on
|
||||
* the entity (user request 2026-10-06: 海上的群岛按照整体包络来判断 hit).
|
||||
* Priority: small target > land polygon > archipelago envelope — tapping the
|
||||
* Spanish coast that happens to lie in the Balearic envelope still opens
|
||||
* Spain; only sea inside the envelope belongs to the archipelago.
|
||||
*/
|
||||
object AwardHitTest {
|
||||
|
||||
/** Projected size (max of width/height, px) below which a region is
|
||||
* rendered as a minimum-size dot instead of its own shape. */
|
||||
const val MIN_SHAPE_PX = 8f
|
||||
|
||||
/** Minimum tap target (px, square) for SMALL regions: any region whose
|
||||
* projected size is below this gets an inflated square hit area of this
|
||||
* size around its bbox centre (user request 2026-10-05 — small DXCCs
|
||||
* like Hong Kong are hard to hit with a finger at their own size). */
|
||||
const val MIN_TAP_TARGET_PX = 80f
|
||||
|
||||
/** Largest per-island span (deg) for a region to count as a scattered sea
|
||||
* archipelago: every ring must be a small island, never a mainland
|
||||
* (Japan/UK/Indonesia stay polygon-only). Archipelago islands are
|
||||
* finger-proof at low zoom, so their hit region is the overall envelope. */
|
||||
const val ARCHIPELAGO_MAX_RING_DEG = 2.5
|
||||
|
||||
/**
|
||||
* The region under a tap at [tapLat]/[tapLon] on the map at [zoom], or
|
||||
* null when the tap lands on empty space. [bounds] holds each region's
|
||||
* `[minLon, minLat, maxLon, maxLat]` in [regions] order
|
||||
* (AwardBoundaryOverlay.regionBounds).
|
||||
*/
|
||||
fun hitRegion(
|
||||
regions: List<AwardRegion>,
|
||||
bounds: List<DoubleArray>,
|
||||
tapLat: Double,
|
||||
tapLon: Double,
|
||||
zoom: Double
|
||||
): AwardRegion? {
|
||||
val worldWidthPx = 256.0 * 2.0.pow(zoom)
|
||||
val lonDegPerPx = 360.0 / worldWidthPx
|
||||
val latDegPerPx = (lonDegPerPx * cos(Math.toRadians(tapLat))).coerceAtLeast(1e-9)
|
||||
var bestSmall: AwardRegion? = null
|
||||
var bestSmallDist = Double.MAX_VALUE
|
||||
var bestPoly: AwardRegion? = null
|
||||
var bestPolyArea = Double.MAX_VALUE
|
||||
var bestArch: AwardRegion? = null
|
||||
var bestArchArea = Double.MAX_VALUE
|
||||
for (i in regions.indices) {
|
||||
val b = bounds.getOrNull(i) ?: continue
|
||||
// Cheap bbox prefilter with the tap-target slack (longitudes wrap).
|
||||
val slack = MIN_TAP_TARGET_PX / 2.0
|
||||
if (tapLat < b[1] - slack * latDegPerPx || tapLat > b[3] + slack * latDegPerPx) continue
|
||||
val cLon = (b[0] + b[2]) / 2.0
|
||||
val dLon = wrap180(tapLon - cLon)
|
||||
if (abs(dLon) > (b[2] - b[0]) / 2.0 + slack * lonDegPerPx) continue
|
||||
val w = (b[2] - b[0]) / lonDegPerPx
|
||||
val h = (b[3] - b[1]) / latDegPerPx
|
||||
val cLat = (b[1] + b[3]) / 2.0
|
||||
val dx = dLon / lonDegPerPx
|
||||
val dy = (tapLat - cLat) / latDegPerPx
|
||||
val dist = hypot(dx, dy)
|
||||
val region = regions[i]
|
||||
if (max(w, h) <= MIN_TAP_TARGET_PX) {
|
||||
// Small region: hit by an inflated square target around the
|
||||
// bbox centre — at least MIN_TAP_TARGET_PX on each side — so
|
||||
// finger taps land even on dot-sized entities.
|
||||
if (abs(dx) <= slack && abs(dy) <= slack && dist < bestSmallDist) {
|
||||
bestSmall = region
|
||||
bestSmallDist = dist
|
||||
}
|
||||
} else if (contains(region, tapLat, tapLon)) {
|
||||
// Land polygon hit: the smallest enclosing region wins.
|
||||
if (w * h < bestPolyArea) {
|
||||
bestPoly = region
|
||||
bestPolyArea = w * h
|
||||
}
|
||||
} else if (isArchipelago(region)) {
|
||||
// Scattered sea archipelago: the overall envelope is the hit
|
||||
// region (open sea between the islands belongs to the entity).
|
||||
// The envelope is measured in one unwrapped longitude frame so
|
||||
// antimeridian groups (Fiji-style) keep a contiguous box.
|
||||
val env = envelope(region)
|
||||
val halfW = (env[2] - env[1]) / 2.0
|
||||
val halfH = (env[4] - env[3]) / 2.0
|
||||
val envLon = env[0] + wrap180(tapLon - env[0])
|
||||
val inEnvelope =
|
||||
abs(envLon - (env[1] + env[2]) / 2.0) <= halfW + slack * lonDegPerPx &&
|
||||
abs(tapLat - (env[3] + env[4]) / 2.0) <= halfH + slack * latDegPerPx
|
||||
val area = 4.0 * halfW * halfH
|
||||
if (inEnvelope && area < bestArchArea) {
|
||||
bestArch = region
|
||||
bestArchArea = area
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestSmall ?: bestPoly ?: bestArch
|
||||
}
|
||||
|
||||
/**
|
||||
* Scattered sea archipelago: at least two disjoint island rings and every
|
||||
* ring small (<= [ARCHIPELAGO_MAX_RING_DEG] in both lon and lat, measured
|
||||
* in an unwrapped longitude frame) — no dominant mainland. Mainland
|
||||
* countries with stray islands (Japan, UK, Indonesia) fail the size test
|
||||
* and keep plain polygon hits.
|
||||
*/
|
||||
fun isArchipelago(region: AwardRegion): Boolean {
|
||||
if (region.rings.size < 2) return false
|
||||
for (ring in region.rings) {
|
||||
if (ring.isEmpty()) continue
|
||||
val anchor = ring[0][0]
|
||||
var minX = 0.0
|
||||
var maxX = 0.0
|
||||
var minY = ring[0][1]
|
||||
var maxY = ring[0][1]
|
||||
for (p in ring) {
|
||||
val x = wrap180(p[0] - anchor)
|
||||
if (x < minX) minX = x
|
||||
if (x > maxX) maxX = x
|
||||
if (p[1] < minY) minY = p[1]
|
||||
if (p[1] > maxY) maxY = p[1]
|
||||
}
|
||||
if (max(maxX - minX, maxY - minY) > ARCHIPELAGO_MAX_RING_DEG) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Envelope of all rings in one longitude frame anchored at the first ring's
|
||||
* first point: `[anchorLon, minX, maxX, minY, maxY]`. Longitudes past ±180
|
||||
* are unwrapped into the anchor's frame, keeping antimeridian groups whole.
|
||||
*/
|
||||
private fun envelope(region: AwardRegion): DoubleArray {
|
||||
val first = region.rings.firstOrNull { it.isNotEmpty() } ?: return doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
val anchor = first[0][0]
|
||||
var minX = 0.0
|
||||
var maxX = 0.0
|
||||
var minY = first[0][1]
|
||||
var maxY = first[0][1]
|
||||
for (ring in region.rings) {
|
||||
for (p in ring) {
|
||||
val x = wrap180(p[0] - anchor)
|
||||
if (x < minX) minX = x
|
||||
if (x > maxX) maxX = x
|
||||
if (p[1] < minY) minY = p[1]
|
||||
if (p[1] > maxY) maxY = p[1]
|
||||
}
|
||||
}
|
||||
return doubleArrayOf(anchor, anchor + minX, anchor + maxX, minY, maxY)
|
||||
}
|
||||
|
||||
/** Even-odd point-in-polygon across all rings (rings are disjoint parts). */
|
||||
fun contains(region: AwardRegion, lat: Double, lon: Double): Boolean =
|
||||
region.rings.any { ringContains(it, lat, lon) }
|
||||
|
||||
/**
|
||||
* Point-in-ring with the ring unwrapped into one continuous longitude
|
||||
* frame (same trick as the overlay's tracer). Rings that cross the
|
||||
* antimeridian (Fiji, the Pacific CQ zones) are stored with raw lons past
|
||||
* ±180 or folded; testing them in raw coordinates would split the polygon
|
||||
* at the date line and miss taps inside it. The tap is shifted to the
|
||||
* frame's nearest world copy first.
|
||||
*/
|
||||
private fun ringContains(ring: List<DoubleArray>, lat: Double, lon: Double): Boolean {
|
||||
val n = ring.size
|
||||
if (n < 3) return false
|
||||
val anchor = ring[0][0]
|
||||
val lons = DoubleArray(n)
|
||||
var prevDelta = 0.0
|
||||
for (i in 0 until n) {
|
||||
var delta = ring[i][0] - anchor
|
||||
if (i > 0) {
|
||||
while (delta - prevDelta > 180.0) delta -= 360.0
|
||||
while (prevDelta - delta > 180.0) delta += 360.0
|
||||
}
|
||||
lons[i] = anchor + delta
|
||||
prevDelta = delta
|
||||
}
|
||||
val tapLon = anchor + wrap180(lon - anchor)
|
||||
var inside = false
|
||||
for (j in 0 until n) {
|
||||
val k = (j + 1) % n
|
||||
val y1 = ring[j][1]
|
||||
val y2 = ring[k][1]
|
||||
if ((y1 > lat) != (y2 > lat)) {
|
||||
val x = lons[j] + (lat - y1) / (y2 - y1) * (lons[k] - lons[j])
|
||||
if (x > tapLon) inside = !inside
|
||||
}
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
/** Shortest signed longitude difference a - b, in (-180, 180]. */
|
||||
private fun wrap180(deg: Double): Double {
|
||||
var d = deg % 360.0
|
||||
if (d <= -180.0) d += 360.0
|
||||
if (d > 180.0) d -= 360.0
|
||||
return d
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
@@ -85,7 +86,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.model.AwardCalculator
|
||||
import com.rtbishop.look4sat.core.domain.model.AwardProgress
|
||||
import com.rtbishop.look4sat.core.domain.model.AwardType
|
||||
import com.rtbishop.look4sat.core.domain.model.firstCallsByGrid
|
||||
import com.rtbishop.look4sat.core.domain.model.MapSource
|
||||
import com.rtbishop.look4sat.core.domain.model.myStationGrids
|
||||
import com.rtbishop.look4sat.core.domain.model.scopedToStation
|
||||
import com.rtbishop.look4sat.core.domain.model.stationGridSetKey
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
@@ -300,16 +305,13 @@ private fun MapScreen(
|
||||
// Tapped worked grid -> centered QSO dialog. Local UI state: the map is the
|
||||
// only consumer and it resets when leaving the page.
|
||||
var selectedGrid by remember { mutableStateOf<String?>(null) }
|
||||
// Tapped award region (DXCC/WAPC/WAJA/WAZ/WAS) -> its QSO detail dialog,
|
||||
// paired with the award type it was tapped under. Local UI state like the
|
||||
// grid selection above: the map is the only consumer.
|
||||
var selectedRegion by remember { mutableStateOf<Pair<AwardType, AwardRegion>?>(null) }
|
||||
// Selected award filter. Lives in an Activity-scoped ViewModel so it
|
||||
// survives page switches; defaults to VUCC only once per process (cold start).
|
||||
var selectedAward by mapFilterViewModel.selectedAward
|
||||
// First callsign worked in each grid (earliest QSO by time), used by the
|
||||
// "首通呼号" label mode — derived from the same QSO store as the worked fills.
|
||||
val firstCallsByGrid: Map<String, String> = remember(uiState.workedGridQsos) {
|
||||
uiState.workedGridQsos.mapNotNull { (grid, qsos) ->
|
||||
qsos.minByOrNull { it.epochMs }?.let { grid to it.call }
|
||||
}.toMap()
|
||||
}
|
||||
// 台址名单: QSOs grouped by the 台址's GRID SET. A station location (台址)
|
||||
// can span several grids (MY_GRIDSQUARE + MY_VUCC_GRIDS), and QSOs whose
|
||||
// location covers the same grid set belong to the same 台址; groups with an
|
||||
@@ -317,18 +319,24 @@ private fun MapScreen(
|
||||
// can differ between records of the same physical 台址 when LoTW omits
|
||||
// optional fields). Data synced before multi-grid support falls back to
|
||||
// one group per grid, preserving the old selector exactly.
|
||||
// Each group's `worked` cells are derived through scopedToStation (the
|
||||
// covering rule): a single-grid 台址 also counts the cells worked by any
|
||||
// multi-grid 台址 covering it (OL62 ← OL62/63, 四格点同理) — the reverse
|
||||
// never leaks (2026-10-05 用户定稿, 方案 A).
|
||||
val stationGroups: List<StationGroup> = remember(uiState.workedGridQsos) {
|
||||
val byGridSet = LinkedHashMap<String, Pair<Set<String>, MutableSet<String>>>()
|
||||
for ((grid, qsos) in uiState.workedGridQsos) {
|
||||
val gridSets = LinkedHashMap<String, Set<String>>()
|
||||
for ((_, qsos) in uiState.workedGridQsos) {
|
||||
for (q in qsos) {
|
||||
val gs = q.myGrids.ifEmpty { q.myGrid?.let { setOf(it) }.orEmpty() }
|
||||
if (gs.isEmpty()) continue
|
||||
val key = gs.sorted().joinToString(",")
|
||||
byGridSet.getOrPut(key) { gs to mutableSetOf() }.second.add(grid)
|
||||
val key = q.stationGridSetKey() ?: continue
|
||||
gridSets.getOrPut(key) { q.myStationGrids() }
|
||||
}
|
||||
}
|
||||
byGridSet.map { (key, v) -> StationGroup(id = key, grids = v.first, worked = v.second) }
|
||||
.sortedByDescending { it.workedCount }
|
||||
gridSets.map { (key, grids) ->
|
||||
val worked = uiState.workedGridQsos.filterValues { qsos ->
|
||||
qsos.scopedToStation(key).isNotEmpty()
|
||||
}.keys
|
||||
StationGroup(id = key, grids = grids, worked = worked)
|
||||
}.sortedByDescending { it.workedCount }
|
||||
}
|
||||
// Selected 台址 for VUCC counting; defaults to the group with the most
|
||||
// worked grids. Null = "All" (every 台址 combined). Empty when the store
|
||||
@@ -352,6 +360,13 @@ private fun MapScreen(
|
||||
if (selectedStationId == null) uiState.workedGrids
|
||||
else stationGroups.firstOrNull { it.id == selectedStationId }?.worked ?: emptySet()
|
||||
}
|
||||
// First callsign worked in each grid (earliest QSO by time), used by the
|
||||
// "首通呼号" label mode. Scoped to the selected 台址 like the fills: with a
|
||||
// specific operated grid chosen, the label must show ITS first contact, not
|
||||
// another 台址's (user decision 2026-10-05); "All" = global first call.
|
||||
val firstCallsByGrid: Map<String, String> = remember(uiState.workedGridQsos, selectedStationId) {
|
||||
uiState.workedGridQsos.firstCallsByGrid(selectedStationId)
|
||||
}
|
||||
val isGridMode = uiState.isGridMode
|
||||
// True when this composition restored a saved viewport. Only grid mode
|
||||
// restores the viewport the user left (satellite mode keeps its original
|
||||
@@ -365,6 +380,20 @@ private fun MapScreen(
|
||||
val receiver = object : org.osmdroid.events.MapEventsReceiver {
|
||||
override fun singleTapConfirmedHelper(p: GeoPoint?): Boolean {
|
||||
if (!isGridMode || p == null) return false
|
||||
// Award views (DXCC/WAPC/WAJA/WAZ/WAS): the tap resolves to the
|
||||
// boundary region under the finger and opens its QSO detail
|
||||
// dialog. No zoom gate here — unlike the grid path below — so
|
||||
// tiny islands stay reachable while zoomed out (Tianditu tiles
|
||||
// have no zoom cap, so "just zoom in" is not an answer).
|
||||
val awardMode = selectedAward?.takeIf { it != AwardType.VUCC }
|
||||
if (awardMode != null) {
|
||||
val overlay = mapView.overlays.getOrNull(OVERLAY_GRID) as? AwardBoundaryOverlay
|
||||
?: return false
|
||||
val region = overlay.regionAt(p.latitude, p.longitude, mapView.zoomLevelDouble)
|
||||
?: return false
|
||||
selectedRegion = awardMode to region
|
||||
return true
|
||||
}
|
||||
val zoom = mapView.zoomLevelDouble
|
||||
// Allow tapping worked cells as soon as the 4-char grid LINES
|
||||
// appear (GRID_ZOOM_SUB); grid labels now appear at that zoom too.
|
||||
@@ -387,6 +416,12 @@ private fun MapScreen(
|
||||
(mapView.overlays.getOrNull(OVERLAY_GRID) as? MaidenheadGridOverlay)?.selectedGrid = selectedGrid
|
||||
mapView.invalidate()
|
||||
}
|
||||
// Same for the award boundary overlay's selected region.
|
||||
LaunchedEffect(selectedRegion) {
|
||||
(mapView.overlays.getOrNull(OVERLAY_GRID) as? AwardBoundaryOverlay)?.selectedCode =
|
||||
selectedRegion?.second?.code
|
||||
mapView.invalidate()
|
||||
}
|
||||
LaunchedEffect(uiState.track) {
|
||||
// In grid mode the map is centered on the local grid square; following
|
||||
// the satellite subpoint here would override that centering on every
|
||||
@@ -539,7 +574,11 @@ private fun MapScreen(
|
||||
selectedGrid?.let { grid ->
|
||||
WorkedGridQsoDialog(
|
||||
grid = grid,
|
||||
qsos = uiState.workedGridQsos[grid].orEmpty().sortedBy { it.epochMs },
|
||||
// Scope to the selected 台址 (null = All): a specific station location
|
||||
// must not show other locations' QSOs (bug report 2026-10-05).
|
||||
qsos = uiState.workedGridQsos[grid].orEmpty()
|
||||
.scopedToStation(selectedStationId)
|
||||
.sortedBy { it.epochMs },
|
||||
marked = uiState.markedGrids[grid].orEmpty(),
|
||||
isUtc = uiState.isUtc,
|
||||
matchCalculating = matchCalculating,
|
||||
@@ -550,6 +589,22 @@ private fun MapScreen(
|
||||
onPinMark = { call -> onAction(MapAction.PinMarkedStation(grid, call)) }
|
||||
)
|
||||
}
|
||||
// Region detail dialog for a tapped award region. Read-only: the Match
|
||||
// button and station marks are grid-scoped and stay hidden here. The list
|
||||
// is the GLOBAL one (all 台址) — the non-VUCC awards count and fill the
|
||||
// map globally, so their detail lists use the same scope.
|
||||
selectedRegion?.let { (type, region) ->
|
||||
val regionQsos = remember(uiState.workedGridQsos, type, region) {
|
||||
AwardCalculator.regionQsos(uiState.workedGridQsos, type, region.code)
|
||||
}
|
||||
RegionQsoDialog(
|
||||
type = type,
|
||||
region = region,
|
||||
qsos = regionQsos,
|
||||
isUtc = uiState.isUtc,
|
||||
onDismiss = { selectedRegion = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// region Worked-grid QSO dialog
|
||||
@@ -706,6 +761,110 @@ private fun WorkedGridQsoDialog(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered dialog listing the confirmed satellite QSOs counted for a tapped
|
||||
* award region (DXCC/WAPC/WAJA/WAZ/WAS). Read-only variant of the worked-grid
|
||||
* dialog: the header shows the region name + code, and the same expandable
|
||||
* callsign rows. Match / station marks are grid-scoped and stay hidden.
|
||||
* Dismissed by tapping outside. An unworked region opens with an empty list.
|
||||
*/
|
||||
@Composable
|
||||
private fun RegionQsoDialog(
|
||||
type: AwardType,
|
||||
region: AwardRegion,
|
||||
qsos: List<com.rtbishop.look4sat.core.domain.model.GridQso>,
|
||||
isUtc: Boolean,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
// WAZ regions are named by their bare zone number in the asset, so the
|
||||
// title gets the full "CQ ZONE 24" wording (user request 2026-10-05) and
|
||||
// the code is not repeated in the subtitle. DXCC carries its award prefix
|
||||
// in the subtitle ("DXCC 318", same request); other awards keep the bare
|
||||
// code.
|
||||
val isZone = type == AwardType.WAZ
|
||||
val codeLabel = when {
|
||||
isZone -> ""
|
||||
type == AwardType.DXCC -> "DXCC ${region.code} · "
|
||||
else -> "${region.code} · "
|
||||
}
|
||||
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
modifier = Modifier.fillMaxWidth(0.88f)
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp)
|
||||
// Match the grid dialog header height exactly: its Match button
|
||||
// enforces a 48dp minimum touch target, inflating the row and
|
||||
// lowering the title to 6dp from the dialog edge.
|
||||
.defaultMinSize(minHeight = 48.dp)
|
||||
) {
|
||||
// Header mirrors the worked-grid dialog exactly (same font,
|
||||
// same paddings, same weight layout) — user request
|
||||
// 2026-10-05: 标题字体/位置与 VUCC 的统一.
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = if (isZone) "CQ ZONE ${region.code}" else regionDisplayName(region),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
|
||||
)
|
||||
Text(
|
||||
text = codeLabel + stringResource(
|
||||
R.string.grid_qso_calls_count,
|
||||
qsos.map { it.call }.distinct().size,
|
||||
qsos.size
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
androidx.compose.material3.HorizontalDivider()
|
||||
Column(
|
||||
Modifier
|
||||
.heightIn(max = 380.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
if (qsos.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.region_qso_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
)
|
||||
} else {
|
||||
// Same grouping as the worked-grid dialog: by callsign
|
||||
// preserving first-contact order (list is oldest-first).
|
||||
val grouped = remember(qsos) {
|
||||
qsos.groupBy { it.call }.entries.sortedBy { it.value.first().epochMs }
|
||||
}
|
||||
grouped.forEach { (call, callQsos) ->
|
||||
WorkedGridCallRow(call, callQsos = callQsos, isUtc = isUtc)
|
||||
androidx.compose.material3.HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Region display label, same language rule as the overlay's map labels. */
|
||||
@Composable
|
||||
private fun regionDisplayName(region: AwardRegion): String {
|
||||
val useEnglish =
|
||||
androidx.compose.ui.platform.LocalConfiguration.current.locales[0].language != "zh"
|
||||
return if (useEnglish) region.nameEn ?: region.name else region.name
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MarkedStationRow(
|
||||
marked: com.rtbishop.look4sat.core.domain.model.MarkedStation,
|
||||
|
||||
@@ -94,22 +94,11 @@ class MapViewModel(
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.wavelogSettings.collectLatest { _ ->
|
||||
val workedGrids = settingsRepo.getWorkedGrids()
|
||||
val markedGrids = settingsRepo.getMarkedGridStations()
|
||||
// Marked stations auto-clear once their grid becomes worked:
|
||||
// the reminder has served its purpose (user req: 网格变绿后标记自动清除).
|
||||
val stale = markedGrids.keys.filter { it in workedGrids }
|
||||
val activeMarks = if (stale.isEmpty()) markedGrids else markedGrids - stale.toSet()
|
||||
if (stale.isNotEmpty()) settingsRepo.setMarkedGridStations(activeMarks)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
workedGrids = workedGrids,
|
||||
workedGridQsos = settingsRepo.getWorkedGridQsos(),
|
||||
roamedGrids = settingsRepo.getRoamedGrids(),
|
||||
markedGrids = activeMarks
|
||||
)
|
||||
}
|
||||
// Grid + mark data reloads whenever the screen becomes visible (and once at
|
||||
// init): the LoTW/Wavelog syncs run from Settings or the app start, so
|
||||
// re-entering the map picks their results up.
|
||||
isScreenVisible.collect { visible ->
|
||||
if (visible) refreshMapGridData()
|
||||
}
|
||||
}
|
||||
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
|
||||
@@ -117,6 +106,25 @@ class MapViewModel(
|
||||
getStationPosition()
|
||||
}
|
||||
|
||||
/** Loads the worked-grid / mark data into the map state (see the visibility collector). */
|
||||
private fun refreshMapGridData() {
|
||||
val workedGrids = settingsRepo.getWorkedGrids()
|
||||
val markedGrids = settingsRepo.getMarkedGridStations()
|
||||
// Marked stations auto-clear once their grid becomes worked:
|
||||
// the reminder has served its purpose (user req: 网格变绿后标记自动清除).
|
||||
val stale = markedGrids.keys.filter { it in workedGrids }
|
||||
val activeMarks = if (stale.isEmpty()) markedGrids else markedGrids - stale.toSet()
|
||||
if (stale.isNotEmpty()) settingsRepo.setMarkedGridStations(activeMarks)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
workedGrids = workedGrids,
|
||||
workedGridQsos = settingsRepo.getWorkedGridQsos(),
|
||||
roamedGrids = settingsRepo.getRoamedGrids(),
|
||||
markedGrids = activeMarks
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onAction(action: MapAction) {
|
||||
when (action) {
|
||||
MapAction.SelectPrev -> scrollSelection(true)
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* 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.feature.map
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pin the award-region tap rules: tiny regions (rendered as dots) are hit by a
|
||||
* tap radius around their bbox centre, normal regions by point-in-polygon,
|
||||
* tiny dots win over enclosing polygons (Macao inside Guangdong), and rings
|
||||
* crossing the antimeridian stay tappable on both sides of the date line.
|
||||
*/
|
||||
class AwardHitTestTest {
|
||||
|
||||
/** Axis-aligned box region: [lon1, lon2] x [lat1, lat2] as one ring. */
|
||||
private fun box(code: String, lon1: Double, lat1: Double, lon2: Double, lat2: Double) = AwardRegion(
|
||||
code = code,
|
||||
name = code,
|
||||
labelLon = (lon1 + lon2) / 2.0,
|
||||
labelLat = (lat1 + lat2) / 2.0,
|
||||
rings = listOf(
|
||||
listOf(
|
||||
doubleArrayOf(lon1, lat1), doubleArrayOf(lon2, lat1),
|
||||
doubleArrayOf(lon2, lat2), doubleArrayOf(lon1, lat2)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
private fun boundsOf(regions: List<AwardRegion>): List<DoubleArray> = regions.map { r ->
|
||||
val lons = r.rings.flatten().map { it[0] }
|
||||
val lats = r.rings.flatten().map { it[1] }
|
||||
doubleArrayOf(lons.min(), lats.min(), lons.max(), lats.max())
|
||||
}
|
||||
|
||||
private fun hit(regions: List<AwardRegion>, lat: Double, lon: Double, zoom: Double): AwardRegion? =
|
||||
AwardHitTest.hitRegion(regions, boundsOf(regions), lat, lon, zoom)
|
||||
|
||||
@Test
|
||||
fun `tiny region is hit inside the inflated target and missed beyond it`() {
|
||||
// Macao-sized box (0.07 x 0.10 deg): 1.6 px wide at zoom 5 -> a dot,
|
||||
// but its tap target is the MIN_TAP_TARGET_PX square around the centre.
|
||||
val regions = listOf(box("MO", 113.535, 22.108, 113.604, 22.212))
|
||||
assertEquals("MO", hit(regions, 22.16, 113.57, 5.0)?.code)
|
||||
// half a degree away is still inside the 40 px half-target at zoom 5
|
||||
assertEquals("MO", hit(regions, 22.16, 114.07, 5.0)?.code)
|
||||
// two degrees away (~45 px) is outside it
|
||||
assertNull(hit(regions, 22.16, 115.57, 5.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `small region gets an inflated tap target beyond its own size`() {
|
||||
// Hong Kong-sized box: 13 x 9 px at zoom 5 — too small for a finger
|
||||
// at its own size, so the tap target inflates to 80 px square
|
||||
// (user request 2026-10-05).
|
||||
val regions = listOf(box("HK", 113.843, 22.178, 114.406, 22.559))
|
||||
assertEquals("HK", hit(regions, 22.37, 114.77, 5.0)?.code) // ~0.6 deg off = 14 px
|
||||
assertEquals("HK", hit(regions, 22.37, 115.37, 5.0)?.code) // ~1.2 deg off = 27 px
|
||||
assertNull(hit(regions, 22.37, 116.37, 5.0)) // ~2.2 deg off = 50 px
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normal region is hit by point in polygon only`() {
|
||||
val regions = listOf(box("GD", 109.5, 20.2, 117.5, 25.6))
|
||||
assertEquals("GD", hit(regions, 23.1, 113.3, 5.0)?.code)
|
||||
assertNull(hit(regions, 30.0, 113.3, 5.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tiny region wins over the polygon that encloses it`() {
|
||||
val regions = listOf(
|
||||
box("GD", 109.5, 20.2, 117.5, 25.6),
|
||||
box("MO", 113.535, 22.108, 113.604, 22.212)
|
||||
)
|
||||
assertEquals("MO", hit(regions, 22.16, 113.57, 5.0)?.code)
|
||||
// away from the dot the enclosing province answers for its own area
|
||||
assertEquals("GD", hit(regions, 24.5, 110.0, 5.0)?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `smallest enclosing polygon wins`() {
|
||||
val regions = listOf(
|
||||
box("CN", 73.0, 18.0, 135.0, 53.6),
|
||||
box("HK", 113.8, 22.1, 114.5, 22.6)
|
||||
)
|
||||
assertEquals("HK", hit(regions, 22.3, 114.1, 5.0)?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ring crossing the antimeridian is tappable on both sides`() {
|
||||
// Fiji-style ring whose raw lons run past +180 (wide enough to stay in
|
||||
// the polygon-branch of the hit test).
|
||||
val region = AwardRegion(
|
||||
code = "176",
|
||||
name = "Fiji",
|
||||
labelLon = 180.0,
|
||||
labelLat = -18.0,
|
||||
rings = listOf(
|
||||
listOf(
|
||||
doubleArrayOf(172.0, -22.0), doubleArrayOf(188.0, -22.0),
|
||||
doubleArrayOf(188.0, -14.0), doubleArrayOf(172.0, -14.0)
|
||||
)
|
||||
)
|
||||
)
|
||||
val regions = listOf(region)
|
||||
assertEquals("176", hit(regions, -18.0, 175.0, 4.0)?.code)
|
||||
assertEquals("176", hit(regions, -18.0, -175.0, 4.0)?.code)
|
||||
assertNull(hit(regions, -18.0, 165.0, 4.0))
|
||||
}
|
||||
|
||||
/** Two 0.2 x 0.2 deg island boxes [lon] apart, as one archipelago entity. */
|
||||
private fun archipelago(code: String, lon1: Double, lon2: Double, lat: Double = -10.0) =
|
||||
AwardRegion(
|
||||
code = code,
|
||||
name = code,
|
||||
labelLon = (lon1 + lon2) / 2.0,
|
||||
labelLat = lat,
|
||||
rings = listOf(
|
||||
listOf(
|
||||
doubleArrayOf(lon1 - 0.1, lat - 0.1), doubleArrayOf(lon1 + 0.1, lat - 0.1),
|
||||
doubleArrayOf(lon1 + 0.1, lat + 0.1), doubleArrayOf(lon1 - 0.1, lat + 0.1)
|
||||
),
|
||||
listOf(
|
||||
doubleArrayOf(lon2 - 0.1, lat - 0.1), doubleArrayOf(lon2 + 0.1, lat - 0.1),
|
||||
doubleArrayOf(lon2 + 0.1, lat + 0.1), doubleArrayOf(lon2 - 0.1, lat + 0.1)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `sea archipelago is hit by its overall envelope between the islands`() {
|
||||
// Two tiny islands 10 deg apart: at zoom 5 the bbox is ~230 px, so the
|
||||
// per-region inflated target does not apply — the whole envelope does
|
||||
// (user request 2026-10-06: 海上的群岛按照整体包络来判断 hit).
|
||||
val regions = listOf(archipelago("191", -165.0, -155.0))
|
||||
assertEquals("191", hit(regions, -10.0, -160.0, 5.0)?.code) // open sea in the middle
|
||||
assertEquals("191", hit(regions, -10.0, -156.0, 5.0)?.code) // near the east island
|
||||
assertNull(hit(regions, -10.0, -152.0, 5.0)) // ~2.8 deg past the envelope
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `small entity inside the archipelago envelope keeps its own target`() {
|
||||
val regions = listOf(
|
||||
archipelago("191", -165.0, -155.0),
|
||||
box("KS", -161.1, -9.6, -160.9, -9.4) // tiny standalone island in the gap
|
||||
)
|
||||
// Near KS the small target (80 px) wins over the archipelago envelope.
|
||||
assertEquals("KS", hit(regions, -9.5, -160.4, 5.0)?.code)
|
||||
// Away from it the open sea belongs to the archipelago.
|
||||
assertEquals("191", hit(regions, -10.0, -156.0, 5.0)?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mainland country with a stray island is not envelope hit`() {
|
||||
// Big mainland ring + one small island ring: sea inside the bbox is NOT
|
||||
// a tap target (the group is not a scattered archipelago).
|
||||
val regions = listOf(
|
||||
AwardRegion(
|
||||
code = "JP",
|
||||
name = "JP",
|
||||
labelLon = 133.0,
|
||||
labelLat = 37.0,
|
||||
rings = listOf(
|
||||
listOf(
|
||||
doubleArrayOf(130.0, 31.0), doubleArrayOf(135.0, 31.0),
|
||||
doubleArrayOf(135.0, 42.0), doubleArrayOf(130.0, 42.0)
|
||||
),
|
||||
listOf(
|
||||
doubleArrayOf(138.0, 34.2), doubleArrayOf(138.3, 34.2),
|
||||
doubleArrayOf(138.3, 34.5), doubleArrayOf(138.0, 34.5)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
assertNull(hit(regions, 35.0, 137.0, 5.0)) // sea inside the bbox
|
||||
assertEquals("JP", hit(regions, 37.0, 132.0, 5.0)?.code) // on the mainland
|
||||
assertEquals("JP", hit(regions, 34.35, 138.15, 5.0)?.code) // on the island itself
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `archipelago envelope crossing the antimeridian stays contiguous`() {
|
||||
// Two islands straddling 180: the raw bbox is 357 deg wide, but the
|
||||
// unwrapped envelope is a tight 3 deg box around the date line.
|
||||
val regions = listOf(archipelago("FJ", 178.65, -178.65, lat = -18.0))
|
||||
assertEquals("FJ", hit(regions, -18.0, 180.0, 5.0)?.code) // sea gap at the date line
|
||||
assertEquals("FJ", hit(regions, -18.0, -180.0, 5.0)?.code) // same, other sign
|
||||
assertNull(hit(regions, -18.0, 176.0, 5.0)) // well west of the group
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
|
||||
package com.rtbishop.look4sat.feature.map
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* Regression test for dialog header spacing parity. The worked-grid (VUCC) dialog header
|
||||
* carries a trailing Match button whose 48dp minimum touch target inflates the header row,
|
||||
* pushing its title down to 6dp from the dialog edge. Region dialogs (DXCC/WAPC/WAJA/WAS/
|
||||
* WAZ) without the button used to hug the top edge at 4dp, reading as cramped. The region
|
||||
* header now pins the same 48dp row height, so BOTH titles must land at the identical
|
||||
* offset below their header's top edge. Measured via real Compose layout (not guessed),
|
||||
* see look4sat-customization/references/robolectric-compose-ui-testing-wsl-2026-09.md.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34])
|
||||
class DialogHeaderSpacingTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun gridAndRegionHeadersPlaceTitlesAtTheSameOffset() {
|
||||
composeRule.setContent {
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Fixed anchors make the expected offsets unambiguous:
|
||||
// each title must sit exactly TITLE_OFFSET below its header.
|
||||
Box(modifier = Modifier.offset(y = GRID_Y)) {
|
||||
GridHeader()
|
||||
}
|
||||
Box(modifier = Modifier.offset(y = REGION_Y)) {
|
||||
RegionHeader()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithTag(GRID_TITLE_TAG)
|
||||
.assertTopPositionInRootIsEqualTo(GRID_Y + TITLE_OFFSET)
|
||||
composeRule.onNodeWithTag(REGION_TITLE_TAG)
|
||||
.assertTopPositionInRootIsEqualTo(REGION_Y + TITLE_OFFSET)
|
||||
}
|
||||
|
||||
/** Grid-dialog header replica: title + counts column plus the Match button. */
|
||||
@Composable
|
||||
private fun GridHeader() {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "OL62",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.testTag(GRID_TITLE_TAG)
|
||||
)
|
||||
Text(text = "3 callsigns · 5 QSOs", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
TextButton(onClick = {}) {
|
||||
Text(text = "Match", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
/** Region-dialog header replica: identical, without the trailing button. */
|
||||
@Composable
|
||||
private fun RegionHeader() {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp)
|
||||
.defaultMinSize(minHeight = 48.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "CQ ZONE 24",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.testTag(REGION_TITLE_TAG)
|
||||
)
|
||||
Text(text = "3 callsigns · 5 QSOs", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val GRID_TITLE_TAG = "gridDialogTitle"
|
||||
const val REGION_TITLE_TAG = "regionDialogTitle"
|
||||
val GRID_Y = 100.dp
|
||||
val REGION_Y = 300.dp
|
||||
/** Title offset from the header's top edge — must be identical for both dialogs. */
|
||||
val TITLE_OFFSET = 6.dp
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
/*
|
||||
* 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.feature.map
|
||||
|
||||
import java.io.File
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Guards `assets/awards/dxcc.json`, the DXCC boundary asset behind the grid-mode
|
||||
* award overlay (`AwardBoundaryOverlay` matches worked codes against
|
||||
* `region.code`). The 2026-10 rebuild split Russia into European (54) /
|
||||
* Asiatic (15), extracted sub-entities (Alaska, Hawaii, East Malaysia,
|
||||
* French Guiana, Corsica, Crete, Sardinia, Kaliningrad, Franz Josef Land)
|
||||
* and partitioned the UK into England/Scotland/Wales/Northern Ireland —
|
||||
* and re-keyed Germany (81 -> 230) / Palestine (196 -> 510) to current ADIF
|
||||
* codes — these tests pin the invariants that regeneration must keep.
|
||||
*/
|
||||
class DxccAssetTest {
|
||||
|
||||
private val assetRelPath = "src/main/assets/awards/dxcc.json"
|
||||
|
||||
private data class CityCheck(
|
||||
val name: String, val lon: Double, val lat: Double,
|
||||
val inside: Int, val outside: Int
|
||||
)
|
||||
|
||||
private fun loadRegions(): List<JSONObject> {
|
||||
// Unit-test working dir varies (module dir vs repo root) — walk upwards.
|
||||
val file = generateSequence(File("").absoluteFile) { it.parentFile }
|
||||
.mapNotNull { base ->
|
||||
listOf(File(base, assetRelPath), File(base, "feature/map/$assetRelPath"))
|
||||
.firstOrNull { it.isFile }
|
||||
}
|
||||
.firstOrNull()
|
||||
?: error("dxcc.json not found starting from ${File("").absolutePath}")
|
||||
val root = JSONObject(file.readText())
|
||||
val arr = root.getJSONArray("regions")
|
||||
return (0 until arr.length()).map { arr.getJSONObject(it) }
|
||||
}
|
||||
|
||||
private fun List<JSONObject>.byCode(code: Int): JSONObject =
|
||||
first { it.getInt("code") == code }
|
||||
|
||||
/** Even-odd ray casting across all rings of a region (rings are disjoint parts). */
|
||||
private fun regionContains(region: JSONObject, lon: Double, lat: Double): Boolean =
|
||||
ringsContain(region.getJSONArray("rings"), lon, lat)
|
||||
|
||||
private fun ringsContain(rings: JSONArray, lon: Double, lat: Double): Boolean {
|
||||
var inside = false
|
||||
for (r in 0 until rings.length()) {
|
||||
val ring = rings.getJSONArray(r)
|
||||
val n = ring.length()
|
||||
for (j in 0 until n) {
|
||||
val a = ring.getJSONArray(j)
|
||||
val b = ring.getJSONArray((j + 1) % n)
|
||||
val y1 = a.getDouble(1)
|
||||
val y2 = b.getDouble(1)
|
||||
if ((y1 > lat) != (y2 > lat)) {
|
||||
val x = a.getDouble(0) + (lat - y1) / (y2 - y1) * (b.getDouble(0) - a.getDouble(0))
|
||||
if (x > lon) inside = !inside
|
||||
}
|
||||
}
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `required ADIF entity codes are present exactly once`() {
|
||||
val regions = loadRegions()
|
||||
val codes = regions.map { it.getInt("code") }
|
||||
assertEquals("duplicate codes", codes.size, codes.toSet().size)
|
||||
val required = listOf(
|
||||
15, 54, // Asiatic / European Russia
|
||||
6, 110, 291, // Alaska / Hawaii / rest of USA
|
||||
46, 299, // East / West Malaysia
|
||||
63, 214, 227, // French Guiana / Corsica / rest of France
|
||||
40, 236, // Crete / rest of Greece
|
||||
225, 248, // Sardinia / rest of Italy
|
||||
126, 61, // Kaliningrad / Franz Josef Land
|
||||
223, 279, 294, 265, // England / Scotland / Wales / N. Ireland
|
||||
230, 510 // Germany / Palestine (current ADIF codes)
|
||||
)
|
||||
required.forEach { code -> assertTrue("missing code $code", code in codes) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `island and microstate batch is present`() {
|
||||
// 2026-10 additions (Natural Earth 1:10m admin-0 map units): common
|
||||
// island / microstate DXCC entities that 1:110m never carried, so a
|
||||
// worked entity like Singapore (381) could not light up at all.
|
||||
val regions = loadRegions()
|
||||
val codes = regions.map { it.getInt("code") }
|
||||
val batch = listOf(
|
||||
381, 304, // Singapore / Bahrain
|
||||
203, 260, 278, 251, 295, 233, // European microstates + Gibraltar
|
||||
257, 21, 29, 149, 256, 222, 114, 122, 106, // Malta / Balearic / Canary / Azores / Madeira / Faroe / Man / Jersey / Guernsey
|
||||
62, 64, 65, 285, 69, 89, 91, 517, 79, 84, // Caribbean
|
||||
94, 95, 97, 77, 98, 249, 96,
|
||||
103, 166, 9, 190, 160, 175, 191, 234 // Pacific
|
||||
)
|
||||
batch.forEach { code -> assertTrue("missing code $code", code in codes) }
|
||||
assertEquals("Singapore", regions.byCode(381).getString("name"))
|
||||
assertEquals("Canary Is.", regions.byCode(29).getString("name"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `B-plan island batch is present`() {
|
||||
// 2026-10 B plan: every remaining DXCC entity that has a REAL Natural
|
||||
// Earth 1:10m polygon (own feature or a ring window split out of a
|
||||
// parent: Rodrigues/Agalega of Mauritius, Ducie of Pitcairn, South
|
||||
// Georgia/Sandwich, the St. Helena family). Atoll/reef entities
|
||||
// without land polygons stay missing by design.
|
||||
val regions = loadRegions()
|
||||
val codes = regions.map { it.getInt("code") }
|
||||
val batch = listOf(
|
||||
4, 5, 12, 20, 22, 24, 35, 36, 38, 105, 111, 118, 123, 157, 159,
|
||||
165, 168, 169, 172, 173, 174, 182, 188, 189, 197, 205, 207,
|
||||
213, 219, 235, 240, 247, 250, 259, 270, 274, 277, 282, 297,
|
||||
298, 379, 409, 411, 453, 506, 513, 516, 518, 522
|
||||
)
|
||||
batch.forEach { code -> assertTrue("missing code $code", code in codes) }
|
||||
assertEquals("Wake I.", regions.byCode(297).getString("name"))
|
||||
assertEquals("Rodrigues I.", regions.byCode(207).getString("name"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no region uses an ADIF-deleted entity code`() {
|
||||
// ADIF 3.1.7 deleted DXCC codes. A polygon keyed to a deleted code
|
||||
// never matches LoTW exports (they carry the active code, e.g.
|
||||
// Germany 81 -> 230, Palestine 196 -> 510), so the award never fills.
|
||||
val deleted = setOf(
|
||||
2, 8, 19, 23, 25, 26, 28, 30, 39, 42, 44, 55, 57, 58, 59, 67, 68,
|
||||
81, 85, 93, 101, 102, 113, 115, 119, 127, 128, 134, 139, 151, 154, 155,
|
||||
164, 178, 183, 184, 186, 193, 194, 196, 198, 200, 208, 210, 218, 220,
|
||||
226, 228, 229, 231, 243, 244, 255, 258, 261, 264, 267, 268, 271, 307,
|
||||
488, 493
|
||||
)
|
||||
loadRegions().forEach { region ->
|
||||
val code = region.getInt("code")
|
||||
assertTrue("${region.getString("name")} uses deleted code $code", code !in deleted)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `entities carry their ADIF names`() {
|
||||
val regions = loadRegions()
|
||||
assertEquals("England", regions.byCode(223).getString("name"))
|
||||
assertEquals("European Russia", regions.byCode(54).getString("name"))
|
||||
assertEquals("Asiatic Russia", regions.byCode(15).getString("name"))
|
||||
assertEquals("Scotland", regions.byCode(279).getString("name"))
|
||||
assertEquals("Alaska", regions.byCode(6).getString("name"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every region has drawable rings within coordinate bounds`() {
|
||||
loadRegions().forEach { region ->
|
||||
val name = region.getString("name")
|
||||
val rings = region.getJSONArray("rings")
|
||||
assertTrue("$name: no rings", rings.length() >= 1)
|
||||
for (r in 0 until rings.length()) {
|
||||
val ring = rings.getJSONArray(r)
|
||||
assertTrue("$name: ring $r has ${ring.length()} pts", ring.length() >= 3)
|
||||
for (p in 0 until ring.length()) {
|
||||
val pt = ring.getJSONArray(p)
|
||||
val lon = pt.getDouble(0)
|
||||
val lat = pt.getDouble(1)
|
||||
assertTrue("$name: lon out of range $lon", lon in -180.0..180.0)
|
||||
assertTrue("$name: lat out of range $lat", lat in -90.0..90.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cities fall on the correct side of the rebuilt boundaries`() {
|
||||
val regions = loadRegions()
|
||||
val checks = listOf(
|
||||
// European/Asiatic Russia split along the Urals (OSM ridge + Ural river)
|
||||
CityCheck("Moscow", 37.61, 55.75, inside = 54, outside = 15),
|
||||
CityCheck("Perm", 56.24, 58.01, inside = 54, outside = 15),
|
||||
CityCheck("Yekaterinburg", 60.61, 56.84, inside = 15, outside = 54),
|
||||
CityCheck("Novosibirsk", 82.93, 55.03, inside = 15, outside = 54),
|
||||
CityCheck("Kaliningrad", 20.51, 54.71, inside = 126, outside = 54),
|
||||
// UK four-entity partition
|
||||
CityCheck("London", -0.13, 51.51, inside = 223, outside = 279),
|
||||
CityCheck("Edinburgh", -3.19, 55.95, inside = 279, outside = 223),
|
||||
CityCheck("Cardiff", -3.18, 51.48, inside = 294, outside = 223),
|
||||
CityCheck("Belfast", -5.93, 54.60, inside = 265, outside = 223),
|
||||
// Extracted sub-entities
|
||||
CityCheck("Anchorage", -149.90, 61.22, inside = 6, outside = 291),
|
||||
CityCheck("Central Oahu", -157.90, 21.45, inside = 110, outside = 291),
|
||||
CityCheck("Los Angeles", -118.24, 34.05, inside = 291, outside = 6),
|
||||
CityCheck("Kuching", 110.34, 1.55, inside = 46, outside = 299),
|
||||
CityCheck("Kuala Lumpur", 101.69, 3.14, inside = 299, outside = 46),
|
||||
CityCheck("Cayenne", -52.32, 4.94, inside = 63, outside = 227),
|
||||
CityCheck("Ajaccio", 8.74, 41.93, inside = 214, outside = 227),
|
||||
CityCheck("Heraklion", 25.14, 35.34, inside = 40, outside = 236),
|
||||
CityCheck("Cagliari", 9.12, 39.22, inside = 225, outside = 248)
|
||||
)
|
||||
for (c in checks) {
|
||||
assertTrue("${c.name} should be in ${c.inside}",
|
||||
regionContains(regions.byCode(c.inside), c.lon, c.lat))
|
||||
assertTrue("${c.name} should NOT be in ${c.outside}",
|
||||
!regionContains(regions.byCode(c.outside), c.lon, c.lat))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `merged map units are framed by their DXCC parent`() {
|
||||
// Natural Earth draws Somaliland and N. Cyprus as separate map units.
|
||||
// Both belong to their DXCC entity (6O Somalia / 5B Cyprus) and are
|
||||
// merged into the parent rings — without the merge the northern
|
||||
// territories had no boundary at all (nothing drawn, never greened).
|
||||
val regions = loadRegions()
|
||||
val checks = listOf(
|
||||
Triple("Hargeisa", 44.06 to 9.56, 232),
|
||||
Triple("Berbera", 45.02 to 10.44, 232),
|
||||
Triple("Garowe", 48.48 to 8.40, 232),
|
||||
Triple("Kyrenia", 33.32 to 35.34, 215),
|
||||
Triple("Nicosia", 33.38 to 35.19, 215)
|
||||
)
|
||||
for ((city, pos, code) in checks) {
|
||||
assertTrue("$city should be inside $code",
|
||||
regionContains(regions.byCode(code), pos.first, pos.second))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Norway keeps only its mainland ring`() {
|
||||
// Norway's Natural Earth geometry carries coarse Svalbard rings (7-17 pts).
|
||||
// Svalbard (259) is a separate DXCC entity with its own detailed rings, so
|
||||
// the coarse copies were deleted — they drew every island twice (a simple
|
||||
// and a coast-fitting outline) and greening Norway also greened JW.
|
||||
val norway = loadRegions().byCode(266)
|
||||
val rings = norway.getJSONArray("rings")
|
||||
assertEquals("Norway ring count", 1, rings.length())
|
||||
for (r in 0 until rings.length()) {
|
||||
val ring = rings.getJSONArray(r)
|
||||
for (p in 0 until ring.length()) {
|
||||
val lat = ring.getJSONArray(p).getDouble(1)
|
||||
assertTrue("Norway vertex at $lat — Svalbard ring leftover", lat <= 73.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `worked fill knockouts cover the vetted parent child pairs`() {
|
||||
// fill_rings = parent territory minus separate child DXCC entities, so a
|
||||
// worked parent (e.g. South Africa 462) never greens a child entity
|
||||
// (Lesotho 432) that has its own code. Pairs were vetted geometrically
|
||||
// (child area >=50% inside the parent); this test pins them so future
|
||||
// asset edits re-trigger the review.
|
||||
val expected = mapOf(
|
||||
462 to setOf(432), // South Africa - Lesotho
|
||||
318 to setOf(321), // China - Hong Kong
|
||||
299 to setOf(381), // Malaysia - Singapore
|
||||
227 to setOf(203, 260), // France - Andorra, Monaco
|
||||
248 to setOf(278, 295), // Italy - San Marino, Vatican
|
||||
206 to setOf(251), // Austria - Liechtenstein
|
||||
287 to setOf(251), // Switzerland - Liechtenstein
|
||||
281 to setOf(203, 233), // Spain - Andorra, Gibraltar
|
||||
70 to setOf(105) // Cuba - Guantanamo Bay
|
||||
)
|
||||
val regions = loadRegions()
|
||||
val withFill = regions.filter { it.has("fill_rings") }.map { it.getInt("code") }.toSet()
|
||||
assertEquals("regions carrying fill_rings", expected.keys, withFill)
|
||||
for ((parentCode, childCodes) in expected) {
|
||||
val parent = regions.byCode(parentCode)
|
||||
val fillRings = parent.getJSONArray("fill_rings")
|
||||
assertTrue("${parent.getString("name")}: empty fill_rings", fillRings.length() >= 1)
|
||||
// The parent's own anchor must survive the knockout (no over-cutting).
|
||||
val pLon = parent.getDouble("label_lon")
|
||||
val pLat = parent.getDouble("label_lat")
|
||||
assertTrue("${parent.getString("name")}: anchor lost in fill",
|
||||
ringsContain(fillRings, pLon, pLat))
|
||||
for (childCode in childCodes) {
|
||||
val child = regions.byCode(childCode)
|
||||
val childRings = child.getJSONArray("rings")
|
||||
// Probes: the label anchor plus each ring's centroid. Some anchors
|
||||
// drift outside their own ring (Guantanamo Bay), so a probe only
|
||||
// counts when it really sits inside the child's own geometry.
|
||||
val probes = mutableListOf(
|
||||
child.getDouble("label_lon") to child.getDouble("label_lat")
|
||||
)
|
||||
for (r in 0 until childRings.length()) {
|
||||
val ring = childRings.getJSONArray(r)
|
||||
var sx = 0.0; var sy = 0.0
|
||||
for (p in 0 until ring.length()) {
|
||||
sx += ring.getJSONArray(p).getDouble(0)
|
||||
sy += ring.getJSONArray(p).getDouble(1)
|
||||
}
|
||||
probes.add(sx / ring.length() to sy / ring.length())
|
||||
}
|
||||
for ((cLon, cLat) in probes) {
|
||||
if (regionContains(child, cLon, cLat) && regionContains(parent, cLon, cLat)) {
|
||||
assertFalse("${parent.getString("name")} fill still covers ${child.getString("name")}",
|
||||
ringsContain(fillRings, cLon, cLat))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,8 @@ import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
@@ -106,7 +107,9 @@ class FakeSettingsRepo(
|
||||
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 fun updateWavelogUploadSettings(settings: WavelogUploadSettings) = Unit
|
||||
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = TODO()
|
||||
@@ -134,7 +137,7 @@ class FakeSettingsRepo(
|
||||
override fun setSatelliteMode(catnum: Int, mode: String) = TODO()
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
override fun setAmSatCallsign(callsign: String) = TODO()
|
||||
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
|
||||
|
||||
override fun getWorkedGrids(): Set<String> = TODO()
|
||||
override fun setWorkedGrids(grids: Set<String>) = TODO()
|
||||
override fun getWorkedGridQsos(): Map<String, List<GridQso>> = TODO()
|
||||
@@ -148,4 +151,12 @@ class FakeSettingsRepo(
|
||||
override fun setLastLotwSyncDate(date: String) = Unit
|
||||
override fun getLastLotwSyncCallsign(): String = ""
|
||||
override fun setLastLotwSyncCallsign(callsign: String) = Unit
|
||||
override fun getWavelogStations(): List<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
|
||||
}
|
||||
@@ -68,6 +68,7 @@ import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
|
||||
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
|
||||
import com.rtbishop.look4sat.core.presentation.gridsLabel
|
||||
import com.rtbishop.look4sat.core.presentation.LoTWPositionWarningDialog
|
||||
import com.rtbishop.look4sat.core.presentation.WavelogUploadPreviewDialog
|
||||
|
||||
@Composable
|
||||
fun LogPage(
|
||||
@@ -212,7 +213,7 @@ fun LogPage(
|
||||
onClick = logViewModel::prepareUpload,
|
||||
enabled = !logUiState.busy,
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text("上传 LoTW", fontSize = 13.sp) }
|
||||
) { Text(if (logUiState.wavelogMode) "上传 Wavelog" else "上传 LoTW", fontSize = 13.sp) }
|
||||
}
|
||||
if (logUiState.busy) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -266,12 +267,21 @@ fun LogPage(
|
||||
}
|
||||
}
|
||||
|
||||
logUiState.wavelogPreview?.let { preview ->
|
||||
WavelogUploadPreviewDialog(
|
||||
preview = preview,
|
||||
busy = logUiState.busy,
|
||||
onConfirm = logViewModel::confirmWavelogUpload,
|
||||
onDismiss = logViewModel::dismissWavelogPreview
|
||||
)
|
||||
}
|
||||
|
||||
if (logUiState.message.isNotBlank()) {
|
||||
AlertDialog(
|
||||
onDismissRequest = logViewModel::clearMessage,
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle("LoTW Upload") },
|
||||
title = { SheetDialogTitle(if (logUiState.wavelogMode) "Wavelog Upload" else "LoTW Upload") },
|
||||
text = { Text(logUiState.message) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = logViewModel::clearMessage) { Text("OK") }
|
||||
|
||||
@@ -21,13 +21,21 @@ import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
|
||||
import com.rtbishop.look4sat.core.domain.logbook.officialSatelliteName
|
||||
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
|
||||
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogConfirmedSegment
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogIdleSegment
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogTransportFailureSegment
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogUploadCandidates
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
|
||||
import com.rtbishop.look4sat.core.domain.repository.positionWarning
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -78,6 +86,10 @@ data class LogUiState(
|
||||
val message: String = "",
|
||||
val postText: String? = null,
|
||||
val busy: Boolean = false,
|
||||
/** True when uploads route through Wavelog (Wavelog configured — LoTW paused). */
|
||||
val wavelogMode: Boolean = false,
|
||||
/** Wavelog mode: the prepared batch awaiting the operator's confirmation. */
|
||||
val wavelogPreview: WavelogUploadPreview? = null,
|
||||
/** Out-of-window contact waiting for the operator's confirmation (null when closed). */
|
||||
val outOfWindowLog: OutOfWindowLog? = null
|
||||
)
|
||||
@@ -85,6 +97,7 @@ data class LogUiState(
|
||||
class LogViewModel(
|
||||
private val qsoRepository: IQsoRepository,
|
||||
private val lotwUploadRepository: ILoTWUploadRepository,
|
||||
private val wavelogUploadRepository: IWavelogUploadRepository,
|
||||
private val settingsRepo: ISettingsRepo
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -112,6 +125,13 @@ class LogViewModel(
|
||||
)
|
||||
}
|
||||
}
|
||||
// Upload routing follows the Wavelog configuration: once it is ready the log
|
||||
// page uploads through Wavelog and skips LoTW entirely (the one-of-two rule).
|
||||
viewModelScope.launch {
|
||||
settingsRepo.wavelogUploadSettings.collect { settings ->
|
||||
_uiState.update { it.copy(wavelogMode = settings.isReady) }
|
||||
}
|
||||
}
|
||||
// The roaming hint tracks the freshest position data: refresh on init and every fix.
|
||||
viewModelScope.launch {
|
||||
settingsRepo.stationPosition.collect { refreshPositionHintNow() }
|
||||
@@ -256,6 +276,12 @@ class LogViewModel(
|
||||
_uiState.update { it.copy(busy = true, message = "") }
|
||||
try {
|
||||
val all = qsoRepository.records.first()
|
||||
val wavelogSettings = settingsRepo.wavelogUploadSettings.value
|
||||
if (wavelogSettings.isReady) {
|
||||
// Wavelog mode: the upload goes to Wavelog only (LoTW stays out).
|
||||
prepareWavelogUpload(all, wavelogSettings)
|
||||
return@launch
|
||||
}
|
||||
// Only local (non-confirmed) records are candidates for upload;
|
||||
// LoTW-imported confirmations are the feedback side.
|
||||
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == QsoStatus.COMPLETE }
|
||||
@@ -291,6 +317,24 @@ class LogViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Wavelog-mode branch of [prepareUpload]: prepare the batch and ask for confirmation. */
|
||||
private suspend fun prepareWavelogUpload(all: List<QsoRecord>, settings: WavelogUploadSettings) {
|
||||
val preview = runCatching {
|
||||
wavelogUploadRepository.prepare(wavelogUploadCandidates(all), settings)
|
||||
}.getOrNull()
|
||||
if (preview == null) {
|
||||
_uiState.update { it.copy(busy = false, message = "Wavelog upload failed to prepare — check the configuration") }
|
||||
return
|
||||
}
|
||||
if (preview.count == 0) {
|
||||
_uiState.update {
|
||||
it.copy(busy = false, message = wavelogIdleSegment(preview) ?: "No pending QSOs to upload to Wavelog")
|
||||
}
|
||||
return
|
||||
}
|
||||
_uiState.update { it.copy(busy = false, wavelogPreview = preview) }
|
||||
}
|
||||
|
||||
fun confirmUpload() {
|
||||
val preview = _uiState.value.preview ?: return
|
||||
viewModelScope.launch {
|
||||
@@ -312,8 +356,38 @@ class LogViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Confirms the Wavelog-mode batch prepared by [prepareUpload]. */
|
||||
fun confirmWavelogUpload() {
|
||||
val preview = _uiState.value.wavelogPreview ?: return
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(busy = true) }
|
||||
val msg = uploadWavelogNow(preview, settingsRepo.wavelogUploadSettings.value)
|
||||
_uiState.update { it.copy(busy = false, wavelogPreview = null, message = msg) }
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissPreview() = _uiState.update { it.copy(preview = null) }
|
||||
|
||||
fun dismissWavelogPreview() = _uiState.update { it.copy(wavelogPreview = null) }
|
||||
|
||||
/**
|
||||
* Uploads one prepared Wavelog batch and marks what the server accepted (stamped
|
||||
* with the station profile the batch went out through). Returns the operator-facing
|
||||
* message for this upload.
|
||||
*/
|
||||
private suspend fun uploadWavelogNow(
|
||||
preview: WavelogUploadPreview,
|
||||
settings: WavelogUploadSettings
|
||||
): String = try {
|
||||
val outcome = wavelogUploadRepository.upload(preview, settings)
|
||||
if (outcome is WavelogUploadOutcome.Imported && outcome.markIds.isNotEmpty()) {
|
||||
qsoRepository.markWavelogUploaded(outcome.markIds, settings.stationId)
|
||||
}
|
||||
wavelogConfirmedSegment(outcome, preview)
|
||||
} catch (_: Exception) {
|
||||
wavelogTransportFailureSegment()
|
||||
}
|
||||
|
||||
/** Operator chose "ignore" on the position check: keep the prepared preview. */
|
||||
fun ignorePositionWarning() = _uiState.update { it.copy(uploadPositionWarning = null) }
|
||||
|
||||
@@ -347,7 +421,10 @@ class LogViewModel(
|
||||
companion object {
|
||||
fun factory(container: IMainContainer) = viewModelFactory {
|
||||
initializer {
|
||||
LogViewModel(container.qsoRepository, container.lotwUploadRepository, container.settingsRepo)
|
||||
LogViewModel(
|
||||
container.qsoRepository, container.lotwUploadRepository,
|
||||
container.wavelogUploadRepository, container.settingsRepo
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,5 +132,12 @@ sealed interface RadarAction {
|
||||
|
||||
// Calculator actions
|
||||
data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction
|
||||
|
||||
/**
|
||||
* The transponder currently driving the calculator (null when the satellite has
|
||||
* no linear transponder). The per-satellite offset follows it instead of the
|
||||
* transceiver selection, so it loads and saves even when nothing is selected.
|
||||
*/
|
||||
data class CalculatorTransponderChanged(val catnum: Int?) : RadarAction
|
||||
data class UpdateCalculatorFrequency(val txHz: Long?, val rxHz: Long?) : RadarAction
|
||||
}
|
||||
@@ -69,6 +69,7 @@ class RadarViewModel(
|
||||
private val stationPos = settingsRepo.stationPosition.value
|
||||
private val magDeclination = sensorsRepo.getMagDeclination(stationPos)
|
||||
private var transponders: List<SatRadio> = emptyList()
|
||||
private var calculatorCatnum: Int? = null
|
||||
private var sstvDecoder: SstvDecoder? = null
|
||||
private var sstvRecordingJob: Job? = null
|
||||
private var cwDecoder: CwDecoder? = null
|
||||
@@ -158,12 +159,8 @@ class RadarViewModel(
|
||||
transponders = allRadios.filter { it.downlinkLow != null }
|
||||
if (allRadios.isNotEmpty()) {
|
||||
val firstUuid = allRadios.first().uuid
|
||||
val offsetKHz = allRadios.first().catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
transceivers = it.transceivers.copy(selectedUuid = firstUuid),
|
||||
calculatorOffsetKHz = offsetKHz
|
||||
)
|
||||
it.copy(transceivers = it.transceivers.copy(selectedUuid = firstUuid))
|
||||
}
|
||||
transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) }
|
||||
}
|
||||
@@ -241,15 +238,8 @@ class RadarViewModel(
|
||||
// Compute toggle state before the update so we don't read post-update value
|
||||
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
|
||||
val newUuid = if (isTogglingOff) null else action.uuid
|
||||
val offsetKHz = if (!isTogglingOff) {
|
||||
transponders.find { it.uuid == action.uuid }
|
||||
?.catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
|
||||
} else ""
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
transceivers = it.transceivers.copy(selectedUuid = newUuid),
|
||||
calculatorOffsetKHz = offsetKHz
|
||||
)
|
||||
it.copy(transceivers = it.transceivers.copy(selectedUuid = newUuid))
|
||||
}
|
||||
// Only update the tracking service when selecting a different transponder to
|
||||
// avoid resetting a user-adjusted TX base on re-expand
|
||||
@@ -319,7 +309,9 @@ class RadarViewModel(
|
||||
_uiState.update { it.copy(cw = it.cw.copy(isExpanded = action.expanded)) }
|
||||
}
|
||||
is RadarAction.ChangeCalculatorOffset -> {
|
||||
val catnum = _uiState.value.transceivers.selectedUuid?.let { uuid ->
|
||||
// Keyed by the transponder driving the calculator, so the offset saves
|
||||
// even when no transceiver is selected (or selection was toggled off)
|
||||
val catnum = calculatorCatnum ?: _uiState.value.transceivers.selectedUuid?.let { uuid ->
|
||||
transponders.find { it.uuid == uuid }?.catnum
|
||||
}
|
||||
if (catnum != null) {
|
||||
@@ -327,6 +319,11 @@ class RadarViewModel(
|
||||
}
|
||||
_uiState.update { it.copy(calculatorOffsetKHz = action.offsetKHz) }
|
||||
}
|
||||
is RadarAction.CalculatorTransponderChanged -> {
|
||||
calculatorCatnum = action.catnum
|
||||
val offsetKHz = action.catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
|
||||
_uiState.update { it.copy(calculatorOffsetKHz = offsetKHz) }
|
||||
}
|
||||
is RadarAction.UpdateCalculatorFrequency -> {
|
||||
_uiState.update {
|
||||
if (it.calculatorTxHz == action.txHz && it.calculatorRxHz == action.rxHz) it
|
||||
|
||||
@@ -176,6 +176,13 @@ fun CalculatorPage(
|
||||
?: calculatorTransceivers.firstOrNull()
|
||||
}
|
||||
|
||||
// Keep the per-satellite offset in sync with the transponder driving the
|
||||
// calculator: load the saved value on open/satellite change, and remember
|
||||
// its catnum so editing saves even when no transceiver is selected
|
||||
LaunchedEffect(selectedTransceiver?.uuid) {
|
||||
onAction(RadarAction.CalculatorTransponderChanged(selectedTransceiver?.catnum))
|
||||
}
|
||||
|
||||
if (selectedTransceiver == null) {
|
||||
EmptyTransceiversContent(modifier)
|
||||
return
|
||||
|
||||
+59
-15
@@ -44,6 +44,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
@@ -58,15 +59,21 @@ import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun LoTWUploadCard(
|
||||
hasCertificate: Boolean,
|
||||
stationGrid: String,
|
||||
enabled: Boolean = true,
|
||||
showUploadConfigDialog: () -> Unit
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth().clickable { showUploadConfigDialog() }) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth().clickable(enabled = enabled) { showUploadConfigDialog() }) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.alpha(if (enabled) 1f else 0.5f)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_lotw_upload_title),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
@@ -81,6 +88,15 @@ fun LoTWUploadCard(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2
|
||||
)
|
||||
if (!enabled) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_lotw_disabled_wavelog),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,6 +112,7 @@ fun LoTWUploadConfigDialog(
|
||||
initialGrid: String = "",
|
||||
onDismiss: () -> Unit,
|
||||
onImport: (ByteArray, CharArray) -> Unit,
|
||||
onPreview: (ByteArray, CharArray) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onSaveStation: (LoTWStation) -> Unit
|
||||
) {
|
||||
@@ -140,6 +157,19 @@ fun LoTWUploadConfigDialog(
|
||||
}
|
||||
}.getOrNull() ?: uri.lastPathSegment.orEmpty()
|
||||
|
||||
// Surface the certificate's region field before the import is confirmed: re-read
|
||||
// the picked .p12 whenever file or password changes (debounced) and hand it to a
|
||||
// silent preview — a wrong password simply keeps the pending hint, a successful
|
||||
// parse fills the Province/State dropdown in the station form below.
|
||||
LaunchedEffect(selectedFile, password) {
|
||||
val uri = selectedFile ?: return@LaunchedEffect
|
||||
delay(400)
|
||||
val bytes = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()
|
||||
if (bytes != null && bytes.isNotEmpty()) onPreview(bytes, password.toCharArray())
|
||||
}
|
||||
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.prefs_lotw_upload_title),
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -200,6 +230,11 @@ fun LoTWUploadConfigDialog(
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_lotw_upload_password_hint),
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
CardButton(
|
||||
onClick = {
|
||||
val bytes = runCatching {
|
||||
@@ -212,7 +247,9 @@ fun LoTWUploadConfigDialog(
|
||||
}
|
||||
},
|
||||
text = stringResource(R.string.prefs_lotw_upload_import_confirm),
|
||||
isEnabled = password.isNotBlank() && !busy,
|
||||
// An empty field is a legitimate attempt: certificates exported
|
||||
// without a password import with a blank password.
|
||||
isEnabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
@@ -232,12 +269,15 @@ fun LoTWUploadConfigDialog(
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(capitalization = KeyboardCapitalization.Characters),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
// Country-specific region field (US_STATE, CN_PROVINCE, …) shown only when
|
||||
// the certificate's DXCC entity defines one; selecting it fills CQZ/ITUZ.
|
||||
// Country-specific region field (US_STATE, CN_PROVINCE, …): the entry is part
|
||||
// of the form from the first frame (pending hint while no DXCC is known), gets
|
||||
// its options as soon as a certificate is previewed/imported, and a selection
|
||||
// fills CQZ/ITUZ. Entities without a region field keep it hidden.
|
||||
// Drawn as a plain Box (not OutlinedTextField): a read-only text field's
|
||||
// internal gesture handler consumes the tap, so clickable never fires.
|
||||
// Options expand inline inside the sheet (no Popup/Dialog window stacking).
|
||||
if (regionField != null) {
|
||||
val regionPending = certificate == null && stationMeta == null
|
||||
if (regionField != null || regionPending) {
|
||||
var regionExpanded by remember { mutableStateOf(false) }
|
||||
val fieldShape = MaterialTheme.shapes.extraSmall
|
||||
Box(
|
||||
@@ -246,21 +286,25 @@ fun LoTWUploadConfigDialog(
|
||||
.clip(fieldShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline, fieldShape)
|
||||
.clickable { regionExpanded = !regionExpanded }
|
||||
.clickable(enabled = regionField != null) { regionExpanded = !regionExpanded }
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
) {
|
||||
Column {
|
||||
Text(regionField.label, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
text = regionField?.label ?: stringResource(R.string.prefs_lotw_upload_region_label),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(3.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = if (region.isBlank()) {
|
||||
stringResource(R.string.prefs_lotw_upload_region_hint)
|
||||
} else {
|
||||
"$region — $selectedRegionName"
|
||||
text = when {
|
||||
regionPending -> stringResource(R.string.prefs_lotw_upload_region_pending)
|
||||
region.isBlank() -> stringResource(R.string.prefs_lotw_upload_region_hint)
|
||||
else -> "$region — $selectedRegionName"
|
||||
},
|
||||
fontSize = 16.sp,
|
||||
color = if (region.isBlank()) {
|
||||
color = if (regionPending || region.isBlank()) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
@@ -277,9 +321,9 @@ fun LoTWUploadConfigDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = regionExpanded) {
|
||||
AnimatedVisibility(visible = regionExpanded && regionField != null) {
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 280.dp)) {
|
||||
items(regionField.options, key = { it.code }) { option ->
|
||||
items(regionField?.options.orEmpty(), key = { it.code }) { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${option.code} — ${option.name}", fontSize = 13.sp) },
|
||||
onClick = {
|
||||
|
||||
+3
@@ -64,6 +64,9 @@ fun LoTWUploadDestination(navigateUp: () -> Unit) {
|
||||
onImport = { bytes, password ->
|
||||
viewModel.onAction(SettingsAction.ImportLoTWCertificate(bytes, password))
|
||||
},
|
||||
onPreview = { bytes, password ->
|
||||
viewModel.onAction(SettingsAction.PreviewLoTWCertificate(bytes, password))
|
||||
},
|
||||
onRemove = { viewModel.onAction(SettingsAction.RemoveLoTWCertificate) },
|
||||
onSaveStation = { viewModel.onAction(SettingsAction.SaveLoTWStation(it)) }
|
||||
)
|
||||
|
||||
+66
-2
@@ -13,6 +13,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -23,6 +24,8 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -54,6 +57,7 @@ import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
|
||||
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
|
||||
import com.rtbishop.look4sat.core.presentation.gridsLabel
|
||||
import com.rtbishop.look4sat.core.presentation.LoTWPositionWarningDialog
|
||||
import com.rtbishop.look4sat.core.presentation.WavelogUploadPreviewDialog
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import com.rtbishop.look4sat.core.presentation.SwipeController
|
||||
@@ -104,7 +108,15 @@ fun LogbookDialog(
|
||||
onStartSelection: (Long) -> Unit = {},
|
||||
onToggleSelection: (Long) -> Unit = {},
|
||||
onExitSelection: () -> Unit = {},
|
||||
onResubmitSelected: () -> Unit = {}
|
||||
onResubmitSelected: () -> Unit = {},
|
||||
/** Wavelog mode: the prepared batch awaiting confirmation (null otherwise). */
|
||||
wavelogPreview: com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview? = null,
|
||||
/** True when uploads route through Wavelog — titles and previews follow the mode. */
|
||||
wavelogMode: Boolean = false,
|
||||
/** Wavelog station profiles for the 台址 selector (empty hides the selector). */
|
||||
stationOptions: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList(),
|
||||
stationFilter: String? = null,
|
||||
onStationFilterChange: (String?) -> Unit = {}
|
||||
) {
|
||||
val swipeController = rememberSwipeController()
|
||||
// Entering selection mode closes any row left swiped open — reveals are off there.
|
||||
@@ -145,6 +157,51 @@ fun LogbookDialog(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (stationOptions.isNotEmpty()) {
|
||||
// 台址 selector: filters the list to one Wavelog station profile
|
||||
// (its QSOs) — "All" keeps everything. The menu scrolls natively.
|
||||
var stationMenuOpen by remember { mutableStateOf(false) }
|
||||
val currentLabel = stationOptions.firstOrNull { it.id == stationFilter }?.let { station ->
|
||||
listOf(station.name, station.callsign).filter { it.isNotBlank() }.joinToString(" · ")
|
||||
} ?: stringResource(R.string.prefs_logbook_station_all)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.prefs_logbook_station_label), fontSize = 12.sp)
|
||||
Box {
|
||||
TextButton(onClick = { stationMenuOpen = true }) {
|
||||
Text(currentLabel, fontSize = 12.sp)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = stationMenuOpen,
|
||||
onDismissRequest = { stationMenuOpen = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.prefs_logbook_station_all)) },
|
||||
onClick = {
|
||||
onStationFilterChange(null)
|
||||
stationMenuOpen = false
|
||||
}
|
||||
)
|
||||
stationOptions.forEach { station ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
listOf(station.name, station.callsign, station.grid)
|
||||
.filter { it.isNotBlank() }.joinToString(" · ")
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
onStationFilterChange(station.id)
|
||||
stationMenuOpen = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
@@ -177,6 +234,13 @@ fun LogbookDialog(
|
||||
onFixStation = { onFixGrid(listOf(positionWarning.currentGrid)) },
|
||||
onIgnore = onIgnorePositionWarning
|
||||
)
|
||||
} else if (wavelogPreview != null) {
|
||||
WavelogUploadPreviewDialog(
|
||||
preview = wavelogPreview,
|
||||
busy = uploadBusy,
|
||||
onConfirm = onConfirmUpload,
|
||||
onDismiss = onDismissPreview
|
||||
)
|
||||
} else if (preview != null) {
|
||||
LogbookUploadPreviewDialog(
|
||||
preview = preview,
|
||||
@@ -190,7 +254,7 @@ fun LogbookDialog(
|
||||
onDismissRequest = onDismissMessage,
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle("LoTW Upload") },
|
||||
title = { SheetDialogTitle(if (wavelogMode) "Wavelog Upload" else "LoTW Upload") },
|
||||
text = { Text(uploadMessage) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismissMessage) { Text("OK") }
|
||||
|
||||
+173
-30
@@ -28,6 +28,8 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -39,6 +41,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -46,6 +49,7 @@ import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -69,6 +73,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -158,23 +163,32 @@ fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Uni
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun WavelogDialog(
|
||||
initialSettings: com.rtbishop.look4sat.core.domain.model.WavelogSettings,
|
||||
workedGridsCount: Int,
|
||||
fun WavelogUploadDialog(
|
||||
initialSettings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings,
|
||||
stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>,
|
||||
rights: String,
|
||||
probeBusy: Boolean,
|
||||
isSyncing: Boolean,
|
||||
lastSyncEpochMs: Long,
|
||||
message: String?,
|
||||
dismiss: () -> Unit,
|
||||
onSave: (com.rtbishop.look4sat.core.domain.model.WavelogSettings) -> Unit,
|
||||
onSync: (com.rtbishop.look4sat.core.domain.model.WavelogSettings) -> Unit
|
||||
onSave: (com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings) -> Unit,
|
||||
onFetchStations: (url: String, apiKey: String) -> Unit,
|
||||
onSelectStation: (com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo) -> Unit,
|
||||
onSyncIncremental: () -> Unit,
|
||||
onSyncFull: () -> Unit,
|
||||
onClear: () -> Unit
|
||||
) {
|
||||
val url = rememberSaveable { mutableStateOf(initialSettings.url) }
|
||||
val token = rememberSaveable { mutableStateOf(initialSettings.token) }
|
||||
val apiKey = rememberSaveable { mutableStateOf(initialSettings.apiKey) }
|
||||
var showClearConfirm by remember { mutableStateOf(false) }
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.prefs_wavelog_title),
|
||||
title = stringResource(R.string.prefs_wavelog_upload_title),
|
||||
onCancel = dismiss,
|
||||
onAccept = {
|
||||
onSave(com.rtbishop.look4sat.core.domain.model.WavelogSettings(url.value, token.value))
|
||||
onSave(initialSettings.copy(url = url.value, apiKey = apiKey.value))
|
||||
dismiss()
|
||||
}
|
||||
) {
|
||||
@@ -182,23 +196,120 @@ fun WavelogDialog(
|
||||
value = url.value,
|
||||
onValueChange = { url.value = it },
|
||||
label = { Text(text = stringResource(id = R.string.prefs_wavelog_url)) },
|
||||
placeholder = { Text(text = "http://192.168.1.10") },
|
||||
placeholder = { Text(text = "http://100.x.x.x/wavelog") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = token.value,
|
||||
onValueChange = { token.value = it },
|
||||
label = { Text(text = stringResource(id = R.string.prefs_wavelog_token)) },
|
||||
placeholder = { Text(text = "abcdef123456:1") },
|
||||
value = apiKey.value,
|
||||
onValueChange = { apiKey.value = it },
|
||||
label = { Text(text = stringResource(id = R.string.prefs_wavelog_upload_apikey)) },
|
||||
placeholder = { Text(text = "wl…") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_wavelog_hint, workedGridsCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
|
||||
) {
|
||||
TextButton(onClick = { onFetchStations(url.value, apiKey.value) }, enabled = !probeBusy) {
|
||||
Text(
|
||||
text = if (probeBusy) stringResource(R.string.prefs_wavelog_upload_probing)
|
||||
else stringResource(R.string.prefs_wavelog_upload_fetch)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (rights.isNotBlank()) {
|
||||
Text(
|
||||
text = when (rights) {
|
||||
"rw" -> stringResource(R.string.prefs_wavelog_upload_rights_rw)
|
||||
else -> stringResource(R.string.prefs_wavelog_upload_rights_r)
|
||||
},
|
||||
color = if (rights == "rw") MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
}
|
||||
if (stations.isEmpty() && initialSettings.stationId.isBlank()) {
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_wavelog_upload_station_none),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
} else {
|
||||
// Many station profiles must stay reachable: the list scrolls inside a
|
||||
// bounded height instead of stretching (or clipping) the dialog.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 240.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
stations.forEach { station ->
|
||||
val selected = station.id == initialSettings.stationId
|
||||
Text(
|
||||
text = (if (selected) "● " else "○ ") + listOf(station.name, station.callsign, station.grid)
|
||||
.filter { it.isNotBlank() }.joinToString(" · "),
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelectStation(station) }
|
||||
.padding(horizontal = LocalSpacing.current.large, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (initialSettings.stationId.isNotBlank()) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.prefs_wavelog_upload_configured,
|
||||
listOf(initialSettings.stationName, initialSettings.stationCallsign, initialSettings.stationGrid)
|
||||
.filter { it.isNotBlank() }.joinToString(" · ")
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
}
|
||||
if (lastSyncEpochMs != 0L) {
|
||||
val timePattern = stringResource(R.string.prefs_updated_time)
|
||||
val lastSyncText = remember(lastSyncEpochMs) {
|
||||
java.text.SimpleDateFormat(timePattern, java.util.Locale.getDefault())
|
||||
.format(java.util.Date(lastSyncEpochMs))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_updated_title, lastSyncText),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
}
|
||||
// Sync pulls the server's QSOs (and the grid data the map highlights) — the
|
||||
// incremental button resumes from the stored cursor, the full one re-pulls.
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
|
||||
) {
|
||||
CardButton(
|
||||
onClick = onSyncFull,
|
||||
text = stringResource(R.string.prefs_wavelog_sync_full),
|
||||
isEnabled = !isSyncing && !probeBusy
|
||||
)
|
||||
CardButton(
|
||||
onClick = onSyncIncremental,
|
||||
text = stringResource(R.string.prefs_wavelog_sync_incremental),
|
||||
isEnabled = !isSyncing && !probeBusy
|
||||
)
|
||||
}
|
||||
if (isSyncing) {
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_wavelog_syncing),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
}
|
||||
if (message != null) {
|
||||
Text(
|
||||
text = message,
|
||||
@@ -207,22 +318,54 @@ fun WavelogDialog(
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
}
|
||||
// Sync uses the values typed in the fields directly — saving and syncing
|
||||
// happen in one step, no need to close and reopen the dialog.
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_wavelog_upload_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
TextButton(
|
||||
onClick = { showClearConfirm = true },
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { onSync(com.rtbishop.look4sat.core.domain.model.WavelogSettings(url.value, token.value)) },
|
||||
enabled = !isSyncing
|
||||
) {
|
||||
Text(text = if (isSyncing) stringResource(R.string.prefs_wavelog_syncing)
|
||||
else stringResource(R.string.prefs_wavelog_sync))
|
||||
}
|
||||
Text(text = stringResource(R.string.prefs_wavelog_clear), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(0.dp))
|
||||
}
|
||||
if (showClearConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showClearConfirm = false },
|
||||
title = { Text(stringResource(R.string.prefs_wavelog_clear_title)) },
|
||||
text = { Text(stringResource(R.string.prefs_wavelog_clear_message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showClearConfirm = false
|
||||
onClear()
|
||||
}) { Text(stringResource(R.string.btn_accept)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showClearConfirm = false }) { Text(stringResource(R.string.btn_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** LoTW→Wavelog hand-over guard: shown when completing the Wavelog config would switch the mode. */
|
||||
@Composable
|
||||
fun WavelogSwitchWarningDialog(
|
||||
onConfirm: () -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text(stringResource(R.string.prefs_wavelog_switch_title)) },
|
||||
text = { Text(stringResource(R.string.prefs_wavelog_switch_message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) { Text(stringResource(R.string.prefs_wavelog_switch_continue)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onCancel) { Text(stringResource(R.string.btn_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+123
-6
@@ -54,6 +54,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -69,11 +70,12 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogSyncMode
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
@@ -286,8 +288,9 @@ private fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
if (dialogs.logbook) {
|
||||
val stationFilter = uiState.logbookStationFilter
|
||||
LogbookDialog(
|
||||
records = uiState.logbookRecords,
|
||||
records = uiState.logbookRecords.filter { stationFilter == null || it.wavelogStation == stationFilter },
|
||||
satelliteCandidates = uiState.satelliteCatalog,
|
||||
uploadBusy = uiState.logbookUploadBusy,
|
||||
uploadMessage = uiState.logbookUploadMessage,
|
||||
@@ -318,7 +321,12 @@ private fun SettingsScreen(
|
||||
onStartSelection = { onAction(SettingsAction.StartLogbookSelection(it)) },
|
||||
onToggleSelection = { onAction(SettingsAction.ToggleLogbookSelection(it)) },
|
||||
onExitSelection = { onAction(SettingsAction.ExitLogbookSelection) },
|
||||
onResubmitSelected = { onAction(SettingsAction.ResubmitSelectedLogbook) }
|
||||
onResubmitSelected = { onAction(SettingsAction.ResubmitSelectedLogbook) },
|
||||
wavelogPreview = uiState.logbookWavelogPreview,
|
||||
wavelogMode = uiState.wavelogUploadSettings.isReady,
|
||||
stationOptions = if (uiState.wavelogUploadSettings.isReady) uiState.wavelogUploadStations else emptyList(),
|
||||
stationFilter = uiState.logbookStationFilter,
|
||||
onStationFilterChange = { onAction(SettingsAction.SetLogbookStationFilter(it)) }
|
||||
)
|
||||
}
|
||||
if (dialogs.lotwUpload) {
|
||||
@@ -335,11 +343,37 @@ private fun SettingsScreen(
|
||||
onClearStationGridPrefill()
|
||||
},
|
||||
onImport = { bytes, password -> onAction(SettingsAction.ImportLoTWCertificate(bytes, password)) },
|
||||
onPreview = { bytes, password -> onAction(SettingsAction.PreviewLoTWCertificate(bytes, password)) },
|
||||
onRemove = { onAction(SettingsAction.RemoveLoTWCertificate) },
|
||||
onSaveStation = { onAction(SettingsAction.SaveLoTWStation(it)) }
|
||||
)
|
||||
}
|
||||
|
||||
if (dialogs.wavelogUpload) {
|
||||
WavelogUploadDialog(
|
||||
initialSettings = uiState.wavelogUploadSettings,
|
||||
stations = uiState.wavelogUploadStations,
|
||||
rights = uiState.wavelogUploadRights,
|
||||
probeBusy = uiState.wavelogUploadProbeBusy,
|
||||
isSyncing = uiState.wavelogSyncing,
|
||||
lastSyncEpochMs = uiState.wavelogLastSyncEpochMs,
|
||||
message = uiState.wavelogUploadMessage,
|
||||
dismiss = { dialogs.wavelogUpload = false },
|
||||
onSave = { onAction(SettingsAction.UpdateWavelogUpload(it)) },
|
||||
onFetchStations = { url, apiKey -> onAction(SettingsAction.FetchWavelogUploadStations(url, apiKey)) },
|
||||
onSelectStation = { onAction(SettingsAction.SelectWavelogUploadStation(it)) },
|
||||
onSyncIncremental = { onAction(SettingsAction.SyncWavelog(WavelogSyncMode.Incremental)) },
|
||||
onSyncFull = { onAction(SettingsAction.SyncWavelog(WavelogSyncMode.Full)) },
|
||||
onClear = { onAction(SettingsAction.ClearWavelogConfig) }
|
||||
)
|
||||
}
|
||||
if (uiState.wavelogSwitchWarning) {
|
||||
WavelogSwitchWarningDialog(
|
||||
onConfirm = { onAction(SettingsAction.ConfirmWavelogSwitch) },
|
||||
onCancel = { onAction(SettingsAction.CancelWavelogSwitch) }
|
||||
)
|
||||
}
|
||||
|
||||
// URLs for top bar
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val upstreamTitle = stringResource(R.string.prefs_upstream_title)
|
||||
@@ -408,6 +442,7 @@ private fun SettingsScreen(
|
||||
}
|
||||
) { _ ->
|
||||
val isVerticalLayout = isVerticalLayout()
|
||||
val wavelogMode = uiState.wavelogUploadSettings.isReady
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(if (isVerticalLayout) 1 else 2),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
@@ -450,6 +485,7 @@ private fun SettingsScreen(
|
||||
LoTWUploadCard(
|
||||
hasCertificate = uiState.lotwCertificate != null,
|
||||
stationGrid = uiState.lotwStation?.grid.orEmpty(),
|
||||
enabled = !wavelogMode,
|
||||
showUploadConfigDialog = { onAction(SettingsAction.LoadLoTWUploadStatus); dialogs.lotwUpload = true }
|
||||
)
|
||||
}
|
||||
@@ -458,13 +494,22 @@ private fun SettingsScreen(
|
||||
settings = uiState.lotwSettings,
|
||||
workedGridsCount = uiState.workedGridsCount,
|
||||
lastSyncEpochMs = uiState.lotwLastSyncEpochMs,
|
||||
enabled = !wavelogMode,
|
||||
showLoTWDialog = { dialogs.lotw = true }
|
||||
)
|
||||
}
|
||||
item {
|
||||
WavelogUploadCard(
|
||||
settings = uiState.wavelogUploadSettings,
|
||||
lastSyncEpochMs = uiState.wavelogLastSyncEpochMs,
|
||||
showDialog = { dialogs.wavelogUpload = true }
|
||||
)
|
||||
}
|
||||
item {
|
||||
OtherCard(
|
||||
settings = uiState.otherSettings,
|
||||
onCompassCalibration = { dialogs.compassCalibration = true },
|
||||
loTWEnabled = !wavelogMode,
|
||||
onAction = onAction
|
||||
)
|
||||
}
|
||||
@@ -689,6 +734,7 @@ private fun MapSettingsCard(onClick: () -> Unit) {
|
||||
private fun OtherCard(
|
||||
settings: OtherSettings,
|
||||
onCompassCalibration: () -> Unit,
|
||||
loTWEnabled: Boolean = true,
|
||||
onAction: (SettingsAction) -> Unit
|
||||
) {
|
||||
ElevatedCard(
|
||||
@@ -707,7 +753,11 @@ private fun OtherCard(
|
||||
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
|
||||
onAction(SettingsAction.ToggleUpdate(it))
|
||||
}
|
||||
SwitchRow(R.string.prefs_other_switch_lotw_sync, settings.stateOfAutoLotwSync) {
|
||||
SwitchRow(
|
||||
R.string.prefs_other_switch_lotw_sync,
|
||||
settings.stateOfAutoLotwSync,
|
||||
enabled = loTWEnabled
|
||||
) {
|
||||
onAction(SettingsAction.ToggleAutoLotwSync(it))
|
||||
}
|
||||
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
|
||||
@@ -847,10 +897,15 @@ private fun LoTWCard(
|
||||
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
||||
workedGridsCount: Int,
|
||||
lastSyncEpochMs: Long,
|
||||
enabled: Boolean = true,
|
||||
showLoTWDialog: () -> Unit
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.alpha(if (enabled) 1f else 0.5f)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_lotw_title),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
@@ -873,10 +928,70 @@ private fun LoTWCard(
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
if (!enabled) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_lotw_disabled_wavelog),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
CardButton(
|
||||
onClick = showLoTWDialog,
|
||||
text = stringResource(id = R.string.prefs_wavelog_configure),
|
||||
isEnabled = enabled,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WavelogUploadCard(
|
||||
settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings,
|
||||
lastSyncEpochMs: Long,
|
||||
showDialog: () -> Unit
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_wavelog_upload_title),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_wavelog_exclusive),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = if (settings.isReady) {
|
||||
stringResource(
|
||||
R.string.prefs_wavelog_upload_configured,
|
||||
listOf(settings.stationName, settings.stationCallsign, settings.stationGrid)
|
||||
.filter { it.isNotBlank() }.joinToString(" · ")
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.prefs_wavelog_upload_not_configured)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2
|
||||
)
|
||||
if (lastSyncEpochMs != 0L) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = formatUpdateTime(updateTime = lastSyncEpochMs),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
CardButton(
|
||||
onClick = showDialog,
|
||||
text = stringResource(id = R.string.prefs_wavelog_configure),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
@@ -1029,6 +1144,7 @@ private class DialogVisibility {
|
||||
var compassCalibration by mutableStateOf(false)
|
||||
var logbook by mutableStateOf(false)
|
||||
var lotwUpload by mutableStateOf(false)
|
||||
var wavelogUpload by mutableStateOf(false)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -1036,7 +1152,7 @@ private fun rememberDialogVisibility(): DialogVisibility {
|
||||
return rememberSaveable(saver = run {
|
||||
androidx.compose.runtime.saveable.Saver(
|
||||
save = {
|
||||
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw, it.compassCalibration, it.logbook, it.lotwUpload)
|
||||
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw, it.compassCalibration, it.logbook, it.lotwUpload, it.wavelogUpload)
|
||||
},
|
||||
restore = {
|
||||
DialogVisibility().apply {
|
||||
@@ -1046,6 +1162,7 @@ private fun rememberDialogVisibility(): DialogVisibility {
|
||||
compassCalibration = it.getOrElse(8) { false }
|
||||
logbook = it.getOrElse(9) { false }
|
||||
lotwUpload = it.getOrElse(10) { false }
|
||||
wavelogUpload = it.getOrElse(11) { false }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+34
-6
@@ -22,7 +22,7 @@ import com.rtbishop.look4sat.core.domain.model.LatestRelease
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
||||
@@ -57,10 +57,25 @@ data class SettingsState(
|
||||
val radioControlSettings: RadioControlSettings,
|
||||
val dataSourcesSettings: DataSourcesSettings,
|
||||
val dataSourcesStatus: Map<String, Int> = emptyMap(),
|
||||
val wavelogSettings: WavelogSettings = WavelogSettings(),
|
||||
val workedGridsCount: Int = 0,
|
||||
/** Wavelog configuration (one block; LoTW and Wavelog are mutually exclusive). */
|
||||
val wavelogUploadSettings: WavelogUploadSettings = WavelogUploadSettings(),
|
||||
val wavelogUploadStations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList(),
|
||||
val wavelogUploadRights: String = "",
|
||||
val wavelogUploadProbeBusy: Boolean = false,
|
||||
/** Config-block feedback (probe failures, sync results); also shown inside the config dialog. */
|
||||
val wavelogUploadMessage: String? = null,
|
||||
/** Wavelog sync (download of QSOs + grids). */
|
||||
val wavelogSyncing: Boolean = false,
|
||||
val wavelogMessage: String? = null,
|
||||
/** Epoch ms of the last successful Wavelog sync (0 = never). */
|
||||
val wavelogLastSyncEpochMs: Long = 0L,
|
||||
/** Mode-switch guard: a change that would hand the logbook over to Wavelog is
|
||||
* waiting for the operator's confirmation (LoTW is configured). */
|
||||
val wavelogSwitchWarning: Boolean = false,
|
||||
/** Logbook 台址 selector: selected Wavelog station id (null = 全部). */
|
||||
val logbookStationFilter: String? = null,
|
||||
/** Wavelog-mode logbook upload: prepared batch awaiting confirmation. */
|
||||
val logbookWavelogPreview: com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview? = null,
|
||||
val lotwSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings = com.rtbishop.look4sat.core.domain.model.LoTWSettings(),
|
||||
val lotwSyncing: Boolean = false,
|
||||
val lotwSyncMode: LoTWSyncMode? = null,
|
||||
@@ -146,9 +161,20 @@ sealed interface SettingsAction {
|
||||
// Data sources
|
||||
data class UpdateDataSources(val settings: DataSourcesSettings) : SettingsAction
|
||||
|
||||
// Wavelog worked grids
|
||||
data class UpdateWavelog(val settings: WavelogSettings) : SettingsAction
|
||||
data class SyncWorkedGrids(val settings: WavelogSettings) : SettingsAction
|
||||
// Wavelog configuration (single block; LoTW and Wavelog are mutually exclusive)
|
||||
data class UpdateWavelogUpload(val settings: WavelogUploadSettings) : SettingsAction
|
||||
data class FetchWavelogUploadStations(val url: String, val apiKey: String) : SettingsAction
|
||||
data class SelectWavelogUploadStation(val station: com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo) : SettingsAction
|
||||
/** Download QSOs (+ grids) from Wavelog. */
|
||||
data class SyncWavelog(val mode: com.rtbishop.look4sat.core.domain.repository.WavelogSyncMode) : SettingsAction
|
||||
/** Confirmed the LoTW→Wavelog mode switch warning. */
|
||||
data object ConfirmWavelogSwitch : SettingsAction
|
||||
/** Declined the switch warning — nothing changes. */
|
||||
data object CancelWavelogSwitch : SettingsAction
|
||||
/** Wipe the Wavelog config (back to LoTW mode). */
|
||||
data object ClearWavelogConfig : SettingsAction
|
||||
/** Logbook 台址 selector choice (null = 全部). */
|
||||
data class SetLogbookStationFilter(val stationId: String?) : SettingsAction
|
||||
|
||||
// LoTW confirmed grids
|
||||
data class UpdateLoTW(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction
|
||||
@@ -182,6 +208,8 @@ sealed interface SettingsAction {
|
||||
// LoTW upload configuration (certificate + station)
|
||||
data object LoadLoTWUploadStatus : SettingsAction
|
||||
data class ImportLoTWCertificate(val bytes: ByteArray, val password: CharArray) : SettingsAction
|
||||
/** Pre-import .p12 parse (not persisted) so the region field appears before confirming. */
|
||||
data class PreviewLoTWCertificate(val bytes: ByteArray, val password: CharArray) : SettingsAction
|
||||
data object RemoveLoTWCertificate : SettingsAction
|
||||
data class SaveLoTWStation(val station: com.rtbishop.look4sat.core.domain.repository.LoTWStation) : SettingsAction
|
||||
|
||||
|
||||
+302
-25
@@ -23,7 +23,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
@@ -35,12 +35,24 @@ import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
|
||||
import com.rtbishop.look4sat.core.domain.repository.lotwCursorEpochMs
|
||||
import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.positionWarning
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogSyncRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogSyncFetch
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.applyWavelogSyncResult
|
||||
import com.rtbishop.look4sat.core.domain.repository.resolveWavelogSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
||||
import com.rtbishop.look4sat.core.domain.logbook.resubmitCandidates
|
||||
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogConfirmedSegment
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogIdleSegment
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogTransportFailureSegment
|
||||
import com.rtbishop.look4sat.core.domain.logbook.wavelogUploadCandidates
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -53,7 +65,8 @@ class SettingsViewModel(
|
||||
private val databaseRepo: IDatabaseRepo,
|
||||
private val settingsRepo: ISettingsRepo,
|
||||
private val updateRepo: IUpdateRepository,
|
||||
private val wavelogRepo: IWavelogRepository,
|
||||
private val wavelogSyncRepository: IWavelogSyncRepository,
|
||||
private val wavelogUploadRepository: IWavelogUploadRepository,
|
||||
private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository,
|
||||
private val qsoRepository: com.rtbishop.look4sat.core.domain.logbook.IQsoRepository,
|
||||
private val lotwUploadRepository: com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository,
|
||||
@@ -78,7 +91,9 @@ class SettingsViewModel(
|
||||
radioControlSettings = settingsRepo.radioControlSettings.value,
|
||||
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
|
||||
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
|
||||
wavelogSettings = settingsRepo.wavelogSettings.value,
|
||||
wavelogUploadSettings = settingsRepo.wavelogUploadSettings.value,
|
||||
wavelogUploadStations = settingsRepo.getWavelogStations(),
|
||||
wavelogLastSyncEpochMs = settingsRepo.getLastWavelogSyncEpochMs(),
|
||||
workedGridsCount = settingsRepo.getWorkedGrids().size,
|
||||
compassAccuracy = sensorsRepo.compassAccuracy.value,
|
||||
compassHeadingDegrees = correctedCompassHeading(),
|
||||
@@ -152,8 +167,8 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.wavelogSettings.collect { settings ->
|
||||
_uiState.update { it.copy(wavelogSettings = settings) }
|
||||
settingsRepo.wavelogUploadSettings.collect { settings ->
|
||||
_uiState.update { it.copy(wavelogUploadSettings = settings) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
@@ -222,9 +237,15 @@ class SettingsViewModel(
|
||||
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
||||
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
||||
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
|
||||
// Wavelog worked grids
|
||||
is SettingsAction.UpdateWavelog -> settingsRepo.updateWavelogSettings(action.settings)
|
||||
is SettingsAction.SyncWorkedGrids -> syncWorkedGrids(action.settings)
|
||||
// Wavelog configuration (single block; LoTW and Wavelog are mutually exclusive)
|
||||
is SettingsAction.UpdateWavelogUpload -> applyWavelogUploadSettings(action.settings)
|
||||
is SettingsAction.FetchWavelogUploadStations -> fetchWavelogUploadStations(action.url, action.apiKey)
|
||||
is SettingsAction.SelectWavelogUploadStation -> applyWavelogUploadStation(action.station)
|
||||
is SettingsAction.SyncWavelog -> syncWavelog(action.mode)
|
||||
SettingsAction.ConfirmWavelogSwitch -> confirmWavelogSwitch()
|
||||
SettingsAction.CancelWavelogSwitch -> cancelWavelogSwitch()
|
||||
SettingsAction.ClearWavelogConfig -> clearWavelogConfig()
|
||||
is SettingsAction.SetLogbookStationFilter -> _uiState.update { it.copy(logbookStationFilter = action.stationId) }
|
||||
// LoTW confirmed grids
|
||||
is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
|
||||
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode)
|
||||
@@ -246,6 +267,7 @@ class SettingsViewModel(
|
||||
// LoTW upload configuration
|
||||
SettingsAction.LoadLoTWUploadStatus -> loadLoTWUploadStatus()
|
||||
is SettingsAction.ImportLoTWCertificate -> importLoTWCertificate(action.bytes, action.password)
|
||||
is SettingsAction.PreviewLoTWCertificate -> previewLoTWCertificate(action.bytes, action.password)
|
||||
SettingsAction.RemoveLoTWCertificate -> removeLoTWCertificate()
|
||||
is SettingsAction.SaveLoTWStation -> saveLoTWStation(action.station)
|
||||
// Update checker
|
||||
@@ -257,29 +279,187 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// region Wavelog worked grids
|
||||
// Wavelog configuration + sync
|
||||
|
||||
private fun syncWorkedGrids(settings: WavelogSettings) {
|
||||
if (!settings.isConfigured) {
|
||||
_uiState.update { it.copy(wavelogMessage = "Wavelog URL/token not configured") }
|
||||
/** A switch that needs the LoTW→Wavelog hand-over confirmation before it applies. */
|
||||
private var pendingWavelogSwitch: (() -> Unit)? = null
|
||||
|
||||
private fun currentLoTWConfigured(): Boolean =
|
||||
settingsRepo.lotwSettings.value.isConfigured || _uiState.value.lotwCertificate != null
|
||||
|
||||
/**
|
||||
* Applies a Wavelog config change. Completing the configuration (the block turning
|
||||
* ready) while LoTW is configured pauses for the operator's confirmation: from then
|
||||
* on uploads and syncs go through Wavelog only and the LoTW sides stay unavailable
|
||||
* (the one-of-two rule). A half-filled config changes nothing about the routing.
|
||||
*/
|
||||
private fun applyWavelogUploadSettings(settings: WavelogUploadSettings) {
|
||||
val current = settingsRepo.wavelogUploadSettings.value
|
||||
if (!current.isReady && settings.isReady && currentLoTWConfigured()) {
|
||||
pendingWavelogSwitch = { settingsRepo.updateWavelogUploadSettings(settings) }
|
||||
_uiState.update { it.copy(wavelogSwitchWarning = true) }
|
||||
return
|
||||
}
|
||||
// Persist the credentials first, then sync with the freshly-typed values.
|
||||
settingsRepo.updateWavelogSettings(settings)
|
||||
_uiState.update { it.copy(wavelogSyncing = true, wavelogMessage = null) }
|
||||
settingsRepo.updateWavelogUploadSettings(settings)
|
||||
}
|
||||
|
||||
private fun applyWavelogUploadStation(station: WavelogStationInfo) {
|
||||
val current = settingsRepo.wavelogUploadSettings.value
|
||||
val updated = current.copy(
|
||||
stationId = station.id,
|
||||
stationName = station.name,
|
||||
stationCallsign = station.callsign,
|
||||
stationGrid = station.grid
|
||||
)
|
||||
if (!current.isReady && updated.isReady && currentLoTWConfigured()) {
|
||||
pendingWavelogSwitch = { settingsRepo.updateWavelogUploadSettings(updated) }
|
||||
_uiState.update { it.copy(wavelogSwitchWarning = true) }
|
||||
return
|
||||
}
|
||||
settingsRepo.updateWavelogUploadSettings(updated)
|
||||
}
|
||||
|
||||
private fun confirmWavelogSwitch() {
|
||||
pendingWavelogSwitch?.invoke()
|
||||
pendingWavelogSwitch = null
|
||||
_uiState.update { it.copy(wavelogSwitchWarning = false) }
|
||||
}
|
||||
|
||||
private fun cancelWavelogSwitch() {
|
||||
pendingWavelogSwitch = null
|
||||
_uiState.update { it.copy(wavelogSwitchWarning = false) }
|
||||
}
|
||||
|
||||
/** Wipe the configuration — routing falls back to LoTW on the next check. */
|
||||
private fun clearWavelogConfig() {
|
||||
settingsRepo.updateWavelogUploadSettings(WavelogUploadSettings())
|
||||
_uiState.update { it.copy(logbookStationFilter = null, logbookWavelogPreview = null) }
|
||||
}
|
||||
|
||||
private fun fetchWavelogUploadStations(url: String, apiKey: String) {
|
||||
if (_uiState.value.wavelogUploadProbeBusy) return
|
||||
_uiState.update { it.copy(wavelogUploadProbeBusy = true, wavelogUploadMessage = null) }
|
||||
viewModelScope.launch {
|
||||
val grids = wavelogRepo.fetchWorkedGrids(settings.url, settings.token)
|
||||
val probe = wavelogUploadRepository.probe(url.trim(), apiKey.trim())
|
||||
if (probe == null) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
wavelogUploadProbeBusy = false,
|
||||
wavelogUploadMessage = "Wavelog probe failed — check URL/key/network"
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
// Persist the full list: the logbook's 台址 selector and the sync use it,
|
||||
// not just the dialog that fetched it.
|
||||
settingsRepo.setWavelogStations(probe.stations)
|
||||
_uiState.update {
|
||||
if (grids != null) {
|
||||
settingsRepo.setWorkedGrids(grids)
|
||||
it.copy(wavelogSyncing = false, workedGridsCount = grids.size, wavelogMessage = null)
|
||||
it.copy(
|
||||
wavelogUploadProbeBusy = false,
|
||||
wavelogUploadStations = probe.stations,
|
||||
wavelogUploadRights = probe.rights
|
||||
)
|
||||
}
|
||||
// Single-station setups select themselves; a saved station that the server
|
||||
// still reports stays selected.
|
||||
val current = settingsRepo.wavelogUploadSettings.value
|
||||
val savedStillPresent = current.stationId.isNotBlank() && probe.stations.any { it.id == current.stationId }
|
||||
val auto = if (probe.stations.size == 1 && !savedStillPresent) probe.stations.single() else null
|
||||
if (auto != null) applyWavelogUploadStation(auto)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads QSOs from Wavelog and folds them into the logbook + map grid data.
|
||||
* Incremental resumes from the per-station cursors; the full mode re-pulls each
|
||||
* station from the start (healing anything a cursor missed) — neither ever deletes
|
||||
* data the app already has.
|
||||
*/
|
||||
private fun syncWavelog(requested: WavelogSyncMode) {
|
||||
if (_uiState.value.wavelogSyncing) return
|
||||
val settings = settingsRepo.wavelogUploadSettings.value
|
||||
if (!settings.isConfigured) {
|
||||
_uiState.update { it.copy(wavelogUploadMessage = "Wavelog URL/key not configured") }
|
||||
return
|
||||
}
|
||||
val stations = settingsRepo.getWavelogStations()
|
||||
if (stations.isEmpty()) {
|
||||
_uiState.update { it.copy(wavelogUploadMessage = "Fetch stations first — no station profile known") }
|
||||
return
|
||||
}
|
||||
val mode = resolveWavelogSyncMode(settingsRepo.getWavelogSyncUrl(), settings.url, requested)
|
||||
_uiState.update { it.copy(wavelogSyncing = true, wavelogUploadMessage = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val fetch = wavelogSyncRepository.fetchNewRecords(
|
||||
settings.url,
|
||||
settings.apiKey,
|
||||
stations,
|
||||
settingsRepo.getWavelogSyncCursors(),
|
||||
full = mode == WavelogSyncMode.Full
|
||||
)
|
||||
if (fetch == null) {
|
||||
_uiState.update {
|
||||
it.copy(wavelogSyncing = false, wavelogUploadMessage = "Wavelog sync failed — check URL/key/network")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
val merge = if (fetch.records.isEmpty()) {
|
||||
com.rtbishop.look4sat.core.domain.logbook.AdifImportResult(0, 0, 0)
|
||||
} else {
|
||||
it.copy(wavelogSyncing = false, wavelogMessage = "Sync failed — check URL/token/network")
|
||||
qsoRepository.mergeWavelog(fetch.records)
|
||||
}
|
||||
// Stations that failed keep their previous cursor; fetched ones advance.
|
||||
val cursors = settingsRepo.getWavelogSyncCursors() + fetch.cursors
|
||||
applyWavelogSyncResult(settingsRepo, fetch.records, cursors, settings.url)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
wavelogSyncing = false,
|
||||
workedGridsCount = settingsRepo.getWorkedGrids().size,
|
||||
wavelogLastSyncEpochMs = settingsRepo.getLastWavelogSyncEpochMs(),
|
||||
wavelogUploadMessage = wavelogSyncSummary(mode, fetch, merge, stations.size)
|
||||
)
|
||||
}
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(wavelogSyncing = false, wavelogUploadMessage = "Wavelog sync failed — check URL/key/network")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun wavelogSyncSummary(
|
||||
mode: WavelogSyncMode,
|
||||
fetch: WavelogSyncFetch,
|
||||
merge: com.rtbishop.look4sat.core.domain.logbook.AdifImportResult,
|
||||
totalStations: Int
|
||||
): String {
|
||||
val head = if (mode == WavelogSyncMode.Full) "Full Wavelog sync" else "Incremental Wavelog sync"
|
||||
val base = "$head — ${merge.imported} new, ${merge.skipped} already there"
|
||||
return if (fetch.failedStations.isEmpty()) base
|
||||
else "$base · ${fetch.failedStations.size}/$totalStations station(s) failed"
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads one prepared Wavelog batch and marks what the server accepted (stamped
|
||||
* with the station profile the batch went out through). Returns the operator-facing
|
||||
* message for this upload.
|
||||
*/
|
||||
private suspend fun uploadWavelogNow(
|
||||
preview: WavelogUploadPreview,
|
||||
settings: WavelogUploadSettings
|
||||
): String = try {
|
||||
val outcome = wavelogUploadRepository.upload(preview, settings)
|
||||
if (outcome is WavelogUploadOutcome.Imported && outcome.markIds.isNotEmpty()) {
|
||||
qsoRepository.markWavelogUploaded(outcome.markIds, settings.stationId)
|
||||
}
|
||||
wavelogConfirmedSegment(outcome, preview)
|
||||
} catch (_: Exception) {
|
||||
wavelogTransportFailureSegment()
|
||||
}
|
||||
|
||||
private fun syncLoTWGrids(
|
||||
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
||||
mode: LoTWSyncMode
|
||||
@@ -385,6 +565,12 @@ class SettingsViewModel(
|
||||
_uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") }
|
||||
try {
|
||||
val all = qsoRepository.records.first()
|
||||
val wavelogSettings = settingsRepo.wavelogUploadSettings.value
|
||||
if (wavelogSettings.isReady) {
|
||||
// Wavelog mode: the logbook upload goes to Wavelog only.
|
||||
prepareLogbookWavelogUpload(all, wavelogSettings)
|
||||
return@launch
|
||||
}
|
||||
// Only local (non-confirmed) records are candidates for upload;
|
||||
// LoTW-imported confirmations are the feedback side.
|
||||
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE }
|
||||
@@ -420,7 +606,42 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Wavelog-mode branch of [prepareLogbookUpload]: prepare the batch, then confirm. */
|
||||
private suspend fun prepareLogbookWavelogUpload(
|
||||
all: List<com.rtbishop.look4sat.core.domain.logbook.QsoRecord>,
|
||||
settings: WavelogUploadSettings
|
||||
) {
|
||||
val preview = runCatching {
|
||||
wavelogUploadRepository.prepare(wavelogUploadCandidates(all), settings)
|
||||
}.getOrNull()
|
||||
if (preview == null) {
|
||||
_uiState.update {
|
||||
it.copy(logbookUploadBusy = false, logbookUploadMessage = "Wavelog upload failed to prepare — check the configuration")
|
||||
}
|
||||
return
|
||||
}
|
||||
if (preview.count == 0) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
logbookUploadBusy = false,
|
||||
logbookUploadMessage = wavelogIdleSegment(preview) ?: "No pending QSOs to upload to Wavelog"
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookWavelogPreview = preview) }
|
||||
}
|
||||
|
||||
private fun confirmLogbookUpload() {
|
||||
val wavelogPreview = _uiState.value.logbookWavelogPreview
|
||||
if (wavelogPreview != null) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(logbookUploadBusy = true) }
|
||||
val msg = uploadWavelogNow(wavelogPreview, settingsRepo.wavelogUploadSettings.value)
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookWavelogPreview = null, logbookUploadMessage = msg) }
|
||||
}
|
||||
return
|
||||
}
|
||||
val preview = _uiState.value.logbookPreview ?: return
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(logbookUploadBusy = true) }
|
||||
@@ -450,7 +671,7 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null) }
|
||||
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null, logbookWavelogPreview = null) }
|
||||
|
||||
/** Operator chose "ignore" on the position check: keep the prepared preview. */
|
||||
private fun ignoreLogbookPositionWarning() = _uiState.update { it.copy(logbookPositionWarning = null) }
|
||||
@@ -486,7 +707,41 @@ class SettingsViewModel(
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") }
|
||||
try {
|
||||
val selected = resubmitCandidates(qsoRepository.records.first(), _uiState.value.logbookSelectedIds)
|
||||
val all = qsoRepository.records.first()
|
||||
val wavelogSettings = settingsRepo.wavelogUploadSettings.value
|
||||
if (wavelogSettings.isReady) {
|
||||
// Wavelog mode: re-send the checked records through Wavelog (the
|
||||
// server dedupes what is already there).
|
||||
val selected = all.filter {
|
||||
it.id in _uiState.value.logbookSelectedIds &&
|
||||
it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE
|
||||
}
|
||||
if (selected.isEmpty()) {
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No complete QSOs in the selection") }
|
||||
return@launch
|
||||
}
|
||||
val preview = runCatching {
|
||||
wavelogUploadRepository.prepare(selected, wavelogSettings)
|
||||
}.getOrNull()
|
||||
if (preview == null) {
|
||||
_uiState.update {
|
||||
it.copy(logbookUploadBusy = false, logbookUploadMessage = "Wavelog upload failed to prepare — check the configuration")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
if (preview.count == 0) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
logbookUploadBusy = false,
|
||||
logbookUploadMessage = wavelogIdleSegment(preview) ?: "No QSOs to resubmit"
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookWavelogPreview = preview) }
|
||||
return@launch
|
||||
}
|
||||
val selected = resubmitCandidates(all, _uiState.value.logbookSelectedIds)
|
||||
if (selected.isEmpty()) {
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No complete QSOs in the selection") }
|
||||
return@launch
|
||||
@@ -534,7 +789,10 @@ class SettingsViewModel(
|
||||
_uiState.update { it.copy(lotwUploadBusy = true, lotwUploadError = null) }
|
||||
try {
|
||||
val cert = lotwUploadRepository.importCertificate(bytes, password)
|
||||
_uiState.update { it.copy(lotwCertificate = cert, lotwUploadBusy = false, lotwUploadError = null) }
|
||||
// Publish the region field for the fresh certificate immediately —
|
||||
// previously it only appeared after closing and reopening the dialog.
|
||||
val meta = runCatching { lotwUploadRepository.stationMeta(cert.dxcc) }.getOrNull()
|
||||
_uiState.update { it.copy(lotwCertificate = cert, lotwStationMeta = meta, lotwUploadBusy = false, lotwUploadError = null) }
|
||||
} catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) {
|
||||
val error = when (e.reason) {
|
||||
com.rtbishop.look4sat.core.domain.repository.LoTWProblem.CERTIFICATE_PASSWORD -> LoTWUploadError.PASSWORD
|
||||
@@ -549,6 +807,24 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// Monotonic token so a slow stale preview never overwrites a newer one's meta.
|
||||
private var certificatePreviewSeq = 0
|
||||
|
||||
private fun previewLoTWCertificate(bytes: ByteArray, password: CharArray) {
|
||||
val seq = ++certificatePreviewSeq
|
||||
viewModelScope.launch {
|
||||
// Silent by design: a wrong password or a non-p12 file must not disturb
|
||||
// the dialog — only a successful parse publishes the region field.
|
||||
val meta = runCatching { lotwUploadRepository.previewCertificate(bytes, password) }.fold(
|
||||
onSuccess = { cert -> runCatching { lotwUploadRepository.stationMeta(cert.dxcc) }.getOrNull() },
|
||||
onFailure = { null }
|
||||
)
|
||||
if (meta != null && seq == certificatePreviewSeq) {
|
||||
_uiState.update { it.copy(lotwStationMeta = meta) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeLoTWCertificate() {
|
||||
viewModelScope.launch {
|
||||
lotwUploadRepository.removeCertificate()
|
||||
@@ -703,7 +979,8 @@ class SettingsViewModel(
|
||||
databaseRepo = container.databaseRepo,
|
||||
settingsRepo = container.settingsRepo,
|
||||
updateRepo = container.updateRepo,
|
||||
wavelogRepo = container.wavelogRepo,
|
||||
wavelogSyncRepository = container.wavelogSyncRepository,
|
||||
wavelogUploadRepository = container.wavelogUploadRepository,
|
||||
lotwRepo = container.lotwRepo,
|
||||
qsoRepository = container.qsoRepository,
|
||||
lotwUploadRepository = container.lotwUploadRepository,
|
||||
|
||||
@@ -400,6 +400,7 @@ private fun ReportDialog(
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
|
||||
val conflictedIds = day.slots.filter { it.isConflicted }.flatMap { it.reportIds }.toSet()
|
||||
InfoDialog(
|
||||
title = "$statusName · ${day.dateLabel}",
|
||||
onDismiss = onDismiss,
|
||||
@@ -448,6 +449,10 @@ private fun ReportDialog(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(text = report.statusText, fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||
if (report.id in conflictedIds) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
ConflictBadge()
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "${report.call} ${report.dateUtc} ${report.timeUtc}" +
|
||||
@@ -463,6 +468,23 @@ private fun ReportDialog(
|
||||
}
|
||||
}
|
||||
|
||||
/** Orange "冲突" tag on reports whose 2h slot has no strict majority (Conflicting reports). */
|
||||
@Composable
|
||||
private fun ConflictBadge() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Color(0xFFFE6100).copy(alpha = 0.15f))
|
||||
.padding(horizontal = 5.dp, vertical = 1.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.amsat_conflict),
|
||||
fontSize = 11.sp,
|
||||
color = Color(0xFFFE6100)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AmSatUploadPanel(
|
||||
statusName: String,
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
# Configure your build environment: http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
org.gradle.caching=true
|
||||
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=true
|
||||
org.gradle.workers.max=4
|
||||
org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=768m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=false
|
||||
org.gradle.workers.max=2
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "563"
|
||||
appVersionCode = "575"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.8-ba7opf.8"
|
||||
appVersionName = "4.4.8-ba7opf.11"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user