feat: data source toggles + HTTP status codes (upstream #242)

Port upstream PR #242 data layer: per-source enabled toggles and HTTP
status codes for custom data sources.

- IRemoteSource: NetworkResult(code, stream) + getNetworkStream
- RemoteSource: return HTTP status code (200 ok / error code / ERR)
- ISettingsRepo: dataSourcesStatus StateFlow<Map<String,Int>>
- SettingsRepo: persist enabled flags (single JSON per key) + status
- DatabaseRepo: filter by enabled toggles, write back status codes
- Settings.kt: DataSourcesSettings + satelliteEnabled/transceiversEnabled
- Sync test fakes (AmSatRepositoryTest, DatabaseRepoTest, Fakes.kt)

Fork-specific features preserved: builtinTypesByUrl reverse lookup,
satnogs migration, map-type data source structure.
This commit is contained in:
atsunatsu committed 2026-08-29 16:21:59 +08:00
1 parent 346803fe6e
commit 7f390117b8
9 files changed
+116 -23

No files matched your search

@@ -67,27 +67,43 @@ class DatabaseRepo(
override suspend fun updateFromRemote() = withContext(dispatcher) { override suspend fun updateFromRemote() = withContext(dispatcher) {
val settings = settingsRepo.dataSourcesSettings.value val settings = settingsRepo.dataSourcesSettings.value
val tleUrls = settings.satelliteUrls.filter { it.isNotBlank() }.map(::normalizeUrl).distinct() // Keep the raw URL as the status key (matches what the settings UI
val radioUrls = settings.transceiversUrls.filter { it.isNotBlank() }.map(::normalizeUrl).distinct() // 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 val builtinTypesByUrl = Sources.satelliteDataUrls
.filterValues { it.isNotBlank() } .filterValues { it.isNotBlank() }
.mapValues { normalizeUrl(it.value) } .mapValues { normalizeUrl(it.value) }
.entries .entries
.associate { (type, url) -> url to type } .associate { (type, url) -> url to type }
val importedTypeIds = mutableMapOf<String, MutableList<Int>>() val importedTypeIds = mutableMapOf<String, MutableList<Int>>()
// launch all network requests concurrently // launch all network requests concurrently, keeping the raw url as key for status reporting
val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } } val tleJobs = tleUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } } 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. // parse fetched data concurrently and associate known built-in URLs with existing type filters.
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) -> val importedEntries = tleResults.flatMap { (rawUrl, result) ->
val entries = stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty() val normUrl = normalizeUrl(rawUrl)
val type = builtinTypesByUrl[url] ?: customSourceType 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 }) importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum })
entries entries
}.distinctBy { it.catnum } }.distinctBy { it.catnum }
importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) } importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) -> val importedRadios = radioResults.flatMap { (rawUrl, result) ->
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty() val normUrl = normalizeUrl(rawUrl)
result.stream?.let { dataParser.parseJSONStream(unwrapIfZipped(normUrl, it)) }.orEmpty()
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid } }.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
// insert parsed data into the database // insert parsed data into the database
localSource.insertEntries(importedEntries) localSource.insertEntries(importedEntries)
@@ -98,6 +98,8 @@ class SettingsRepo(
private val keyTransceiversUrl = "transceiversUrl" private val keyTransceiversUrl = "transceiversUrl"
private val keySatelliteUrls = "satelliteUrls" private val keySatelliteUrls = "satelliteUrls"
private val keyTransceiversUrls = "transceiversUrls" private val keyTransceiversUrls = "transceiversUrls"
private val keySatelliteEnabled = "satelliteEnabled"
private val keyTransceiversEnabled = "transceiversEnabled"
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration" private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
private val separatorComma = "," private val separatorComma = ","
private val separatorUrl = "\n" private val separatorUrl = "\n"
@@ -399,12 +401,21 @@ class SettingsRepo(
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
override fun updateDataSourcesSettings(settings: 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 { preferences.edit {
putString(keySatelliteUrls, settings.satelliteUrls.joinToString(separatorUrl)) putString(keySatelliteUrls, normalized.satelliteUrls.joinToString(separatorUrl))
putString(keyTransceiversUrls, settings.transceiversUrls.joinToString(separatorUrl)) putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
putBoolean(keySatnogsTleSourceMigration, true) putBoolean(keySatnogsTleSourceMigration, true)
} }
_dataSourcesSettings.value = settings _dataSourcesSettings.value = normalized
} }
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings( private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
@@ -419,9 +430,27 @@ class SettingsRepo(
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() }, defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
legacyEnabledKey = keyUseCustomTransceivers, legacyEnabledKey = keyUseCustomTransceivers,
legacyUrlKey = keyTransceiversUrl 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<Boolean> {
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<String>, flags: List<Boolean>): List<Boolean> {
if (flags.size >= urls.size) return flags.take(urls.size)
return flags + List(urls.size - flags.size) { true }
}
private fun getDataSourceUrls( private fun getDataSourceUrls(
key: String, key: String,
defaultUrls: List<String>, defaultUrls: List<String>,
@@ -467,6 +496,15 @@ class SettingsRepo(
} }
//endregion //endregion
//region # Data sources status
private val _dataSourcesStatus = MutableStateFlow<Map<String, Int>>(emptyMap())
override val dataSourcesStatus: StateFlow<Map<String, Int>> = _dataSourcesStatus
override fun updateDataSourcesStatus(status: Map<String, Int>) {
_dataSourcesStatus.value = status
}
//endregion
//region # Radio control settings //region # Radio control settings
private val keyRadioControlEnabled = "radioControlEnabled" private val keyRadioControlEnabled = "radioControlEnabled"
private val keyRadioModel = "radioModel" private val keyRadioModel = "radioModel"
@@ -20,6 +20,7 @@ package com.rtbishop.look4sat.core.data.source
import android.content.ContentResolver import android.content.ContentResolver
import androidx.core.net.toUri import androidx.core.net.toUri
import com.rtbishop.look4sat.core.domain.source.IRemoteSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType 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 { try {
val networkRequest = Request.Builder().url(url).build() val networkRequest = Request.Builder().url(url).build()
val response = httpClient.newCall(networkRequest).execute() val response = httpClient.newCall(networkRequest).execute()
if (!response.isSuccessful) { if (!response.isSuccessful) {
val code = response.code
response.close() response.close()
return@withContext null return@withContext NetworkResult(code, null)
} }
// Return the body stream directly as the caller is responsible for closing it. // Return the body stream directly as the caller is responsible for closing it.
// Closing the stream returns the connection to OkHttp's pool. // 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) { } catch (exception: Exception) {
println("RemoteSource network stream exception: $exception") println("RemoteSource network stream exception: $exception")
null NetworkResult(NetworkResult.CONNECTION_ERROR, null)
} }
} }
@@ -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.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 com.rtbishop.look4sat.core.domain.source.NetworkResult
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest 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 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? { override suspend fun getAmSatCatalog(): String? {
if (responseDelayMillis > 0L) delay(responseDelayMillis) if (responseDelayMillis > 0L) delay(responseDelayMillis)
@@ -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.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource 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.source.Sources
import com.rtbishop.look4sat.core.domain.utility.DataParser import com.rtbishop.look4sat.core.domain.utility.DataParser
import kotlinx.coroutines.ExperimentalCoroutinesApi 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 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 override suspend fun getAmSatCatalog(): String? = null
@@ -253,6 +255,12 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
dataSourcesSettings.value = settings dataSourcesSettings.value = settings
} }
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
override fun updateDataSourcesStatus(status: Map<String, Int>) {
(dataSourcesStatus as? MutableStateFlow)?.value = status
}
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = "" override fun getSatelliteOffset(catnum: Int): String = ""
@@ -68,8 +68,13 @@ data class OtherSettings(
data class DataSourcesSettings( data class DataSourcesSettings(
val satelliteUrls: List<String>, val satelliteUrls: List<String>,
val transceiversUrls: List<String> val transceiversUrls: List<String>,
) val satelliteEnabled: List<Boolean> = emptyList(),
val transceiversEnabled: List<Boolean> = emptyList()
) {
fun isSatelliteEnabled(index: Int): Boolean = satelliteEnabled.getOrElse(index) { true }
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
}
data class RadioControlSettings( data class RadioControlSettings(
val enabled: Boolean, val enabled: Boolean,
@@ -73,6 +73,11 @@ interface ISettingsRepo {
fun updateDataSourcesSettings(settings: DataSourcesSettings) fun updateDataSourcesSettings(settings: DataSourcesSettings)
//endregion //endregion
//region # Data sources status
val dataSourcesStatus: StateFlow<Map<String, Int>>
fun updateDataSourcesStatus(status: Map<String, Int>)
//endregion
//region # Radio control settings //region # Radio control settings
val radioControlSettings: StateFlow<RadioControlSettings> val radioControlSettings: StateFlow<RadioControlSettings>
fun updateRadioControlSettings(settings: RadioControlSettings) fun updateRadioControlSettings(settings: RadioControlSettings)
@@ -19,9 +19,17 @@ package com.rtbishop.look4sat.core.domain.source
import java.io.InputStream 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 { interface IRemoteSource {
suspend fun getFileStream(uri: String): InputStream? suspend fun getFileStream(uri: String): InputStream?
suspend fun getNetworkStream(url: String): InputStream? suspend fun getNetworkStream(url: String): NetworkResult
suspend fun getAmSatCatalog(): String? suspend fun getAmSatCatalog(): String?
suspend fun getAmSatReports(hours: Int, limit: Int): String? suspend fun getAmSatReports(hours: Int, limit: Int): String?
suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>?
@@ -83,6 +83,11 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti
override fun updateRCSettings(settings: RCSettings) = TODO() override fun updateRCSettings(settings: RCSettings) = TODO()
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO() override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO() override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO() override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
override fun getSatelliteOffset(catnum: Int): String = "" override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO() override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()