Compare commits

..
Author SHA1 Message Date
atsunatsu f997d95cd4 chore: bump version to 4.4.7-ba7opf.7 (versionCode 477) 2026-09-17 10:41:11 +08:00
atsunatsu e0a1d279ce i18n(zh): localize update time to yyyy年M月d日 HH:mm:ss 2026-09-17 10:20:26 +08:00
atsunatsu 059c816075 feat(lotw): show last sync time on settings, precise incremental cursor
The stored sync cursor was 'yyyyMMdd', which LoTW's qso_qslsince silently
ignores (measured live: it falls back to the system-supplied default).
Write a full UTC 'yyyy-MM-dd HH:mm:ss' cursor instead — the documented
format LoTW honors — so incremental pulls are exact (second precision)
and the settings page can show the last successful sync time on the LoTW
card, formatted like the ephemeris update time (prefs_updated_title).
Legacy yyyyMMdd cursors are normalized for the API, the gate and the
display (lotwCursorApi/lotwCursorDate/lotwCursorEpochMs).
2026-09-17 10:13:52 +08:00
atsunatsu 60dfa5d600 feat(lotw): auto-sync grids on app start with separate toggle
Mirrors the ephemeris auto-update check: on every app start, if LoTW
credentials are configured, the new 'auto-sync LoTW grids' toggle is on,
at least one manual sync was done before, and today has not synced yet
(ARRL rate limit, ~once a day), pull confirmations since the last sync
and merge them. Same callsign -> incremental merge; callsign change ->
full replace. Manual dialog and automatic path share the orchestration
(mode resolution, gate, merge/persist) moved to core:domain
LoTWGridSync.kt; LoTWSyncMode moved to core:domain. 8 unit tests.
2026-09-16 20:18:22 +08:00
atsunatsu 5f1eb239ed feat(lotw): cancel in-flight sync, honest progress bar, drop stale hint
- Cancel: the dialog's cancel/back while syncing now aborts the download job
  (cooperative cancellation in the stream reader, CancellationException
  propagated, nothing written to storage). An accidental full sync can be
  stopped immediately.
- Progress: the size estimate is conservative (900 B/record vs measured 720)
  so the bar never hits 100% before the download truly ends; storage writes
  moved to the IO dispatcher (multi-MB per-grid QSO JSON used to freeze the
  main thread after download); when the bar caps, the UI switches to
  'Processing downloaded data…' instead of looking stuck.
- Removed the outdated '3-10 seconds' hint (misleading for large accounts).
2026-09-16 18:17:52 +08:00
atsunatsu 01ca06b43f chore: bump version to 4.4.7-ba7opf.6 (versionCode 476) 2026-09-16 16:58:05 +08:00
atsunatsu 22363c2549 fix(map): roamed-grid stripes keep geographic density
Stripe spacing now scales as 44px * 2^(zoom-7) instead of a fixed 44px on
screen: at the map's max zoom (7.0) the stripes keep the established look,
and at lower zooms they shrink with the map, so stripes stay pinned to the
map (same stripes-per-cell at every level) — denser on screen when zoomed
out, sparser when zoomed in.
2026-09-16 16:53:54 +08:00
atsunatsu 45fe755470 ui(dialogs): pad title away from cancel; LoTW buttons bottom corners
- SharedDialog title gets start padding when only a cancel button is shown
  (was flush against it); dialogs with both buttons unchanged.
- LoTW dialog: full-sync button bottom-left, incremental-merge bottom-right
  (SpaceBetween instead of side-by-side).
2026-09-16 16:53:50 +08:00
atsunatsu 0ce177cfbf fix(mutual/radar): bigger radar shortcut; no bounce-back on finished pass
- Pass card header: radar icon button enlarged to 48dp touch target with a
  24dp icon, spanning both header rows vertically (name/time + elevations);
  the pass info column narrows so the Opposite elevation shifts left.
- RadarDestination no longer auto-returns instantly when the mutual pass has
  already ended — the auto-return now only runs while the pass is actually in
  progress. Previously the collapsed-card radar shortcut appeared broken
  ('flash back to Mutual') whenever the computed pass had finished.
- Fakes: add the 4 last-sync bookkeeping members missed by the LoTW commit.
2026-09-16 16:53:46 +08:00
24 changed files with 688 additions and 122 deletions

No files matched your search

@@ -24,6 +24,13 @@ import android.os.Bundle
import com.rtbishop.look4sat.core.data.injection.MainContainer
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult
import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
import com.rtbishop.look4sat.core.domain.repository.lotwSyncToday
import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
import com.rtbishop.look4sat.core.domain.repository.shouldAutoSyncLoTW
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
@@ -41,6 +48,8 @@ class MainApplication : Application(), IContainerProvider {
clearAmSatCacheOnAppBackground()
// trigger automatic update every 48 hours
container.appScope.launch { checkAutoUpdate() }
// automatic LoTW grid sync on every app start (gated, see checkLoTWAutoSync)
container.appScope.launch { checkLoTWAutoSync() }
// load satellite data on every app start
container.appScope.launch { container.satelliteRepo.initRepository() }
}
@@ -79,4 +88,37 @@ class MainApplication : Application(), IContainerProvider {
}
}
}
/**
* Automatic LoTW grid sync, mirroring [checkAutoUpdate]: checked on every
* app start, but only pulls when due — LoTW credentials configured, the
* LoTW auto-sync toggle on, at least one prior (manual) sync, and not
* already synced today (ARRL limits report pulls, ~once a day per account).
* Incremental when the callsign is unchanged; a callsign change falls back
* to a full replace so no stale grids from another account linger.
* Failures are silent and simply retried on the next app start — the manual
* sync button still surfaces the explicit cause to the user.
*/
private suspend fun checkLoTWAutoSync(timeNow: Long = System.currentTimeMillis()) {
val settingsRepo = container.settingsRepo
val lotwSettings = settingsRepo.lotwSettings.value
if (!shouldAutoSyncLoTW(
isConfigured = lotwSettings.isConfigured,
autoLotwSyncEnabled = settingsRepo.otherSettings.value.stateOfAutoLotwSync,
lastSyncDate = settingsRepo.getLastLotwSyncDate(),
today = lotwSyncToday(timeNow)
)
) return
val callsign = lotwSettings.callsign.trim().uppercase()
val mode = resolveLoTWSyncMode(settingsRepo.getLastLotwSyncCallsign(), callsign, requested = null)
val since = if (mode == LoTWSyncMode.Incremental) lotwCursorApi(settingsRepo.getLastLotwSyncDate()) else ""
println("Started periodic LoTW grid sync (${mode.name.lowercase()})")
val result = container.lotwRepo.fetchConfirmedGridQsos(callsign, lotwSettings.password, since)
if (result is LoTWResult.Success) {
val count = applyLoTWGridResult(settingsRepo, result, mode, callsign, timeNow)
println("Periodic LoTW grid sync finished: $count worked grids")
} else {
println("Periodic LoTW grid sync skipped (${result::class.simpleName})")
}
}
}
@@ -22,7 +22,9 @@ import com.rtbishop.look4sat.core.domain.repository.LoTWPhase
import com.rtbishop.look4sat.core.domain.repository.LoTWProgress
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import kotlin.coroutines.coroutineContext
import java.net.HttpURLConnection
import java.net.SocketTimeoutException
import java.net.URL
@@ -91,7 +93,7 @@ class LoTWRepository : ILoTWRepository {
return Triple(grids, qsos, roamed)
}
private fun fetchReportBody(
private suspend fun fetchReportBody(
callsign: String,
password: String,
since: String,
@@ -151,6 +153,9 @@ class LoTWRepository : ILoTWRepository {
!body.contains("<eoh>", ignoreCase = true) -> Result.failure(RateLimitException())
else -> Result.success(body)
}
} catch (e: java.util.concurrent.CancellationException) {
// Cancellation must propagate — it is not a network failure.
throw e
} catch (e: SocketTimeoutException) {
Result.failure(TimeoutException(e.message ?: "timed out"))
} catch (e: SSLException) {
@@ -167,7 +172,7 @@ class LoTWRepository : ILoTWRepository {
* body size is estimated as NUMREC * AVG_RECORD_BYTES so the UI can show a
* meaningful progress bar and remaining-time estimate.
*/
private fun readBodyWithProgress(
private suspend fun readBodyWithProgress(
stream: java.io.InputStream,
isGzip: Boolean,
onProgress: (LoTWProgress) -> Unit
@@ -185,6 +190,11 @@ class LoTWRepository : ILoTWRepository {
var lastSampleBytes = 0L
var lastEmitMs = 0L
while (true) {
// Cooperative cancellation: lets the user abort a full sync that
// was started by mistake. Throws CancellationException once the
// job is cancelled; the blocking read below is fast while chunks
// keep arriving, so the abort lands on the next chunk boundary.
coroutineContext.ensureActive()
val n = reader.read(buf)
if (n <= 0) break
sb.append(buf, 0, n)
@@ -440,9 +450,11 @@ class LoTWRepository : ILoTWRepository {
val NUMREC_REGEX = Regex("<APP_LoTW_NUMREC:\\d+>(\\d+)")
/**
* Measured 2026-09-16 (BH6RJD, 2367 QSLs): 1.7 MB body streamed at
* ~9.5 KB/s ≈ 180 s, i.e. ≈ 720 bytes per ADIF record. Used to predict
* the total body size from the header record count.
* ~9.5 KB/s ≈ 180 s, i.e. ≈ 720 bytes per ADIF record. Use a
* CONSERVATIVE (larger) estimate so the progress bar never hits 100%
* before the download truly ends — an under-estimate made the bar sit
* at 100% while data was still flowing, read as "finished but stuck".
*/
const val AVG_RECORD_BYTES = 720L
const val AVG_RECORD_BYTES = 900L
}
}
@@ -77,6 +77,7 @@ class SettingsRepo(
private val keySelectedTypes = "selectedTypes"
private val keySelectedModes = "selectedModes"
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
private val keyStateOfAutoLotwSync = "stateOfAutoLotwSync"
private val keyStateOfSensors = "stateOfSensors"
private val keyStateOfSweep = "stateOfSweep"
private val keyStateOfUtc = "stateOfUtc"
@@ -538,6 +539,7 @@ class SettingsRepo(
val new = transform(current)
preferences.edit {
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
putBoolean(keyStateOfAutoLotwSync, new.stateOfAutoLotwSync)
putBoolean(keyStateOfSensors, new.stateOfSensors)
putBoolean(keyStateOfSweep, new.stateOfSweep)
putBoolean(keyStateOfUtc, new.stateOfUtc)
@@ -556,6 +558,7 @@ class SettingsRepo(
private fun getOtherSettings(): OtherSettings = OtherSettings(
stateOfAutoUpdate = preferences.getBoolean(keyStateOfAutoUpdate, true),
stateOfAutoLotwSync = preferences.getBoolean(keyStateOfAutoLotwSync, true),
stateOfSensors = preferences.getBoolean(keyStateOfSensors, true),
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
@@ -64,7 +64,10 @@ data class OtherSettings(
val shouldSeeWhatsNew: Boolean,
val sstvMode: String = "Auto",
val lowElevation: Double = 15.0,
val highElevation: Double = 45.0
val highElevation: Double = 45.0,
/** Independent auto-sync toggle for LoTW confirmed grids (separate from the
* ephemeris auto-update; only effective after at least one manual sync). */
val stateOfAutoLotwSync: Boolean = true
)
data class DataSourcesSettings(
@@ -0,0 +1,169 @@
/*
* 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
import com.rtbishop.look4sat.core.domain.model.GridQso
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* Shared LoTW sync orchestration used by BOTH the manual sync (settings screen)
* and the automatic sync on app start (MainApplication), so the two paths can
* never drift apart:
*
* - [LoTWSyncMode.Incremental] pulls only the confirmations since the last sync
* and MERGES them into the stored grids/QSOs (grids only grow; QSO detail is
* deduped by call + QSO time).
* - [LoTWSyncMode.Full] pulls everything and REPLACES the stored data.
*/
/**
* UTC date ("yyyyMMdd") of the given instant — the "already synced today"
* gate granularity.
*/
fun lotwSyncToday(now: Long = System.currentTimeMillis()): String =
SimpleDateFormat("yyyyMMdd", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
.format(Date(now))
/**
* Full UTC timestamp cursor ("yyyy-MM-dd HH:mm:ss") written after a
* successful sync. This is exactly the format LoTW's qso_qslsince accepts
* (measured live 2026-09-17: the legacy "yyyyMMdd" cursor is silently
* ignored by LoTW, which falls back to its own system-supplied default),
* and it keeps enough precision to display the last-sync time like the
* ephemeris update time.
*/
fun lotwSyncCursor(now: Long = System.currentTimeMillis()): String =
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
.format(Date(now))
/** Date part of a stored cursor as "yyyyMMdd", tolerating the legacy
* "yyyyMMdd" format and the current "yyyy-MM-dd HH:mm:ss" format. */
fun lotwCursorDate(cursor: String): String = when {
cursor.length >= 10 && cursor[4] == '-' -> cursor.substring(0, 10).replace("-", "")
else -> cursor.take(8)
}
/** qso_qslsince value for the API: passes the full timestamp through and
* normalizes a legacy "yyyyMMdd" cursor to the documented "yyyy-MM-dd". */
fun lotwCursorApi(cursor: String): String = when {
cursor.isBlank() -> ""
cursor.length >= 10 && cursor[4] == '-' -> cursor
else -> "${cursor.take(4)}-${cursor.substring(4, 6)}-${cursor.substring(6, 8)}"
}
/** Stored cursor parsed to UTC epoch ms for display; 0 when unparseable. */
fun lotwCursorEpochMs(cursor: String): Long = try {
val pattern = if (cursor.length >= 10 && cursor[4] == '-') "yyyy-MM-dd HH:mm:ss" else "yyyyMMdd"
SimpleDateFormat(pattern, Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
.parse(cursor)?.time ?: 0L
} catch (_: Exception) {
0L
}
/**
* Resolves the effective sync mode for a given request:
* - an explicit [LoTWSyncMode.Full] request is always honored;
* - an [LoTWSyncMode.Incremental] request is honored only when the callsign
* matches the last sync — a first sync, an unknown/empty record or a
* callsign change falls back to [LoTWSyncMode.Full] so no stale grids from
* another account linger.
* With [requested] == null (automatic sync) it picks incremental whenever
* possible and full otherwise.
*/
fun resolveLoTWSyncMode(
lastSyncCallsign: String,
callsign: String,
requested: LoTWSyncMode?
): LoTWSyncMode {
if (requested == LoTWSyncMode.Full) return LoTWSyncMode.Full
val sameCall = lastSyncCallsign.isNotBlank() && lastSyncCallsign == callsign
return if (sameCall) LoTWSyncMode.Incremental else LoTWSyncMode.Full
}
/**
* Whether the automatic LoTW sync should run now — mirrors the ephemeris
* auto-update check: credentials configured, the LoTW auto-sync toggle on, at
* least one prior manual sync (the first sync stays manual/full), and not
* already synced today (ARRL limits report pulls, ~once a day per account).
*/
fun shouldAutoSyncLoTW(
isConfigured: Boolean,
autoLotwSyncEnabled: Boolean,
lastSyncDate: String,
today: String
): Boolean {
val lastDate = lotwCursorDate(lastSyncDate)
return isConfigured && autoLotwSyncEnabled && lastDate.isNotBlank() && lastDate != today
}
/**
* Applies a successful LoTW report to the stored grid data and advances the
* sync cursor so the next incremental pull asks LoTW for only the
* confirmations since today. Persistence runs on the IO dispatcher — building
* the per-grid QSO JSON and writing SharedPreferences blocks the calling
* thread, and for large accounts (multi-MB JSON) that froze the main thread
* after a manual sync ('bar done but app stuck').
*
* @return the resulting number of worked grids.
*/
suspend fun applyLoTWGridResult(
settingsRepo: ISettingsRepo,
result: LoTWResult.Success,
mode: LoTWSyncMode,
callsign: String,
now: Long = System.currentTimeMillis()
): Int {
val existingGrids = settingsRepo.getWorkedGrids()
val existingQsos = settingsRepo.getWorkedGridQsos()
val existingRoamed = settingsRepo.getRoamedGrids()
val mergedGrids = if (mode == LoTWSyncMode.Incremental) existingGrids + result.grids else result.grids
val mergedQsos = if (mode == LoTWSyncMode.Incremental) mergeGridQsos(existingQsos, result.qsos) else result.qsos
val mergedRoamed = if (mode == LoTWSyncMode.Incremental) existingRoamed + result.roamedGrids else result.roamedGrids
settingsRepo.setLastLotwSyncDate(lotwSyncCursor(now))
settingsRepo.setLastLotwSyncCallsign(callsign)
withContext(Dispatchers.IO) {
settingsRepo.setWorkedGrids(mergedGrids)
settingsRepo.setWorkedGridQsos(mergedQsos)
settingsRepo.setRoamedGrids(mergedRoamed)
}
return mergedGrids.size
}
/** Merge fresh QSO detail into existing per-grid lists, dedup by call + QSO time. */
fun mergeGridQsos(
existing: Map<String, List<GridQso>>,
fresh: Map<String, List<GridQso>>
): Map<String, List<GridQso>> {
val merged = existing.mapValues { (_, list) -> list.toMutableList() }.toMutableMap()
fresh.forEach { (grid, list) ->
val target = merged.getOrPut(grid) { mutableListOf() }
val known = target.mapTo(mutableSetOf()) { it.call to it.epochMs }
list.forEach { qso ->
if (known.add(qso.call to qso.epochMs)) target.add(qso)
}
}
return merged
}
@@ -46,3 +46,6 @@ sealed class LoTWResult {
/** Any other network-level failure (DNS, TLS, connection reset, HTTP 5xx). */
data class NetworkError(val detail: String) : LoTWResult()
}
/** What a LoTW sync does: pull everything, or only new QSLs since the last sync. */
enum class LoTWSyncMode { Full, Incremental }
@@ -0,0 +1,290 @@
/*
* 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
import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.GridQso
import com.rtbishop.look4sat.core.domain.model.LoTWSettings
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.predict.GeoPos
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class LoTWGridSyncTest {
// 2026-09-16T00:00:00Z — fixed "now" for the cursor assertions.
private val now = 1_789_516_800_000L
// region resolveLoTWSyncMode
@Test
fun resolveModeHonorsExplicitFull() {
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("BA7OPF", "BA7OPF", LoTWSyncMode.Full))
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("", "BA7OPF", LoTWSyncMode.Full))
}
@Test
fun resolveModeAllowsIncrementalOnlyForSameCallsign() {
assertEquals(
LoTWSyncMode.Incremental,
resolveLoTWSyncMode("BA7OPF", "BA7OPF", LoTWSyncMode.Incremental)
)
assertEquals(
LoTWSyncMode.Full,
resolveLoTWSyncMode("BG7ABC", "BA7OPF", LoTWSyncMode.Incremental)
)
assertEquals(
LoTWSyncMode.Full,
resolveLoTWSyncMode("", "BA7OPF", LoTWSyncMode.Incremental)
)
}
@Test
fun resolveModeForAutoSyncPicksIncrementalWhenPossible() {
assertEquals(LoTWSyncMode.Incremental, resolveLoTWSyncMode("BA7OPF", "BA7OPF", null))
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("BG7ABC", "BA7OPF", null))
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("", "BA7OPF", null))
}
// endregion
// region shouldAutoSyncLoTW
@Test
fun autoSyncGateRequiresEverything() {
val today = "20260916"
// Not configured.
assertFalse(shouldAutoSyncLoTW(false, true, "2026-09-15 10:00:00", today))
// LoTW auto-sync toggle off.
assertFalse(shouldAutoSyncLoTW(true, false, "2026-09-15 10:00:00", today))
// Never synced manually — first sync stays manual/full.
assertFalse(shouldAutoSyncLoTW(true, true, "", today))
// Already synced today (both legacy and current cursor formats) — ARRL rate-limit guard.
assertFalse(shouldAutoSyncLoTW(true, true, "20260916", today))
assertFalse(shouldAutoSyncLoTW(true, true, "2026-09-16 08:00:00", today))
// All gates open — yesterday in either format.
assertTrue(shouldAutoSyncLoTW(true, true, "20260915", today))
assertTrue(shouldAutoSyncLoTW(true, true, "2026-09-15 10:00:00", today))
}
// endregion
// region cursor helpers
@Test
fun cursorApiNormalizesLegacyAndPassesFullThrough() {
assertEquals("2026-09-16", lotwCursorApi("20260916"))
assertEquals("2026-09-16 08:16:02", lotwCursorApi("2026-09-16 08:16:02"))
assertEquals("", lotwCursorApi(""))
}
@Test
fun cursorDateToleratesBothFormats() {
assertEquals("20260916", lotwCursorDate("20260916"))
assertEquals("20260916", lotwCursorDate("2026-09-16 08:16:02"))
assertEquals("", lotwCursorDate(""))
}
@Test
fun cursorEpochMsParsesBothFormatsUtc() {
// 2026-09-16T00:00:00Z in both spellings.
assertEquals(now, lotwCursorEpochMs("20260916"))
assertEquals(now, lotwCursorEpochMs("2026-09-16 00:00:00"))
assertEquals(0L, lotwCursorEpochMs(""))
assertEquals(0L, lotwCursorEpochMs("garbage"))
}
// endregion
// region mergeGridQsos
@Test
fun mergeGridQsosDedupsByCallAndTime() {
val existing = mapOf(
"OL62" to listOf(qso("BA7ABC", 1000L)),
"OL63" to listOf(qso("BA7DEF", 2000L))
)
val fresh = mapOf(
// Same grid, same QSO re-delivered by an overlapping since-date.
"OL62" to listOf(qso("BA7ABC", 1000L)),
// Same grid, brand-new QSO.
"OL62" to listOf(qso("BA7GHI", 3000L)),
// New grid entirely.
"PM95" to listOf(qso("BA7JKL", 4000L))
)
val merged = mergeGridQsos(existing, fresh)
assertEquals(setOf("OL62", "OL63", "PM95"), merged.keys)
assertEquals(2, merged["OL62"]?.size) // BA7ABC once, BA7GHI added
assertEquals(1, merged["OL63"]?.size)
assertEquals(1, merged["PM95"]?.size)
}
@Test
fun mergeGridQsosHandlesEmptySides() {
val fresh = mapOf("OL62" to listOf(qso("BA7ABC", 1000L)))
assertEquals(fresh, mergeGridQsos(emptyMap(), fresh))
// An empty incremental pull must leave the stored side untouched.
assertEquals(fresh, mergeGridQsos(fresh, emptyMap()))
assertEquals(emptyMap<String, List<GridQso>>(), mergeGridQsos(emptyMap(), emptyMap()))
}
// endregion
// region applyLoTWGridResult
@Test
fun applyIncrementalMergesAndAdvancesCursor() = runBlocking {
val repo = FakeSettingsRepo().apply {
setWorkedGrids(setOf("OL62"))
setWorkedGridQsos(mapOf("OL62" to listOf(qso("BA7ABC", 1000L))))
setRoamedGrids(setOf("OL62"))
setLastLotwSyncDate("20260915")
setLastLotwSyncCallsign("BA7OPF")
}
val result = LoTWResult.Success(
grids = setOf("OL62", "PM95"),
qsos = mapOf("OL62" to listOf(qso("BA7ABC", 1000L)), "PM95" to listOf(qso("BA7GHI", 2000L))),
roamedGrids = setOf("PM95")
)
val count = applyLoTWGridResult(repo, result, LoTWSyncMode.Incremental, "BA7OPF", now)
assertEquals(2, count)
assertEquals(setOf("OL62", "PM95"), repo.getWorkedGrids())
assertEquals(1, repo.getWorkedGridQsos()["OL62"]?.size) // deduped
assertEquals(setOf("OL62", "PM95"), repo.getRoamedGrids())
assertEquals("2026-09-16 00:00:00", repo.getLastLotwSyncDate())
assertEquals("BA7OPF", repo.getLastLotwSyncCallsign())
}
@Test
fun applyFullReplacesEverythingAndAdvancesCursor() = runBlocking {
val repo = FakeSettingsRepo().apply {
setWorkedGrids(setOf("OL62", "STALE"))
setWorkedGridQsos(mapOf("STALE" to listOf(qso("BG7OLD", 1L))))
setRoamedGrids(setOf("STALE"))
setLastLotwSyncDate("20260910")
setLastLotwSyncCallsign("BG7OLD")
}
val result = LoTWResult.Success(
grids = setOf("PM95"),
qsos = mapOf("PM95" to listOf(qso("BA7GHI", 2000L))),
roamedGrids = setOf("PM95")
)
val count = applyLoTWGridResult(repo, result, LoTWSyncMode.Full, "BA7OPF", now)
assertEquals(1, count)
assertEquals(setOf("PM95"), repo.getWorkedGrids())
assertEquals(setOf("PM95"), repo.getWorkedGridQsos().keys)
assertEquals(setOf("PM95"), repo.getRoamedGrids())
assertEquals("2026-09-16 00:00:00", repo.getLastLotwSyncDate())
assertEquals("BA7OPF", repo.getLastLotwSyncCallsign())
}
// endregion
private fun qso(call: String, epochMs: Long): GridQso =
GridQso(call, epochMs, "FO-29", "FM", "70CM", "2M")
/**
* Minimal ISettingsRepo fake backing only the LoTW sync surface; the rest
* throws (mirrors the FakeSettingsRepo pattern used in feature tests).
*/
private class FakeSettingsRepo : ISettingsRepo {
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(23.13, 113.26))
override val appVersionName: String = "test"
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
PassesSettings(hoursAhead = 24, minElevation = 10.0, selectedModes = emptyList())
)
override val databaseState: StateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
)
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
OtherSettings(
stateOfAutoUpdate = true,
stateOfSensors = false,
stateOfSweep = false,
stateOfUtc = false,
stateOfLightTheme = false,
stateOfNightMode = false,
stateOfMapGrid = false,
shouldSeeWarning = false,
shouldSeeWhatsNew = false
)
)
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList())
)
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
private var workedGrids: Set<String> = emptySet()
private var workedGridQsos: Map<String, List<GridQso>> = emptyMap()
private var roamedGrids: Set<String> = emptySet()
private var lastSyncDate: String = ""
private var lastSyncCallsign: String = ""
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 getLastLotwSyncDate(): String = lastSyncDate
override fun setLastLotwSyncDate(date: String) { lastSyncDate = date }
override fun getLastLotwSyncCallsign(): String = lastSyncCallsign
override fun setLastLotwSyncCallsign(callsign: String) { lastSyncCallsign = callsign }
override fun setSelectedIds(ids: List<Int>) = TODO()
override fun setSelectedTypes(types: List<String>) = TODO()
override fun setPassesSettings(settings: PassesSettings) = 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 getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
override fun updateDatabaseState(state: DatabaseState) = TODO()
override fun updateRCSettings(settings: RCSettings) = TODO()
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
override fun getSatelliteOffset(catnum: Int): String = ""
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 updateLoTWSettings(settings: LoTWSettings) = TODO()
}
}
@@ -375,7 +375,10 @@ fun SharedDialog(
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
.padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp)
.padding(
start = if (onCancel != null) padding else 0.dp,
end = if (onAccept != null) padding else 0.dp
)
)
if (onAccept != null) {
CardButton(
@@ -147,6 +147,7 @@
<string name="prefs_other_title">Otros ajustes</string>
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
<string name="prefs_other_switch_lotw_sync">Sincronizar automáticamente las cuadrículas LoTW confirmadas</string>
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
@@ -146,6 +146,7 @@
<string name="prefs_other_title">Другие настройки</string>
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
<string name="prefs_other_switch_lotw_sync">Автосинхронизация подтверждённых сеток LoTW</string>
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
@@ -147,6 +147,7 @@
<string name="prefs_other_title">වෙනත් සැකසුම්</string>
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
<string name="prefs_other_switch_lotw_sync">LoTW තහවුරු කළ ජාල ස්වයං සමමුහුර්තකරණය</string>
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
<string name="prefs_other_switch_night_mode">රතු රාත්‍රී පෙරහන සබල කරන්න</string>
@@ -189,6 +189,7 @@
<string name="prefs_other_title">Diğer ayarlar</string>
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
<string name="prefs_other_switch_lotw_sync">LoTW onaylı kareleri otomatik senkronize et</string>
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string>
@@ -147,6 +147,7 @@
<string name="prefs_other_title">Інші налаштування</string>
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
<string name="prefs_other_switch_lotw_sync">Автосинхронізація підтверджених сіток LoTW</string>
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
@@ -133,7 +133,7 @@
<string name="prefs_privacy_title">隐私政策</string>
<string name="prefs_updated_title">更新:%s</string>
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
<string name="prefs_updated_time">yyyy年M月d日 HH:mm:ss</string>
<string name="prefs_loc_title">站点位置</string>
<string name="prefs_loc_gps_title">GPS 定位</string>
@@ -160,7 +160,6 @@
<string name="prefs_lotw_callsign">LoTW 呼号</string>
<string name="prefs_lotw_password">LoTW 密码</string>
<string name="prefs_lotw_hint">直接从 ARRL LoTW 拉取该呼号已确认 QSO 的网格并覆盖本地已存集合(当前 %1$d 个)。密码仅保存在手机本地。点"同步"会自动保存凭据并拉取。</string>
<string name="prefs_lotw_sync_duration_hint">同步通常约需 3–10 秒;首次同步或 ARRL 服务器繁忙时可能更久。</string>
<string name="prefs_lotw_sync">同步</string>
<string name="prefs_lotw_syncing">同步中…</string>
<string name="prefs_lotw_configured">已从 LoTW 同步 — 共 %1$d 个网格</string>
@@ -175,6 +174,7 @@
<string name="lotw_sync_rate_hint">下载速度约每 100 条 QSO 需 8 秒(另有 5–40 秒服务器生成时间)</string>
<string name="lotw_sync_progress_connecting">正在连接 LoTW 服务器…</string>
<string name="lotw_sync_progress_downloading">正在下载报告…</string>
<string name="lotw_sync_progress_saving">正在处理下载的数据…</string>
<string name="lotw_sync_progress_count">本次同步 %1$d 条 QSO…</string>
<string name="lotw_sync_progress_qso">本次同步 %1$d 条 QSO,预计还需 %2$d 秒</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
@@ -237,6 +237,7 @@
<string name="prefs_other_title">其他设置</string>
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
<string name="prefs_other_switch_lotw_sync">自动同步 LoTW 已确认网格</string>
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
@@ -195,7 +195,6 @@
<string name="prefs_lotw_callsign">LoTW callsign</string>
<string name="prefs_lotw_password">LoTW password</string>
<string name="prefs_lotw_hint">Fetch confirmed-QSO grids directly from ARRL LoTW for this callsign and replace the stored set (currently %1$d). The password is stored on this device only. Tapping Sync saves the credentials and fetches in one step.</string>
<string name="prefs_lotw_sync_duration_hint">A sync usually takes about 3–10 seconds; the first one or a busy ARRL server can take longer.</string>
<string name="prefs_lotw_sync">Sync</string>
<string name="prefs_lotw_syncing">Syncing…</string>
<string name="prefs_lotw_configured">Synced from LoTW — %1$d grids total</string>
@@ -210,6 +209,7 @@
<string name="lotw_sync_rate_hint">Download runs at ~8 s per 100 QSOs (plus 5–40 s server generation time)</string>
<string name="lotw_sync_progress_connecting">Connecting to LoTW…</string>
<string name="lotw_sync_progress_downloading">Downloading report…</string>
<string name="lotw_sync_progress_saving">Processing downloaded data…</string>
<string name="lotw_sync_progress_count">Syncing %1$d QSOs…</string>
<string name="lotw_sync_progress_qso">Syncing %1$d QSOs, ~%2$d s remaining</string>
<string name="prefs_locator_text">Set station\'s position using locator</string>
@@ -271,6 +271,7 @@
<string name="prefs_other_title">Other settings</string>
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
<string name="prefs_other_switch_update">Enable automatic data update</string>
<string name="prefs_other_switch_lotw_sync">Auto-sync LoTW confirmed grids</string>
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
@@ -226,7 +226,7 @@ class MaidenheadGridOverlay : Overlay() {
if (xRight < 0f || xLeft > canvas.width) continue
val label = cellLabel(lat, lon, zoom)
if (label in roamedGrids && label != ownGrid) {
drawStripes(canvas, xLeft, yTop, xRight, yBottom)
drawStripes(canvas, xLeft, yTop, xRight, yBottom, zoom)
}
}
}
@@ -246,7 +246,7 @@ class MaidenheadGridOverlay : Overlay() {
val xRight = xRightBase + turn * worldWidthPx.toFloat()
if (xRight < 0f || xLeft > canvas.width) continue
if (yBottom < 0f || yTop > canvas.height) continue
drawStripes(canvas, xLeft, yTop, xRight, yBottom)
drawStripes(canvas, xLeft, yTop, xRight, yBottom, zoom)
}
}
}
@@ -396,17 +396,21 @@ class MaidenheadGridOverlay : Overlay() {
* rectangle, from top-left to bottom-right. The paint is nearly opaque so
* stripes stay blue even over a green worked fill underneath.
*/
private fun drawStripes(canvas: Canvas, xLeft: Float, yTop: Float, xRight: Float, yBottom: Float) {
private fun drawStripes(canvas: Canvas, xLeft: Float, yTop: Float, xRight: Float, yBottom: Float, zoom: Double) {
if (xRight <= xLeft || yBottom <= yTop) return
canvas.save()
canvas.clipRect(xLeft, yTop, xRight, yBottom)
val height = yBottom - yTop
// Geographic density is kept constant: spacing is the reference pixel
// spacing (STRIPE_SPACING_PX at the map's max zoom) scaled by 2^(zoom-max),
// so stripes shrink/grow with the map instead of staying fixed on screen.
val spacing = STRIPE_SPACING_PX * Math.pow(2.0, zoom - MAX_GRID_ZOOM).toFloat()
// Start one stripe-width left of the cell so the top-left corner is
// always covered; each stripe runs from (x, top) to (x+height, bottom).
var x = xLeft - height
while (x < xRight) {
canvas.drawLine(x, yTop, x + height, yBottom, roamStripePaint)
x += STRIPE_SPACING_PX
x += spacing
}
canvas.restore()
}
@@ -509,5 +513,12 @@ class MaidenheadGridOverlay : Overlay() {
// 44f spacing (was 16f — too dense per user) with 14f-wide stripes;
// sparse GridMaster-style zebra.
const val STRIPE_SPACING_PX = 44f
/**
* Reference zoom for stripe spacing: the map's max zoom (MapScreen sets
* maxZoomLevel = 7.0). At this zoom stripes are STRIPE_SPACING_PX apart;
* at lower zooms spacing scales by 2^(zoom-max) so the geographic
* density matches the max-zoom look at every level.
*/
const val MAX_GRID_ZOOM = 7.0
}
}
@@ -567,50 +567,63 @@ private fun MutualPassCard(
.clickable(onClick = onClick)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = pass.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
Text(
text = "${timeFormat.format(Date(pass.startTime))} - ${timeFormat.format(Date(pass.endTime))}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Radar shortcut, visible even on the collapsed card: icon only,
// rightmost position so the pass info shifts left. The card's own
// click (expand) is not triggered by the IconButton's tap.
IconButton(onClick = onNavigateToRadar) {
// Header: pass info (name/time + elevations) on the left, and a
// larger radar shortcut on the right that spans both rows vertically.
// The pass info column narrows, so the "Opposite" elevation shifts
// left to make room for the taller button.
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = pass.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Text(
text = "${timeFormat.format(Date(pass.startTime))} - ${timeFormat.format(Date(pass.endTime))}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.mutual_elevation_you, pass.maxElevationA.toInt()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.mutual_elevation_opposite, pass.maxElevationB.toInt()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary
)
}
}
// Radar shortcut, visible even on the collapsed card: a taller
// button spanning both header rows. The card's own click
// (expand) is not triggered by the IconButton's tap.
IconButton(
onClick = onNavigateToRadar,
modifier = Modifier.size(48.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_radar),
contentDescription = stringResource(R.string.mutual_open_radar),
modifier = Modifier.size(18.dp)
modifier = Modifier.size(24.dp)
)
}
}
Spacer(Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.mutual_elevation_you, pass.maxElevationA.toInt()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.mutual_elevation_opposite, pass.maxElevationB.toInt()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary
)
}
AnimatedVisibility(
visible = isExpanded,
@@ -116,4 +116,8 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti
override fun getRoamedGrids(): Set<String> = TODO()
override fun setRoamedGrids(grids: Set<String>) = TODO()
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
override fun getLastLotwSyncDate(): String = ""
override fun setLastLotwSyncDate(date: String) = Unit
override fun getLastLotwSyncCallsign(): String = ""
override fun setLastLotwSyncCallsign(callsign: String) = Unit
}
@@ -101,14 +101,17 @@ fun RadarDestination(navigateUp: () -> Unit) {
}
LaunchedEffect(mutualData.endTime) {
if (mutualData.endTime <= 0L) return@LaunchedEffect
while (true) {
val remainingMs = mutualData.endTime - System.currentTimeMillis()
if (remainingMs <= 0L) {
navigateUpAndClearMutual()
return@LaunchedEffect
}
// Auto-return only while the mutual pass is actually in progress. An
// already-finished pass must NOT bounce the radar page back instantly
// (that made the pass-card radar shortcut look broken: tapping it while
// the computed pass had ended returned to Mutual immediately).
var remainingMs = mutualData.endTime - System.currentTimeMillis()
if (remainingMs <= 0L) return@LaunchedEffect
while (remainingMs > 0L) {
delay(remainingMs.coerceAtMost(1000L))
remainingMs = mutualData.endTime - System.currentTimeMillis()
}
navigateUpAndClearMutual()
}
// Sync actual permission state on every recomposition so it survives screen re-entry
val hasPermission = ContextCompat.checkSelfPermission(
@@ -87,6 +87,7 @@ import androidx.compose.ui.zIndex
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.presentation.CardButton
@@ -233,6 +234,8 @@ fun LoTWDialog(
progress: com.rtbishop.look4sat.core.domain.repository.LoTWProgress?,
message: String?,
dismiss: () -> Unit,
/** Called on cancel/back while a sync is running: aborts the job. */
onCancelSync: () -> Unit,
onSave: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit,
onSyncFull: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit,
onSyncIncremental: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit
@@ -241,8 +244,10 @@ fun LoTWDialog(
val pass = rememberSaveable { mutableStateOf(initialSettings.password) }
SharedDialog(
title = stringResource(R.string.prefs_lotw_title),
onDismissRequest = dismiss,
onCancel = dismiss,
// While syncing, cancel/back aborts the download instead of merely
// hiding the dialog — a full sync started by mistake must be stoppable.
onDismissRequest = { if (isSyncing) onCancelSync() else dismiss() },
onCancel = { if (isSyncing) onCancelSync() else dismiss() },
// The two sync buttons below are the primary actions: saving
// credentials and fetching confirmed grids happen in one step.
onAccept = null
@@ -276,6 +281,11 @@ fun LoTWDialog(
progress == null ||
progress.phase == com.rtbishop.look4sat.core.domain.repository.LoTWPhase.Connecting ->
stringResource(R.string.lotw_sync_progress_connecting)
// Download finished (or the size estimate reached its cap):
// parsing and saving may still run — say so instead of the bar
// sitting at 100% with no feedback, which reads as "stuck".
progress.fraction >= 1f ->
stringResource(R.string.lotw_sync_progress_saving)
progress.qsoCount > 0 && progress.remainingSeconds > 0 ->
stringResource(R.string.lotw_sync_progress_qso, progress.qsoCount, progress.remainingSeconds)
progress.qsoCount > 0 ->
@@ -307,12 +317,6 @@ fun LoTWDialog(
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
Text(
text = stringResource(R.string.prefs_lotw_sync_duration_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
if (message != null) {
Text(
@@ -323,9 +327,10 @@ fun LoTWDialog(
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
) {
// Full sync bottom-left, incremental merge bottom-right.
CardButton(
onClick = { onSyncFull(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value)) },
text = stringResource(R.string.lotw_sync_full),
@@ -67,6 +67,7 @@ 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.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.MainTheme
@@ -223,6 +224,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
progress = uiState.lotwProgress,
message = lotwErrorMessage,
dismiss = { dialogs.lotw = false },
onCancelSync = { onAction(SettingsAction.CancelLoTWSync); dialogs.lotw = false },
onSave = { onAction(SettingsAction.UpdateLoTW(it)) },
onSyncFull = { onAction(SettingsAction.SyncLoTWGrids(it, LoTWSyncMode.Full)) },
onSyncIncremental = { onAction(SettingsAction.SyncLoTWGrids(it, LoTWSyncMode.Incremental)) }
@@ -336,6 +338,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
LoTWCard(
settings = uiState.lotwSettings,
workedGridsCount = uiState.workedGridsCount,
lastSyncEpochMs = uiState.lotwLastSyncEpochMs,
showLoTWDialog = { dialogs.lotw = true }
)
}
@@ -535,7 +538,7 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.height(272.dp)
.height(320.dp)
) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
@@ -548,6 +551,9 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
onAction(SettingsAction.ToggleUpdate(it))
}
SwitchRow(R.string.prefs_other_switch_lotw_sync, settings.stateOfAutoLotwSync) {
onAction(SettingsAction.ToggleAutoLotwSync(it))
}
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
onAction(SettingsAction.ToggleSweep(it))
}
@@ -577,6 +583,7 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole
private fun LoTWCard(
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
workedGridsCount: Int,
lastSyncEpochMs: Long,
showLoTWDialog: () -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
@@ -595,6 +602,14 @@ private fun LoTWCard(
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)
// Last successful sync, shown exactly like the ephemeris update time.
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 = showLoTWDialog,
@@ -24,6 +24,7 @@ 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.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import java.io.File
data class PositionSettings(
@@ -64,12 +65,11 @@ data class SettingsState(
val lotwSyncMode: LoTWSyncMode? = null,
val lotwProgress: com.rtbishop.look4sat.core.domain.repository.LoTWProgress? = null,
val lotwError: LoTWError? = null,
/** Epoch ms of the last successful LoTW sync (0 = never) — shown like the ephemeris update time. */
val lotwLastSyncEpochMs: Long = 0L,
val updateChecker: UpdateCheckerState = UpdateCheckerState()
)
/** What a LoTW sync button does: pull everything, or only new QSLs since the last sync. */
enum class LoTWSyncMode { Full, Incremental }
/** LoTW sync failure, kept as a translatable code until the UI renders it. */
sealed interface LoTWError {
data object NotConfigured : LoTWError
@@ -95,6 +95,7 @@ sealed interface SettingsAction {
// Toggles
data class ToggleUtc(val value: Boolean) : SettingsAction
data class ToggleUpdate(val value: Boolean) : SettingsAction
data class ToggleAutoLotwSync(val value: Boolean) : SettingsAction
data class ToggleSweep(val value: Boolean) : SettingsAction
data class ToggleSensor(val value: Boolean) : SettingsAction
data class ToggleLightTheme(val value: Boolean) : SettingsAction
@@ -117,6 +118,8 @@ sealed interface SettingsAction {
val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
val mode: LoTWSyncMode
) : SettingsAction
/** Abort an in-flight LoTW sync (wrong button / changed mind). */
data object CancelLoTWSync : SettingsAction
// Update checker
data object CheckForUpdate : SettingsAction
@@ -27,6 +27,12 @@ import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult
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.IWavelogRepository
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
@@ -49,6 +55,8 @@ class SettingsViewModel(
private val defaultPosSettings = PositionSettings(false, settingsRepo.stationPosition.value, 0)
private val defaultDataSettings = DataSettings(false, 0, 0, 0L)
/** In-flight LoTW sync job, cancelled by CancelLoTWSync. */
private var lotwSyncJob: kotlinx.coroutines.Job? = null
private val _uiState = MutableStateFlow(
SettingsState(
appVersionName = settingsRepo.appVersionName,
@@ -60,7 +68,8 @@ class SettingsViewModel(
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
wavelogSettings = settingsRepo.wavelogSettings.value,
workedGridsCount = settingsRepo.getWorkedGrids().size
workedGridsCount = settingsRepo.getWorkedGrids().size,
lotwLastSyncEpochMs = lotwCursorEpochMs(settingsRepo.getLastLotwSyncDate())
)
)
@@ -145,6 +154,7 @@ class SettingsViewModel(
// Toggles
is SettingsAction.ToggleUtc -> settingsRepo.updateOtherSettings { it.copy(stateOfUtc = action.value) }
is SettingsAction.ToggleUpdate -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoUpdate = action.value) }
is SettingsAction.ToggleAutoLotwSync -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoLotwSync = action.value) }
is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) }
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
@@ -159,6 +169,7 @@ class SettingsViewModel(
// LoTW confirmed grids
is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode)
SettingsAction.CancelLoTWSync -> cancelLoTWSync()
// Update checker
SettingsAction.CheckForUpdate -> checkForUpdate()
SettingsAction.DownloadUpdate -> downloadUpdate()
@@ -205,75 +216,53 @@ class SettingsViewModel(
// a first sync, an unknown/empty record or a callsign change must fall
// back to a full report so no stale grids from another account linger.
val callsign = settings.callsign.trim().uppercase()
val lastCallsign = settingsRepo.getLastLotwSyncCallsign()
val effectiveMode = if (mode == LoTWSyncMode.Incremental &&
lastCallsign.isNotBlank() && lastCallsign == callsign
) LoTWSyncMode.Incremental else LoTWSyncMode.Full
val effectiveMode = resolveLoTWSyncMode(settingsRepo.getLastLotwSyncCallsign(), callsign, mode)
val since = if (effectiveMode == LoTWSyncMode.Incremental) {
settingsRepo.getLastLotwSyncDate()
lotwCursorApi(settingsRepo.getLastLotwSyncDate())
} else ""
_uiState.update {
it.copy(lotwSyncing = true, lotwSyncMode = effectiveMode, lotwProgress = null, lotwError = null)
}
viewModelScope.launch {
lotwSyncJob = viewModelScope.launch {
val result = lotwRepo.fetchConfirmedGridQsos(callsign, settings.password, since) { progress ->
_uiState.update { it.copy(lotwProgress = progress) }
}
when (result) {
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.Success -> {
is LoTWResult.Success -> {
// Incremental: merge new grids/QSOs into the stored set (dedup
// by call + QSO time); full: the fresh report replaces it all.
val existingGrids = settingsRepo.getWorkedGrids()
val existingQsos = settingsRepo.getWorkedGridQsos()
val existingRoamed = settingsRepo.getRoamedGrids()
val mergedGrids = if (effectiveMode == LoTWSyncMode.Incremental) {
existingGrids + result.grids
} else result.grids
val mergedQsos = if (effectiveMode == LoTWSyncMode.Incremental) {
mergeGridQsos(existingQsos, result.qsos)
} else result.qsos
val mergedRoamed = if (effectiveMode == LoTWSyncMode.Incremental) {
existingRoamed + result.roamedGrids
} else result.roamedGrids
settingsRepo.setWorkedGrids(mergedGrids)
settingsRepo.setWorkedGridQsos(mergedQsos)
settingsRepo.setRoamedGrids(mergedRoamed)
// Remember when/what we synced, so the next incremental pull
// asks LoTW for only the confirmations since today.
val today = java.text.SimpleDateFormat("yyyyMMdd", java.util.Locale.US)
.apply { timeZone = java.util.TimeZone.getTimeZone("UTC") }
.format(java.util.Date())
settingsRepo.setLastLotwSyncDate(today)
settingsRepo.setLastLotwSyncCallsign(callsign)
// Shared with the automatic sync on app start (MainApplication).
val mergedGridsCount = applyLoTWGridResult(settingsRepo, result, effectiveMode, callsign)
_uiState.update { state ->
state.copy(
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
workedGridsCount = mergedGrids.size, lotwError = null
workedGridsCount = mergedGridsCount, lotwError = null,
lotwLastSyncEpochMs = lotwCursorEpochMs(settingsRepo.getLastLotwSyncDate())
)
}
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.BadCredentials ->
is LoTWResult.BadCredentials ->
_uiState.update { state ->
state.copy(
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
lotwError = LoTWError.BadCredentials
)
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.RateLimited ->
is LoTWResult.RateLimited ->
_uiState.update { state ->
state.copy(
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
lotwError = LoTWError.RateLimited
)
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.Timeout ->
is LoTWResult.Timeout ->
_uiState.update { state ->
state.copy(
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
lotwError = LoTWError.Timeout
)
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.NetworkError ->
is LoTWResult.NetworkError ->
_uiState.update { state ->
state.copy(
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
@@ -284,20 +273,11 @@ class SettingsViewModel(
}
}
/** Merge fresh QSO detail into existing per-grid lists, dedup by call + QSO time. */
private fun mergeGridQsos(
existing: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>,
fresh: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>
): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> {
val merged = existing.mapValues { (_, list) -> list.toMutableList() }.toMutableMap()
fresh.forEach { (grid, list) ->
val target = merged.getOrPut(grid) { mutableListOf() }
val known = target.mapTo(mutableSetOf()) { it.call to it.epochMs }
list.forEach { qso ->
if (known.add(qso.call to qso.epochMs)) target.add(qso)
}
}
return merged
/** Abort the in-flight LoTW sync job and reset its progress state. */
private fun cancelLoTWSync() {
lotwSyncJob?.cancel()
lotwSyncJob = null
_uiState.update { it.copy(lotwSyncing = false, lotwSyncMode = null, lotwProgress = null) }
}
// endregion
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "475"
appVersionCode = "477"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.7-ba7opf.5"
appVersionName = "4.4.7-ba7opf.7"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry