diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt index d5ed5aa1..9c056729 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt @@ -67,27 +67,43 @@ class DatabaseRepo( override suspend fun updateFromRemote() = withContext(dispatcher) { val settings = settingsRepo.dataSourcesSettings.value - val tleUrls = settings.satelliteUrls.filter { it.isNotBlank() }.map(::normalizeUrl).distinct() - val radioUrls = settings.transceiversUrls.filter { it.isNotBlank() }.map(::normalizeUrl).distinct() + // Keep the raw URL as the status key (matches what the settings UI + // displays) while requesting with the normalized URL. + val tleUrls = settings.satelliteUrls + .filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) } + .map { it to normalizeUrl(it) } + .distinctBy { it.second } + val radioUrls = settings.transceiversUrls + .filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) } + .map { it to normalizeUrl(it) } + .distinctBy { it.second } val builtinTypesByUrl = Sources.satelliteDataUrls .filterValues { it.isNotBlank() } .mapValues { normalizeUrl(it.value) } .entries .associate { (type, url) -> url to type } val importedTypeIds = mutableMapOf>() - // launch all network requests concurrently - val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } } - val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } } + // launch all network requests concurrently, keeping the raw url as key for status reporting + val tleJobs = tleUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } } + val radioJobs = radioUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } } + val tleResults = tleJobs.awaitAll() + val radioResults = radioJobs.awaitAll() + // report the HTTP status code of every source (200, 404, ...) + settingsRepo.updateDataSourcesStatus( + (tleResults + radioResults).associate { (url, result) -> url to result.code } + ) // parse fetched data concurrently and associate known built-in URLs with existing type filters. - val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) -> - val entries = stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty() - val type = builtinTypesByUrl[url] ?: customSourceType + val importedEntries = tleResults.flatMap { (rawUrl, result) -> + val normUrl = normalizeUrl(rawUrl) + val entries = result.stream?.let { parseSatelliteStream(normUrl, unwrapIfZipped(normUrl, it)) }.orEmpty() + val type = builtinTypesByUrl[normUrl] ?: customSourceType importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum }) entries }.distinctBy { it.catnum } importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) } - val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) -> - stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty() + val importedRadios = radioResults.flatMap { (rawUrl, result) -> + val normUrl = normalizeUrl(rawUrl) + result.stream?.let { dataParser.parseJSONStream(unwrapIfZipped(normUrl, it)) }.orEmpty() }.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid } // insert parsed data into the database localSource.insertEntries(importedEntries) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt index 7811de91..da83ca0a 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt @@ -98,6 +98,8 @@ class SettingsRepo( private val keyTransceiversUrl = "transceiversUrl" private val keySatelliteUrls = "satelliteUrls" private val keyTransceiversUrls = "transceiversUrls" + private val keySatelliteEnabled = "satelliteEnabled" + private val keyTransceiversEnabled = "transceiversEnabled" private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration" private val separatorComma = "," private val separatorUrl = "\n" @@ -399,12 +401,21 @@ class SettingsRepo( override val dataSourcesSettings: StateFlow = _dataSourcesSettings override fun updateDataSourcesSettings(settings: DataSourcesSettings) { + // Normalize the enabled lists so they are positionally aligned with the URL lists. + // Missing entries default to enabled (true), keeping the persisted "one flag per URL" + // invariant intact even when a default empty list is used to construct the model. + val normalized = settings.copy( + satelliteEnabled = alignFlags(settings.satelliteUrls, settings.satelliteEnabled), + transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled) + ) preferences.edit { - putString(keySatelliteUrls, settings.satelliteUrls.joinToString(separatorUrl)) - putString(keyTransceiversUrls, settings.transceiversUrls.joinToString(separatorUrl)) + putString(keySatelliteUrls, normalized.satelliteUrls.joinToString(separatorUrl)) + putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl)) + putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma)) + putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma)) putBoolean(keySatnogsTleSourceMigration, true) } - _dataSourcesSettings.value = settings + _dataSourcesSettings.value = normalized } private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings( @@ -419,9 +430,27 @@ class SettingsRepo( defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() }, legacyEnabledKey = keyUseCustomTransceivers, legacyUrlKey = keyTransceiversUrl - ) + ), + satelliteEnabled = readEnabledFlags(keySatelliteEnabled), + transceiversEnabled = readEnabledFlags(keyTransceiversEnabled) ) + /** Read the persisted per-source enabled flags (empty when never stored). */ + private fun readEnabledFlags(key: String): List { + return preferences.getString(key, null) + ?.split(separatorComma) + ?.mapNotNull { it.trim() } + ?.filter { it == "true" || it == "false" } + ?.map { it == "true" } + ?: emptyList() + } + + /** Keep the flags positionally aligned with the URL list, defaulting to enabled. */ + private fun alignFlags(urls: List, flags: List): List { + if (flags.size >= urls.size) return flags.take(urls.size) + return flags + List(urls.size - flags.size) { true } + } + private fun getDataSourceUrls( key: String, defaultUrls: List, @@ -467,6 +496,15 @@ class SettingsRepo( } //endregion + //region # Data sources status + private val _dataSourcesStatus = MutableStateFlow>(emptyMap()) + override val dataSourcesStatus: StateFlow> = _dataSourcesStatus + + override fun updateDataSourcesStatus(status: Map) { + _dataSourcesStatus.value = status + } + //endregion + //region # Radio control settings private val keyRadioControlEnabled = "radioControlEnabled" private val keyRadioModel = "radioModel" diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt index 564907fc..9cc9e075 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt @@ -20,6 +20,7 @@ package com.rtbishop.look4sat.core.data.source import android.content.ContentResolver import androidx.core.net.toUri import com.rtbishop.look4sat.core.domain.source.IRemoteSource +import com.rtbishop.look4sat.core.domain.source.NetworkResult import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType @@ -44,20 +45,26 @@ class RemoteSource( } } - override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) { + override suspend fun getNetworkStream(url: String): NetworkResult = withContext(dispatcher) { try { val networkRequest = Request.Builder().url(url).build() val response = httpClient.newCall(networkRequest).execute() if (!response.isSuccessful) { + val code = response.code response.close() - return@withContext null + return@withContext NetworkResult(code, null) } // Return the body stream directly as the caller is responsible for closing it. // Closing the stream returns the connection to OkHttp's pool. - response.body.byteStream().buffered() + val body = response.body + if (body == null) { + response.close() + return@withContext NetworkResult(response.code, null) + } + NetworkResult(response.code, body.byteStream().buffered()) } catch (exception: Exception) { println("RemoteSource network stream exception: $exception") - null + NetworkResult(NetworkResult.CONNECTION_ERROR, null) } } 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 357c3a85..7b883fba 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,7 @@ 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 com.rtbishop.look4sat.core.domain.source.NetworkResult import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest @@ -88,7 +89,7 @@ private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L) override suspend fun getFileStream(uri: String): InputStream? = null - override suspend fun getNetworkStream(url: String): InputStream? = null + override suspend fun getNetworkStream(url: String): NetworkResult = NetworkResult(404, null) override suspend fun getAmSatCatalog(): String? { if (responseDelayMillis > 0L) delay(responseDelayMillis) diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt index e0c952c4..774b4e86 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt @@ -31,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalObject import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.source.ILocalSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource +import com.rtbishop.look4sat.core.domain.source.NetworkResult import com.rtbishop.look4sat.core.domain.source.Sources import com.rtbishop.look4sat.core.domain.utility.DataParser import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -149,7 +150,8 @@ private class FakeRemoteSource : IRemoteSource { override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke() - override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke() + override suspend fun getNetworkStream(url: String): NetworkResult = + networkStreams[url]?.let { NetworkResult(200, it()) } ?: NetworkResult(404, null) override suspend fun getAmSatCatalog(): String? = null @@ -253,6 +255,12 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou dataSourcesSettings.value = settings } + override val dataSourcesStatus: StateFlow> = MutableStateFlow(emptyMap()) + + override fun updateDataSourcesStatus(status: Map) { + (dataSourcesStatus as? MutableStateFlow)?.value = status + } + override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit override fun getSatelliteOffset(catnum: Int): String = "" diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt index e462e343..a5458d1e 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt @@ -68,8 +68,13 @@ data class OtherSettings( data class DataSourcesSettings( val satelliteUrls: List, - val transceiversUrls: List -) + val transceiversUrls: List, + val satelliteEnabled: List = emptyList(), + val transceiversEnabled: List = emptyList() +) { + fun isSatelliteEnabled(index: Int): Boolean = satelliteEnabled.getOrElse(index) { true } + fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true } +} data class RadioControlSettings( val enabled: Boolean, diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt index 860256ca..69abea0a 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt @@ -73,6 +73,11 @@ interface ISettingsRepo { fun updateDataSourcesSettings(settings: DataSourcesSettings) //endregion + //region # Data sources status + val dataSourcesStatus: StateFlow> + fun updateDataSourcesStatus(status: Map) + //endregion + //region # Radio control settings val radioControlSettings: StateFlow fun updateRadioControlSettings(settings: RadioControlSettings) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt index 7b09d8b5..6c4e4e95 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt @@ -19,9 +19,17 @@ package com.rtbishop.look4sat.core.domain.source import java.io.InputStream +/** Result of a network download: the HTTP status code and the response body stream. */ +data class NetworkResult(val code: Int, val stream: InputStream?) { + companion object { + /** Code used when a request fails before any HTTP response is received. */ + const val CONNECTION_ERROR = -1 + } +} + interface IRemoteSource { suspend fun getFileStream(uri: String): InputStream? - suspend fun getNetworkStream(url: String): InputStream? + suspend fun getNetworkStream(url: String): NetworkResult suspend fun getAmSatCatalog(): String? suspend fun getAmSatReports(hours: Int, limit: Int): String? suspend fun submitAmSatReport(payloadJson: String): Pair? diff --git a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt index 1c9f68a7..b4a035d0 100644 --- a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt +++ b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt @@ -83,6 +83,11 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti override fun updateRCSettings(settings: RCSettings) = TODO() override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO() override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO() + + override val dataSourcesStatus: StateFlow> = MutableStateFlow(emptyMap()) + + override fun updateDataSourcesStatus(status: Map) = TODO() + override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO() override fun getSatelliteOffset(catnum: Int): String = "" override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()