mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 19:17:21 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcd16a0196 | ||
|
|
9e88609efc | ||
|
|
322becdda8 | ||
|
|
7f93a10556 | ||
|
|
3cd5b49f98 | ||
|
|
4dbe896124 | ||
|
|
b47bd78ecd | ||
|
|
c89e79e28f | ||
|
|
b40a79e3c6 | ||
|
|
6c23696e22 | ||
|
|
f997d95cd4 | ||
|
|
e0a1d279ce | ||
|
|
059c816075 | ||
|
|
60dfa5d600 | ||
|
|
5f1eb239ed | ||
|
|
01ca06b43f | ||
|
|
22363c2549 | ||
|
|
45fe755470 | ||
|
|
0ce177cfbf |
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})")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ import com.rtbishop.look4sat.core.presentation.Screen
|
||||
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
||||
import com.rtbishop.look4sat.feature.map.MapDestination
|
||||
import com.rtbishop.look4sat.feature.map.MapFilterViewModel
|
||||
import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
||||
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||
@@ -169,6 +170,12 @@ fun MainScreen(
|
||||
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||
factory = MutualViewModel.factory(container)
|
||||
)
|
||||
// Activity-scoped so the map's award filter survives page switches;
|
||||
// resets to VUCC only on cold start (fresh process).
|
||||
val mapFilterViewModel: MapFilterViewModel = viewModel(
|
||||
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||
factory = MapFilterViewModel.factory()
|
||||
)
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalElevationThresholds provides ElevationThresholds(
|
||||
@@ -237,7 +244,7 @@ fun MainScreen(
|
||||
)
|
||||
}
|
||||
entry<Screen.Map> {
|
||||
MapDestination()
|
||||
MapDestination(mapFilterViewModel = mapFilterViewModel)
|
||||
}
|
||||
entry<Screen.Mutual> {
|
||||
MutualScreen(
|
||||
|
||||
+3
@@ -35,6 +35,9 @@ internal class CoreDataPlugin : Plugin<Project> {
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.kotlin.coroutines)
|
||||
implementation(libs.other.okhttp)
|
||||
// android.jar's org.json is a stub ("not mocked") in unit tests;
|
||||
// provide a real implementation so JSON parsing is testable.
|
||||
testImplementation(libs.test.json)
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -221,7 +221,22 @@ class AmSatRepository(
|
||||
}
|
||||
}
|
||||
val days = (0 until 3).map { d ->
|
||||
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
|
||||
// 当天最近连续相同状态报告数:从最新一条往回数,遇状态不同即停。
|
||||
// 空时段(无报告)不打断;按状态文本严格比较,不跨天。
|
||||
val dayStart = nowSec - (d + 1) * 86400L
|
||||
val dayEnd = nowSec - d * 86400L
|
||||
val dayReports = byName[name].orEmpty()
|
||||
.filter { it.reportedTimeUtcSec in dayStart until dayEnd }
|
||||
.sortedByDescending { it.reportedTimeUtcSec }
|
||||
val streakCount = if (dayReports.isEmpty()) 0 else {
|
||||
val newestStatus = dayReports.first().report
|
||||
dayReports.takeWhile { it.report == newestStatus }.size
|
||||
}
|
||||
SatDay(
|
||||
dateLabel = labels[d],
|
||||
slots = slots.subList(d * 12, (d + 1) * 12),
|
||||
streakCount = streakCount
|
||||
)
|
||||
}
|
||||
SatStatus(name = name, days = days)
|
||||
}
|
||||
|
||||
+17
-5
@@ -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
|
||||
}
|
||||
}
|
||||
+19
-2
@@ -111,12 +111,29 @@ class SelectionRepo(
|
||||
/**
|
||||
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
|
||||
* and lowercases the query once up front instead of per-item.
|
||||
*
|
||||
* Fuzzy search: the query is split into space-separated tokens and every
|
||||
* token must appear in the satellite name after both sides are normalized
|
||||
* (lowercased, non-alphanumeric separators such as dashes, spaces, brackets
|
||||
* and dots stripped). This makes "ao7" match "AO-7 (AMSAT-OSCAR 7)" and
|
||||
* "iss zarya" match "ISS (ZARYA)" — exact continuous-substring matching
|
||||
* previously failed whenever the name contained a separator the query lacked.
|
||||
*/
|
||||
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
|
||||
if (query.isBlank()) return items
|
||||
val catnum = query.toIntOrNull()
|
||||
if (catnum != null) return items.filter { it.catnum == catnum }
|
||||
val lowerQuery = query.lowercase()
|
||||
return items.filter { it.name.lowercase().contains(lowerQuery) }
|
||||
val tokens = query.split(' ')
|
||||
.map { normalizeForSearch(it) }
|
||||
.filter { it.isNotEmpty() }
|
||||
if (tokens.isEmpty()) return items
|
||||
return items.filter { item ->
|
||||
val normalizedName = normalizeForSearch(item.name)
|
||||
tokens.all { normalizedName.contains(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Lowercases and strips all non-alphanumeric chars for fuzzy matching. */
|
||||
private fun normalizeForSearch(text: String): String =
|
||||
text.lowercase().filter { it.isLetterOrDigit() }
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
+66
-2
@@ -12,6 +12,10 @@ import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
import java.io.InputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
class AmSatRepositoryTest {
|
||||
|
||||
@@ -75,6 +79,52 @@ class AmSatRepositoryTest {
|
||||
assertEquals(1, remoteSource.catalogRequests)
|
||||
assertEquals(1, remoteSource.reportRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildStatusesStreakCountsConsecutiveSameStatusReportsPerDay() = runTest {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
val remoteSource = FakeAmSatRemoteSource(
|
||||
reportsJson = amSatReportsJson(
|
||||
// 当天(day 0)最新槽(slot 0)混合状态:最新 Heard、次新 Heard、再旧 Not Heard → 连续=2
|
||||
report("r1", "AO-7", "Heard", nowSec - 3600),
|
||||
report("r2", "AO-7", "Heard", nowSec - 5400),
|
||||
report("r3", "AO-7", "Not Heard", nowSec - 7000),
|
||||
// 空时段(无报告)不打断:slot 8 的 Heard 更旧,不应计入(被 r3 打断)
|
||||
report("r4", "AO-7", "Heard", nowSec - 63000),
|
||||
// 前一天(day 1):最新 Heard,更旧的 Not Heard → 连续=1,且不跨天合并
|
||||
report("r5", "AO-7", "Heard", nowSec - 90000),
|
||||
report("r6", "AO-7", "Not Heard", nowSec - 100000)
|
||||
// day 2 无报告 → 0
|
||||
)
|
||||
)
|
||||
val repository = AmSatRepository(remoteSource)
|
||||
|
||||
val page = repository.fetchStatus()
|
||||
val status = page?.statuses?.single()
|
||||
assertEquals("AO-7", status?.name)
|
||||
assertEquals(3, status?.days?.size)
|
||||
|
||||
assertEquals(2, status?.days?.get(0)?.streakCount)
|
||||
assertEquals(1, status?.days?.get(1)?.streakCount)
|
||||
assertEquals(0, status?.days?.get(2)?.streakCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildStatusesStreakCountsAllReportsWhenStatusNeverChanges() = runTest {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
val remoteSource = FakeAmSatRemoteSource(
|
||||
reportsJson = amSatReportsJson(
|
||||
report("r1", "AO-7", "Heard", nowSec - 1800),
|
||||
report("r2", "AO-7", "Heard", nowSec - 3600),
|
||||
report("r3", "AO-7", "Heard", nowSec - 5400),
|
||||
report("r4", "AO-7", "Heard", nowSec - 30000)
|
||||
)
|
||||
)
|
||||
val repository = AmSatRepository(remoteSource)
|
||||
|
||||
val page = repository.fetchStatus()
|
||||
assertEquals(4, page?.statuses?.single()?.days?.get(0)?.streakCount)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
|
||||
@@ -83,7 +133,10 @@ private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
|
||||
cacheField.set(this, page)
|
||||
}
|
||||
|
||||
private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L) : IRemoteSource {
|
||||
private class FakeAmSatRemoteSource(
|
||||
private val responseDelayMillis: Long = 0L,
|
||||
private val reportsJson: String = """{"data":[]}"""
|
||||
) : IRemoteSource {
|
||||
var catalogRequests = 0
|
||||
var reportRequests = 0
|
||||
|
||||
@@ -100,8 +153,19 @@ private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L)
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? {
|
||||
if (responseDelayMillis > 0L) delay(responseDelayMillis)
|
||||
reportRequests += 1
|
||||
return """{"data":[]}"""
|
||||
return reportsJson
|
||||
}
|
||||
|
||||
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
||||
}
|
||||
|
||||
private fun amSatReportsJson(vararg reports: String): String =
|
||||
"""{"data":[${reports.joinToString(",")}]}"""
|
||||
|
||||
private fun report(id: String, name: String, status: String, epochSec: Long): String =
|
||||
"""{"id":"$id","name":"$name","callsign":"BA7OPF","report":"$status","grid_square":"OL62","reported_time":"${isoUtc(epochSec)}"}"""
|
||||
|
||||
private fun isoUtc(epochSec: Long): String =
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}.format(Date(epochSec * 1000))
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
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.SatItem
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SelectionRepoSearchTest {
|
||||
|
||||
private val sampleItems = listOf(
|
||||
SatItem(catnum = 25544, name = "ISS (ZARYA)"),
|
||||
SatItem(catnum = 7530, name = "AO-7 (AMSAT-OSCAR 7)"),
|
||||
SatItem(catnum = 39444, name = "AO-73 (FUNcube-1)"),
|
||||
SatItem(catnum = 43803, name = "JO-97 (BIRDS-3)"),
|
||||
SatItem(catnum = 99999, name = "FO-29"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `query without separators matches name with dashes spaces brackets`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("ao7")
|
||||
val results = repo.getEntriesFlow().first()
|
||||
// "ao7" is a substring of the normalized "AO-73 (FUNcube-1)" too, so a
|
||||
// fuzzy search legitimately returns both AO-7 (first) and AO-73. The
|
||||
// key guarantee is that AO-7 — which was unreachable before because of
|
||||
// the dashes/brackets — is now found.
|
||||
assertTrue(results.map { it.catnum }.contains(7530))
|
||||
assertEquals(7530, results.first().catnum)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query without separators matches name with only dashes`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("fo29")
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(listOf(99999), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `space-separated tokens all must match in any order`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("zarya iss")
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(listOf(25544), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exact name query still matches`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("AO-7 (AMSAT-OSCAR 7)")
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(listOf(7530), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `partial token query matches substring`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("funcube")
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(listOf(39444), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `numeric query matches catnum exactly`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("25544")
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(listOf(25544), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no query returns all items`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("")
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(sampleItems.size, results.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unmatched query returns nothing`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("zzzznomatch")
|
||||
assertTrue(repo.getEntriesFlow().first().isEmpty())
|
||||
}
|
||||
|
||||
private fun createRepo(items: List<SatItem>): ISelectionRepo {
|
||||
return SelectionRepo(
|
||||
dispatcher = Dispatchers.Unconfined,
|
||||
localSource = FakeLocalSourceForSearch(items),
|
||||
settingsRepo = FakeSettingsRepoForSearch()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeLocalSourceForSearch(private val items: List<SatItem>) : ILocalSource {
|
||||
override suspend fun getEntriesTotal(): Int = items.size
|
||||
override suspend fun getEntriesList(): List<SatItem> = items
|
||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||
override suspend fun insertEntries(entries: List<OrbitalData>) = Unit
|
||||
override suspend fun deleteEntries() = Unit
|
||||
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||
override suspend fun getRadiosTotal(): Int = 0
|
||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) = Unit
|
||||
override suspend fun deleteManagedRadios() = Unit
|
||||
override suspend fun deleteRadios() = Unit
|
||||
}
|
||||
|
||||
private class FakeSettingsRepoForSearch : ISettingsRepo {
|
||||
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 = 0.0, selectedModes = emptyList())
|
||||
)
|
||||
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||
override val databaseState: MutableStateFlow<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(
|
||||
false, false, false, false, false, false, false,
|
||||
shouldSeeWarning = false, shouldSeeWhatsNew = false
|
||||
)
|
||||
)
|
||||
override val dataSourcesSettings: MutableStateFlow<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<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
|
||||
override val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||
override fun setSelectedTypes(types: List<String>) = Unit
|
||||
override fun setPassesSettings(settings: PassesSettings) = Unit
|
||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
|
||||
override fun setStationPosition(): Boolean = true
|
||||
override fun setStationPosition(locator: String): Boolean = true
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
|
||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = Unit
|
||||
override fun updateDatabaseState(state: DatabaseState) = Unit
|
||||
override fun updateRCSettings(settings: RCSettings) = Unit
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
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()
|
||||
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) = Unit
|
||||
override fun getRoamedGrids(): Set<String> = emptySet()
|
||||
override fun setRoamedGrids(grids: Set<String>) = Unit
|
||||
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit
|
||||
override fun getLastLotwSyncDate(): String = ""
|
||||
override fun setLastLotwSyncDate(date: String) = Unit
|
||||
override fun getLastLotwSyncCallsign(): String = ""
|
||||
override fun setLastLotwSyncCallsign(callsign: String) = Unit
|
||||
}
|
||||
@@ -20,7 +20,8 @@ data class SatSlot(
|
||||
/** One satellite day (12 two-hour slots) */
|
||||
data class SatDay(
|
||||
val dateLabel: String, // "Aug 4"
|
||||
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
||||
val slots: List<SatSlot>, // 12 槽(00-02 ... 22-24)
|
||||
val streakCount: Int = 0 // 当天最近连续相同状态报告数(0 = 当天无报告)
|
||||
)
|
||||
|
||||
/** One satellite, 3 days of state */
|
||||
|
||||
@@ -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(
|
||||
|
||||
+169
@@ -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 }
|
||||
+290
@@ -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()
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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>
|
||||
@@ -207,7 +208,7 @@
|
||||
\n• Alexandru Csete (Gpredict)
|
||||
\n• Dr T.S. Kelso (Celestrak)
|
||||
\n• Libre Space Foundation (SatNOGS)
|
||||
\n• BG7NTA (CW decoding feature)</string>
|
||||
\n• BG7NTA BG5JSU (CW decoding feature)</string>
|
||||
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
|
||||
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -256,7 +257,7 @@
|
||||
\n• Dr T.S. Kelso (Celestrak)
|
||||
\n• Libre Space Foundation (SatNOGS)
|
||||
\n• xdsopl 和 Robot36 贡献者!
|
||||
\n• BG7NTA(CW解码功能)</string>
|
||||
\n• BG7NTA BG5JSU(CW解码功能)</string>
|
||||
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
||||
|
||||
<!-- 匹配页 Mutual match page -->
|
||||
|
||||
@@ -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>
|
||||
@@ -290,7 +291,7 @@
|
||||
\n* Dr T.S. Kelso (Celestrak)
|
||||
\n* Libre Space Foundation (SatNOGS)
|
||||
\n* xdsopl and Robot36 contributors!
|
||||
\n* BG7NTA (CW decoding feature)
|
||||
\n* BG7NTA BG5JSU (CW decoding feature)
|
||||
</string>
|
||||
<string name="prefs_outro_license">The app comes with no warranty</string>
|
||||
|
||||
|
||||
+15
-4
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.rtbishop.look4sat.feature.map
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.model.AwardType
|
||||
|
||||
/**
|
||||
* Session-scoped holder for the map's award filter selection.
|
||||
*
|
||||
* Scoped to the Activity (not the navigation entry) so the selection survives
|
||||
* switching to another page and back; it resets to VUCC only when the process
|
||||
* starts fresh (cold start), which is what the user expects as the default.
|
||||
*/
|
||||
class MapFilterViewModel : ViewModel() {
|
||||
|
||||
/** Last chosen award chip; null means "All". Initialized to VUCC once per process. */
|
||||
val selectedAward: MutableState<AwardType?> = mutableStateOf(AwardType.VUCC)
|
||||
|
||||
/** Last map viewport (center + zoom), saved when leaving the page. Null until first visit. */
|
||||
var mapCenterLat: Double? = null
|
||||
var mapCenterLon: Double? = null
|
||||
var mapZoom: Double? = null
|
||||
|
||||
/** Persists the current viewport so a later re-entry restores exactly where the user left off. */
|
||||
fun saveMapViewState(centerLat: Double, centerLon: Double, zoom: Double) {
|
||||
mapCenterLat = centerLat
|
||||
mapCenterLon = centerLon
|
||||
mapZoom = zoom
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory() = viewModelFactory {
|
||||
initializer { MapFilterViewModel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,13 +144,30 @@ private val moonIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MapDestination() {
|
||||
fun MapDestination(
|
||||
mapFilterViewModel: MapFilterViewModel
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val mapView = rememberMapViewWithLifecycle()
|
||||
val mapView = rememberMapViewWithLifecycle(mapFilterViewModel, restoreViewport = uiState.isGridMode)
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
// Save the viewport when leaving the page, but only in grid mode: the
|
||||
// satellite mode intentionally keeps its original behavior (default center,
|
||||
// follow the selected satellite), so a satellite-mode exit must not
|
||||
// overwrite the last grid-mode viewport.
|
||||
DisposableEffect(mapView, uiState.isGridMode) {
|
||||
onDispose {
|
||||
if (uiState.isGridMode) {
|
||||
mapFilterViewModel.saveMapViewState(
|
||||
mapView.mapCenter.latitude,
|
||||
mapView.mapCenter.longitude,
|
||||
mapView.zoomLevelDouble
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
DisposableEffect(lifecycle) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
@@ -165,11 +182,16 @@ fun MapDestination() {
|
||||
viewModel.onAction(MapAction.SetVisible(false))
|
||||
}
|
||||
}
|
||||
MapScreen(uiState, viewModel::onAction, mapView)
|
||||
MapScreen(uiState, viewModel::onAction, mapView, mapFilterViewModel)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView: MapView) {
|
||||
private fun MapScreen(
|
||||
uiState: MapState,
|
||||
onAction: (MapAction) -> Unit,
|
||||
mapView: MapView,
|
||||
mapFilterViewModel: MapFilterViewModel
|
||||
) {
|
||||
val rotateMod = Modifier.rotate(180f)
|
||||
val timeString = uiState.mapData?.aosTime ?: "00:00:00"
|
||||
val isTimeAos = uiState.mapData?.isTimeAos ?: true
|
||||
@@ -177,9 +199,9 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
// 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) }
|
||||
// Selected award filter. Entering grid mode defaults to VUCC (the plain
|
||||
// worked-grid view); the reset effect below re-asserts that on every entry.
|
||||
var selectedAward by remember { mutableStateOf<AwardType?>(AwardType.VUCC) }
|
||||
// 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
|
||||
// Six-award progress derived from the confirmed QSO store; recomputed when
|
||||
// the store changes (LoTW/Wavelog sync).
|
||||
// Per operated-grid VUCC breakdown: myGrid -> set of worked grids worked from it.
|
||||
@@ -215,6 +237,14 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
else vuccByMyGrid[selectedMyGrid] ?: emptySet()
|
||||
}
|
||||
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
|
||||
// follow-the-satellite behavior); this implies grid mode was already on
|
||||
// when re-entering, which is why the grid-mode auto-centering below can
|
||||
// treat the entry as already centered.
|
||||
val restoredViewport = remember {
|
||||
isGridMode && mapFilterViewModel.mapCenterLat != null && mapFilterViewModel.mapZoom != null
|
||||
}
|
||||
DisposableEffect(isGridMode, workedGrids) {
|
||||
val receiver = object : org.osmdroid.events.MapEventsReceiver {
|
||||
override fun singleTapConfirmedHelper(p: GeoPoint?): Boolean {
|
||||
@@ -240,12 +270,6 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
(mapView.overlays.getOrNull(OVERLAY_GRID) as? MaidenheadGridOverlay)?.selectedGrid = selectedGrid
|
||||
mapView.invalidate()
|
||||
}
|
||||
// Re-assert the VUCC default each time grid mode is entered; while already
|
||||
// in grid mode the user's chip choice is preserved.
|
||||
LaunchedEffect(uiState.isGridMode) {
|
||||
if (uiState.isGridMode) selectedAward = AwardType.VUCC
|
||||
}
|
||||
|
||||
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
|
||||
@@ -281,9 +305,14 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
}
|
||||
ElevatedCard(modifier = Modifier.weight(1f)) {
|
||||
Box(contentAlignment = Alignment.BottomCenter) {
|
||||
// Track grid-mode transitions; initial value false so that entering
|
||||
// the map page with grid mode already ON also centers the map.
|
||||
var prevGridMode by remember { mutableStateOf(false) }
|
||||
// Track grid-mode transitions. When the map was just restored to
|
||||
// the viewport the user left (re-entry after a page switch) and
|
||||
// grid mode is already on, treat the entry as already centered so
|
||||
// the restored position survives. Cold-start entries and in-page
|
||||
// toggles still center on the station grid as before.
|
||||
var prevGridMode by remember {
|
||||
mutableStateOf(restoredViewport && uiState.isGridMode)
|
||||
}
|
||||
AndroidView({ mapView }) { view ->
|
||||
// Award filter mode: show the selected award's regions with
|
||||
// worked ones filled green; hide satellite layers like grid mode.
|
||||
@@ -297,6 +326,10 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
prevGridMode = uiState.isGridMode
|
||||
} else {
|
||||
// Center on the station grid whenever entering grid mode.
|
||||
// (prevGridMode is initialized to "already centered" when
|
||||
// the map was just restored to the viewport the user left,
|
||||
// so a re-entry keeps the restored position; in-page
|
||||
// toggles still center normally.)
|
||||
// Only mark the transition as consumed once a real position
|
||||
// was available; otherwise a first frame with a null
|
||||
// stationPosition would swallow the centering forever.
|
||||
@@ -1173,7 +1206,10 @@ private fun setMoonPosition(moonLatDeg: Double, moonLonDeg: Double, mapView: Map
|
||||
|
||||
// region MapView lifecycle
|
||||
@Composable
|
||||
private fun rememberMapViewWithLifecycle(): MapView {
|
||||
private fun rememberMapViewWithLifecycle(
|
||||
mapFilterViewModel: MapFilterViewModel,
|
||||
restoreViewport: Boolean
|
||||
): MapView {
|
||||
val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
|
||||
val context = LocalContext.current
|
||||
val isVertical = isVerticalLayout()
|
||||
@@ -1189,8 +1225,19 @@ private fun rememberMapViewWithLifecycle(): MapView {
|
||||
setTileSource(tileSource)
|
||||
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels, isVertical)
|
||||
maxZoomLevel = 7.0
|
||||
controller.setCenter(GeoPoint(48.8575, 6.3514))
|
||||
controller.setZoom(minZoomLevel + 2)
|
||||
// Restore the viewport the user left on the previous visit, but only
|
||||
// in grid mode: satellite mode intentionally keeps its original
|
||||
// behavior (default center, then follows the selected satellite).
|
||||
val savedLat = mapFilterViewModel.mapCenterLat
|
||||
val savedLon = mapFilterViewModel.mapCenterLon
|
||||
val savedZoom = mapFilterViewModel.mapZoom
|
||||
if (restoreViewport && savedLat != null && savedLon != null && savedZoom != null) {
|
||||
controller.setCenter(GeoPoint(savedLat, savedLon))
|
||||
controller.setZoom(savedZoom)
|
||||
} else {
|
||||
controller.setCenter(GeoPoint(48.8575, 6.3514))
|
||||
controller.setZoom(minZoomLevel + 2)
|
||||
}
|
||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
||||
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
|
||||
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
|
||||
@@ -1208,6 +1255,7 @@ private fun rememberMapViewWithLifecycle(): MapView {
|
||||
}
|
||||
// The overlay caches below are file-level (shared across MapView instances), so they must be
|
||||
// released with the MapView or they keep the Activity and its bitmaps alive after disposal.
|
||||
// (The viewport save lives in MapDestination, where the grid/satellite mode is known.)
|
||||
DisposableEffect(mapView) {
|
||||
onDispose { clearMapCaches() }
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
@@ -49,11 +50,13 @@ import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -74,6 +77,7 @@ import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@Composable
|
||||
fun MutualScreen(
|
||||
@@ -150,6 +154,7 @@ fun MutualScreen(
|
||||
}
|
||||
) { isVertical ->
|
||||
MutualContent(
|
||||
viewModel = viewModel,
|
||||
state = state,
|
||||
isVertical = isVertical,
|
||||
onQuery = viewModel::queryMutualPasses,
|
||||
@@ -172,6 +177,7 @@ fun MutualScreen(
|
||||
|
||||
@Composable
|
||||
private fun MutualContent(
|
||||
viewModel: MutualViewModel,
|
||||
state: MutualUiState,
|
||||
isVertical: Boolean,
|
||||
onQuery: () -> Unit,
|
||||
@@ -195,8 +201,28 @@ private fun MutualContent(
|
||||
}
|
||||
}
|
||||
|
||||
// List state survives page switches via the Activity-scoped ViewModel: the
|
||||
// initial position is restored from the VM, and scrolling writes back to it,
|
||||
// so returning to this page keeps exactly where the user left off. A new
|
||||
// query (queryGeneration bump) rebuilds the state at the top.
|
||||
val listState = remember(viewModel.queryGeneration) {
|
||||
LazyListState(
|
||||
firstVisibleItemIndex = viewModel.listScrollIndex,
|
||||
firstVisibleItemScrollOffset = viewModel.listScrollOffset
|
||||
)
|
||||
}
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||
.distinctUntilChanged()
|
||||
.collect { (index, offset) ->
|
||||
viewModel.listScrollIndex = index
|
||||
viewModel.listScrollOffset = offset
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
// Error message
|
||||
@@ -567,50 +593,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,
|
||||
|
||||
@@ -69,6 +69,15 @@ class MutualViewModel(
|
||||
private val _uiState = MutableStateFlow(MutualUiState())
|
||||
val uiState: StateFlow<MutualUiState> = _uiState.asStateFlow()
|
||||
|
||||
// Session-scoped scroll position of the pass list (Activity-scoped VM, so it
|
||||
// survives switching to another page and back). Reset to the top whenever a
|
||||
// new query replaces the results.
|
||||
var listScrollIndex: Int = 0
|
||||
var listScrollOffset: Int = 0
|
||||
|
||||
/** Bumped on every new query so the list scrolls back to the top. */
|
||||
var queryGeneration: Int = 0
|
||||
|
||||
init {
|
||||
// Pre-fill station A with the user's current station position (as grid),
|
||||
// and default min elevation to the same value used by the main radar passes
|
||||
@@ -201,6 +210,11 @@ class MutualViewModel(
|
||||
selectedPassIndex = -1
|
||||
)
|
||||
}
|
||||
// The results list is about to be replaced, so the scroll position must
|
||||
// not leak from the previous query's list.
|
||||
listScrollIndex = 0
|
||||
listScrollOffset = 0
|
||||
queryGeneration += 1
|
||||
|
||||
viewModelScope.launch {
|
||||
val time = System.currentTimeMillis()
|
||||
|
||||
@@ -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(
|
||||
|
||||
+14
-9
@@ -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),
|
||||
|
||||
+16
-1
@@ -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,
|
||||
|
||||
+6
-3
@@ -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
|
||||
|
||||
+29
-49
@@ -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
|
||||
|
||||
+6
-5
@@ -338,15 +338,16 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
|
||||
|
||||
/**
|
||||
* Day block: colored by the newest reported status among the day's 12 slots
|
||||
* (gray when none); the number is the TOTAL report count of the whole day
|
||||
* (sum over all slots), not the count of the newest slot alone.
|
||||
* (gray when none); the number is the count of the most recent consecutive
|
||||
* reports sharing that same status (day.streakCount), not the total report
|
||||
* count of the whole day.
|
||||
*/
|
||||
@Composable
|
||||
private fun DayCell(day: SatDay, modifier: Modifier, onClick: () -> Unit) {
|
||||
val noReportGray = 0xFFC0C0C0L
|
||||
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
|
||||
val color = Color(slot.statusColor)
|
||||
val totalCount = day.slots.sumOf { it.count }
|
||||
val streakCount = day.streakCount
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(28.dp)
|
||||
@@ -355,8 +356,8 @@ private fun DayCell(day: SatDay, modifier: Modifier, onClick: () -> Unit) {
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (totalCount > 0) {
|
||||
Text(text = totalCount.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
if (streakCount > 0) {
|
||||
Text(text = streakCount.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "475"
|
||||
appVersionCode = "481"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.7-ba7opf.5"
|
||||
appVersionName = "4.4.7-ba7opf.11"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
@@ -79,6 +79,8 @@ other-osmdroid = { module = "org.osmdroid:osmdroid-android", version.ref = "othe
|
||||
|
||||
test-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlin-coroutines" }
|
||||
test-junit4 = { module = "junit:junit", version.ref = "test-junit4" }
|
||||
# Real org.json for unit tests (android.jar stub throws "not mocked")
|
||||
test-json = { module = "org.json:json", version = "20240303" }
|
||||
|
||||
androidTest-junit = { module = "androidx.test.ext:junit", version.ref = "androidTest-junit" }
|
||||
androidTest-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "androidTest-espresso" }
|
||||
|
||||
Reference in new issue
Block a user