mirror of
https://github.com/rt-bishop/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Tweaked data source management, migrated DB and Settings
This commit is contained in:
1 parent
d4a2fae0cd
commit
6f3e3351c9
34 files changed
+1290
-437
No files matched your search
@@ -19,6 +19,7 @@ package com.rtbishop.look4sat.core.data.database
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.MapColumn
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
@@ -39,6 +40,18 @@ interface Look4SatDao {
|
||||
@Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)")
|
||||
suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry>
|
||||
|
||||
@Query("SELECT catnum, epoch FROM entries")
|
||||
suspend fun getEntriesEpochs(): Map<@MapColumn("catnum") Int, @MapColumn("epoch") Double>
|
||||
|
||||
@Query("SELECT catnum, name FROM entries")
|
||||
suspend fun getEntriesNames(): Map<@MapColumn("catnum") Int, @MapColumn("name") String>
|
||||
|
||||
@Query("UPDATE entries SET name = :name WHERE catnum = :catnum")
|
||||
suspend fun renameEntry(catnum: Int, name: String)
|
||||
|
||||
@Query("DELETE FROM entries WHERE catnum IN (:ids)")
|
||||
suspend fun deleteEntriesWithIds(ids: List<Int>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertEntries(entries: List<SatEntry>)
|
||||
|
||||
@@ -65,4 +78,7 @@ interface Look4SatDao {
|
||||
|
||||
@Query("DELETE FROM radios")
|
||||
suspend fun deleteRadios()
|
||||
|
||||
@Query("DELETE FROM radios WHERE isCustom = 0")
|
||||
suspend fun deleteManagedRadios()
|
||||
}
|
||||
@@ -19,19 +19,23 @@ package com.rtbishop.look4sat.core.data.database
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
|
||||
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
|
||||
|
||||
@Database(entities = [SatEntry::class, SatRadio::class], version = 1, exportSchema = false)
|
||||
const val DATABASE_NAME = "Look4SatDBv400"
|
||||
|
||||
@Database(entities = [SatEntry::class, SatRadio::class], version = 2, exportSchema = false)
|
||||
abstract class Look4SatDb : RoomDatabase() {
|
||||
abstract fun look4SatDao(): Look4SatDao
|
||||
}
|
||||
|
||||
//val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
// override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// database.execSQL("CREATE TABLE entries_backup (name TEXT NOT NULL, epoch REAL NOT NULL, meanmo REAL NOT NULL, eccn REAL NOT NULL, incl REAL NOT NULL, raan REAL NOT NULL, argper REAL NOT NULL, meanan REAL NOT NULL, catnum INTEGER NOT NULL, bstar REAL NOT NULL, xincl REAL NOT NULL, xnodeo REAL NOT NULL, omegao REAL NOT NULL, xmo REAL NOT NULL, xno REAL NOT NULL, orbitalPeriod REAL NOT NULL, isDeepSpace INTEGER NOT NULL, comment TEXT, PRIMARY KEY(catnum))")
|
||||
// database.execSQL("INSERT INTO entries_backup (name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, orbitalPeriod, isDeepSpace, comment) SELECT name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, 1440 / meanmo, 1440 / meanmo >= 225.0, comment FROM entries")
|
||||
// database.execSQL("DROP TABLE entries")
|
||||
// database.execSQL("ALTER TABLE entries_backup RENAME TO entries")
|
||||
// }
|
||||
//}
|
||||
/** Adds the mean motion derivative, needed to tell decayed satellites apart, and marks the
|
||||
* transceivers that were imported from a file, so that remote updates leave them alone. */
|
||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE entries ADD COLUMN ndot REAL NOT NULL DEFAULT 0.0")
|
||||
db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
@@ -30,5 +30,6 @@ data class SatEntry(
|
||||
val argper: Double,
|
||||
val meanan: Double,
|
||||
val catnum: Int,
|
||||
val bstar: Double
|
||||
val bstar: Double,
|
||||
val ndot: Double = 0.0
|
||||
)
|
||||
@@ -32,5 +32,7 @@ data class SatRadio(
|
||||
val uplinkHigh: Long?,
|
||||
val uplinkMode: String?,
|
||||
val isInverted: Boolean,
|
||||
val catnum: Int?
|
||||
val catnum: Int?,
|
||||
/** Set for manually imported transceivers, which remote updates must not replace. */
|
||||
val isCustom: Boolean = false
|
||||
)
|
||||
@@ -22,7 +22,10 @@ import android.content.Context
|
||||
import android.hardware.SensorManager
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.location.LocationManager
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import androidx.room.Room
|
||||
import com.rtbishop.look4sat.core.data.database.DATABASE_NAME
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
|
||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||
@@ -149,18 +152,24 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||
val dbDispatcher = Dispatchers.Default
|
||||
val dataParser = DataParser(dbDispatcher)
|
||||
val remoteSource = provideRemoteSource()
|
||||
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
}
|
||||
|
||||
private fun provideLocalSource(): ILocalSource {
|
||||
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
|
||||
val database = builder.fallbackToDestructiveMigration(false).build()
|
||||
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, DATABASE_NAME)
|
||||
val database = builder.addMigrations(MIGRATION_1_2).fallbackToDestructiveMigration(false).build()
|
||||
return LocalSource(database.look4SatDao())
|
||||
}
|
||||
|
||||
private fun provideRemoteSource(): IRemoteSource {
|
||||
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
|
||||
val version = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
||||
val userAgent = "Look4Sat/$version (+https://github.com/rt-bishop/Look4Sat)"
|
||||
// Data providers ask clients to identify themselves, so that they can reach out to the
|
||||
// developer instead of silently blocking every user of the app behind a misbehaving request
|
||||
val client = OkHttpClient.Builder().addInterceptor { chain ->
|
||||
chain.proceed(chain.request().newBuilder().header("User-Agent", userAgent).build())
|
||||
}.build()
|
||||
return RemoteSource(Dispatchers.IO, context.contentResolver, client)
|
||||
}
|
||||
|
||||
private fun provideSatelliteRepo(): ISatelliteRepo {
|
||||
@@ -175,7 +184,9 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
val appPrefsFileName = "${context.packageName}_preferences"
|
||||
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
||||
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
||||
return SettingsRepo(manager, appPreferences, appVersionName)
|
||||
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
val appVersionName = packageInfo.versionName ?: "4.0.4"
|
||||
val appVersionCode = PackageInfoCompat.getLongVersionCode(packageInfo)
|
||||
return SettingsRepo(manager, appPreferences, appVersionName, appVersionCode)
|
||||
}
|
||||
}
|
||||
+76
-10
@@ -43,8 +43,9 @@ class DatabaseRepo(
|
||||
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
||||
var importedCount = 0
|
||||
remoteSource.getFileStream(uri)?.let { stream ->
|
||||
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
|
||||
localSource.insertEntries(entries)
|
||||
val entries = mergeEntries(listOf(parseSatelliteStream(uri, unwrapIfZipped(uri, stream))))
|
||||
insertFresherEntries(entries)
|
||||
// report what the file contained: a valid file holding only stale data is not an error
|
||||
importedCount = entries.size
|
||||
}
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
@@ -55,7 +56,7 @@ class DatabaseRepo(
|
||||
var importedCount = 0
|
||||
remoteSource.getFileStream(uri)?.let { stream ->
|
||||
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
||||
localSource.insertRadios(transceivers)
|
||||
localSource.insertRadios(transceivers, isCustom = true)
|
||||
importedCount = transceivers.size
|
||||
}
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
@@ -76,18 +77,27 @@ class DatabaseRepo(
|
||||
settingsRepo.updateDataSourcesStatus(
|
||||
(tleResults + radioResults).associate { (url, result) -> url to result.code }
|
||||
)
|
||||
// parse fetched data concurrently, keeping the first occurrence per primary key
|
||||
// so sources listed higher in the dialog take priority over lower ones
|
||||
val importedEntries = tleResults.flatMap { (url, result) ->
|
||||
// parse fetched data concurrently, merging satellites by freshness instead of source order
|
||||
val parsedPerSource = tleResults.map { (url, result) ->
|
||||
result.stream?.let { val nUrl = normalizeUrl(url); parseSatelliteStream(nUrl, unwrapIfZipped(nUrl, it)) }.orEmpty()
|
||||
}.distinctBy { it.catnum }
|
||||
}
|
||||
val importedRadios = radioResults.flatMap { (url, result) ->
|
||||
result.stream?.let { val nUrl = normalizeUrl(url); dataParser.parseJSONStream(unwrapIfZipped(nUrl, it)) }.orEmpty()
|
||||
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
|
||||
// insert parsed data into the database
|
||||
localSource.insertEntries(importedEntries)
|
||||
localSource.insertRadios(importedRadios)
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
insertFresherEntries(mergeEntries(parsedPerSource))
|
||||
// transceivers are a full snapshot: sources publish active entries only, so a retired
|
||||
// transceiver simply disappears from the feed and has to be dropped locally as well.
|
||||
// Imported ones are kept: no source can refresh them, so nothing would bring them back
|
||||
if (importedRadios.isNotEmpty()) {
|
||||
localSource.deleteManagedRadios()
|
||||
localSource.insertRadios(importedRadios, isCustom = false)
|
||||
}
|
||||
// keep the previous timestamp when every source failed, so the next launch retries
|
||||
val hasFetchedData = parsedPerSource.any { entries -> entries.isNotEmpty() } || importedRadios.isNotEmpty()
|
||||
val previousTimestamp = settingsRepo.databaseState.value.updateTimestamp
|
||||
if (hasFetchedData) pruneStaleEntries()
|
||||
setUpdateSuccessful(if (hasFetchedData) System.currentTimeMillis() else previousTimestamp)
|
||||
}
|
||||
|
||||
override suspend fun clearAllData() = withContext(dispatcher) {
|
||||
@@ -123,6 +133,62 @@ class DatabaseRepo(
|
||||
line.count { it == ',' } >= 4
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the data of every source: orbital elements always come from the set with the newest
|
||||
* epoch, while the name comes from the first source that provides it. Source order is thus a
|
||||
* naming preference only, which also keeps names stable when sources leapfrog each other.
|
||||
*/
|
||||
private fun mergeEntries(sourceEntries: List<List<OrbitalData>>): List<OrbitalData> {
|
||||
val preferredNames = mutableMapOf<Int, String>()
|
||||
val freshestEntries = mutableMapOf<Int, OrbitalData>()
|
||||
sourceEntries.forEach { entries ->
|
||||
entries.forEach { entry ->
|
||||
val name = entry.name.trim()
|
||||
if (name.isNotBlank()) preferredNames.getOrPut(entry.catnum) { name }
|
||||
val current = freshestEntries[entry.catnum]
|
||||
if (current == null || entry.epochDaynum > current.epochDaynum) {
|
||||
freshestEntries[entry.catnum] = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
return freshestEntries.values.map { entry ->
|
||||
val name = preferredNames[entry.catnum]
|
||||
if (name == null || name == entry.name) entry else entry.copy(name = name)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stores the entries that are newer than the ones already saved, renaming the rest in place. */
|
||||
private suspend fun insertFresherEntries(entries: List<OrbitalData>) {
|
||||
val storedEpochs = localSource.getEntriesEpochs()
|
||||
val (fresherEntries, staleEntries) = entries.partition { entry ->
|
||||
val storedEpoch = storedEpochs[entry.catnum]
|
||||
storedEpoch == null || entry.epochDaynum > OrbitalData.epochToDaynum(storedEpoch)
|
||||
}
|
||||
localSource.insertEntries(fresherEntries)
|
||||
// a reordered source list has to rename satellites right away, even the ones holding
|
||||
// elements that are newer than the ones just parsed
|
||||
if (staleEntries.isNotEmpty()) {
|
||||
val storedNames = localSource.getEntriesNames()
|
||||
val renamedEntries = staleEntries.filter { entry -> entry.name != storedNames[entry.catnum] }
|
||||
if (renamedEntries.isNotEmpty()) {
|
||||
localSource.renameEntries(renamedEntries.associate { it.catnum to it.name })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops satellites that no enabled source has refreshed for a month: they either decayed or
|
||||
* disappeared from every catalog. Manually imported data ages out the same way, and every
|
||||
* source republishes active satellites well within that window.
|
||||
*/
|
||||
private suspend fun pruneStaleEntries() {
|
||||
val currentDaynum = OrbitalData.timeToDaynum(System.currentTimeMillis())
|
||||
val staleIds = localSource.getEntriesEpochs()
|
||||
.filterValues { epoch -> currentDaynum - OrbitalData.epochToDaynum(epoch) > 30.0 }
|
||||
.keys.toList()
|
||||
if (staleIds.isNotEmpty()) localSource.deleteEntriesWithIds(staleIds)
|
||||
}
|
||||
|
||||
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
||||
settingsRepo.updateDatabaseState(
|
||||
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
|
||||
|
||||
+26
-11
@@ -45,7 +45,8 @@ import java.util.Locale
|
||||
class SettingsRepo(
|
||||
private val locationManager: LocationManager,
|
||||
private val preferences: SharedPreferences,
|
||||
override val appVersionName: String
|
||||
override val appVersionName: String,
|
||||
override val appVersionCode: Long,
|
||||
) : ISettingsRepo, LocationListenerCompat {
|
||||
|
||||
private val keyBluetoothRotatorAddress = "bluetoothAddress"
|
||||
@@ -87,19 +88,33 @@ class SettingsRepo(
|
||||
private val keyStationTimestamp = "stationTimestamp"
|
||||
private val keyUpdateTimestamp = "updateTimestamp"
|
||||
private val keyShouldSeeWarning = "shouldSeeWarning"
|
||||
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
||||
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew"
|
||||
private val keySstvMode = "sstvMode"
|
||||
private val keyLowElevation = "lowElevation"
|
||||
private val keyHighElevation = "highElevation"
|
||||
private val keyRadarCompassOffset = "radarCompassOffset"
|
||||
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
|
||||
private val keySatelliteUrls = "satelliteUrls"
|
||||
private val keySatellitesUrls = "satellitesUrls"
|
||||
private val keyTransceiversUrls = "transceiversUrls"
|
||||
private val keySatelliteEnabled = "satelliteEnabled"
|
||||
private val keyTransceiversEnabled = "transceiversEnabled"
|
||||
private val keySatellitesUrlsEnabled = "satellitesUrlsEnabled"
|
||||
private val keyTransceiversUrlsEnabled = "transceiversUrlsEnabled"
|
||||
private val keySettingsVersion = "settingsVersion"
|
||||
private val separatorComma = ","
|
||||
private val separatorUrl = "\n"
|
||||
|
||||
init {
|
||||
// The meaning of the data source order changed, so the stored lists are dropped and the defaults
|
||||
// are applied again. The update timestamp is cleared as well, to refresh the migrated data
|
||||
// on the first launch instead of waiting up to 48 hours for the automatic update.
|
||||
if (preferences.getInt(keySettingsVersion, 0) < appVersionCode) {
|
||||
preferences.edit {
|
||||
listOf(keyShouldSeeWhatsNew, keySatellitesUrls, keyTransceiversUrls, keyUpdateTimestamp)
|
||||
.forEach { key -> remove(key) }
|
||||
putInt(keySettingsVersion, appVersionCode.toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//region # Satellites selection settings
|
||||
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
||||
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
|
||||
@@ -384,23 +399,23 @@ class SettingsRepo(
|
||||
transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled)
|
||||
)
|
||||
preferences.edit {
|
||||
putString(keySatelliteUrls, normalized.satelliteUrls.joinToString(separatorUrl))
|
||||
putString(keySatellitesUrls, normalized.satelliteUrls.joinToString(separatorUrl))
|
||||
putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
|
||||
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
|
||||
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
|
||||
putString(keySatellitesUrlsEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
|
||||
putString(keyTransceiversUrlsEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
|
||||
}
|
||||
_dataSourcesSettings.value = normalized
|
||||
}
|
||||
|
||||
private fun getDataSourcesSettings(): DataSourcesSettings {
|
||||
val (satUrls, satEnabled) = parseSources(
|
||||
preferences.getString(keySatelliteUrls, null),
|
||||
preferences.getString(keySatelliteEnabled, null),
|
||||
preferences.getString(keySatellitesUrls, null),
|
||||
preferences.getString(keySatellitesUrlsEnabled, null),
|
||||
Sources.satelliteDataUrls
|
||||
)
|
||||
val (txUrls, txEnabled) = parseSources(
|
||||
preferences.getString(keyTransceiversUrls, null),
|
||||
preferences.getString(keyTransceiversEnabled, null),
|
||||
preferences.getString(keyTransceiversUrlsEnabled, null),
|
||||
Sources.transceiversDataUrls
|
||||
)
|
||||
return DataSourcesSettings(
|
||||
|
||||
@@ -43,6 +43,16 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
||||
return selectedOrbitalObjects
|
||||
}
|
||||
|
||||
override suspend fun getEntriesEpochs() = look4SatDao.getEntriesEpochs()
|
||||
|
||||
override suspend fun getEntriesNames() = look4SatDao.getEntriesNames()
|
||||
|
||||
override suspend fun renameEntries(names: Map<Int, String>) =
|
||||
names.forEach { (catnum, name) -> look4SatDao.renameEntry(catnum, name) }
|
||||
|
||||
override suspend fun deleteEntriesWithIds(ids: List<Int>) =
|
||||
ids.chunked(999).forEach { idsPart -> look4SatDao.deleteEntriesWithIds(idsPart) }
|
||||
|
||||
override suspend fun insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity())
|
||||
|
||||
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
|
||||
@@ -51,12 +61,12 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
||||
|
||||
private fun FrameworkEntry.toDomain() = OrbitalData(
|
||||
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar
|
||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
|
||||
)
|
||||
|
||||
private fun OrbitalData.toEntity() = FrameworkEntry(
|
||||
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar
|
||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
|
||||
)
|
||||
|
||||
private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() }
|
||||
@@ -73,15 +83,17 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
||||
return look4SatDao.getRadiosWithId(id).toDomainRadios()
|
||||
}
|
||||
|
||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
||||
look4SatDao.insertRadios(radios.toFrameworkRadios())
|
||||
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
|
||||
look4SatDao.insertRadios(radios.map { radio -> radio.toFramework(isCustom) })
|
||||
}
|
||||
|
||||
override suspend fun deleteRadios() = look4SatDao.deleteRadios()
|
||||
|
||||
private fun DomainRadio.toFramework() = FrameworkRadio(
|
||||
override suspend fun deleteManagedRadios() = look4SatDao.deleteManagedRadios()
|
||||
|
||||
private fun DomainRadio.toFramework(isCustom: Boolean = false) = FrameworkRadio(
|
||||
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
|
||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, isCustom
|
||||
)
|
||||
|
||||
private fun FrameworkRadio.toDomain() = DomainRadio(
|
||||
@@ -89,8 +101,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
|
||||
)
|
||||
|
||||
private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() }
|
||||
|
||||
private fun List<FrameworkRadio>.toDomainRadios() = this.map { radio -> radio.toDomain() }
|
||||
|
||||
//endregion
|
||||
|
||||
+192
-4
@@ -42,6 +42,7 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.InputStream
|
||||
import java.time.LocalDate
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DatabaseRepoTest {
|
||||
@@ -49,6 +50,10 @@ class DatabaseRepoTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
private val dataParser = DataParser(dispatcher)
|
||||
|
||||
// fixtures carry a current epoch: stale entries are pruned on every remote update
|
||||
private val todayEpoch = LocalDate.now().let { "%02d%03d".format(it.year % 100, it.dayOfYear) }
|
||||
private val fresherEpoch = "$todayEpoch.71955234".toDouble()
|
||||
|
||||
@Test
|
||||
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
|
||||
val uri = "content://look4sat/import/satellites"
|
||||
@@ -102,18 +107,176 @@ class DatabaseRepoTest {
|
||||
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remote update takes the freshest elements and the preferred name`() = runTest(dispatcher) {
|
||||
val primaryUrl = "https://example.com/primary.txt"
|
||||
val secondaryUrl = "https://example.com/secondary.txt"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[primaryUrl] = { validTleStream() }
|
||||
networkStreams[secondaryUrl] = { fresherTleStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(primaryUrl, secondaryUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
repository.updateFromRemote()
|
||||
|
||||
val entry = localSource.insertedEntries.single { it.catnum == 25544 }
|
||||
assertEquals("ISS (ZARYA)", entry.name)
|
||||
assertEquals(fresherEpoch, entry.epoch, 1e-8)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual satellite import does not overwrite fresher data`() = runTest(dispatcher) {
|
||||
val freshUri = "content://look4sat/import/fresh"
|
||||
val staleUri = "content://look4sat/import/stale"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
fileStreams[freshUri] = { fresherTleStream() }
|
||||
fileStreams[staleUri] = { validTleStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo()
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
assertEquals(1, repository.updateTLEFromFile(freshUri))
|
||||
assertEquals(1, repository.updateTLEFromFile(staleUri))
|
||||
assertEquals(fresherEpoch, localSource.insertedEntries.single().epoch, 1e-8)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remote update drops transceivers that are no longer published`() = runTest(dispatcher) {
|
||||
val radioUrl = "https://example.com/transmitters.json"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource()
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = emptyList(),
|
||||
transceiversUrls = listOf(radioUrl)
|
||||
)
|
||||
)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
remoteSource.networkStreams[radioUrl] = { twoRadiosStream() }
|
||||
repository.updateFromRemote()
|
||||
assertEquals(2, localSource.insertedRadios.size)
|
||||
|
||||
remoteSource.networkStreams[radioUrl] = { singleRadioStream() }
|
||||
repository.updateFromRemote()
|
||||
assertEquals(listOf("uuid-alive"), localSource.insertedRadios.map { it.uuid })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remote update renames satellites even when their elements are not newer`() = runTest(dispatcher) {
|
||||
val preferredUrl = "https://example.com/preferred.txt"
|
||||
val freshUrl = "https://example.com/fresh.txt"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[freshUrl] = { fresherTleStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(freshUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
repository.updateFromRemote()
|
||||
assertEquals("ISS", localSource.insertedEntries.single().name)
|
||||
|
||||
// the user moves a source with older elements, but a better name, to the top of the list
|
||||
remoteSource.networkStreams[preferredUrl] = { validTleStream() }
|
||||
settingsRepo.dataSourcesSettings.value = DataSourcesSettings(
|
||||
satelliteUrls = listOf(preferredUrl, freshUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
repository.updateFromRemote()
|
||||
|
||||
val entry = localSource.insertedEntries.single()
|
||||
assertEquals("ISS (ZARYA)", entry.name)
|
||||
assertEquals(fresherEpoch, entry.epoch, 1e-8)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed remote update records the attempt without claiming success`() = runTest(dispatcher) {
|
||||
val failingUrl = "https://example.com/offline.txt"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource()
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(failingUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
)
|
||||
settingsRepo.databaseState.value = DatabaseState(0, 0, 1_000L)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
repository.updateFromRemote()
|
||||
|
||||
// the update timestamp stays put so the data is not presented as fresh, while the recorded
|
||||
// attempt keeps the next app launch from hammering a source that is already failing
|
||||
assertEquals(1_000L, settingsRepo.databaseState.value.updateTimestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remote update keeps the transceivers imported from a file`() = runTest(dispatcher) {
|
||||
val radioUri = "content://imported.json"
|
||||
val radioUrl = "https://example.com/transmitters.json"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
fileStreams[radioUri] = { customRadioStream() }
|
||||
networkStreams[radioUrl] = { singleRadioStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = emptyList(),
|
||||
transceiversUrls = listOf(radioUrl)
|
||||
)
|
||||
)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
repository.updateTransceiversFromFile(radioUri)
|
||||
repository.updateFromRemote()
|
||||
|
||||
// the remote snapshot replaces what the sources publish, the imported entry stays put
|
||||
assertEquals(setOf("uuid-custom", "uuid-alive"), localSource.insertedRadios.map { it.uuid }.toSet())
|
||||
}
|
||||
|
||||
private fun validCsvStream(): InputStream = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
ISS (ZARYA),1998-067A,${LocalDate.now()}T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
private fun validTleStream(): InputStream = """
|
||||
ISS (ZARYA)
|
||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
1 25544U 98067A $todayEpoch.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
private fun fresherTleStream(): InputStream = """
|
||||
ISS
|
||||
1 25544U 98067A $todayEpoch.71955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
private fun twoRadiosStream(): InputStream = """
|
||||
[$radioAlive,{"uuid":"uuid-retired","description":"Retired","alive":true,"downlink_low":437800000,
|
||||
"mode":"FM","invert":false,"norad_cat_id":25544}]
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
private fun singleRadioStream(): InputStream = "[$radioAlive]".byteInputStream()
|
||||
|
||||
private fun customRadioStream(): InputStream = """
|
||||
[{"uuid":"uuid-custom","description":"Local beacon","alive":true,"downlink_low":144800000,
|
||||
"mode":"FM","invert":false,"norad_cat_id":25544}]
|
||||
""".trimIndent().byteInputStream()
|
||||
}
|
||||
|
||||
private const val radioAlive = """{"uuid":"uuid-alive","description":"Voice repeater","alive":true,
|
||||
"downlink_low":145800000,"mode":"FM","invert":false,"norad_cat_id":25544}"""
|
||||
|
||||
private class FakeRemoteSource : IRemoteSource {
|
||||
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||
@@ -134,7 +297,8 @@ private class FakeRemoteSource : IRemoteSource {
|
||||
|
||||
private class FakeLocalSource : ILocalSource {
|
||||
val insertedEntries = mutableListOf<OrbitalData>()
|
||||
private val insertedRadios = mutableListOf<SatRadio>()
|
||||
val insertedRadios = mutableListOf<SatRadio>()
|
||||
private val customRadios = mutableListOf<String>()
|
||||
|
||||
override suspend fun getEntriesTotal(): Int = insertedEntries.size
|
||||
|
||||
@@ -142,6 +306,21 @@ private class FakeLocalSource : ILocalSource {
|
||||
|
||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||
|
||||
override suspend fun getEntriesEpochs(): Map<Int, Double> =
|
||||
insertedEntries.associate { entry -> entry.catnum to entry.epoch }
|
||||
|
||||
override suspend fun getEntriesNames(): Map<Int, String> =
|
||||
insertedEntries.associate { entry -> entry.catnum to entry.name }
|
||||
|
||||
override suspend fun renameEntries(names: Map<Int, String>) = names.forEach { (catnum, name) ->
|
||||
val index = insertedEntries.indexOfFirst { entry -> entry.catnum == catnum }
|
||||
if (index >= 0) insertedEntries[index] = insertedEntries[index].copy(name = name)
|
||||
}
|
||||
|
||||
override suspend fun deleteEntriesWithIds(ids: List<Int>) {
|
||||
insertedEntries.removeAll { entry -> entry.catnum in ids }
|
||||
}
|
||||
|
||||
override suspend fun insertEntries(entries: List<OrbitalData>) {
|
||||
insertedEntries += entries
|
||||
}
|
||||
@@ -156,18 +335,27 @@ private class FakeLocalSource : ILocalSource {
|
||||
|
||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||
|
||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
||||
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
|
||||
insertedRadios.removeAll { stored -> radios.any { radio -> radio.uuid == stored.uuid } }
|
||||
insertedRadios += radios
|
||||
if (isCustom) customRadios += radios.map { radio -> radio.uuid }
|
||||
else customRadios -= radios.map { radio -> radio.uuid }.toSet()
|
||||
}
|
||||
|
||||
override suspend fun deleteRadios() {
|
||||
insertedRadios.clear()
|
||||
customRadios.clear()
|
||||
}
|
||||
|
||||
override suspend fun deleteManagedRadios() {
|
||||
insertedRadios.removeAll { radio -> radio.uuid !in customRadios }
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo {
|
||||
|
||||
override val appVersionName: String = "test"
|
||||
override val appVersionCode: Long = 1L
|
||||
|
||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||
|
||||
|
||||
+12
-3
@@ -93,6 +93,14 @@ class SelectionRepoTest {
|
||||
|
||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||
|
||||
override suspend fun getEntriesEpochs(): Map<Int, Double> = emptyMap()
|
||||
|
||||
override suspend fun getEntriesNames(): Map<Int, String> = emptyMap()
|
||||
|
||||
override suspend fun renameEntries(names: Map<Int, String>) = Unit
|
||||
|
||||
override suspend fun deleteEntriesWithIds(ids: List<Int>) = Unit
|
||||
|
||||
override suspend fun insertEntries(entries: List<com.rtbishop.look4sat.core.domain.predict.OrbitalData>) = Unit
|
||||
|
||||
override suspend fun deleteEntries() = Unit
|
||||
@@ -103,9 +111,11 @@ class SelectionRepoTest {
|
||||
|
||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||
|
||||
override suspend fun insertRadios(radios: List<SatRadio>) = Unit
|
||||
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) = Unit
|
||||
|
||||
override suspend fun deleteRadios() = Unit
|
||||
|
||||
override suspend fun deleteManagedRadios() = Unit
|
||||
}
|
||||
|
||||
private class FakeSettingsRepo(
|
||||
@@ -113,6 +123,7 @@ class SelectionRepoTest {
|
||||
) : ISettingsRepo {
|
||||
|
||||
override val appVersionName: String = "test"
|
||||
override val appVersionCode: Long = 1L
|
||||
|
||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||
|
||||
@@ -181,5 +192,3 @@ class SelectionRepoTest {
|
||||
override fun setAmSatCallsign(callsign: String) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user