refactor: migrate data storage from SharedPreferences to Room database
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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" }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user