feat: cache AMSAT status while app is foreground

This commit is contained in:
atsunatsu committed 2026-08-23 19:27:16 +08:00
1 parent 9a2a715d1f
commit 7924a6f69e
7 files changed
+198 -26

No files matched your search

@@ -17,7 +17,10 @@
*/
package com.rtbishop.look4sat
import android.app.Activity
import android.app.Application
import android.app.Application.ActivityLifecycleCallbacks
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
@@ -35,12 +38,34 @@ class MainApplication : Application(), IContainerProvider {
override fun onCreate() {
super.onCreate()
container = MainContainer(this)
clearAmSatCacheOnAppBackground()
// trigger automatic update every 48 hours
container.appScope.launch { checkAutoUpdate() }
// load satellite data on every app start
container.appScope.launch { container.satelliteRepo.initRepository() }
}
private fun clearAmSatCacheOnAppBackground() {
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
private var startedActivityCount = 0
override fun onActivityStarted(activity: Activity) {
startedActivityCount += 1
}
override fun onActivityStopped(activity: Activity) {
startedActivityCount = (startedActivityCount - 1).coerceAtLeast(0)
if (startedActivityCount == 0) container.amSatRepo.clearStatusCache()
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
})
}
private suspend fun checkAutoUpdate(timeNow: Long = System.currentTimeMillis()) {
if (container.settingsRepo.otherSettings.value.stateOfAutoUpdate) {
val timeDelta = timeNow - container.settingsRepo.databaseState.value.updateTimestamp
@@ -10,6 +10,7 @@ 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.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.text.SimpleDateFormat
@@ -31,11 +32,25 @@ private data class ApiReport(
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
private val statusCacheMutex = Mutex()
@Volatile
private var statusCache: SatStatusPage? = null
@Volatile
private var cacheGeneration = 0
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
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.
@@ -50,7 +65,17 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
val statuses = buildStatuses(names, reports, nowSec)
val reportMap = reports.associate { it.id to toSatReport(it) }
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
SatStatusPage(System.currentTimeMillis(), statuses, reportMap).also { page ->
if (generation == cacheGeneration) statusCache = page
}
} finally {
statusCacheMutex.unlock()
}
}
override fun clearStatusCache() {
statusCache = null
cacheGeneration += 1
}
override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
@@ -0,0 +1,87 @@
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.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Test
import java.io.InputStream
class AmSatRepositoryTest {
@Test
fun fetchStatusReturnsSeededCacheUntilCacheIsCleared() = runTest {
val remoteSource = FakeAmSatRemoteSource()
val repository = AmSatRepository(remoteSource)
val cachedPage = SatStatusPage(
fetchedAtUtcMs = 123L,
statuses = listOf(SatStatus(name = "AO-7", days = emptyList())),
reports = emptyMap()
)
repository.seedStatusCache(cachedPage)
val firstPage = repository.fetchStatus()
val secondPage = repository.fetchStatus()
assertSame(cachedPage, firstPage)
assertSame(cachedPage, secondPage)
assertSame(cachedPage, repository.getCachedStatus())
assertEquals(0, remoteSource.catalogRequests)
assertEquals(0, remoteSource.reportRequests)
repository.clearStatusCache()
assertNull(repository.getCachedStatus())
repository.fetchStatus()
assertEquals(1, remoteSource.catalogRequests)
assertEquals(1, remoteSource.reportRequests)
}
@Test
fun forceRefreshBypassesCachedPage() = runTest {
val remoteSource = FakeAmSatRemoteSource()
val repository = AmSatRepository(remoteSource)
repository.seedStatusCache(
SatStatusPage(
fetchedAtUtcMs = 123L,
statuses = listOf(SatStatus(name = "AO-7", days = emptyList())),
reports = emptyMap()
)
)
repository.fetchStatus(forceRefresh = true)
assertEquals(1, remoteSource.catalogRequests)
assertEquals(1, remoteSource.reportRequests)
}
}
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
val cacheField = AmSatRepository::class.java.getDeclaredField("statusCache")
cacheField.isAccessible = true
cacheField.set(this, page)
}
private class FakeAmSatRemoteSource : IRemoteSource {
var catalogRequests = 0
var reportRequests = 0
override suspend fun getFileStream(uri: String): InputStream? = null
override suspend fun getNetworkStream(url: String): InputStream? = null
override suspend fun getAmSatCatalog(): String? {
catalogRequests += 1
return """{"data":[{"name":"AO-7"}]}"""
}
override suspend fun getAmSatReports(hours: Int, limit: Int): String? {
reportRequests += 1
return """{"data":[]}"""
}
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
}
@@ -6,8 +6,14 @@ import com.rtbishop.look4sat.core.domain.model.SatStatusPage
/** AMSAT satellite status data source */
interface IAmSatRepository {
/** Fetch and parse the AMSAT status page; null on failure */
suspend fun fetchStatus(): SatStatusPage?
/** Cached AMSAT status page for the current foreground session, if any. */
fun getCachedStatus(): SatStatusPage?
/** Fetch and parse the AMSAT status page; null on failure. */
suspend fun fetchStatus(forceRefresh: Boolean = false): SatStatusPage?
/** Clear the foreground-session status cache. */
fun clearStatusCache()
/** Submit a public AMSAT satellite status report. */
suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult
@@ -32,6 +32,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -50,6 +51,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.SatDay
@@ -106,7 +110,22 @@ fun SatStatusDestination() {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
val lifecycleOwner = LocalLifecycleOwner.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
DisposableEffect(lifecycleOwner, viewModel) {
var skipInitialStart = true
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
if (skipInitialStart) {
skipInitialStart = false
} else {
viewModel.fetch()
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
SatStatusScreen(
uiState = uiState,
refresh = viewModel::refresh,
@@ -7,6 +7,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
import com.rtbishop.look4sat.core.domain.model.SatReport
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.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
@@ -51,28 +52,25 @@ class SatStatusViewModel(
private val settingsRepo: ISettingsRepo
) : ViewModel() {
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
private val cachedStatusPage = amSatRepo.getCachedStatus()
private val _uiState = MutableStateFlow(cachedStatusPage?.toUiState() ?: SatStatusUiState(isLoading = true))
val uiState: StateFlow<SatStatusUiState> = _uiState
init {
fetch()
if (cachedStatusPage == null) fetch()
}
fun fetch() {
amSatRepo.getCachedStatus()?.let { page ->
applyStatusPage(page)
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val page = amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) {
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs
)
}
applyStatusPage(page)
} else {
_uiState.update {
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
@@ -91,16 +89,9 @@ class SatStatusViewModel(
_uiState.update { it.copy(isRefreshing = true, error = null) }
viewModelScope.launch {
try {
val page = amSatRepo.fetchStatus()
val page = amSatRepo.fetchStatus(forceRefresh = true)
if (page != null && page.statuses.isNotEmpty()) {
_uiState.update {
it.copy(
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs
)
}
applyStatusPage(page)
} else {
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
}
@@ -110,6 +101,19 @@ class SatStatusViewModel(
}
}
private fun applyStatusPage(page: SatStatusPage) {
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs,
error = null
)
}
}
fun toggleUploadPanel() {
val storedCallsign = settingsRepo.getAmSatCallsign()
val defaultGrid = defaultGridSquare()
@@ -275,6 +279,12 @@ class SatStatusViewModel(
}
}
private fun SatStatusPage.toUiState() = SatStatusUiState(
statuses = statuses,
reports = reports,
fetchedAtUtcMs = fetchedAtUtcMs
)
private data class PreparedUpload(
val report: String,
val callsign: String,
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "447"
appVersionCode = "448"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.6-ba7opf.3"
appVersionName = "4.4.6-ba7opf.4"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry