fix: AMSAT live page timeouts wiped FM/Linear lists; add fallback

Root cause of the recurring empty AMSAT Live filters: OkHttp defaults to a
10s read timeout and amsat.org's live pages take 7-10s+ from many
networks — every sync timed out, fmNames/linearNames stayed empty, and
(with no previous list, e.g. right after clearing data) the filters were
written as EMPTY. The stale pre-whitelist FM list survived only because
older syncs had occasionally succeeded and the failure path preserved it.

Fix:
1. OkHttp timeouts raised (connect 20s / read 30s / write 30s / call 60s)
   in MainContainer.provideRemoteSource so slow AMSAT pages actually load.
2. updateAmSatLiveLists gains a last-resort fallback: when the AMSAT page
   is down AND there is no previous list to clean, derive FM/Linear from
   the local transceivers (FM / SSB,CW,USB,LSB modes) intersected with
   the amateur whitelist, so a network timeout never wipes the filters.
   ISS module aliases stay excluded by the whitelist intersection.

Regression test: page fails + empty previous list -> fallback yields
{25544} (26700 excluded by whitelist).
This commit is contained in:
atsunatsu committed 2026-09-24 11:37:50 +08:00
1 parent a082abb72c
commit 865fa9c6f2
3 files changed
+53 -3

No files matched your search

@@ -74,6 +74,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
class MainContainer(private val context: Context) : IMainContainer {
@@ -177,7 +178,16 @@ class MainContainer(private val context: Context) : IMainContainer {
}
private fun provideRemoteSource(): IRemoteSource {
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
// amsat.org live pages are slow (7-10s+ from some networks); the
// OkHttp defaults (10s read timeout) made every AMSAT Live update
// time out, silently keeping stale FM/Linear lists forever.
val client = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.callTimeout(60, TimeUnit.SECONDS)
.build()
return RemoteSource(Dispatchers.IO, context.contentResolver, client)
}
private fun provideSatelliteRepo(): ISatelliteRepo {
@@ -181,10 +181,20 @@ class DatabaseRepo(
previous.mapNotNull { catnum ->
nameToCatnum.entries.firstOrNull { it.value == catnum }?.key
}.flatMap { resolvePerName(it) }.toSet()
// Last-resort fallback when the AMSAT page is down AND there is no
// previous list to clean (e.g. right after clearing data): derive
// the FM/Linear sets from the local transceivers intersected with
// the amateur whitelist, so a network timeout never wipes the
// filters to empty.
val fmFallback = localSource.getIdsWithModes(listOf("FM")).toSet().intersect(activeCatnums)
val linearFallback = localSource
.getIdsWithModes(listOf("SSB", "CW", "USB", "LSB")).toSet().intersect(activeCatnums)
val fmCatnums = fmNames.flatMap { resolvePerName(it) }.toSet()
.ifEmpty { cleanStaleList(settingsRepo.getAmSatFmCatnums()) }
.ifEmpty { fmFallback }
val linearCatnums = linearNames.flatMap { resolvePerName(it) }.toSet()
.ifEmpty { cleanStaleList(settingsRepo.getAmSatLinearCatnums()) }
.ifEmpty { linearFallback }
settingsRepo.setAmSatCatnums(fmCatnums, linearCatnums)
println("AMSAT live lists updated: FM=${fmCatnums.size}, Linear=${linearCatnums.size}, Active=${activeCatnums.size}")
}.onFailure {
@@ -270,6 +270,35 @@ class DatabaseRepoTest {
assertEquals(setOf(25544), settingsRepo.amSatFm)
}
@Test
fun `amsat fm list falls back to local FM transceivers when page fails and no previous list`() =
runTest(dispatcher) {
// After clearing data, the previous FM list is empty AND the AMSAT
// page is down (timeout) -> the filter must fall back to local FM
// transceivers intersected with the whitelist, never stay empty.
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
val localSource = FakeLocalSource().apply {
idsWithModes = listOf(25544, 26700, 39444) // local FM-transceiver sats
}
val remoteSource = FakeRemoteSource().apply {
networkStreams[satnogsUrl] = { issModulesCsvStream() }
// FM/Linear pages NOT registered -> 404 -> fetch fails.
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(satnogsUrl),
transceiversUrls = emptyList()
)
).apply { amSatActive = setOf(25544) } // whitelist has only ZARYA
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
// Fallback = local FM ids ∩ whitelist = {25544}; ISS module aliases
// (26700) are excluded by the whitelist.
assertEquals(setOf(25544), settingsRepo.amSatFm)
}
private fun amsatFmPageWithUnknownStream(): InputStream = """
<table>
<thead><tr><th>Satellite</th><th>Uplink</th><th>Downlink</th><th>Comment</th></tr></thead>
@@ -320,6 +349,7 @@ private class FakeRemoteSource : IRemoteSource {
private class FakeLocalSource : ILocalSource {
val insertedEntries = mutableListOf<OrbitalData>()
private val insertedRadios = mutableListOf<SatRadio>()
var idsWithModes: List<Int> = emptyList()
override suspend fun getEntriesTotal(): Int = insertedEntries.size
@@ -336,8 +366,8 @@ private class FakeLocalSource : ILocalSource {
insertedEntries.clear()
}
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
override suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int> = emptyList()
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = idsWithModes
override suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int> = idsWithModes
override suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int> = emptyList()
override suspend fun getRadiosTotal(): Int = insertedRadios.size