From bc6e065804845d5b79ea6a846e2f6be4d1de4a9d Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Fri, 28 Aug 2026 00:41:32 +0800 Subject: [PATCH] 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. --- .../com/rtbishop/look4sat/MainApplication.kt | 3 + .../core/data/injection/MainContainer.kt | 2 +- .../core/data/repository/AmSatRepository.kt | 84 ++++++++++++++----- .../data/repository/AmSatRepositoryTest.kt | 21 ++++- .../domain/repository/IAmSatRepository.kt | 3 + 5 files changed, 88 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt b/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt index 295d09d0..d36445ef 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt @@ -50,6 +50,9 @@ class MainApplication : Application(), IContainerProvider { private var startedActivityCount = 0 override fun onActivityStarted(activity: Activity) { + if (startedActivityCount == 0) { + container.appScope.launch { container.amSatRepo.prefetchStatus() } + } startedActivityCount += 1 } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt index 2bca8416..6030d53e 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt @@ -78,7 +78,7 @@ class MainContainer(private val context: Context) : IMainContainer { override val selectionRepo = provideSelectionRepo() override val satelliteRepo = provideSatelliteRepo() 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 { val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt index f076d01f..836ae5ab 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt @@ -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.repository.IAmSatRepository import com.rtbishop.look4sat.core.domain.source.IRemoteSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.json.JSONObject import java.text.SimpleDateFormat @@ -30,13 +35,19 @@ private data class ApiReport( ) /** 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() @Volatile private var statusCache: SatStatusPage? = null + @Volatile + private var statusFetchInFlight: Deferred? = null + @Volatile private var cacheGeneration = 0 @@ -46,36 +57,63 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor override fun getCachedStatus(): SatStatusPage? = statusCache - override suspend fun fetchStatus(forceRefresh: Boolean): SatStatusPage? = withContext(Dispatchers.IO) { - statusCacheMutex.lock() - 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 + override suspend fun fetchStatus(forceRefresh: Boolean): SatStatusPage? { + if (!forceRefresh) statusCache?.let { return it } - val names = parseCatalog(catalogJson) - val reports = parseReports(reportsJson) - - if (names.isEmpty() && reports.isEmpty()) return@withContext null - - val statuses = buildStatuses(names, reports, nowSec) - val reportMap = reports.associate { it.id to toSatReport(it) } - SatStatusPage(System.currentTimeMillis(), statuses, reportMap).also { page -> - if (generation == cacheGeneration) statusCache = page + var cachedPage: SatStatusPage? = null + val inFlightFetch = statusCacheMutex.withLock { + if (!forceRefresh) { + val cached = statusCache + if (cached != null) { + cachedPage = cached + return@withLock null + } } + + statusFetchInFlight ?: scope.async(Dispatchers.IO) { + fetchStatusFromRemote(cacheGeneration) + }.also { statusFetchInFlight = it } + } + + cachedPage?.let { return it } + val fetch = inFlightFetch ?: return null + return try { + fetch.await() } 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() { - statusCache = null cacheGeneration += 1 + statusCache = null + statusFetchInFlight = null } override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) { diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatRepositoryTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatRepositoryTest.kt index a72eae2c..357c3a85 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatRepositoryTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatRepositoryTest.kt @@ -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.SatStatusPage import com.rtbishop.look4sat.core.domain.source.IRemoteSource +import kotlinx.coroutines.async +import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -57,6 +59,21 @@ class AmSatRepositoryTest { assertEquals(1, remoteSource.catalogRequests) 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) { @@ -65,7 +82,7 @@ private fun AmSatRepository.seedStatusCache(page: SatStatusPage) { cacheField.set(this, page) } -private class FakeAmSatRemoteSource : IRemoteSource { +private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L) : IRemoteSource { var catalogRequests = 0 var reportRequests = 0 @@ -74,11 +91,13 @@ private class FakeAmSatRemoteSource : IRemoteSource { override suspend fun getNetworkStream(url: String): InputStream? = null override suspend fun getAmSatCatalog(): String? { + if (responseDelayMillis > 0L) delay(responseDelayMillis) catalogRequests += 1 return """{"data":[{"name":"AO-7"}]}""" } override suspend fun getAmSatReports(hours: Int, limit: Int): String? { + if (responseDelayMillis > 0L) delay(responseDelayMillis) reportRequests += 1 return """{"data":[]}""" } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IAmSatRepository.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IAmSatRepository.kt index 20d87590..a4c8c9be 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IAmSatRepository.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IAmSatRepository.kt @@ -12,6 +12,9 @@ interface IAmSatRepository { /** Fetch and parse the AMSAT status page; null on failure. */ 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. */ fun clearStatusCache()