feat(data): persist ndot and protect manually imported transceivers

- DB v2 migration: entries.ndot column so hasDecayed() actually works after
  reload (parsed ndot was dropped on write, making the decay detection dead
  code); radios.isCustom column to mark manually imported transceivers.
- updateTransceiversFromFile marks imported radios isCustom=true; remote
  updates now deleteManagedRadios() then insert the fresh snapshot, keeping
  manual imports (no source can refresh them, so nothing would bring them back).
- Wire MIGRATION_1_2 into MainContainer (fallbackToDestructiveMigration=false
  would otherwise crash on upgrade).
This commit is contained in:
atsunatsu committed 2026-09-14 14:30:23 +08:00
1 parent 7ef555e377
commit 212dced3c4
10 files changed
+56 -26

No files matched your search

@@ -60,4 +60,7 @@ interface Look4SatDao {
@Query("DELETE FROM radios") @Query("DELETE FROM radios")
suspend fun deleteRadios() suspend fun deleteRadios()
@Query("DELETE FROM radios WHERE isCustom = 0")
suspend fun deleteManagedRadios()
} }
@@ -19,19 +19,21 @@ package com.rtbishop.look4sat.core.data.database
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase 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.SatEntry
import com.rtbishop.look4sat.core.data.database.entity.SatRadio import com.rtbishop.look4sat.core.data.database.entity.SatRadio
@Database(entities = [SatEntry::class, SatRadio::class], version = 1, exportSchema = false) @Database(entities = [SatEntry::class, SatRadio::class], version = 2, exportSchema = false)
abstract class Look4SatDb : RoomDatabase() { abstract class Look4SatDb : RoomDatabase() {
abstract fun look4SatDao(): Look4SatDao abstract fun look4SatDao(): Look4SatDao
} }
//val MIGRATION_1_2 = object : Migration(1, 2) { /** Adds the mean motion derivative, needed to tell decayed satellites apart, and marks the
// override fun migrate(database: SupportSQLiteDatabase) { * transceivers that were imported from a file, so that remote updates leave them alone. */
// 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))") val MIGRATION_1_2 = object : Migration(1, 2) {
// 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") override fun migrate(db: SupportSQLiteDatabase) {
// database.execSQL("DROP TABLE entries") db.execSQL("ALTER TABLE entries ADD COLUMN ndot REAL NOT NULL DEFAULT 0.0")
// database.execSQL("ALTER TABLE entries_backup RENAME TO entries") db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0")
// } }
//} }
@@ -30,5 +30,6 @@ data class SatEntry(
val argper: Double, val argper: Double,
val meanan: Double, val meanan: Double,
val catnum: Int, 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 uplinkHigh: Long?,
val uplinkMode: String?, val uplinkMode: String?,
val isInverted: Boolean, val isInverted: Boolean,
val catnum: Int? val catnum: Int?,
/** Set for manually imported transceivers, which remote updates must not replace. */
val isCustom: Boolean = false
) )
@@ -24,6 +24,7 @@ import android.hardware.display.DisplayManager
import android.location.LocationManager import android.location.LocationManager
import androidx.room.Room import androidx.room.Room
import com.rtbishop.look4sat.core.data.database.Look4SatDb import com.rtbishop.look4sat.core.data.database.Look4SatDb
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
import com.rtbishop.look4sat.core.data.framework.Ft817Controller import com.rtbishop.look4sat.core.data.framework.Ft817Controller
import com.rtbishop.look4sat.core.data.framework.Ic705Controller import com.rtbishop.look4sat.core.data.framework.Ic705Controller
@@ -167,7 +168,10 @@ class MainContainer(private val context: Context) : IMainContainer {
private fun provideLocalSource(): ILocalSource { private fun provideLocalSource(): ILocalSource {
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400") val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
val database = builder.fallbackToDestructiveMigration(false).build() val database = builder
.addMigrations(MIGRATION_1_2)
.fallbackToDestructiveMigration(false)
.build()
return LocalSource(database.look4SatDao()) return LocalSource(database.look4SatDao())
} }
@@ -58,7 +58,7 @@ class DatabaseRepo(
var importedCount = 0 var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream -> remoteSource.getFileStream(uri)?.let { stream ->
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream)) val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
localSource.insertRadios(transceivers) localSource.insertRadios(transceivers, isCustom = true)
importedCount = transceivers.size importedCount = transceivers.size
} }
setUpdateSuccessful(System.currentTimeMillis()) setUpdateSuccessful(System.currentTimeMillis())
@@ -105,9 +105,16 @@ class DatabaseRepo(
val normUrl = normalizeUrl(rawUrl) val normUrl = normalizeUrl(rawUrl)
result.stream?.let { dataParser.parseJSONStream(unwrapIfZipped(normUrl, it)) }.orEmpty() 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) // Transceivers are a full snapshot: sources publish active entries only, so a retired
localSource.insertRadios(importedRadios) // transceiver simply disappears from the feed and has to be dropped locally as well.
// Manually imported ones (isCustom) are kept: no source can refresh them, so nothing
// would bring them back if they were replaced here.
if (importedRadios.isNotEmpty()) {
localSource.deleteManagedRadios()
localSource.insertRadios(importedRadios)
}
if (importedEntries.isNotEmpty()) localSource.insertEntries(importedEntries)
setUpdateSuccessful(System.currentTimeMillis()) setUpdateSuccessful(System.currentTimeMillis())
} }
@@ -51,12 +51,12 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
private fun FrameworkEntry.toDomain() = OrbitalData( private fun FrameworkEntry.toDomain() = OrbitalData(
this.name, this.epoch, this.meanmo, this.eccn, this.incl, 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( private fun OrbitalData.toEntity() = FrameworkEntry(
this.name, this.epoch, this.meanmo, this.eccn, this.incl, 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() } private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() }
@@ -73,20 +73,22 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
return look4SatDao.getRadiosWithId(id).toDomainRadios() return look4SatDao.getRadiosWithId(id).toDomainRadios()
} }
override suspend fun insertRadios(radios: List<SatRadio>) { override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
look4SatDao.insertRadios(radios.toFrameworkRadios()) look4SatDao.insertRadios(radios.map { it.copy(isCustom = isCustom) }.toFrameworkRadios())
} }
override suspend fun deleteManagedRadios() = look4SatDao.deleteManagedRadios()
override suspend fun deleteRadios() = look4SatDao.deleteRadios() override suspend fun deleteRadios() = look4SatDao.deleteRadios()
private fun DomainRadio.toFramework() = FrameworkRadio( private fun DomainRadio.toFramework() = FrameworkRadio(
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode, 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, this.isCustom
) )
private fun FrameworkRadio.toDomain() = DomainRadio( private fun FrameworkRadio.toDomain() = DomainRadio(
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode, 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, this.isCustom
) )
private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() } private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() }
@@ -185,8 +185,12 @@ private class FakeLocalSource : ILocalSource {
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList() 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 += radios insertedRadios += radios.map { it.copy(isCustom = isCustom) }
}
override suspend fun deleteManagedRadios() {
insertedRadios.removeAll { !it.isCustom }
} }
override suspend fun deleteRadios() { override suspend fun deleteRadios() {
@@ -32,5 +32,7 @@ data class SatRadio(
@SerialName("uplink_high") val uplinkHigh: Long?, @SerialName("uplink_high") val uplinkHigh: Long?,
@SerialName("uplink_mode") val uplinkMode: String?, @SerialName("uplink_mode") val uplinkMode: String?,
@SerialName("invert") val isInverted: Boolean, @SerialName("invert") val isInverted: Boolean,
@SerialName("norad_cat_id") val catnum: Int? @SerialName("norad_cat_id") val catnum: Int?,
/** Set for manually imported transceivers, which remote updates must not replace. */
val isCustom: Boolean = false
) )
@@ -31,6 +31,9 @@ interface ILocalSource {
suspend fun getIdsWithModes(modes: List<String>): List<Int> suspend fun getIdsWithModes(modes: List<String>): List<Int>
suspend fun getRadiosTotal(): Int suspend fun getRadiosTotal(): Int
suspend fun getRadiosWithId(id: Int): List<SatRadio> suspend fun getRadiosWithId(id: Int): List<SatRadio>
suspend fun insertRadios(radios: List<SatRadio>) suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean = false)
/** Delete all non-custom transceivers, keeping manually imported ones. */
suspend fun deleteManagedRadios()
suspend fun deleteRadios() suspend fun deleteRadios()
} }