2 Commits

Author SHA1 Message Date
Nedifinita
78cc909ec8 feat: add database import and export functions 2025-08-28 19:48:12 +08:00
Nedifinita
077e0e4266 refactor: clean up dead code 2025-08-28 14:14:45 +08:00
13 changed files with 420 additions and 155 deletions

144
.gitignore vendored
View File

@@ -21,4 +21,146 @@ local.properties
docs
linux
windows
android_original
flutter/ephemeral/
*.suo
*.user
*.userosscache
*.sln.docstates
x64/
x86/
*.[Cc]ache
!*.[Cc]ache/
.gradle/
build/
local.properties
*.log
captures/
.externalNativeBuild/
.cxx/
*.apk
output.json
*.iml
.idea/
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
*.jks
*.keystore
google-services.json
*.hprof
gen-external-apklibs
**/doc/api/
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.fvm/flutter_sdk
.packages
.pub-cache/
.pub/
coverage/
lib/generated_plugin_registrant.dart
**/android/**/gradle-wrapper.jar
**/android/.gradle
**/android/captures/
**/android/gradlew
**/android/gradlew.bat
**/android/key.properties
**/android/local.properties
**/android/**/GeneratedPluginRegistrant.java
**/ios/**/*.mode1v3
**/ios/**/*.mode2v3
**/ios/**/*.moved-aside
**/ios/**/*.pbxuser
**/ios/**/*.perspectivev3
**/ios/**/*sync/
**/ios/**/.sconsign.dblite
**/ios/**/.tags*
**/ios/**/.vagrant/
**/ios/**/DerivedData/
**/ios/**/Icon?
**/ios/**/Pods/
**/ios/**/.symlinks/
**/ios/**/profile
**/ios/**/xcuserdata
**/ios/.generated/
**/ios/Flutter/.last_build_id
**/ios/Flutter/App.framework
**/ios/Flutter/Flutter.framework
**/ios/Flutter/Flutter.podspec
**/ios/Flutter/Generated.xcconfig
**/ios/Flutter/app.flx
**/ios/Flutter/app.zip
**/ios/Flutter/flutter_assets/
**/ios/Flutter/flutter_export_environment.sh
**/ios/ServiceDefinitions.json
**/ios/Runner/GeneratedPluginRegistrant.*
!**/ios/**/default.mode1v3
!**/ios/**/default.mode2v3
!**/ios/**/default.pbxuser
!**/ios/**/default.perspectivev3
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
*.ap_
*.aab
*.dex
*.class
bin/
gen/
out/
.gradle
.signing/
proguard/
/*/build/
/*/local.properties
/*/out
/*/*/build
/*/*/production
.navigation/
*.ipr
*~
*.swp
.externalNativeBuild
obj/
*.iws
/out/
.idea/caches/
.idea/libraries/
.idea/shelf/
.idea/workspace.xml
.idea/tasks.xml
.idea/.name
.idea/compiler.xml
.idea/copyright/profiles_settings.xml
.idea/encodings.xml
.idea/misc.xml
.idea/modules.xml
.idea/scopes/scope_settings.xml
.idea/dictionaries
.idea/vcs.xml
.idea/jsLibraryMappings.xml
.idea/datasources.xml
.idea/dataSources.ids
.idea/sqlDataSources.xml
.idea/dynamic.xml
.idea/uiDesigner.xml
.idea/assetWizardSettings.xml
.idea/gradle.xml
.idea/jarRepositories.xml
.idea/navEditor.xml
.classpath
.project
.cproject
.settings/
.mtj.tmp/
*.war
*.ear
hs_err_pid*
.idea_modules/
atlassian-ide-plugin.xml
.idea/mongoSettings.xml
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
!/gradle/wrapper/gradle-wrapper.jar
macos/Flutter/ephemeral/flutter_export_environment.sh
macos/Flutter/ephemeral/Flutter-Generated.xcconfig

View File

@@ -2,6 +2,12 @@
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="Unnamed">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="lbjconsole_android">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>

View File

@@ -1,12 +1,12 @@
# LBJ Console
LBJ Console 是一款 Android 应用程序,用于通过 BLE 从 [SX1276_Receive_LBJ](https://github.com/undef-i/SX1276_Receive_LBJ) 设备接收并显示列车预警消息,功能包括:
LBJ Console 是一款应用程序,用于通过 BLE 从 [SX1276_Receive_LBJ](https://github.com/undef-i/SX1276_Receive_LBJ) 设备接收并显示列车预警消息,功能包括:
- 接收列车预警消息,支持可选的手机推送通知。
- 在地图上显示预警消息的 GPS 信息。
- 基于内置数据文件显示机车配属,机车类型和车次类型。
主分支目前只适配了 Android 。如需在其它平台上面使用,请参考 [flutter](https://github.com/undef-i/LBJ_Console/tree/flutter) 分支自行编译。
## 数据文件
LBJ Console 依赖以下数据文件,位于 `app/src/main/assets/` 目录,用于支持机车配属和车次信息的展示:

View File

@@ -13,8 +13,8 @@ android {
applicationId = "org.noxylva.lbjconsole"
minSdk = 29
targetSdk = 35
versionCode = 13
versionName = "0.1.3"
versionCode = 14
versionName = "0.1.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -92,4 +92,5 @@ dependencies {
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.startup.runtime)
implementation("com.google.code.gson:gson:2.10.1")
}

View File

@@ -15,6 +15,9 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
@@ -42,11 +45,10 @@
</activity>
<activity
android:name=".SettingsActivity"
android:name=".FilePickerActivity"
android:exported="false"
android:label="Settings"
android:parentActivityName=".MainActivity"
android:theme="@style/Theme.LBJConsole" />
android:theme="@style/Theme.LBJConsole"
android:label="数据管理" />
<service
android:name=".BackgroundService"

View File

@@ -0,0 +1,123 @@
package org.noxylva.lbjconsole
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import org.noxylva.lbjconsole.util.DatabaseExportImportUtil
class FilePickerActivity : ComponentActivity() {
private val exportFilePicker = registerForActivityResult(
ActivityResultContracts.CreateDocument("application/json")
) { uri ->
uri?.let { exportDatabase(it) }
}
private val importFilePicker = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let { importDatabase(it) }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val action = intent.getStringExtra("action") ?: "export"
setContent {
val coroutineScope = rememberCoroutineScope()
when (action) {
"export" -> {
val fileName = "lbj_console_backup_${System.currentTimeMillis()}.json"
exportFilePicker.launch(fileName)
}
"import" -> {
importFilePicker.launch(arrayOf("application/json", "text/plain"))
}
}
}
}
private fun exportDatabase(uri: Uri) {
val coroutineScope = kotlinx.coroutines.MainScope()
coroutineScope.launch {
try {
val databaseUtil = DatabaseExportImportUtil(this@FilePickerActivity)
val json = databaseUtil.exportDatabase()
contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(json.toByteArray())
}
Toast.makeText(
this@FilePickerActivity,
"数据导出成功",
Toast.LENGTH_SHORT
).show()
} catch (e: Exception) {
Toast.makeText(
this@FilePickerActivity,
"导出失败: ${e.message}",
Toast.LENGTH_SHORT
).show()
} finally {
finish()
}
}
}
private fun importDatabase(uri: Uri) {
val coroutineScope = kotlinx.coroutines.MainScope()
coroutineScope.launch {
try {
val databaseUtil = DatabaseExportImportUtil(this@FilePickerActivity)
val success = databaseUtil.importDatabase(uri)
if (success) {
Toast.makeText(
this@FilePickerActivity,
"数据导入成功",
Toast.LENGTH_SHORT
).show()
} else {
Toast.makeText(
this@FilePickerActivity,
"数据导入失败",
Toast.LENGTH_SHORT
).show()
}
} catch (e: Exception) {
Toast.makeText(
this@FilePickerActivity,
"导入失败: ${e.message}",
Toast.LENGTH_SHORT
).show()
} finally {
finish()
}
}
}
companion object {
fun createExportIntent(context: android.content.Context): Intent {
return Intent(context, FilePickerActivity::class.java).apply {
putExtra("action", "export")
}
}
fun createImportIntent(context: android.content.Context): Intent {
return Intent(context, FilePickerActivity::class.java).apply {
putExtra("action", "import")
}
}
}
}

View File

@@ -364,11 +364,7 @@ class MainActivity : ComponentActivity() {
},
appVersion = getAppVersion(),
locoInfoUtil = locoInfoUtil,
trainTypeUtil = trainTypeUtil,
onOpenSettings = {
val intent = Intent(this@MainActivity, SettingsActivity::class.java)
startActivity(intent)
}
trainTypeUtil = trainTypeUtil
)
if (showConnectionDialog) {
@@ -877,9 +873,7 @@ fun MainContent(
mapCenterPosition: Pair<Double, Double>?,
mapZoomLevel: Double,
mapRailwayLayerVisible: Boolean,
onMapStateChange: (Pair<Double, Double>?, Double, Boolean) -> Unit,
onOpenSettings: () -> Unit
onMapStateChange: (Pair<Double, Double>?, Double, Boolean) -> Unit
) {
val statusColor = if (isConnected) Color(0xFF4CAF50) else Color(0xFFFF5722)

View File

@@ -92,7 +92,7 @@ class NotificationService(private val context: Context) {
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
val remoteViews = RemoteViews(context.packageName, R.layout.notification_train_record)

View File

@@ -1,66 +0,0 @@
package org.noxylva.lbjconsole
import android.content.Context
import android.os.Bundle
import android.widget.Switch
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.noxylva.lbjconsole.database.AppSettingsRepository
class SettingsActivity : AppCompatActivity() {
companion object {
suspend fun isBackgroundServiceEnabled(context: Context): Boolean {
val repository = AppSettingsRepository(context)
return repository.getSettings().backgroundServiceEnabled
}
suspend fun setBackgroundServiceEnabled(context: Context, enabled: Boolean) {
val repository = AppSettingsRepository(context)
repository.updateBackgroundServiceEnabled(enabled)
}
}
private lateinit var backgroundServiceSwitch: Switch
private lateinit var appSettingsRepository: AppSettingsRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.title = "Settings"
appSettingsRepository = AppSettingsRepository(this)
initViews()
setupListeners()
}
private fun initViews() {
backgroundServiceSwitch = findViewById(R.id.switch_background_service)
lifecycleScope.launch {
backgroundServiceSwitch.isChecked = isBackgroundServiceEnabled(this@SettingsActivity)
}
}
private fun setupListeners() {
backgroundServiceSwitch.setOnCheckedChangeListener { _, isChecked ->
lifecycleScope.launch {
setBackgroundServiceEnabled(this@SettingsActivity, isChecked)
}
if (isChecked) {
BackgroundService.startService(this)
} else {
BackgroundService.stopService(this)
}
}
}
override fun onSupportNavigateUp(): Boolean {
onBackPressed()
return true
}
}

View File

@@ -1,5 +1,6 @@
package org.noxylva.lbjconsole.ui.screens
import android.content.Intent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -19,9 +20,10 @@ import kotlinx.coroutines.launch
import org.noxylva.lbjconsole.model.MergeSettings
import org.noxylva.lbjconsole.model.GroupBy
import org.noxylva.lbjconsole.model.TimeWindow
import org.noxylva.lbjconsole.SettingsActivity
import org.noxylva.lbjconsole.database.AppSettingsRepository
import org.noxylva.lbjconsole.BackgroundService
import org.noxylva.lbjconsole.NotificationService
import org.noxylva.lbjconsole.FilePickerActivity
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.DisposableEffect
@@ -199,7 +201,8 @@ fun SettingsScreen(
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(context) {
backgroundServiceEnabled = SettingsActivity.isBackgroundServiceEnabled(context)
val repository = AppSettingsRepository(context)
backgroundServiceEnabled = repository.getSettings().backgroundServiceEnabled
}
var notificationEnabled by remember(context, notificationService) {
@@ -231,7 +234,8 @@ fun SettingsScreen(
onCheckedChange = { enabled ->
backgroundServiceEnabled = enabled
coroutineScope.launch {
SettingsActivity.setBackgroundServiceEnabled(context, enabled)
val repository = AppSettingsRepository(context)
repository.updateBackgroundServiceEnabled(enabled)
if (enabled) {
BackgroundService.startService(context)
} else {
@@ -424,6 +428,66 @@ fun SettingsScreen(
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Default.Storage,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
"数据管理",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
val context = LocalContext.current
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Button(
onClick = {
val intent = FilePickerActivity.createExportIntent(context)
context.startActivity(intent)
},
modifier = Modifier.weight(1f).padding(horizontal = 4.dp)
) {
Icon(Icons.Default.Upload, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("导出")
}
Button(
onClick = {
val intent = FilePickerActivity.createImportIntent(context)
context.startActivity(intent)
},
modifier = Modifier.weight(1f).padding(horizontal = 4.dp)
) {
Icon(Icons.Default.Download, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("导入")
}
}
}
}
Text(
text = "LBJ Console v$appVersion by undef-i",
style = MaterialTheme.typography.bodySmall,

View File

@@ -0,0 +1,67 @@
package org.noxylva.lbjconsole.util
import android.content.Context
import android.net.Uri
import android.util.Log
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.noxylva.lbjconsole.database.AppSettingsEntity
import org.noxylva.lbjconsole.database.TrainDatabase
import org.noxylva.lbjconsole.database.TrainRecordEntity
import java.io.*
import java.util.*
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class DatabaseExportImportUtil(private val context: Context) {
private val gson = Gson()
private val database = TrainDatabase.getDatabase(context)
data class SimpleRecordBackup(
val records: List<TrainRecordEntity>
)
suspend fun exportDatabase(): String = withContext(Dispatchers.IO) {
try {
val trainRecords = database.trainRecordDao().getAllRecords()
val backup = SimpleRecordBackup(
records = trainRecords
)
val json = gson.toJson(backup)
json
} catch (e: Exception) {
Log.e("DatabaseExport", "导出失败", e)
throw e
}
}
suspend fun importDatabase(uri: Uri): Boolean = withContext(Dispatchers.IO) {
try {
context.contentResolver.openInputStream(uri)?.use { inputStream ->
val json = inputStream.bufferedReader().use { it.readText() }
val backup: SimpleRecordBackup = gson.fromJson(json, object : TypeToken<SimpleRecordBackup>() {}.type)
database.trainRecordDao().deleteAllRecords()
if (backup.records.isNotEmpty()) {
database.trainRecordDao().insertRecords(backup.records)
}
true
} ?: false
} catch (e: Exception) {
Log.e("DatabaseImport", "导入失败", e)
false
}
}
suspend fun getExportFileUri(): Uri {
val filePath = exportDatabase()
return Uri.parse("file://$filePath")
}
}

View File

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,6m-4,0a4,4 0,1 1,8 0a4,4 0,1 1,-8 0" />
<path
android:fillColor="#FFFFFF"
android:pathData="M12,10L12,10c-2.2,0 -4,1.8 -4,4v6h8v-6C16,11.8 14.2,10 12,10z" />
</vector>

View File

@@ -1,55 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="16dp"
android:background="?android:attr/selectableItemBackground"
android:clickable="true">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Background Service"
android:textSize="16sp"
android:textColor="@android:color/black"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Keep app running in background"
android:textSize="14sp"
android:textColor="@android:color/darker_gray"
android:layout_marginTop="4dp" />
</LinearLayout>
<Switch
android:id="@+id/switch_background_service"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@android:color/darker_gray"
android:layout_marginHorizontal="16dp" />
</LinearLayout>