feat: prefetch AMSAT status on foreground

Start AMSAT status prefetch when the app enters foreground and share the in-flight request with the status page fetch path to avoid duplicate network requests.
This commit is contained in:
atsunatsu committed 2026-08-28 00:41:32 +08:00
1 parent 7924a6f69e
commit bc6e065804
5 files changed
+88 -25

No files matched your search

@@ -50,6 +50,9 @@ class MainApplication : Application(), IContainerProvider {
private var startedActivityCount = 0 private var startedActivityCount = 0
override fun onActivityStarted(activity: Activity) { override fun onActivityStarted(activity: Activity) {
if (startedActivityCount == 0) {
container.appScope.launch { container.amSatRepo.prefetchStatus() }
}
startedActivityCount += 1 startedActivityCount += 1
} }
@@ -78,7 +78,7 @@ class MainContainer(private val context: Context) : IMainContainer {
override val selectionRepo = provideSelectionRepo() override val selectionRepo = provideSelectionRepo()
override val satelliteRepo = provideSatelliteRepo() override val satelliteRepo = provideSatelliteRepo()
override val databaseRepo = provideDatabaseRepo() override val databaseRepo = provideDatabaseRepo()
override val amSatRepo by lazy { AmSatRepository(remoteSource) } override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
override val radioTrackingService: IRadioTrackingService by lazy { override val radioTrackingService: IRadioTrackingService by lazy {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo) RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
@@ -9,8 +9,13 @@ import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.source.IRemoteSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONObject import org.json.JSONObject
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -30,13 +35,19 @@ private data class ApiReport(
) )
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */ /** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository { class AmSatRepository(
private val remoteSource: IRemoteSource,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
) : IAmSatRepository {
private val statusCacheMutex = Mutex() private val statusCacheMutex = Mutex()
@Volatile @Volatile
private var statusCache: SatStatusPage? = null private var statusCache: SatStatusPage? = null
@Volatile
private var statusFetchInFlight: Deferred<SatStatusPage?>? = null
@Volatile @Volatile
private var cacheGeneration = 0 private var cacheGeneration = 0
@@ -46,36 +57,63 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
override fun getCachedStatus(): SatStatusPage? = statusCache override fun getCachedStatus(): SatStatusPage? = statusCache
override suspend fun fetchStatus(forceRefresh: Boolean): SatStatusPage? = withContext(Dispatchers.IO) { override suspend fun fetchStatus(forceRefresh: Boolean): SatStatusPage? {
statusCacheMutex.lock() if (!forceRefresh) statusCache?.let { return it }
try {
if (!forceRefresh) statusCache?.let { return@withContext it }
val generation = cacheGeneration
val nowSec = System.currentTimeMillis() / 1000
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return@withContext null
val names = parseCatalog(catalogJson) var cachedPage: SatStatusPage? = null
val reports = parseReports(reportsJson) val inFlightFetch = statusCacheMutex.withLock {
if (!forceRefresh) {
if (names.isEmpty() && reports.isEmpty()) return@withContext null val cached = statusCache
if (cached != null) {
val statuses = buildStatuses(names, reports, nowSec) cachedPage = cached
val reportMap = reports.associate { it.id to toSatReport(it) } return@withLock null
SatStatusPage(System.currentTimeMillis(), statuses, reportMap).also { page -> }
if (generation == cacheGeneration) statusCache = page
} }
statusFetchInFlight ?: scope.async(Dispatchers.IO) {
fetchStatusFromRemote(cacheGeneration)
}.also { statusFetchInFlight = it }
}
cachedPage?.let { return it }
val fetch = inFlightFetch ?: return null
return try {
fetch.await()
} finally { } finally {
statusCacheMutex.unlock() statusCacheMutex.withLock {
if (statusFetchInFlight === fetch && fetch.isCompleted) statusFetchInFlight = null
}
} }
} }
private suspend fun fetchStatusFromRemote(generation: Int): SatStatusPage? {
val nowSec = System.currentTimeMillis() / 1000
val catalogJson = remoteSource.getAmSatCatalog() ?: return null
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return null
val names = parseCatalog(catalogJson)
val reports = parseReports(reportsJson)
if (names.isEmpty() && reports.isEmpty()) return null
val statuses = buildStatuses(names, reports, nowSec)
val reportMap = reports.associate { it.id to toSatReport(it) }
return SatStatusPage(System.currentTimeMillis(), statuses, reportMap).also { page ->
if (generation == cacheGeneration) statusCache = page
}
}
override suspend fun prefetchStatus() {
fetchStatus(forceRefresh = false)
}
override fun clearStatusCache() { override fun clearStatusCache() {
statusCache = null
cacheGeneration += 1 cacheGeneration += 1
statusCache = null
statusFetchInFlight = null
} }
override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) { override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
@@ -3,6 +3,8 @@ package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.SatStatus import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.source.IRemoteSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@@ -57,6 +59,21 @@ class AmSatRepositoryTest {
assertEquals(1, remoteSource.catalogRequests) assertEquals(1, remoteSource.catalogRequests)
assertEquals(1, remoteSource.reportRequests) assertEquals(1, remoteSource.reportRequests)
} }
@Test
fun fetchStatusSharesStartupPrefetchRequest() = runTest {
val remoteSource = FakeAmSatRemoteSource(responseDelayMillis = 50)
val repository = AmSatRepository(remoteSource)
val prefetch = async { repository.prefetchStatus() }
val pageFetch = async { repository.fetchStatus() }
prefetch.await()
val page = pageFetch.await()
assertSame(page, repository.getCachedStatus())
assertEquals(1, remoteSource.catalogRequests)
assertEquals(1, remoteSource.reportRequests)
}
} }
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) { private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
@@ -65,7 +82,7 @@ private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
cacheField.set(this, page) cacheField.set(this, page)
} }
private class FakeAmSatRemoteSource : IRemoteSource { private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L) : IRemoteSource {
var catalogRequests = 0 var catalogRequests = 0
var reportRequests = 0 var reportRequests = 0
@@ -74,11 +91,13 @@ private class FakeAmSatRemoteSource : IRemoteSource {
override suspend fun getNetworkStream(url: String): InputStream? = null override suspend fun getNetworkStream(url: String): InputStream? = null
override suspend fun getAmSatCatalog(): String? { override suspend fun getAmSatCatalog(): String? {
if (responseDelayMillis > 0L) delay(responseDelayMillis)
catalogRequests += 1 catalogRequests += 1
return """{"data":[{"name":"AO-7"}]}""" return """{"data":[{"name":"AO-7"}]}"""
} }
override suspend fun getAmSatReports(hours: Int, limit: Int): String? { override suspend fun getAmSatReports(hours: Int, limit: Int): String? {
if (responseDelayMillis > 0L) delay(responseDelayMillis)
reportRequests += 1 reportRequests += 1
return """{"data":[]}""" return """{"data":[]}"""
} }
@@ -12,6 +12,9 @@ interface IAmSatRepository {
/** Fetch and parse the AMSAT status page; null on failure. */ /** Fetch and parse the AMSAT status page; null on failure. */
suspend fun fetchStatus(forceRefresh: Boolean = false): SatStatusPage? suspend fun fetchStatus(forceRefresh: Boolean = false): SatStatusPage?
/** Warm the foreground-session cache without forcing a network reload. */
suspend fun prefetchStatus()
/** Clear the foreground-session status cache. */ /** Clear the foreground-session status cache. */
fun clearStatusCache() fun clearStatusCache()