diff --git a/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt b/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt index 58fc429c..295d09d0 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt @@ -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 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 ab954796..f076d01f 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 @@ -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,26 +32,50 @@ 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) { - 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) - 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) + 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 + + 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 + } + } finally { + statusCacheMutex.unlock() + } + } + + override fun clearStatusCache() { + statusCache = null + cacheGeneration += 1 } 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 new file mode 100644 index 00000000..a72eae2c --- /dev/null +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatRepositoryTest.kt @@ -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? = null +} 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 847e072d..20d87590 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 @@ -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 diff --git a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt index bc8c2dc8..ae7404b4 100644 --- a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt +++ b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt @@ -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, diff --git a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt index 544f6cf3..6f6ceb61 100644 --- a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt +++ b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt @@ -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 = _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, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fab55532..995addab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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