diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1b4852e..e3a4d5f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) } android { @@ -85,4 +86,8 @@ dependencies { implementation("org.osmdroid:osmdroid-android: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) } \ No newline at end of file diff --git a/app/src/main/java/org/noxylva/lbjconsole/database/TrainDatabase.kt b/app/src/main/java/org/noxylva/lbjconsole/database/TrainDatabase.kt new file mode 100644 index 0000000..1594d0b --- /dev/null +++ b/app/src/main/java/org/noxylva/lbjconsole/database/TrainDatabase.kt @@ -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 + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/noxylva/lbjconsole/database/TrainRecordDao.kt b/app/src/main/java/org/noxylva/lbjconsole/database/TrainRecordDao.kt new file mode 100644 index 0000000..2c989a9 --- /dev/null +++ b/app/src/main/java/org/noxylva/lbjconsole/database/TrainRecordDao.kt @@ -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 + + @Query("SELECT * FROM train_records ORDER BY timestamp DESC") + fun getAllRecordsFlow(): Flow> + + @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) + + @Delete + suspend fun deleteRecord(record: TrainRecordEntity) + + @Delete + suspend fun deleteRecords(records: List) + + @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) + + @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 + + @Query("SELECT * FROM train_records ORDER BY timestamp DESC LIMIT :limit") + suspend fun getLatestRecords(limit: Int): List + + @Query("SELECT * FROM train_records WHERE timestamp >= :fromTime ORDER BY timestamp DESC") + suspend fun getRecordsFromTime(fromTime: Long): List +} \ No newline at end of file diff --git a/app/src/main/java/org/noxylva/lbjconsole/database/TrainRecordEntity.kt b/app/src/main/java/org/noxylva/lbjconsole/database/TrainRecordEntity.kt new file mode 100644 index 0000000..b300267 --- /dev/null +++ b/app/src/main/java/org/noxylva/lbjconsole/database/TrainRecordEntity.kt @@ -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 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/noxylva/lbjconsole/model/TrainRecordManager.kt b/app/src/main/java/org/noxylva/lbjconsole/model/TrainRecordManager.kt index 89dca85..e2a46d2 100644 --- a/app/src/main/java/org/noxylva/lbjconsole/model/TrainRecordManager.kt +++ b/app/src/main/java/org/noxylva/lbjconsole/model/TrainRecordManager.kt @@ -7,6 +7,8 @@ import android.util.Log import kotlinx.coroutines.* import org.json.JSONArray import org.json.JSONObject +import org.noxylva.lbjconsole.database.TrainDatabase +import org.noxylva.lbjconsole.database.TrainRecordEntity import java.io.File import java.io.FileWriter import java.text.SimpleDateFormat @@ -27,6 +29,8 @@ class TrainRecordManager(private val context: Context) { private val trainRecords = CopyOnWriteArrayList() private val recordCount = AtomicInteger(0) 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()) var mergeSettings = MergeSettings() @@ -34,11 +38,36 @@ class TrainRecordManager(private val context: Context) { init { ioScope.launch { + migrateFromSharedPreferences() loadRecords() loadMergeSettings() } } + private suspend fun migrateFromSharedPreferences() { + try { + val jsonStr = prefs.getString(KEY_RECORDS, null) + if (jsonStr != null && jsonStr != "[]") { + val jsonArray = JSONArray(jsonStr) + val records = mutableListOf() + + 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 filterRoute: String = "" @@ -52,11 +81,16 @@ class TrainRecordManager(private val context: Context) { 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() - saveRecords() + ioScope.launch { + trainRecordDao.insertRecord(TrainRecordEntity.fromTrainRecord(record)) + } return record } @@ -76,6 +110,16 @@ class TrainRecordManager(private val context: Context) { } } + suspend fun getFilteredRecordsFromDatabase(): List { + 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 { @@ -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() { trainRecords.clear() recordCount.set(0) - saveRecords() + ioScope.launch { + trainRecordDao.deleteAllRecords() + } } fun deleteRecord(record: TrainRecord): Boolean { val result = trainRecords.remove(record) if (result) { recordCount.decrementAndGet() - saveRecords() + ioScope.launch { + trainRecordDao.deleteRecordById(record.uniqueId) + } } return result } fun deleteRecords(records: List): Int { var deletedCount = 0 + val idsToDelete = mutableListOf() + records.forEach { record -> if (trainRecords.remove(record)) { deletedCount++ + idsToDelete.add(record.uniqueId) } } if (deletedCount > 0) { recordCount.addAndGet(-deletedCount) - saveRecords() + ioScope.launch { + trainRecordDao.deleteRecordsByIds(idsToDelete) + } } return deletedCount } @@ -151,12 +219,9 @@ class TrainRecordManager(private val context: Context) { private fun saveRecords() { ioScope.launch { try { - val jsonArray = JSONArray() - for (record in trainRecords) { - jsonArray.put(record.toJSON()) - } - prefs.edit().putString(KEY_RECORDS, jsonArray.toString()).apply() - Log.d(TAG, "Saved ${trainRecords.size} records") + val entities = trainRecords.map { TrainRecordEntity.fromTrainRecord(it) } + trainRecordDao.insertRecords(entities) + Log.d(TAG, "Saved ${trainRecords.size} records to database") } catch (e: Exception) { 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 { - val jsonStr = prefs.getString(KEY_RECORDS, "[]") - val jsonArray = JSONArray(jsonStr) + val entities = trainRecordDao.getAllRecords() trainRecords.clear() - for (i in 0 until jsonArray.length()) { - val jsonObject = jsonArray.getJSONObject(i) - trainRecords.add(TrainRecord(jsonObject)) + entities.forEach { entity -> + trainRecords.add(entity.toTrainRecord()) } recordCount.set(trainRecords.size) - Log.d(TAG, "Loaded ${trainRecords.size} records") + Log.d(TAG, "Loaded ${trainRecords.size} records from database") } catch (e: Exception) { Log.e(TAG, "Failed to load records: ${e.message}") } @@ -349,4 +412,41 @@ class TrainRecordManager(private val context: Context) { 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() + 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 + } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f43ce40..66b812b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ espressoCore = "3.6.1" lifecycleRuntimeKtx = "2.9.0" activityCompose = "1.10.1" composeBom = "2024.04.01" +room = "2.6.1" [libraries] 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-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } 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] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", 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" }