refactor: migrate data storage from SharedPreferences to Room database

This commit is contained in:
Nedifinita
2025-08-01 17:36:04 +08:00
parent 39bb8cb440
commit e6e7831b96
6 changed files with 279 additions and 18 deletions

View File

@@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
} }
android { android {
@@ -85,4 +86,8 @@ dependencies {
implementation("org.osmdroid:osmdroid-android:6.1.16") implementation("org.osmdroid:osmdroid-android:6.1.16")
implementation("org.osmdroid:osmdroid-mapsforge:6.1.16") implementation("org.osmdroid:osmdroid-mapsforge:6.1.16")
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
} }

View File

@@ -0,0 +1,35 @@
package org.noxylva.lbjconsole.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
entities = [TrainRecordEntity::class],
version = 1,
exportSchema = false
)
abstract class TrainDatabase : RoomDatabase() {
abstract fun trainRecordDao(): TrainRecordDao
companion object {
@Volatile
private var INSTANCE: TrainDatabase? = null
fun getDatabase(context: Context): TrainDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
TrainDatabase::class.java,
"train_database"
).build()
INSTANCE = instance
instance
}
}
}
}

View File

@@ -0,0 +1,50 @@
package org.noxylva.lbjconsole.database
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface TrainRecordDao {
@Query("SELECT * FROM train_records ORDER BY timestamp DESC")
suspend fun getAllRecords(): List<TrainRecordEntity>
@Query("SELECT * FROM train_records ORDER BY timestamp DESC")
fun getAllRecordsFlow(): Flow<List<TrainRecordEntity>>
@Query("SELECT * FROM train_records WHERE uniqueId = :uniqueId")
suspend fun getRecordById(uniqueId: String): TrainRecordEntity?
@Query("SELECT COUNT(*) FROM train_records")
suspend fun getRecordCount(): Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRecord(record: TrainRecordEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRecords(records: List<TrainRecordEntity>)
@Delete
suspend fun deleteRecord(record: TrainRecordEntity)
@Delete
suspend fun deleteRecords(records: List<TrainRecordEntity>)
@Query("DELETE FROM train_records")
suspend fun deleteAllRecords()
@Query("DELETE FROM train_records WHERE uniqueId = :uniqueId")
suspend fun deleteRecordById(uniqueId: String)
@Query("DELETE FROM train_records WHERE uniqueId IN (:uniqueIds)")
suspend fun deleteRecordsByIds(uniqueIds: List<String>)
@Query("SELECT * FROM train_records WHERE train LIKE '%' || :train || '%' AND route LIKE '%' || :route || '%' AND (:direction = '全部' OR (:direction = '上行' AND direction = 3) OR (:direction = '下行' AND direction = 1)) ORDER BY timestamp DESC")
suspend fun getFilteredRecords(train: String, route: String, direction: String): List<TrainRecordEntity>
@Query("SELECT * FROM train_records ORDER BY timestamp DESC LIMIT :limit")
suspend fun getLatestRecords(limit: Int): List<TrainRecordEntity>
@Query("SELECT * FROM train_records WHERE timestamp >= :fromTime ORDER BY timestamp DESC")
suspend fun getRecordsFromTime(fromTime: Long): List<TrainRecordEntity>
}

View File

@@ -0,0 +1,66 @@
package org.noxylva.lbjconsole.database
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.noxylva.lbjconsole.model.TrainRecord
import org.json.JSONObject
import java.util.*
@Entity(tableName = "train_records")
data class TrainRecordEntity(
@PrimaryKey val uniqueId: String,
val timestamp: Long,
val receivedTimestamp: Long,
val train: String,
val direction: Int,
val speed: String,
val position: String,
val time: String,
val loco: String,
val locoType: String,
val lbjClass: String,
val route: String,
val positionInfo: String,
val rssi: Double
) {
fun toTrainRecord(): TrainRecord {
val jsonData = JSONObject().apply {
put("uniqueId", uniqueId)
put("timestamp", timestamp)
put("receivedTimestamp", receivedTimestamp)
put("train", train)
put("dir", direction)
put("speed", speed)
put("pos", position)
put("time", time)
put("loco", loco)
put("loco_type", locoType)
put("lbj_class", lbjClass)
put("route", route)
put("position_info", positionInfo)
put("rssi", rssi)
}
return TrainRecord(jsonData)
}
companion object {
fun fromTrainRecord(record: TrainRecord): TrainRecordEntity {
return TrainRecordEntity(
uniqueId = record.uniqueId,
timestamp = record.timestamp.time,
receivedTimestamp = record.receivedTimestamp.time,
train = record.train,
direction = record.direction,
speed = record.speed,
position = record.position,
time = record.time,
loco = record.loco,
locoType = record.locoType,
lbjClass = record.lbjClass,
route = record.route,
positionInfo = record.positionInfo,
rssi = record.rssi
)
}
}
}

View File

@@ -7,6 +7,8 @@ import android.util.Log
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.noxylva.lbjconsole.database.TrainDatabase
import org.noxylva.lbjconsole.database.TrainRecordEntity
import java.io.File import java.io.File
import java.io.FileWriter import java.io.FileWriter
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -27,6 +29,8 @@ class TrainRecordManager(private val context: Context) {
private val trainRecords = CopyOnWriteArrayList<TrainRecord>() private val trainRecords = CopyOnWriteArrayList<TrainRecord>()
private val recordCount = AtomicInteger(0) private val recordCount = AtomicInteger(0)
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val database = TrainDatabase.getDatabase(context)
private val trainRecordDao = database.trainRecordDao()
private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
var mergeSettings = MergeSettings() var mergeSettings = MergeSettings()
@@ -34,11 +38,36 @@ class TrainRecordManager(private val context: Context) {
init { init {
ioScope.launch { ioScope.launch {
migrateFromSharedPreferences()
loadRecords() loadRecords()
loadMergeSettings() loadMergeSettings()
} }
} }
private suspend fun migrateFromSharedPreferences() {
try {
val jsonStr = prefs.getString(KEY_RECORDS, null)
if (jsonStr != null && jsonStr != "[]") {
val jsonArray = JSONArray(jsonStr)
val records = mutableListOf<TrainRecordEntity>()
for (i in 0 until jsonArray.length()) {
val jsonObject = jsonArray.getJSONObject(i)
val trainRecord = TrainRecord(jsonObject)
records.add(TrainRecordEntity.fromTrainRecord(trainRecord))
}
if (records.isNotEmpty()) {
trainRecordDao.insertRecords(records)
prefs.edit().remove(KEY_RECORDS).apply()
Log.d(TAG, "Migrated ${records.size} records from SharedPreferences to Room database")
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to migrate records: ${e.message}")
}
}
private var filterTrain: String = "" private var filterTrain: String = ""
private var filterRoute: String = "" private var filterRoute: String = ""
@@ -52,11 +81,16 @@ class TrainRecordManager(private val context: Context) {
while (trainRecords.size > MAX_RECORDS) { while (trainRecords.size > MAX_RECORDS) {
trainRecords.removeAt(trainRecords.size - 1) val removedRecord = trainRecords.removeAt(trainRecords.size - 1)
ioScope.launch {
trainRecordDao.deleteRecordById(removedRecord.uniqueId)
}
} }
recordCount.incrementAndGet() recordCount.incrementAndGet()
saveRecords() ioScope.launch {
trainRecordDao.insertRecord(TrainRecordEntity.fromTrainRecord(record))
}
return record return record
} }
@@ -76,6 +110,16 @@ class TrainRecordManager(private val context: Context) {
} }
} }
suspend fun getFilteredRecordsFromDatabase(): List<TrainRecord> {
return try {
val entities = trainRecordDao.getFilteredRecords(filterTrain, filterRoute, filterDirection)
entities.map { it.toTrainRecord() }
} catch (e: Exception) {
Log.e(TAG, "Failed to get filtered records from database: ${e.message}")
emptyList()
}
}
private fun matchFilter(record: TrainRecord): Boolean { private fun matchFilter(record: TrainRecord): Boolean {
@@ -118,32 +162,56 @@ class TrainRecordManager(private val context: Context) {
} }
suspend fun refreshRecordsFromDatabase() {
try {
val entities = trainRecordDao.getAllRecords()
trainRecords.clear()
entities.forEach { entity ->
trainRecords.add(entity.toTrainRecord())
}
recordCount.set(trainRecords.size)
Log.d(TAG, "Refreshed ${trainRecords.size} records from database")
} catch (e: Exception) {
Log.e(TAG, "Failed to refresh records from database: ${e.message}")
}
}
fun clearRecords() { fun clearRecords() {
trainRecords.clear() trainRecords.clear()
recordCount.set(0) recordCount.set(0)
saveRecords() ioScope.launch {
trainRecordDao.deleteAllRecords()
}
} }
fun deleteRecord(record: TrainRecord): Boolean { fun deleteRecord(record: TrainRecord): Boolean {
val result = trainRecords.remove(record) val result = trainRecords.remove(record)
if (result) { if (result) {
recordCount.decrementAndGet() recordCount.decrementAndGet()
saveRecords() ioScope.launch {
trainRecordDao.deleteRecordById(record.uniqueId)
}
} }
return result return result
} }
fun deleteRecords(records: List<TrainRecord>): Int { fun deleteRecords(records: List<TrainRecord>): Int {
var deletedCount = 0 var deletedCount = 0
val idsToDelete = mutableListOf<String>()
records.forEach { record -> records.forEach { record ->
if (trainRecords.remove(record)) { if (trainRecords.remove(record)) {
deletedCount++ deletedCount++
idsToDelete.add(record.uniqueId)
} }
} }
if (deletedCount > 0) { if (deletedCount > 0) {
recordCount.addAndGet(-deletedCount) recordCount.addAndGet(-deletedCount)
saveRecords() ioScope.launch {
trainRecordDao.deleteRecordsByIds(idsToDelete)
}
} }
return deletedCount return deletedCount
} }
@@ -151,12 +219,9 @@ class TrainRecordManager(private val context: Context) {
private fun saveRecords() { private fun saveRecords() {
ioScope.launch { ioScope.launch {
try { try {
val jsonArray = JSONArray() val entities = trainRecords.map { TrainRecordEntity.fromTrainRecord(it) }
for (record in trainRecords) { trainRecordDao.insertRecords(entities)
jsonArray.put(record.toJSON()) Log.d(TAG, "Saved ${trainRecords.size} records to database")
}
prefs.edit().putString(KEY_RECORDS, jsonArray.toString()).apply()
Log.d(TAG, "Saved ${trainRecords.size} records")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to save records: ${e.message}") Log.e(TAG, "Failed to save records: ${e.message}")
} }
@@ -164,19 +229,17 @@ class TrainRecordManager(private val context: Context) {
} }
private fun loadRecords() { private suspend fun loadRecords() {
try { try {
val jsonStr = prefs.getString(KEY_RECORDS, "[]") val entities = trainRecordDao.getAllRecords()
val jsonArray = JSONArray(jsonStr)
trainRecords.clear() trainRecords.clear()
for (i in 0 until jsonArray.length()) { entities.forEach { entity ->
val jsonObject = jsonArray.getJSONObject(i) trainRecords.add(entity.toTrainRecord())
trainRecords.add(TrainRecord(jsonObject))
} }
recordCount.set(trainRecords.size) recordCount.set(trainRecords.size)
Log.d(TAG, "Loaded ${trainRecords.size} records") Log.d(TAG, "Loaded ${trainRecords.size} records from database")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to load records: ${e.message}") Log.e(TAG, "Failed to load records: ${e.message}")
} }
@@ -349,4 +412,41 @@ class TrainRecordManager(private val context: Context) {
mergeSettings = MergeSettings() mergeSettings = MergeSettings()
} }
} }
suspend fun exportRecordsToJson(): JSONArray {
val jsonArray = JSONArray()
try {
val entities = trainRecordDao.getAllRecords()
entities.forEach { entity ->
val record = entity.toTrainRecord()
jsonArray.put(record.toJSON())
}
Log.d(TAG, "Exported ${entities.size} records to JSON")
} catch (e: Exception) {
Log.e(TAG, "Failed to export records to JSON: ${e.message}")
}
return jsonArray
}
suspend fun importRecordsFromJson(jsonArray: JSONArray): Int {
var importedCount = 0
try {
val records = mutableListOf<TrainRecordEntity>()
for (i in 0 until jsonArray.length()) {
val jsonObject = jsonArray.getJSONObject(i)
val trainRecord = TrainRecord(jsonObject)
records.add(TrainRecordEntity.fromTrainRecord(trainRecord))
}
if (records.isNotEmpty()) {
trainRecordDao.insertRecords(records)
importedCount = records.size
refreshRecordsFromDatabase()
Log.d(TAG, "Imported $importedCount records from JSON")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to import records from JSON: ${e.message}")
}
return importedCount
}
} }

View File

@@ -8,6 +8,7 @@ espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.9.0" lifecycleRuntimeKtx = "2.9.0"
activityCompose = "1.10.1" activityCompose = "1.10.1"
composeBom = "2024.04.01" composeBom = "2024.04.01"
room = "2.6.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -24,9 +25,13 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version = "2.0.0-1.0.21" }