mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
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:
1 parent
346803fe6e
commit
7f390117b8
9 files changed
+116
-23
No files matched your search
+26
-10
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -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)
|
||||||
|
|||||||
+9
-1
@@ -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,
|
||||||
|
|||||||
+5
@@ -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()
|
||||||
|
|||||||
Reference in new issue
Block a user