mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-04 11:37:22 +00:00
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:
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)
|
||||||
|
|||||||
+61
-23
@@ -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) {
|
||||||
|
|||||||
+20
-1
@@ -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":[]}"""
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user