20 Commits

Author SHA1 Message Date
undef-i
65bf7b52c6 fix: correct the error in train_number_info.csv 2025-08-05 20:43:16 +08:00
undef-i
4278de2a8d fix: correct incorrect rendering and status of map marker points 2025-08-03 18:40:14 +08:00
Nedifinita
59e9987d7f feat: add train type recognition and restructure settings storage 2025-08-01 20:07:35 +08:00
Nedifinita
4e97dcafd7 fix: hide duplicate info when train and loco both match 2025-08-01 17:51:24 +08:00
Nedifinita
4cad3679a9 fix: improve TRAIN_OR_LOCO merge display and settings scroll 2025-08-01 17:44:26 +08:00
Nedifinita
e6e7831b96 refactor: migrate data storage from SharedPreferences to Room database 2025-08-01 17:36:04 +08:00
Nedifinita
39bb8cb440 fix: optimize the logic for saving scroll position 2025-08-01 17:35:34 +08:00
Nedifinita
be8dc6bc72 feat: add custom train information notification layout 2025-07-26 17:31:09 +08:00
Nedifinita
cd3128c24b feat: add option for automatically connecting to Bluetooth devices 2025-07-26 17:08:08 +08:00
Nedifinita
e1773370d6 fix: simplify device name matching logic 2025-07-26 01:00:24 +08:00
Nedifinita
c8ab5f7ff8 feat: add LBJ message notification 2025-07-26 00:40:45 +08:00
Nedifinita
e1d02a8a55 feat: add background keep-alive service and related setting functions 2025-07-26 00:19:56 +08:00
Nedifinita
aaf414d384 refactor: optimize record management and UI interaction logic
- Move the loading and saving operations of TrainRecordManager to the IO goroutine for execution
- Optimize the data structure of recentRecords in MainActivity to be a mutableStateList
- Improve the interaction effect and device connection status display of ConnectionDialog
- Delete the MergedHistoryScreen file that is no longer in use
- Increase the number of threads for map tile downloads and file system operations
2025-07-25 23:40:14 +08:00
Nedifinita
3edc8632be feat: add animation effects and visual feedback 2025-07-22 23:18:50 +08:00
Nedifinita
799410eeb2 feat: add BLE disconnection cleanup and enhance record management 2025-07-22 17:29:15 +08:00
Nedifinita
d64138cea5 feat: add record merging functionality and optimize settings page 2025-07-19 21:07:11 +08:00
Nedifinita
a1a9a479f9 feat: enhance MainActivity UI with edge-to-edge support and improved TopAppBar layout in HistoryScreen 2025-07-19 19:10:58 +08:00
Nedifinita
9389ef6e6a refactor: improve layout and formatting of HistoryScreen UI components 2025-07-19 18:32:15 +08:00
Nedifinita
a60b8c58ff feat: add timestamp logging for received messages, optimize page details 2025-07-18 23:53:55 +08:00
Nedifinita
936b960d6a feat: modernize bluetooth apis 2025-07-18 18:54:36 +08:00
59 changed files with 4540 additions and 1348 deletions

4
.gitignore vendored
View File

@@ -13,9 +13,9 @@ captures
.externalNativeBuild .externalNativeBuild
.cxx .cxx
local.properties local.properties
local.properties
*.ps1 *.ps1
.*.bat .*.bat
*.jks *.jks
*.keystore *.keystore
*.base64 *.base64
docs

View File

@@ -1,15 +1,19 @@
# LBJ Console # LBJ Console
LBJ Console is an Android app designed to receive and display LBJ messages via BLE from the [SX1276_Receive_LBJ](https://github.com/undef-i/SX1276_Receive_LBJ) device. LBJ Console 是一款 Android 应用程序,用于通过 BLE 从 [SX1276_Receive_LBJ](https://github.com/undef-i/SX1276_Receive_LBJ) device 设备接收并显示列车预警消息,功能包括:
## Roadmap - 接收列车预警消息,支持可选的手机推送通知。
- Tab state persistence - 显示预警消息的 GPS 信息于地图。
- Record filtering (train number, time range) - 基于内置数据文件显示机车配属和车次类型。
- Record management page optimization
- Optional train merge by locomotive/number
- Offline data storage
- Add record timestamps
# License
This project is licensed under the GNU General Public License v3.0 (GPLv3). This license ensures that the software remains free and open source, requiring that any modifications or derivative works must also be released under the same license terms. ## 数据文件
LBJ Console 依赖以下数据文件,位于 `app/src/main/assets/` 目录,用于支持机车配属和车次信息的展示:
- `loco_info.csv`:包含机车配属信息,格式为 `机车型号,机车编号起始值,机车编号结束值,所属铁路局及机务段,备注`
- `train_info.csv`:包含车次类型信息,格式为 `正则表达式,车次类型`
# 许可证
该项目采用 GNU 通用公共许可证 v3.0GPLv3授权。该许可证确保软件保持免费和开源要求任何修改或衍生作品也必须在相同许可证条款下发布。

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 {
@@ -12,8 +13,8 @@ android {
applicationId = "org.noxylva.lbjconsole" applicationId = "org.noxylva.lbjconsole"
minSdk = 29 minSdk = 29
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 11
versionName = "0.0.1" versionName = "0.1.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -59,6 +60,7 @@ android {
} }
lint { lint {
disable += "NullSafeMutableLiveData" disable += "NullSafeMutableLiveData"
warning += "MissingPermission"
} }
} }
@@ -81,8 +83,13 @@ dependencies {
debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.test.manifest)
implementation("org.json:json:20231013") implementation("org.json:json:20231013")
implementation("androidx.compose.material:material-icons-extended:1.5.4") implementation("androidx.compose.material:material-icons-extended:1.5.4")
implementation("androidx.appcompat:appcompat:1.6.1")
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)
implementation(libs.androidx.startup.runtime)
} }

View File

@@ -11,6 +11,10 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<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-feature android:name="android.hardware.bluetooth_le" android:required="true"/> <uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
@@ -22,14 +26,14 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.LBJReceiver" android:theme="@style/Theme.LBJConsole"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
tools:targetApi="31"> tools:targetApi="31">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/Theme.LBJReceiver"> android:theme="@style/Theme.LBJConsole">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -37,6 +41,19 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".SettingsActivity"
android:exported="false"
android:label="Settings"
android:parentActivityName=".MainActivity"
android:theme="@style/Theme.LBJConsole" />
<service
android:name=".BackgroundService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider" android:authorities="${applicationId}.provider"

View File

@@ -0,0 +1,82 @@
"^[Gg](4000|[1-3]\d{3}|[1-9]\d{0,2})$","直通图定高速动车组"
"^[Gg](400[1-9]|40[1-9]\d|4[1-8]\d{2}|49[0-8]\d|499[0-8])$","直通临客高速动车组"
"^[Gg](9000|[6-8]\d{3}|500[1-9]|50[1-9]\d|5[1-9]\d{2})$","管内图定高速动车组"
"^[Gg](900[1-9]|90[1-9]\d|9[1-8]\d{2}|99[0-8]\d|999[0-8])$","管内临客高速动车组"
"^[Cc]([1-8]\d{3}|9000)$","图定城际动车组"
"^[Cc](900[1-9]|90[1-9]\d|9[1-8]\d{2}|99[0-8]\d|999[0-8])$","临客城际动车组"
"^[Cc][1-9]\d{2}$","动力集中城际动车组"
"^[IDid](4000|[1-3]\d{3}|[1-9]\d{0,2})$","直通图定动车组"
"^[IDid](400[1-9]|40[1-9]\d|4[1-8]\d{2}|49[0-8]\d|499[0-8])$","直通临客动车组"
"^[IDid](9000|[6-8]\d{3}|500[1-9]|50[1-9]\d|5[1-9]\d{2})$","管内图定动车组"
"^[IDid](900[1-9]|90[1-9]\d|9[1-8]\d{2}|99[0-8]\d|999[0-8])$","管内临客动车组"
"^[IDid](8([0-8]\d|9[0-8])|7(0[1-9]|[1-9]\d))$","动力集中动车组"
"^[IDid](300|[12]\d{2}|[1-9]\d?)$","跨局动力集中动车组"
"^[PZpz](4000|[1-3]\d{3}|[1-9]\d{0,2})$","直通图定直达特快旅客列车"
"^[PZpz](400[1-9]|40[1-9]\d|4[1-8]\d{2}|49[0-8]\d|499[0-8])$","直通临客直达特快旅客列车"
"^[PZpz](9000|[6-8]\d{3}|500[1-9]|50[1-9]\d|5[1-9]\d{2})$","管内图定直达特快旅客列车"
"^[PZpz](900[1-9]|90[1-9]\d|9[1-8]\d{2}|99[0-8]\d|999[0-8])$","管内临客直达特快旅客列车"
"^[QTqt](3000|[12]\d{3}|[1-9]\d{0,2})$","直通图定特快旅客列车"
"^[QTqt](300[1-9]|30[1-9]\d|3[1-8]\d{2}|39[0-8]\d|399[0-8])$","直通临客特快旅客列车"
"^[QTqt](4(00[1-9]|0[1-9]\d|[1-8]\d{2}|9[0-8]\d|99[0-8]))$","管内临客特快旅客列车"
"^[QTqt]([5-8]\d{3}|9([0-8]\d{2}|9[0-8]\d|99[0-8])|500[1-9]|50[1-9]\d|5[1-9]\d{2})$","管内图定特快旅客列车"
"^[WKwk](4000|[1-3]\d{3}|[1-9]\d{0,2})$","直通图定快速旅客列车"
"^[WKwk](400[1-9]|40[1-9]\d|4[1-8]\d{2}|49[0-8]\d|499[0-8])$","直通临客快速旅客列车"
"^[WKwk](6([0-8]\d{2}|9[0-8]\d|99[0-8])|500[1-9]|50[1-9]\d|5[1-9]\d{2})$","管内临客快速旅客列车"
"^[WKwk](8\d{3}|9([0-8]\d{2}|9[0-8]\d|99[0-8])|7(00[1-9]|0[1-9]\d|[1-9]\d{2}))$","管内图定快速旅客列车"
"^[Vv1](00[1-9]|0[1-9]\d|[1-9]\d{2})$","跨三局及以上图定普通旅客快车"
"^[Bb2](00[1-9]|0[1-9]\d|[1-9]\d{2})$","跨两局图定普通旅客快车"
"^3(00[1-9]|0[1-9]\d|[1-9]\d{2})$","跨局临时普通旅客快车"
"^[Uu4](00[1-9]|0[1-9]\d|[1-9]\d{2})$","管内图定普通旅客快车"
"^[Xx5](000|1[9][9]|200|3[9][9]|400)$","管内图定普通旅客快车"
"^6(19[0-8]|1[0-8]\d|0[1-9]\d|00[1-9])$","直通普通旅客慢车"
"^(6(20[1-9]|2[1-9]\d|[3-9]\d{2})|7([0-4]\d{2}|5([0-8]\d|9[0-8])))$","管内普通旅客慢车"
"^(8([0-8]\d{2}|9[0-8]\d|99[0-8])|7(60[1-9]|6[1-9]\d|[7-9]\d{2}))$","通勤列车"
"^[Yy](500|[1-4]\d{2}|[1-9]\d?)$","跨局旅游列车"
"^[Yy](50[1-9]|5[1-9]\d|[6-9]\d{2})$","管内旅游列车"
"^[Ss][1-9]\d{0,3}$","市郊旅客列车"
"^[Ll](6([0-8]\d{2}|9[0-8]\d|99[0-8])|[1-5]\d{3}|[1-9]\d{0,2})$","直通临时旅客列车"
"^[Ll]([7-9]\d{3})$","管内临时旅客列车"
"^[Xx](19[0-8]|1[0-8]\d|[1-9]\d?)$","特快货物班列"
"^[Xx](39[0-8]|3[0-8]\d|2[1-9]\d|20[1-9])$","快速货物班列"
"^[Xx]2(40[1-9]|4[1-9]\d|[5-9]\d{2})$","直通货物快运列车"
"^[Xx]([4-9]\d{2}|4[1-9]\d|40[1-9])$","管内货物快运列车"
"^[Xx]8\d{3}$","中欧中亚集装箱班列"
"^[Xx]9([0-4]\d{2}|500)$","中亚集装箱班列"
"^[Xx]9(50[1-9]|5[1-9]\d|[6-9]\d{2})$","水铁联运班列"
"^[Xx][1-4]\d{4}$","加挂零散快运车辆货物列车"
"^1(000[1-9]|00[1-9]\d|0[1-9]\d{2}|[1-9]\d{3})$","技术直达列车"
"^2\d{4}$","直通货物列车"
"^3\d{4}$","区段摘挂列车"
"^4([0-3]\d{3}|4([0-8]\d{2}|9[0-8]\d|99[0-8]))$","摘挂列车"
"^4(500[1-9]|50[1-9]\d|5[1-9]\d{2}|[6-9]\d{3})$","小运转列车"
"^6\d{4}$","自备列车"
"^70\d{3}$","超限货物列车"
"^7([1-6]\d{3}|7([0-8]\d{2}|9[0-8]\d|99[0-8]))$","重载货物列车"
"^78\d{3}$","保温列车"
"^8(0\d{3}|1([0-8]\d{2}|9[0-8]\d|99[0-8]))$","普快货物班列"
"^8(200[1-9]|20[1-9]\d|2[1-9]\d{2}|[34]\d{3})$","煤炭直达列车"
"^85\d{3}$","石油直达列车"
"^86\d{3}$","始发直达列车"
"^87\d{3}$","空车直达列车"
"^(90\d{3}|91([0-8]\d{2}|9[0-8]\d|99[0-8]))$","军用列车"
"^50\d{3}$","客车单机"
"^51\d{3}$","货车单机"
"^52\d{3}$","小运转单机"
"^5(3\d{3}|4([0-8]\d{2}|9[0-8]\d|99[0-8]))$","补机列车"
"^55(300|[0-2]\d{2})$","普通客货试运转列车"
"^55(500|30[1-9]|3[1-9]\d|4\d{2})$","高速动车组试运转列车"
"^55(50[1-9]|5[1-9]\d|[6-9]\d{2})$","普通动车组试运转列车"
"^56\d{3}$","轻油动车与轨道车"
"^57\d{3}$","路用列车"
"^58(10[1-9]|1[1-9]\d|[2-8]\d{2}|9([0-8]\d|9[0-8]))$","救援列车"
"^DJ(400|[1-3]\d{2}|[1-9]\d?)$","动车组检测列车300直通"
"^DJ([4-9]\d{2}|40[1-9]|4[1-9]\d)$","动车组检测列车300管内"
"^DJ1(400|[0-3]\d{2})$","动车组检测列车250直通"
"^DJ1(40[1-9]|4[1-9]\d|[5-9]\d{2})$","动车组检测列车250管内"
"^DJ[56]\d{3}$","直通动车组确认列车"
"^DJ[78]\d{3}$","管内动车组确认列车"
"^[Ff][GDCZTKgdcztk]?\d{1,4}$","因故折返旅客列车"
"^0[GDCZTKgdcztk]\d{1,4}$","回送图定客车底"
"^00(100|[1-9]\d?)$","有火回送动车组车底"
"^00(10[1-9]|1[1-9]\d|2([0-8]\d|9[0-8]))$","无火回送动车组车底"
"^00(30[1-9]|3[1-9]\d|4([0-8]\d|9[0-8]))$","无火回送普速客车底"
1 ^[Gg](4000|[1-3]\d{3}|[1-9]\d{0,2})$ 直通图定高速动车组
2 ^[Gg](400[1-9]|40[1-9]\d|4[1-8]\d{2}|49[0-8]\d|499[0-8])$ 直通临客高速动车组
3 ^[Gg](9000|[6-8]\d{3}|500[1-9]|50[1-9]\d|5[1-9]\d{2})$ 管内图定高速动车组
4 ^[Gg](900[1-9]|90[1-9]\d|9[1-8]\d{2}|99[0-8]\d|999[0-8])$ 管内临客高速动车组
5 ^[Cc]([1-8]\d{3}|9000)$ 图定城际动车组
6 ^[Cc](900[1-9]|90[1-9]\d|9[1-8]\d{2}|99[0-8]\d|999[0-8])$ 临客城际动车组
7 ^[Cc][1-9]\d{2}$ 动力集中城际动车组
8 ^[IDid](4000|[1-3]\d{3}|[1-9]\d{0,2})$ 直通图定动车组
9 ^[IDid](400[1-9]|40[1-9]\d|4[1-8]\d{2}|49[0-8]\d|499[0-8])$ 直通临客动车组
10 ^[IDid](9000|[6-8]\d{3}|500[1-9]|50[1-9]\d|5[1-9]\d{2})$ 管内图定动车组
11 ^[IDid](900[1-9]|90[1-9]\d|9[1-8]\d{2}|99[0-8]\d|999[0-8])$ 管内临客动车组
12 ^[IDid](8([0-8]\d|9[0-8])|7(0[1-9]|[1-9]\d))$ 动力集中动车组
13 ^[IDid](300|[12]\d{2}|[1-9]\d?)$ 跨局动力集中动车组
14 ^[PZpz](4000|[1-3]\d{3}|[1-9]\d{0,2})$ 直通图定直达特快旅客列车
15 ^[PZpz](400[1-9]|40[1-9]\d|4[1-8]\d{2}|49[0-8]\d|499[0-8])$ 直通临客直达特快旅客列车
16 ^[PZpz](9000|[6-8]\d{3}|500[1-9]|50[1-9]\d|5[1-9]\d{2})$ 管内图定直达特快旅客列车
17 ^[PZpz](900[1-9]|90[1-9]\d|9[1-8]\d{2}|99[0-8]\d|999[0-8])$ 管内临客直达特快旅客列车
18 ^[QTqt](3000|[12]\d{3}|[1-9]\d{0,2})$ 直通图定特快旅客列车
19 ^[QTqt](300[1-9]|30[1-9]\d|3[1-8]\d{2}|39[0-8]\d|399[0-8])$ 直通临客特快旅客列车
20 ^[QTqt](4(00[1-9]|0[1-9]\d|[1-8]\d{2}|9[0-8]\d|99[0-8]))$ 管内临客特快旅客列车
21 ^[QTqt]([5-8]\d{3}|9([0-8]\d{2}|9[0-8]\d|99[0-8])|500[1-9]|50[1-9]\d|5[1-9]\d{2})$ 管内图定特快旅客列车
22 ^[WKwk](4000|[1-3]\d{3}|[1-9]\d{0,2})$ 直通图定快速旅客列车
23 ^[WKwk](400[1-9]|40[1-9]\d|4[1-8]\d{2}|49[0-8]\d|499[0-8])$ 直通临客快速旅客列车
24 ^[WKwk](6([0-8]\d{2}|9[0-8]\d|99[0-8])|500[1-9]|50[1-9]\d|5[1-9]\d{2})$ 管内临客快速旅客列车
25 ^[WKwk](8\d{3}|9([0-8]\d{2}|9[0-8]\d|99[0-8])|7(00[1-9]|0[1-9]\d|[1-9]\d{2}))$ 管内图定快速旅客列车
26 ^[Vv1](00[1-9]|0[1-9]\d|[1-9]\d{2})$ 跨三局及以上图定普通旅客快车
27 ^[Bb2](00[1-9]|0[1-9]\d|[1-9]\d{2})$ 跨两局图定普通旅客快车
28 ^3(00[1-9]|0[1-9]\d|[1-9]\d{2})$ 跨局临时普通旅客快车
29 ^[Uu4](00[1-9]|0[1-9]\d|[1-9]\d{2})$ 管内图定普通旅客快车
30 ^[Xx5](000|1[9][9]|200|3[9][9]|400)$ 管内图定普通旅客快车
31 ^6(19[0-8]|1[0-8]\d|0[1-9]\d|00[1-9])$ 直通普通旅客慢车
32 ^(6(20[1-9]|2[1-9]\d|[3-9]\d{2})|7([0-4]\d{2}|5([0-8]\d|9[0-8])))$ 管内普通旅客慢车
33 ^(8([0-8]\d{2}|9[0-8]\d|99[0-8])|7(60[1-9]|6[1-9]\d|[7-9]\d{2}))$ 通勤列车
34 ^[Yy](500|[1-4]\d{2}|[1-9]\d?)$ 跨局旅游列车
35 ^[Yy](50[1-9]|5[1-9]\d|[6-9]\d{2})$ 管内旅游列车
36 ^[Ss][1-9]\d{0,3}$ 市郊旅客列车
37 ^[Ll](6([0-8]\d{2}|9[0-8]\d|99[0-8])|[1-5]\d{3}|[1-9]\d{0,2})$ 直通临时旅客列车
38 ^[Ll]([7-9]\d{3})$ 管内临时旅客列车
39 ^[Xx](19[0-8]|1[0-8]\d|[1-9]\d?)$ 特快货物班列
40 ^[Xx](39[0-8]|3[0-8]\d|2[1-9]\d|20[1-9])$ 快速货物班列
41 ^[Xx]2(40[1-9]|4[1-9]\d|[5-9]\d{2})$ 直通货物快运列车
42 ^[Xx]([4-9]\d{2}|4[1-9]\d|40[1-9])$ 管内货物快运列车
43 ^[Xx]8\d{3}$ 中欧中亚集装箱班列
44 ^[Xx]9([0-4]\d{2}|500)$ 中亚集装箱班列
45 ^[Xx]9(50[1-9]|5[1-9]\d|[6-9]\d{2})$ 水铁联运班列
46 ^[Xx][1-4]\d{4}$ 加挂零散快运车辆货物列车
47 ^1(000[1-9]|00[1-9]\d|0[1-9]\d{2}|[1-9]\d{3})$ 技术直达列车
48 ^2\d{4}$ 直通货物列车
49 ^3\d{4}$ 区段摘挂列车
50 ^4([0-3]\d{3}|4([0-8]\d{2}|9[0-8]\d|99[0-8]))$ 摘挂列车
51 ^4(500[1-9]|50[1-9]\d|5[1-9]\d{2}|[6-9]\d{3})$ 小运转列车
52 ^6\d{4}$ 自备列车
53 ^70\d{3}$ 超限货物列车
54 ^7([1-6]\d{3}|7([0-8]\d{2}|9[0-8]\d|99[0-8]))$ 重载货物列车
55 ^78\d{3}$ 保温列车
56 ^8(0\d{3}|1([0-8]\d{2}|9[0-8]\d|99[0-8]))$ 普快货物班列
57 ^8(200[1-9]|20[1-9]\d|2[1-9]\d{2}|[34]\d{3})$ 煤炭直达列车
58 ^85\d{3}$ 石油直达列车
59 ^86\d{3}$ 始发直达列车
60 ^87\d{3}$ 空车直达列车
61 ^(90\d{3}|91([0-8]\d{2}|9[0-8]\d|99[0-8]))$ 军用列车
62 ^50\d{3}$ 客车单机
63 ^51\d{3}$ 货车单机
64 ^52\d{3}$ 小运转单机
65 ^5(3\d{3}|4([0-8]\d{2}|9[0-8]\d|99[0-8]))$ 补机列车
66 ^55(300|[0-2]\d{2})$ 普通客货试运转列车
67 ^55(500|30[1-9]|3[1-9]\d|4\d{2})$ 高速动车组试运转列车
68 ^55(50[1-9]|5[1-9]\d|[6-9]\d{2})$ 普通动车组试运转列车
69 ^56\d{3}$ 轻油动车与轨道车
70 ^57\d{3}$ 路用列车
71 ^58(10[1-9]|1[1-9]\d|[2-8]\d{2}|9([0-8]\d|9[0-8]))$ 救援列车
72 ^DJ(400|[1-3]\d{2}|[1-9]\d?)$ 动车组检测列车300直通
73 ^DJ([4-9]\d{2}|40[1-9]|4[1-9]\d)$ 动车组检测列车300管内
74 ^DJ1(400|[0-3]\d{2})$ 动车组检测列车250直通
75 ^DJ1(40[1-9]|4[1-9]\d|[5-9]\d{2})$ 动车组检测列车250管内
76 ^DJ[56]\d{3}$ 直通动车组确认列车
77 ^DJ[78]\d{3}$ 管内动车组确认列车
78 ^[Ff][GDCZTKgdcztk]?\d{1,4}$ 因故折返旅客列车
79 ^0[GDCZTKgdcztk]\d{1,4}$ 回送图定客车底
80 ^00(100|[1-9]\d?)$ 有火回送动车组车底
81 ^00(10[1-9]|1[1-9]\d|2([0-8]\d|9[0-8]))$ 无火回送动车组车底
82 ^00(30[1-9]|3[1-9]\d|4([0-8]\d|9[0-8]))$ 无火回送普速客车底

View File

@@ -18,9 +18,7 @@ import java.util.*
class BLEClient(private val context: Context) : BluetoothGattCallback() { class BLEClient(private val context: Context) : BluetoothGattCallback() {
companion object { companion object {
const val TAG = "LBJ_BT" const val TAG = "LBJ_BT"
const val SCAN_PERIOD = 10000L
val SERVICE_UUID = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb") val SERVICE_UUID = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb")
val CHAR_UUID = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb") val CHAR_UUID = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb")
@@ -42,20 +40,69 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
private var targetDeviceName: String? = null private var targetDeviceName: String? = null
private var bluetoothLeScanner: BluetoothLeScanner? = null private var bluetoothLeScanner: BluetoothLeScanner? = null
private var continuousScanning = false
private var autoReconnect = true
private var lastKnownDeviceAddress: String? = null
private var connectionAttempts = 0
private var isReconnecting = false
private var highFrequencyReconnect = true
private var reconnectHandler = Handler(Looper.getMainLooper())
private var reconnectRunnable: Runnable? = null
private var connectionLostCallback: (() -> Unit)? = null
private var connectionSuccessCallback: ((String) -> Unit)? = null
private var specifiedDeviceAddress: String? = null
private var targetDeviceAddress: String? = null
private var isDialogOpen = false
private var isManualDisconnect = false
private var isAutoConnectBlocked = false
private val leScanCallback = object : ScanCallback() { private val leScanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) { override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device val device = result.device
val deviceName = device.name val deviceName = device.name
if (targetDeviceName != null) {
if (deviceName == null || !deviceName.equals(targetDeviceName, ignoreCase = true)) { val shouldShowDevice = when {
return targetDeviceName != null -> {
deviceName != null && deviceName.equals(targetDeviceName, ignoreCase = true)
}
else -> {
true
}
}
if (shouldShowDevice) {
Log.d(TAG, "Showing filtered device: $deviceName")
scanCallback?.invoke(device)
}
if (!isConnected && !isReconnecting && !isDialogOpen && !isAutoConnectBlocked) {
val deviceAddress = device.address
val isSpecifiedDevice = specifiedDeviceAddress == deviceAddress
val isTargetDevice = targetDeviceName != null && deviceName != null && deviceName.equals(targetDeviceName, ignoreCase = true)
val isKnownDevice = lastKnownDeviceAddress == deviceAddress
val isSpecificTargetAddress = targetDeviceAddress == deviceAddress
if (isSpecificTargetAddress || isSpecifiedDevice || (specifiedDeviceAddress == null && isTargetDevice) || (specifiedDeviceAddress == null && isKnownDevice)) {
val priority = when {
isSpecificTargetAddress -> "specific target address"
isSpecifiedDevice -> "specified device"
isTargetDevice -> "target device name"
else -> "known device"
}
Log.i(TAG, "Found device ($priority): $deviceName, auto-connecting")
lastKnownDeviceAddress = deviceAddress
connectImmediately(deviceAddress)
} }
} }
scanCallback?.invoke(device)
} }
override fun onScanFailed(errorCode: Int) { override fun onScanFailed(errorCode: Int) {
Log.e(TAG, "BLE scan failed code=$errorCode") Log.e(TAG, "BLE scan failed code=$errorCode")
if (continuousScanning) {
handler.post {
restartScan()
}
}
} }
} }
@@ -90,12 +137,13 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
try { try {
scanCallback = callback scanCallback = callback
this.targetDeviceName = targetDeviceName this.targetDeviceName = targetDeviceName
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() ?: run { val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val bluetoothAdapter = bluetoothManager.adapter ?: run {
Log.e(TAG, "Bluetooth adapter unavailable") Log.e(TAG, "Bluetooth adapter unavailable")
return return
} }
if (!bluetoothAdapter.isEnabled) { if (bluetoothAdapter.isEnabled != true) {
Log.e(TAG, "Bluetooth adapter disabled") Log.e(TAG, "Bluetooth adapter disabled")
return return
} }
@@ -106,12 +154,9 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
return return
} }
handler.postDelayed({
stopScan()
}, SCAN_PERIOD)
isScanning = true isScanning = true
Log.d(TAG, "Starting BLE scan target=${targetDeviceName ?: "Any"}") continuousScanning = true
Log.d(TAG, "Starting continuous BLE scan target=${targetDeviceName ?: "Any"}")
bluetoothLeScanner?.startScan(leScanCallback) bluetoothLeScanner?.startScan(leScanCallback)
} catch (e: SecurityException) { } catch (e: SecurityException) {
Log.e(TAG, "Scan security error: ${e.message}") Log.e(TAG, "Scan security error: ${e.message}")
@@ -126,6 +171,40 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
if (isScanning) { if (isScanning) {
bluetoothLeScanner?.stopScan(leScanCallback) bluetoothLeScanner?.stopScan(leScanCallback)
isScanning = false isScanning = false
continuousScanning = false
Log.d(TAG, "Stopped BLE scan")
}
}
@SuppressLint("MissingPermission")
private fun restartScan() {
if (!continuousScanning) return
try {
bluetoothLeScanner?.stopScan(leScanCallback)
bluetoothLeScanner?.startScan(leScanCallback)
isScanning = true
Log.d(TAG, "Restarted BLE scan")
} catch (e: Exception) {
Log.e(TAG, "Failed to restart scan: ${e.message}")
}
}
private fun connectImmediately(address: String) {
if (isReconnecting) return
isReconnecting = true
handler.post {
connect(address) { connected ->
isReconnecting = false
if (connected) {
connectionAttempts = 0
Log.i(TAG, "Successfully connected to $address")
} else {
connectionAttempts++
Log.w(TAG, "Connection attempt $connectionAttempts failed for $address")
}
}
} }
} }
@@ -150,13 +229,14 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
} }
try { try {
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() ?: run { val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val bluetoothAdapter = bluetoothManager.adapter ?: run {
Log.e(TAG, "Bluetooth adapter unavailable") Log.e(TAG, "Bluetooth adapter unavailable")
handler.post { onConnectionStateChange?.invoke(false) } handler.post { onConnectionStateChange?.invoke(false) }
return false return false
} }
if (!bluetoothAdapter.isEnabled) { if (bluetoothAdapter.isEnabled != true) {
Log.e(TAG, "Bluetooth adapter is disabled") Log.e(TAG, "Bluetooth adapter is disabled")
handler.post { onConnectionStateChange?.invoke(false) } handler.post { onConnectionStateChange?.invoke(false) }
return false return false
@@ -181,17 +261,7 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
bluetoothGatt = device.connectGatt(context, false, this, BluetoothDevice.TRANSPORT_LE) bluetoothGatt = device.connectGatt(context, false, this, BluetoothDevice.TRANSPORT_LE)
Log.d(TAG, "Connecting to address=$address") Log.d(TAG, "Connecting to address=$address")
handler.postDelayed({
if (!isConnected && deviceAddress == address) {
Log.e(TAG, "Connection timeout reconnecting")
bluetoothGatt?.close()
bluetoothGatt =
device.connectGatt(context, false, this, BluetoothDevice.TRANSPORT_LE)
}
}, 10000)
return true return true
} catch (e: Exception) { } catch (e: Exception) {
@@ -205,11 +275,97 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
fun isConnected(): Boolean { fun isConnected(): Boolean {
return isConnected return isConnected
} }
@SuppressLint("MissingPermission")
fun checkActualConnectionState(): Boolean {
bluetoothGatt?.let { gatt ->
try {
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val connectedDevices = bluetoothManager.getConnectedDevices(BluetoothProfile.GATT)
val isActuallyConnected = connectedDevices.any { it.address == deviceAddress }
if (isActuallyConnected && !isConnected) {
Log.d(TAG, "Found existing GATT connection, updating internal state")
isConnected = true
return true
} else if (!isActuallyConnected && isConnected) {
Log.d(TAG, "GATT connection lost, updating internal state")
isConnected = false
return false
}
return isActuallyConnected
} catch (e: Exception) {
Log.e(TAG, "Error checking actual connection state: ${e.message}")
return isConnected
}
}
return isConnected
}
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
fun disconnect() { fun disconnect() {
bluetoothGatt?.disconnect() Log.d(TAG, "Manual disconnect initiated")
isConnected = false
isManualDisconnect = true
isAutoConnectBlocked = true
stopHighFrequencyReconnect()
stopScan()
bluetoothGatt?.let { gatt ->
try {
gatt.disconnect()
Thread.sleep(100)
gatt.close()
} catch (e: Exception) {
Log.e(TAG, "Disconnect error: ${e.message}")
}
}
bluetoothGatt = null
dataBuffer.clear()
connectionStateCallback = null
Log.d(TAG, "Manual disconnect - auto connect blocked, deviceAddress preserved: $deviceAddress")
}
@SuppressLint("MissingPermission")
fun connectManually(address: String, onConnectionStateChange: ((Boolean) -> Unit)? = null): Boolean {
Log.d(TAG, "Manual connection to device: $address")
stopScan()
stopHighFrequencyReconnect()
isManualDisconnect = false
isAutoConnectBlocked = false
autoReconnect = true
highFrequencyReconnect = true
return connect(address, onConnectionStateChange)
}
@SuppressLint("MissingPermission")
fun closeManually() {
Log.d(TAG, "Manual close - will restore auto reconnect")
isConnected = false
isManualDisconnect = false
isAutoConnectBlocked = false
bluetoothGatt?.let { gatt ->
try {
gatt.disconnect()
gatt.close()
} catch (e: Exception) {
Log.e(TAG, "Close error: ${e.message}")
}
}
bluetoothGatt = null
deviceAddress = null
autoReconnect = true
highFrequencyReconnect = true
Log.d(TAG, "Auto reconnect mechanism restored and GATT cleaned up")
} }
@@ -284,30 +440,31 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
if (status != BluetoothGatt.GATT_SUCCESS) { if (status != BluetoothGatt.GATT_SUCCESS) {
Log.e(TAG, "Connection error status=$status") Log.e(TAG, "Connection error status=$status")
isConnected = false isConnected = false
isReconnecting = false
if (status == 133 || status == 8) { if (status == 133 || status == 8) {
Log.e(TAG, "GATT error closing connection") Log.e(TAG, "GATT error, attempting immediate reconnection")
try { try {
gatt.close() gatt.close()
bluetoothGatt = null bluetoothGatt = null
bluetoothLeScanner = null
deviceAddress?.let { address -> deviceAddress?.let { address ->
handler.postDelayed({ if (autoReconnect) {
Log.d(TAG, "Reconnecting to device") Log.d(TAG, "Immediate reconnection to device")
val device = handler.post {
BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address) val device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address)
bluetoothGatt = device.connectGatt( bluetoothGatt = device.connectGatt(
context, context,
false, false,
this, this,
BluetoothDevice.TRANSPORT_LE BluetoothDevice.TRANSPORT_LE
) )
}, 2000) }
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Reconnect error: ${e.message}") Log.e(TAG, "Immediate reconnect error: ${e.message}")
} }
} }
@@ -318,32 +475,42 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
when (newState) { when (newState) {
BluetoothProfile.STATE_CONNECTED -> { BluetoothProfile.STATE_CONNECTED -> {
isConnected = true isConnected = true
isReconnecting = false
isManualDisconnect = false
connectionAttempts = 0
Log.i(TAG, "Connected to GATT server") Log.i(TAG, "Connected to GATT server")
handler.post { connectionStateCallback?.invoke(true) } handler.post { connectionStateCallback?.invoke(true) }
deviceAddress?.let { address ->
handler.post { connectionSuccessCallback?.invoke(address) }
}
handler.post {
handler.postDelayed({
try { try {
gatt.discoverServices() gatt.discoverServices()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Service discovery failed: ${e.message}") Log.e(TAG, "Service discovery failed: ${e.message}")
} }
}, 500) }
} }
BluetoothProfile.STATE_DISCONNECTED -> { BluetoothProfile.STATE_DISCONNECTED -> {
isConnected = false isConnected = false
Log.i(TAG, "Disconnected from GATT server") isReconnecting = false
Log.i(TAG, "Disconnected from GATT server, manual=$isManualDisconnect")
handler.post { connectionStateCallback?.invoke(false) } handler.post {
connectionStateCallback?.invoke(false)
if (!isManualDisconnect) {
connectionLostCallback?.invoke()
}
}
if (!deviceAddress.isNullOrBlank() && autoReconnect && highFrequencyReconnect && !isManualDisconnect) {
if (!deviceAddress.isNullOrBlank()) { startHighFrequencyReconnect(deviceAddress!!)
handler.postDelayed({ } else if (isManualDisconnect) {
Log.d(TAG, "Reconnecting after disconnect") Log.d(TAG, "Manual disconnect - no auto reconnect")
connect(deviceAddress!!, connectionStateCallback)
}, 3000)
} }
} }
} }
@@ -355,17 +522,19 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
private var lastDataTime = 0L private var lastDataTime = 0L
@Suppress("DEPRECATION")
override fun onCharacteristicChanged( override fun onCharacteristicChanged(
gatt: BluetoothGatt, gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic characteristic: BluetoothGattCharacteristic
) { ) {
super.onCharacteristicChanged(gatt, characteristic) super.onCharacteristicChanged(gatt, characteristic)
@Suppress("DEPRECATION")
val newData = characteristic.value?.let { val newData = characteristic.value?.let {
String(it, StandardCharsets.UTF_8) String(it, StandardCharsets.UTF_8)
} ?: return } ?: return
Log.d(TAG, "Received data len=${newData.length} preview=${newData.take(50)}") Log.d(TAG, "Received data len=${newData.length} preview=${newData}")
dataBuffer.append(newData) dataBuffer.append(newData)
@@ -379,18 +548,17 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
val bufferContent = dataBuffer.toString() val bufferContent = dataBuffer.toString()
val currentTime = System.currentTimeMillis() val currentTime = System.currentTimeMillis()
if (lastDataTime > 0) {
if (lastDataTime > 0 && currentTime - lastDataTime > 5000) { val timeDiff = currentTime - lastDataTime
Log.w(TAG, "Data timeout ${(currentTime - lastDataTime) / 1000}s") if (timeDiff > 10000) {
Log.w(TAG, "Long data gap: ${timeDiff / 1000}s")
}
} }
Log.d(TAG, "Buffer size=${dataBuffer.length} bytes") Log.d(TAG, "Buffer size=${dataBuffer.length} bytes")
tryExtractJson(bufferContent) tryExtractJson(bufferContent)
lastDataTime = currentTime lastDataTime = currentTime
} }
@@ -472,7 +640,7 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
private fun processJsonString(jsonStr: String): Boolean { private fun processJsonString(jsonStr: String): Boolean {
try { try {
val jsonObject = JSONObject(jsonStr) val jsonObject = JSONObject(jsonStr)
Log.d(TAG, "Parsed JSON len=${jsonStr.length} preview=${jsonStr.take(50)}") Log.d(TAG, "Parsed JSON len=${jsonStr.length} preview=${jsonStr}")
handler.post { handler.post {
@@ -509,9 +677,16 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
) )
if (descriptor != null) { if (descriptor != null) {
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val writeResult = gatt.writeDescriptor(descriptor) val writeResult = gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
Log.d(TAG, "Descriptor write result=$writeResult") Log.d(TAG, "Descriptor write result=$writeResult")
} else {
@Suppress("DEPRECATION")
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
@Suppress("DEPRECATION")
val writeResult = gatt.writeDescriptor(descriptor)
Log.d(TAG, "Descriptor write result=$writeResult")
}
} else { } else {
Log.e(TAG, "Descriptor not found") Log.e(TAG, "Descriptor not found")
@@ -538,10 +713,134 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
private fun requestDataAfterDelay() { private fun requestDataAfterDelay() {
handler.postDelayed({ handler.post {
statusCallback?.let { callback -> statusCallback?.let { callback ->
getStatus(callback) getStatus(callback)
} }
}, 1000) }
}
fun setAutoReconnect(enabled: Boolean) {
autoReconnect = enabled
Log.d(TAG, "Auto reconnect set to: $enabled")
}
fun setHighFrequencyReconnect(enabled: Boolean) {
highFrequencyReconnect = enabled
if (!enabled) {
stopHighFrequencyReconnect()
}
Log.d(TAG, "High frequency reconnect set to: $enabled")
}
fun setConnectionLostCallback(callback: (() -> Unit)?) {
connectionLostCallback = callback
}
fun setConnectionSuccessCallback(callback: ((String) -> Unit)?) {
connectionSuccessCallback = callback
}
fun setSpecifiedDeviceAddress(address: String?) {
specifiedDeviceAddress = address
Log.d(TAG, "Set specified device address: $address")
}
fun getSpecifiedDeviceAddress(): String? = specifiedDeviceAddress
fun setDialogOpen(isOpen: Boolean) {
isDialogOpen = isOpen
Log.d(TAG, "Dialog open state set to: $isOpen")
}
fun setAutoConnectBlocked(blocked: Boolean) {
isAutoConnectBlocked = blocked
Log.d(TAG, "Auto connect blocked set to: $blocked")
}
fun resetManualDisconnectState() {
isManualDisconnect = false
isAutoConnectBlocked = false
Log.d(TAG, "Manual disconnect state reset - auto reconnect enabled")
}
fun setTargetDeviceAddress(address: String?) {
targetDeviceAddress = address
Log.d(TAG, "Set target device address: $address")
}
fun getTargetDeviceAddress(): String? = targetDeviceAddress
private fun startHighFrequencyReconnect(address: String) {
stopHighFrequencyReconnect()
Log.d(TAG, "Starting high frequency reconnect for: $address")
reconnectRunnable = Runnable {
if (!isConnected && autoReconnect && highFrequencyReconnect) {
Log.d(TAG, "High frequency reconnect attempt ${connectionAttempts + 1} for: $address")
connect(address, connectionStateCallback)
if (!isConnected) {
val delay = when {
connectionAttempts < 10 -> 100L
connectionAttempts < 30 -> 200L
connectionAttempts < 60 -> 500L
else -> 1000L
}
reconnectHandler.postDelayed(reconnectRunnable!!, delay)
}
}
}
reconnectHandler.post(reconnectRunnable!!)
}
private fun stopHighFrequencyReconnect() {
reconnectRunnable?.let {
reconnectHandler.removeCallbacks(it)
reconnectRunnable = null
Log.d(TAG, "Stopped high frequency reconnect")
}
}
fun getConnectionAttempts(): Int = connectionAttempts
fun getLastKnownDeviceAddress(): String? = lastKnownDeviceAddress
@SuppressLint("MissingPermission")
fun disconnectAndCleanup() {
isConnected = false
autoReconnect = false
highFrequencyReconnect = false
isManualDisconnect = false
isAutoConnectBlocked = false
stopHighFrequencyReconnect()
stopScan()
bluetoothGatt?.let { gatt ->
try {
gatt.disconnect()
Thread.sleep(200)
gatt.close()
Log.d(TAG, "GATT connection cleaned up")
} catch (e: Exception) {
Log.e(TAG, "Cleanup error: ${e.message}")
}
}
bluetoothGatt = null
bluetoothLeScanner = null
deviceAddress = null
connectionAttempts = 0
dataBuffer.clear()
connectionStateCallback = null
statusCallback = null
trainInfoCallback = null
connectionLostCallback = null
connectionSuccessCallback = null
Log.d(TAG, "BLE client fully disconnected and cleaned up")
} }
} }

View File

@@ -0,0 +1,123 @@
package org.noxylva.lbjconsole
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
class BackgroundService : Service() {
companion object {
private const val NOTIFICATION_ID = 1001
private const val CHANNEL_ID = "background_service_channel"
private const val CHANNEL_NAME = "Background Service"
fun startService(context: Context) {
try {
val intent = Intent(context, BackgroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
} catch (e: Exception) {
// Service start failed, ignore silently
}
}
fun stopService(context: Context) {
val intent = Intent(context, BackgroundService::class.java)
context.stopService(intent)
}
}
private var wakeLock: PowerManager.WakeLock? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
acquireWakeLock()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
try {
val notification = createNotification()
startForeground(NOTIFICATION_ID, notification)
} catch (e: Exception) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
stopSelf()
return START_NOT_STICKY
}
}
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
releaseWakeLock()
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Keep app running in background"
setShowBadge(false)
}
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
private fun createNotification(): Notification {
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("LBJ Console")
.setContentText("Running in background")
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
private fun acquireWakeLock() {
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"LBJConsole::BackgroundWakeLock"
)
wakeLock?.acquire()
}
private fun releaseWakeLock() {
wakeLock?.let {
if (it.isHeld) {
it.release()
}
}
wakeLock = null
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,223 @@
package org.noxylva.lbjconsole
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import kotlinx.coroutines.runBlocking
import org.noxylva.lbjconsole.database.AppSettingsRepository
import org.noxylva.lbjconsole.database.TrainDatabase
import android.os.Build
import android.util.Log
import android.view.View
import android.widget.RemoteViews
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import org.json.JSONObject
import org.noxylva.lbjconsole.model.TrainRecord
class NotificationService(private val context: Context) {
companion object {
const val TAG = "NotificationService"
const val CHANNEL_ID = "lbj_messages"
const val CHANNEL_NAME = "LBJ Messages"
const val NOTIFICATION_ID_BASE = 2000
const val PREFS_NAME = "notification_settings"
const val KEY_ENABLED = "notifications_enabled"
}
private val notificationManager = NotificationManagerCompat.from(context)
private val appSettingsRepository = AppSettingsRepository(context)
private var notificationIdCounter = NOTIFICATION_ID_BASE
init {
createNotificationChannel()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Real-time LBJ train message notifications"
enableVibration(true)
setShowBadge(true)
}
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(channel)
Log.d(TAG, "Notification channel created")
}
}
fun isNotificationEnabled(): Boolean {
return runBlocking {
appSettingsRepository.getSettings().notificationEnabled
}
}
fun setNotificationEnabled(enabled: Boolean) {
runBlocking {
appSettingsRepository.updateNotificationEnabled(enabled)
}
Log.d(TAG, "Notification enabled set to: $enabled")
}
private fun isValidValue(value: String): Boolean {
val trimmed = value.trim()
return trimmed.isNotEmpty() &&
trimmed != "NUL" &&
trimmed != "<NUL>" &&
trimmed != "NA" &&
trimmed != "<NA>" &&
!trimmed.all { it == '*' }
}
fun showTrainNotification(trainRecord: TrainRecord) {
if (!isNotificationEnabled()) {
Log.d(TAG, "Notifications disabled, skipping")
return
}
try {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val remoteViews = RemoteViews(context.packageName, R.layout.notification_train_record)
val trainDisplay = if (isValidValue(trainRecord.lbjClass) && isValidValue(trainRecord.train)) {
"${trainRecord.lbjClass.trim()}${trainRecord.train.trim()}"
} else if (isValidValue(trainRecord.lbjClass)) {
trainRecord.lbjClass.trim()
} else if (isValidValue(trainRecord.train)) {
trainRecord.train.trim()
} else "列车"
remoteViews.setTextViewText(R.id.notification_train_number, trainDisplay)
val directionText = when (trainRecord.direction) {
1 -> ""
3 -> ""
else -> ""
}
if (directionText.isNotEmpty()) {
remoteViews.setTextViewText(R.id.notification_direction, directionText)
remoteViews.setViewVisibility(R.id.notification_direction, View.VISIBLE)
} else {
remoteViews.setViewVisibility(R.id.notification_direction, View.GONE)
}
val locoInfo = when {
isValidValue(trainRecord.locoType) && isValidValue(trainRecord.loco) -> {
val shortLoco = if (trainRecord.loco.length > 5) {
trainRecord.loco.takeLast(5)
} else {
trainRecord.loco
}
"${trainRecord.locoType}-${shortLoco}"
}
isValidValue(trainRecord.locoType) -> trainRecord.locoType
isValidValue(trainRecord.loco) -> trainRecord.loco
else -> ""
}
if (locoInfo.isNotEmpty()) {
remoteViews.setTextViewText(R.id.notification_loco_info, locoInfo)
remoteViews.setViewVisibility(R.id.notification_loco_info, View.VISIBLE)
} else {
remoteViews.setViewVisibility(R.id.notification_loco_info, View.GONE)
}
if (isValidValue(trainRecord.route)) {
remoteViews.setTextViewText(R.id.notification_route, trainRecord.route.trim())
remoteViews.setViewVisibility(R.id.notification_route, View.VISIBLE)
} else {
remoteViews.setViewVisibility(R.id.notification_route, View.GONE)
}
if (isValidValue(trainRecord.position)) {
remoteViews.setTextViewText(R.id.notification_position, "${trainRecord.position.trim()}K")
remoteViews.setViewVisibility(R.id.notification_position, View.VISIBLE)
} else {
remoteViews.setViewVisibility(R.id.notification_position, View.GONE)
}
if (isValidValue(trainRecord.speed)) {
remoteViews.setTextViewText(R.id.notification_speed, "${trainRecord.speed.trim()} km/h")
remoteViews.setViewVisibility(R.id.notification_speed, View.VISIBLE)
} else {
remoteViews.setViewVisibility(R.id.notification_speed, View.GONE)
}
remoteViews.setOnClickPendingIntent(R.id.notification_train_number, pendingIntent)
val summaryParts = mutableListOf<String>()
val routeAndDirection = when {
isValidValue(trainRecord.route) && directionText.isNotEmpty() -> "${trainRecord.route.trim()}${directionText}"
isValidValue(trainRecord.route) -> trainRecord.route.trim()
directionText.isNotEmpty() -> "${directionText}"
else -> null
}
routeAndDirection?.let { summaryParts.add(it) }
if (locoInfo.isNotEmpty()) summaryParts.add(locoInfo)
val summaryText = summaryParts.joinToString("")
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(trainDisplay)
.setContentText(summaryText)
.setCustomContentView(remoteViews)
.setCustomBigContentView(remoteViews)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setWhen(trainRecord.timestamp.time)
.build()
val notificationId = notificationIdCounter++
if (notificationIdCounter > NOTIFICATION_ID_BASE + 1000) {
notificationIdCounter = NOTIFICATION_ID_BASE
}
notificationManager.notify(notificationId, notification)
Log.d(TAG, "Custom notification sent for train: ${trainRecord.train}")
} catch (e: Exception) {
Log.e(TAG, "Failed to show notification: ${e.message}", e)
}
}
fun showTrainNotification(jsonData: JSONObject) {
if (!isNotificationEnabled()) {
Log.d(TAG, "Notifications disabled, skipping")
return
}
try {
val trainRecord = TrainRecord(jsonData)
showTrainNotification(trainRecord)
} catch (e: Exception) {
Log.e(TAG, "Failed to create TrainRecord from JSON: ${e.message}", e)
}
}
fun hasNotificationPermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationManager.areNotificationsEnabled()
} else {
notificationManager.areNotificationsEnabled()
}
}
}

View File

@@ -0,0 +1,66 @@
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

@@ -0,0 +1,31 @@
package org.noxylva.lbjconsole.database
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface AppSettingsDao {
@Query("SELECT * FROM app_settings WHERE id = 1")
suspend fun getSettings(): AppSettingsEntity?
@Query("SELECT * FROM app_settings WHERE id = 1")
fun getSettingsFlow(): Flow<AppSettingsEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSettings(settings: AppSettingsEntity)
@Update
suspend fun updateSettings(settings: AppSettingsEntity)
@Query("DELETE FROM app_settings")
suspend fun deleteAllSettings()
@Query("UPDATE app_settings SET notificationEnabled = :enabled WHERE id = 1")
suspend fun updateNotificationEnabled(enabled: Boolean)
@Transaction
suspend fun saveSettings(settings: AppSettingsEntity) {
insertSettings(settings)
}
}

View File

@@ -0,0 +1,26 @@
package org.noxylva.lbjconsole.database
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "app_settings")
data class AppSettingsEntity(
@PrimaryKey val id: Int = 1,
val deviceName: String = "LBJReceiver",
val currentTab: Int = 0,
val historyEditMode: Boolean = false,
val historySelectedRecords: String = "",
val historyExpandedStates: String = "",
val historyScrollPosition: Int = 0,
val historyScrollOffset: Int = 0,
val settingsScrollPosition: Int = 0,
val mapCenterLat: Float? = null,
val mapCenterLon: Float? = null,
val mapZoomLevel: Float = 10.0f,
val mapRailwayLayerVisible: Boolean = true,
val specifiedDeviceAddress: String? = null,
val searchOrderList: String = "",
val autoConnectEnabled: Boolean = true,
val backgroundServiceEnabled: Boolean = false,
val notificationEnabled: Boolean = false
)

View File

@@ -0,0 +1,127 @@
package org.noxylva.lbjconsole.database
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
class AppSettingsRepository(private val context: Context) {
private val dao = TrainDatabase.getDatabase(context).appSettingsDao()
private val sharedPrefs: SharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
suspend fun getSettings(): AppSettingsEntity {
var settings = dao.getSettings()
if (settings == null) {
settings = migrateFromSharedPreferences()
dao.saveSettings(settings)
}
return settings
}
fun getSettingsFlow(): Flow<AppSettingsEntity?> {
return dao.getSettingsFlow()
}
suspend fun saveSettings(settings: AppSettingsEntity) {
dao.saveSettings(settings)
}
suspend fun updateDeviceName(deviceName: String) {
val current = getSettings()
saveSettings(current.copy(deviceName = deviceName))
}
suspend fun updateCurrentTab(tab: Int) {
val current = getSettings()
saveSettings(current.copy(currentTab = tab))
}
suspend fun updateHistoryEditMode(editMode: Boolean) {
val current = getSettings()
saveSettings(current.copy(historyEditMode = editMode))
}
suspend fun updateHistorySelectedRecords(selectedRecords: String) {
val current = getSettings()
saveSettings(current.copy(historySelectedRecords = selectedRecords))
}
suspend fun updateHistoryExpandedStates(expandedStates: String) {
val current = getSettings()
saveSettings(current.copy(historyExpandedStates = expandedStates))
}
suspend fun updateHistoryScrollPosition(position: Int, offset: Int = 0) {
val current = getSettings()
saveSettings(current.copy(historyScrollPosition = position, historyScrollOffset = offset))
}
suspend fun updateSettingsScrollPosition(position: Int) {
val current = getSettings()
saveSettings(current.copy(settingsScrollPosition = position))
}
suspend fun updateMapSettings(centerLat: Float?, centerLon: Float?, zoomLevel: Float, railwayLayerVisible: Boolean) {
val current = getSettings()
saveSettings(current.copy(
mapCenterLat = centerLat,
mapCenterLon = centerLon,
mapZoomLevel = zoomLevel,
mapRailwayLayerVisible = railwayLayerVisible
))
}
suspend fun updateSpecifiedDeviceAddress(address: String?) {
val current = getSettings()
saveSettings(current.copy(specifiedDeviceAddress = address))
}
suspend fun updateSearchOrderList(orderList: String) {
val current = getSettings()
saveSettings(current.copy(searchOrderList = orderList))
}
suspend fun updateAutoConnectEnabled(enabled: Boolean) {
val current = getSettings()
saveSettings(current.copy(autoConnectEnabled = enabled))
}
suspend fun updateBackgroundServiceEnabled(enabled: Boolean) {
val current = getSettings()
saveSettings(current.copy(backgroundServiceEnabled = enabled))
}
suspend fun updateNotificationEnabled(enabled: Boolean) {
val current = getSettings()
saveSettings(current.copy(notificationEnabled = enabled))
}
private fun migrateFromSharedPreferences(): AppSettingsEntity {
return AppSettingsEntity(
deviceName = sharedPrefs.getString("device_name", "LBJReceiver") ?: "LBJReceiver",
currentTab = sharedPrefs.getInt("current_tab", 0),
historyEditMode = sharedPrefs.getBoolean("history_edit_mode", false),
historySelectedRecords = sharedPrefs.getString("history_selected_records", "") ?: "",
historyExpandedStates = sharedPrefs.getString("history_expanded_states", "") ?: "",
historyScrollPosition = sharedPrefs.getInt("history_scroll_position", 0),
historyScrollOffset = sharedPrefs.getInt("history_scroll_offset", 0),
settingsScrollPosition = sharedPrefs.getInt("settings_scroll_position", 0),
mapCenterLat = if (sharedPrefs.contains("map_center_lat")) sharedPrefs.getFloat("map_center_lat", 0f) else null,
mapCenterLon = if (sharedPrefs.contains("map_center_lon")) sharedPrefs.getFloat("map_center_lon", 0f) else null,
mapZoomLevel = sharedPrefs.getFloat("map_zoom_level", 10.0f),
mapRailwayLayerVisible = sharedPrefs.getBoolean("map_railway_layer_visible", true),
specifiedDeviceAddress = sharedPrefs.getString("specified_device_address", null),
searchOrderList = sharedPrefs.getString("search_order_list", "") ?: "",
autoConnectEnabled = sharedPrefs.getBoolean("auto_connect_enabled", true),
backgroundServiceEnabled = sharedPrefs.getBoolean("background_service_enabled", false),
notificationEnabled = context.getSharedPreferences("notification_settings", Context.MODE_PRIVATE)
.getBoolean("notifications_enabled", false)
)
}
suspend fun clearSharedPreferences() {
sharedPrefs.edit().clear().apply()
}
}

View File

@@ -0,0 +1,69 @@
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, AppSettingsEntity::class],
version = 3,
exportSchema = false
)
abstract class TrainDatabase : RoomDatabase() {
abstract fun trainRecordDao(): TrainRecordDao
abstract fun appSettingsDao(): AppSettingsDao
companion object {
@Volatile
private var INSTANCE: TrainDatabase? = null
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("""
CREATE TABLE IF NOT EXISTS `app_settings` (
`id` INTEGER NOT NULL,
`deviceName` TEXT NOT NULL,
`currentTab` INTEGER NOT NULL,
`historyEditMode` INTEGER NOT NULL,
`historySelectedRecords` TEXT NOT NULL,
`historyExpandedStates` TEXT NOT NULL,
`historyScrollPosition` INTEGER NOT NULL,
`historyScrollOffset` INTEGER NOT NULL,
`settingsScrollPosition` INTEGER NOT NULL,
`mapCenterLat` REAL,
`mapCenterLon` REAL,
`mapZoomLevel` REAL NOT NULL,
`mapRailwayLayerVisible` INTEGER NOT NULL,
`specifiedDeviceAddress` TEXT,
`searchOrderList` TEXT NOT NULL,
`autoConnectEnabled` INTEGER NOT NULL,
`backgroundServiceEnabled` INTEGER NOT NULL,
PRIMARY KEY(`id`)
)
""")
}
}
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE app_settings ADD COLUMN notificationEnabled INTEGER NOT NULL DEFAULT 0")
}
}
fun getDatabase(context: Context): TrainDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
TrainDatabase::class.java,
"train_database"
).addMigrations(MIGRATION_1_2, MIGRATION_2_3).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

@@ -0,0 +1,53 @@
package org.noxylva.lbjconsole.model
data class MergeSettings(
val enabled: Boolean = true,
val groupBy: GroupBy = GroupBy.TRAIN_AND_LOCO,
val timeWindow: TimeWindow = TimeWindow.UNLIMITED
)
enum class GroupBy(val displayName: String) {
TRAIN_ONLY("车次号"),
LOCO_ONLY("机车号"),
TRAIN_OR_LOCO("车次号或机车号"),
TRAIN_AND_LOCO("车次号与机车号")
}
enum class TimeWindow(val displayName: String, val seconds: Long?) {
ONE_HOUR("1小时", 3600),
TWO_HOURS("2小时", 7200),
SIX_HOURS("6小时", 21600),
TWELVE_HOURS("12小时", 43200),
ONE_DAY("24小时", 86400),
UNLIMITED("不限时间", null)
}
fun generateGroupKey(record: TrainRecord, groupBy: GroupBy): String? {
return when (groupBy) {
GroupBy.TRAIN_ONLY -> {
val train = record.train.trim()
if (train.isNotEmpty() && train != "<NUL>") train else null
}
GroupBy.LOCO_ONLY -> {
val loco = record.loco.trim()
if (loco.isNotEmpty() && loco != "<NUL>") loco else null
}
GroupBy.TRAIN_OR_LOCO -> {
val train = record.train.trim()
val loco = record.loco.trim()
when {
train.isNotEmpty() && train != "<NUL>" -> train
loco.isNotEmpty() && loco != "<NUL>" -> loco
else -> null
}
}
GroupBy.TRAIN_AND_LOCO -> {
val train = record.train.trim()
val loco = record.loco.trim()
if (train.isNotEmpty() && train != "<NUL>" &&
loco.isNotEmpty() && loco != "<NUL>") {
"${train}_${loco}"
} else null
}
}
}

View File

@@ -0,0 +1,20 @@
package org.noxylva.lbjconsole.model
import java.util.*
data class MergedTrainRecord(
val groupKey: String,
val records: List<TrainRecord>,
val latestRecord: TrainRecord
) {
val recordCount: Int get() = records.size
val timeSpan: Pair<Date, Date> get() =
records.minByOrNull { it.timestamp }!!.timestamp to
records.maxByOrNull { it.timestamp }!!.timestamp
fun getAllCoordinates() = records.mapNotNull { it.getCoordinates() }
fun getUniqueRoutes() = records.map { it.route }.filter { it.isNotEmpty() && it != "<NUL>" }.toSet()
fun getUniquePositions() = records.map { it.position }.filter { it.isNotEmpty() && it != "<NUL>" }.toSet()
}

View File

@@ -9,9 +9,17 @@ import org.noxylva.lbjconsole.util.LocationUtils
class TrainRecord(jsonData: JSONObject? = null) { class TrainRecord(jsonData: JSONObject? = null) {
companion object { companion object {
const val TAG = "TrainRecord" const val TAG = "TrainRecord"
private var nextId = 0L
@Synchronized
private fun generateUniqueId(): String {
return "${System.currentTimeMillis()}_${++nextId}"
}
} }
val uniqueId: String
var timestamp: Date = Date() var timestamp: Date = Date()
var receivedTimestamp: Date = Date()
var train: String = "" var train: String = ""
var direction: Int = 0 var direction: Int = 0
var speed: String = "" var speed: String = ""
@@ -28,12 +36,28 @@ class TrainRecord(jsonData: JSONObject? = null) {
private var _coordinates: GeoPoint? = null private var _coordinates: GeoPoint? = null
init { init {
uniqueId = if (jsonData?.has("uniqueId") == true) {
jsonData.getString("uniqueId")
} else {
generateUniqueId()
}
jsonData?.let { jsonData?.let {
try { try {
if (jsonData.has("timestamp")) { if (jsonData.has("timestamp")) {
timestamp = Date(jsonData.getLong("timestamp")) timestamp = Date(jsonData.getLong("timestamp"))
} }
if (jsonData.has("receivedTimestamp")) {
receivedTimestamp = Date(jsonData.getLong("receivedTimestamp"))
} else {
receivedTimestamp = if (jsonData.has("timestamp")) {
Date(jsonData.getLong("timestamp"))
} else {
Date()
}
}
updateFromJson(it) updateFromJson(it)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to initialize TrainRecord from JSON: ${e.message}") Log.e(TAG, "Failed to initialize TrainRecord from JSON: ${e.message}")
@@ -60,7 +84,7 @@ class TrainRecord(jsonData: JSONObject? = null) {
_coordinates = null _coordinates = null
Log.d(TAG, "Successfully parsed: train=$train, dir=$direction, speed=$speed") Log.d(TAG, "Successfully parsed: train=$train, dir=$direction, speed=$speed, lbjClass='$lbjClass'")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "JSON parse error: ${e.message}", e) Log.e(TAG, "JSON parse error: ${e.message}", e)
@@ -96,7 +120,7 @@ class TrainRecord(jsonData: JSONObject? = null) {
!trimmed.all { it == '*' } !trimmed.all { it == '*' }
} }
fun toMap(): Map<String, String> { fun toMap(showDetailedTime: Boolean = false): Map<String, String> {
val directionText = when (direction) { val directionText = when (direction) {
1 -> "下行" 1 -> "下行"
3 -> "上行" 3 -> "上行"
@@ -114,12 +138,32 @@ class TrainRecord(jsonData: JSONObject? = null) {
val map = mutableMapOf<String, String>() val map = mutableMapOf<String, String>()
val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault())
map["timestamp"] = dateFormat.format(timestamp)
map["receivedTimestamp"] = dateFormat.format(receivedTimestamp)
if (trainDisplay.isNotEmpty()) map["train"] = trainDisplay if (trainDisplay.isNotEmpty()) map["train"] = trainDisplay
if (directionText != "未知") map["direction"] = directionText if (directionText != "未知") map["direction"] = directionText
if (isValidValue(speed)) map["speed"] = "速度: ${speed.trim()} km/h" if (isValidValue(speed)) map["speed"] = "速度: ${speed.trim()} km/h"
if (isValidValue(position)) map["position"] = "位置: ${position.trim()} km" if (isValidValue(position)) map["position"] = "位置: ${position.trim()} km"
if (isValidValue(time)) map["time"] = "列车时间: ${time.trim()}" val timeToDisplay = if (showDetailedTime) {
val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault())
if (isValidValue(time)) {
"列车时间: $time\n接收时间: ${dateFormat.format(receivedTimestamp)}"
} else {
dateFormat.format(receivedTimestamp)
}
} else {
val currentTime = System.currentTimeMillis()
val diffInSec = (currentTime - receivedTimestamp.time) / 1000
when {
diffInSec < 60 -> "${diffInSec}秒前"
diffInSec < 3600 -> "${diffInSec / 60}分钟前"
else -> "${diffInSec / 3600}小时前"
}
}
map["time"] = timeToDisplay
if (isValidValue(loco)) map["loco"] = "机车号: ${loco.trim()}" if (isValidValue(loco)) map["loco"] = "机车号: ${loco.trim()}"
if (isValidValue(locoType)) map["loco_type"] = "型号: ${locoType.trim()}" if (isValidValue(locoType)) map["loco_type"] = "型号: ${locoType.trim()}"
if (isValidValue(route)) map["route"] = "线路: ${route.trim()}" if (isValidValue(route)) map["route"] = "线路: ${route.trim()}"
@@ -134,7 +178,9 @@ class TrainRecord(jsonData: JSONObject? = null) {
fun toJSON(): JSONObject { fun toJSON(): JSONObject {
val json = JSONObject() val json = JSONObject()
json.put("uniqueId", uniqueId)
json.put("timestamp", timestamp.time) json.put("timestamp", timestamp.time)
json.put("receivedTimestamp", receivedTimestamp.time)
json.put("train", train) json.put("train", train)
json.put("dir", direction) json.put("dir", direction)
json.put("speed", speed) json.put("speed", speed)
@@ -148,4 +194,14 @@ class TrainRecord(jsonData: JSONObject? = null) {
json.put("rssi", rssi) json.put("rssi", rssi)
return json return json
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is TrainRecord) return false
return uniqueId == other.uniqueId
}
override fun hashCode(): Int {
return uniqueId.hashCode()
}
} }

View File

@@ -4,8 +4,11 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Environment import android.os.Environment
import android.util.Log import android.util.Log
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
@@ -19,15 +22,50 @@ class TrainRecordManager(private val context: Context) {
const val MAX_RECORDS = 1000 const val MAX_RECORDS = 1000
private const val PREFS_NAME = "train_records" private const val PREFS_NAME = "train_records"
private const val KEY_RECORDS = "records" private const val KEY_RECORDS = "records"
private const val KEY_MERGE_SETTINGS = "merge_settings"
} }
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())
var mergeSettings = MergeSettings()
private set
init { init {
loadRecords() 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<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}")
}
} }
@@ -38,15 +76,21 @@ class TrainRecordManager(private val context: Context) {
fun addRecord(jsonData: JSONObject): TrainRecord { fun addRecord(jsonData: JSONObject): TrainRecord {
val record = TrainRecord(jsonData) val record = TrainRecord(jsonData)
record.receivedTimestamp = Date()
trainRecords.add(0, record) trainRecords.add(0, record)
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
} }
@@ -66,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 {
@@ -108,109 +162,291 @@ 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
} }
private fun saveRecords() { private fun saveRecords() {
try { ioScope.launch {
val jsonArray = JSONArray() try {
for (record in trainRecords) { val entities = trainRecords.map { TrainRecordEntity.fromTrainRecord(it) }
jsonArray.put(record.toJSON()) trainRecordDao.insertRecords(entities)
Log.d(TAG, "Saved ${trainRecords.size} records to database")
} catch (e: Exception) {
Log.e(TAG, "Failed to save records: ${e.message}")
} }
prefs.edit().putString(KEY_RECORDS, jsonArray.toString()).apply()
Log.d(TAG, "Saved ${trainRecords.size} records")
} catch (e: Exception) {
Log.e(TAG, "Failed to save records: ${e.message}")
} }
} }
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}")
} }
} }
fun exportToCsv(records: List<TrainRecord>): File? {
try {
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val fileName = "train_records_$timeStamp.csv"
val downloadsDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
val file = File(downloadsDir, fileName)
FileWriter(file).use { writer ->
writer.append("时间戳,列车号,列车类型,方向,速度,位置,时间,机车号,机车类型,路线,位置信息,信号强度\n")
for (record in records) {
val map = record.toMap()
writer.append(map["timestamp"]).append(",")
writer.append(map["train"]).append(",")
writer.append(map["lbj_class"]).append(",")
writer.append(map["direction"]).append(",")
writer.append(map["speed"]?.replace(" km/h", "") ?: "").append(",")
writer.append(map["position"]?.replace(" km", "") ?: "").append(",")
writer.append(map["time"]).append(",")
writer.append(map["loco"]).append(",")
writer.append(map["loco_type"]).append(",")
writer.append(map["route"]).append(",")
writer.append(map["position_info"]).append(",")
writer.append(map["rssi"]?.replace(" dBm", "") ?: "").append("\n")
}
}
return file
} catch (e: Exception) {
Log.e(TAG, "Error exporting to CSV: ${e.message}")
return null
}
}
fun getRecordCount(): Int { fun getRecordCount(): Int {
return recordCount.get() return recordCount.get()
} }
fun updateMergeSettings(newSettings: MergeSettings) {
mergeSettings = newSettings
saveMergeSettings()
}
fun getMergedRecords(): List<MergedTrainRecord> {
if (!mergeSettings.enabled) {
return emptyList()
}
val records = getFilteredRecords()
return processRecordsForMerging(records, mergeSettings)
}
fun getMixedRecords(): List<Any> {
if (!mergeSettings.enabled) {
return getFilteredRecords()
}
val allRecords = getFilteredRecords()
val mergedRecords = processRecordsForMerging(allRecords, mergeSettings)
val mergedRecordIds = mergedRecords.flatMap { merged ->
merged.records.map { it.uniqueId }
}.toSet()
val singleRecords = allRecords.filter { record ->
!mergedRecordIds.contains(record.uniqueId)
}
val mixedList = mutableListOf<Any>()
mixedList.addAll(mergedRecords)
mixedList.addAll(singleRecords)
return mixedList.sortedByDescending { item ->
when (item) {
is MergedTrainRecord -> item.latestRecord.timestamp
is TrainRecord -> item.timestamp
else -> Date(0)
}
}
}
private fun processRecordsForMerging(records: List<TrainRecord>, settings: MergeSettings): List<MergedTrainRecord> {
val currentTime = Date()
val validRecords = records.filter { record ->
settings.timeWindow.seconds?.let { windowSeconds ->
(currentTime.time - record.timestamp.time) / 1000 <= windowSeconds
} ?: true
}
return when (settings.groupBy) {
GroupBy.TRAIN_OR_LOCO -> processTrainOrLocoMerging(validRecords)
else -> {
val groupedRecords = mutableMapOf<String, MutableList<TrainRecord>>()
validRecords.forEach { record ->
val groupKey = generateGroupKey(record, settings.groupBy)
if (groupKey != null) {
groupedRecords.getOrPut(groupKey) { mutableListOf() }.add(record)
}
}
groupedRecords.mapNotNull { (groupKey, groupRecords) ->
if (groupRecords.size >= 2) {
val sortedRecords = groupRecords.sortedBy { it.timestamp }
val latestRecord = sortedRecords.maxByOrNull { it.timestamp }!!
MergedTrainRecord(
groupKey = groupKey,
records = sortedRecords,
latestRecord = latestRecord
)
} else null
}.sortedByDescending { it.latestRecord.timestamp }
}
}
}
private fun processTrainOrLocoMerging(records: List<TrainRecord>): List<MergedTrainRecord> {
val groups = mutableListOf<MutableList<TrainRecord>>()
records.forEach { record ->
val train = record.train.trim()
val loco = record.loco.trim()
if ((train.isEmpty() || train == "<NUL>") && (loco.isEmpty() || loco == "<NUL>")) {
return@forEach
}
var foundGroup: MutableList<TrainRecord>? = null
for (group in groups) {
val shouldMerge = group.any { existingRecord ->
val existingTrain = existingRecord.train.trim()
val existingLoco = existingRecord.loco.trim()
(train.isNotEmpty() && train != "<NUL>" && train == existingTrain) ||
(loco.isNotEmpty() && loco != "<NUL>" && loco == existingLoco)
}
if (shouldMerge) {
foundGroup = group
break
}
}
if (foundGroup != null) {
foundGroup.add(record)
} else {
groups.add(mutableListOf(record))
}
}
return groups.mapNotNull { groupRecords ->
if (groupRecords.size >= 2) {
val sortedRecords = groupRecords.sortedBy { it.timestamp }
val latestRecord = sortedRecords.maxByOrNull { it.timestamp }!!
val groupKey = "${latestRecord.train}_OR_${latestRecord.loco}"
MergedTrainRecord(
groupKey = groupKey,
records = sortedRecords,
latestRecord = latestRecord
)
} else null
}.sortedByDescending { it.latestRecord.timestamp }
}
private fun saveMergeSettings() {
ioScope.launch {
try {
val json = JSONObject().apply {
put("enabled", mergeSettings.enabled)
put("groupBy", mergeSettings.groupBy.name)
put("timeWindow", mergeSettings.timeWindow.name)
}
prefs.edit().putString(KEY_MERGE_SETTINGS, json.toString()).apply()
Log.d(TAG, "Saved merge settings")
} catch (e: Exception) {
Log.e(TAG, "Failed to save merge settings: ${e.message}")
}
}
}
private fun loadMergeSettings() {
try {
val jsonStr = prefs.getString(KEY_MERGE_SETTINGS, null)
if (jsonStr != null) {
val json = JSONObject(jsonStr)
mergeSettings = MergeSettings(
enabled = json.getBoolean("enabled"),
groupBy = GroupBy.valueOf(json.getString("groupBy")),
timeWindow = TimeWindow.valueOf(json.getString("timeWindow"))
)
}
Log.d(TAG, "Loaded merge settings: $mergeSettings")
} catch (e: Exception) {
Log.e(TAG, "Failed to load merge settings: ${e.message}")
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

@@ -82,7 +82,7 @@ fun TrainDetailDialog(
DetailItem("机车类型", recordMap["loco_type"] ?: "--") DetailItem("机车类型", recordMap["loco_type"] ?: "--")
DetailItem("列车类型", recordMap["lbj_class"] ?: "--") DetailItem("列车类型", recordMap["lbj_class"] ?: "--")
Divider(modifier = Modifier.padding(vertical = 8.dp)) HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
DetailItem("路线", recordMap["route"] ?: "--") DetailItem("路线", recordMap["route"] ?: "--")

View File

@@ -1,141 +0,0 @@
package org.noxylva.lbjconsole.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.material3.HorizontalDivider
import org.noxylva.lbjconsole.model.TrainRecord
@Composable
fun TrainInfoCard(
trainRecord: TrainRecord,
modifier: Modifier = Modifier
) {
val recordMap = trainRecord.toMap()
Card(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 6.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = recordMap["train"]?.toString() ?: "",
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
Spacer(modifier = Modifier.width(4.dp))
val directionStr = recordMap["direction"]?.toString() ?: ""
val directionColor = when(directionStr) {
"上行" -> MaterialTheme.colorScheme.primary
"下行" -> MaterialTheme.colorScheme.secondary
else -> MaterialTheme.colorScheme.onSurface
}
Surface(
shape = RoundedCornerShape(4.dp),
color = directionColor.copy(alpha = 0.1f),
modifier = Modifier.padding(horizontal = 2.dp)
) {
Text(
text = directionStr,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
fontSize = 12.sp,
color = directionColor
)
}
}
Text(
text = recordMap["timestamp"]?.toString()?.split(" ")?.getOrNull(1) ?: "",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "速度: ${recordMap["speed"] ?: ""}",
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
Text(
text = "位置: ${recordMap["position"] ?: ""}",
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.height(4.dp))
HorizontalDivider(thickness = 0.5.dp)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
CompactInfoItem(label = "机车号", value = recordMap["loco"]?.toString() ?: "")
CompactInfoItem(label = "线路", value = recordMap["route"]?.toString() ?: "")
}
Column(modifier = Modifier.weight(1f)) {
CompactInfoItem(label = "类型", value = recordMap["lbj_class"]?.toString() ?: "")
CompactInfoItem(label = "信号", value = recordMap["rssi"]?.toString() ?: "")
}
}
}
}
}
@Composable
private fun CompactInfoItem(
label: String,
value: String,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
) {
Text(
text = "$label: ",
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
}

View File

@@ -1,338 +0,0 @@
package org.noxylva.lbjconsole.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.noxylva.lbjconsole.model.TrainRecord
import java.text.SimpleDateFormat
import java.util.*
@Composable
fun TrainRecordsList(
records: List<TrainRecord>,
onRecordClick: (TrainRecord) -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (records.isEmpty()) {
Text(
text = "暂无历史记录",
modifier = Modifier.padding(16.dp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 4.dp, horizontal = 8.dp)
) {
items(records) { record ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
.clickable { onRecordClick(record) },
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = record.train,
fontWeight = FontWeight.Bold,
fontSize = 15.sp
)
Spacer(modifier = Modifier.width(4.dp))
val directionText = when (record.direction) {
1 -> "下行"
3 -> "上行"
else -> "未知"
}
val directionColor = when(record.direction) {
1 -> MaterialTheme.colorScheme.secondary
3 -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurface
}
Surface(
color = directionColor.copy(alpha = 0.1f),
shape = MaterialTheme.shapes.small
) {
Text(
text = directionText,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
fontSize = 11.sp,
color = directionColor
)
}
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "位置: ${record.position} km",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Column(
horizontalAlignment = Alignment.End
) {
Text(
text = "${record.speed} km/h",
fontWeight = FontWeight.Medium,
fontSize = 14.sp
)
Spacer(modifier = Modifier.height(2.dp))
val timeStr = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(record.timestamp)
Text(
text = timeStr,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
}
}
@Composable
fun TrainRecordsListWithToolbar(
records: List<TrainRecord>,
onRecordClick: (TrainRecord) -> Unit,
onFilterClick: () -> Unit,
onExportClick: () -> Unit,
onClearClick: () -> Unit,
onDeleteRecords: (List<TrainRecord>) -> Unit,
modifier: Modifier = Modifier
) {
var selectedRecords by remember { mutableStateOf<MutableSet<TrainRecord>>(mutableSetOf()) }
var selectionMode by remember { mutableStateOf(false) }
Column(modifier = modifier.fillMaxSize()) {
@OptIn(ExperimentalMaterial3Api::class)
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 3.dp,
shadowElevation = 3.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (selectionMode) "已选择 ${selectedRecords.size}" else "历史记录 (${records.size})",
style = MaterialTheme.typography.titleMedium
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (selectionMode) {
TextButton(
onClick = {
if (selectedRecords.isNotEmpty()) {
onDeleteRecords(selectedRecords.toList())
}
selectionMode = false
selectedRecords = mutableSetOf()
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("删除")
}
TextButton(onClick = {
selectionMode = false
selectedRecords = mutableSetOf()
}) {
Text("取消")
}
} else {
IconButton(onClick = onFilterClick) {
Icon(
imageVector = Icons.Default.FilterList,
contentDescription = "筛选"
)
}
IconButton(onClick = onExportClick) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "导出"
)
}
}
}
}
}
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(vertical = 4.dp, horizontal = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(records.chunked(2)) { rowRecords ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
rowRecords.forEach { record ->
val isSelected = selectedRecords.contains(record)
Card(
modifier = Modifier
.weight(1f)
.clickable {
if (selectionMode) {
if (isSelected) {
selectedRecords.remove(record)
} else {
selectedRecords.add(record)
}
if (selectedRecords.isEmpty()) {
selectionMode = false
}
} else {
onRecordClick(record)
}
}
.padding(vertical = 2.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
if (selectionMode) {
Checkbox(
checked = isSelected,
onCheckedChange = { checked ->
if (checked) {
selectedRecords.add(record)
} else {
selectedRecords.remove(record)
}
if (selectedRecords.isEmpty()) {
selectionMode = false
}
},
modifier = Modifier.padding(end = 8.dp)
)
}
Text(
text = record.train,
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
modifier = Modifier.weight(1f)
)
if (!selectionMode) {
IconButton(
onClick = {
selectionMode = true
selectedRecords = mutableSetOf(record)
},
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = "删除",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
}
if (record.speed.isNotEmpty() || record.position.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
if (record.speed.isNotEmpty()) {
Text(
text = "${record.speed} km/h",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (record.position.isNotEmpty()) {
Text(
text = "${record.position} km",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
val timeStr = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(record.timestamp)
Text(
text = timeStr,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
}
}
}

View File

@@ -33,6 +33,9 @@ import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.* import org.osmdroid.views.overlay.*
import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider
import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
import org.osmdroid.events.MapListener
import org.osmdroid.events.ScrollEvent
import org.osmdroid.events.ZoomEvent
import org.noxylva.lbjconsole.model.TrainRecord import org.noxylva.lbjconsole.model.TrainRecord
import java.io.File import java.io.File
@@ -41,7 +44,11 @@ import java.io.File
fun MapScreen( fun MapScreen(
records: List<TrainRecord>, records: List<TrainRecord>,
onCenterMap: () -> Unit = {}, onCenterMap: () -> Unit = {},
onLocationError: (String) -> Unit = {} onLocationError: (String) -> Unit = {},
centerPosition: Pair<Double, Double>? = null,
zoomLevel: Double = 10.0,
railwayLayerVisible: Boolean = true,
onStateChange: (Pair<Double, Double>?, Double, Boolean) -> Unit = { _, _, _ -> }
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
@@ -90,7 +97,50 @@ fun MapScreen(
var selectedRecord by remember { mutableStateOf<TrainRecord?>(null) } var selectedRecord by remember { mutableStateOf<TrainRecord?>(null) }
var dialogPosition by remember { mutableStateOf<GeoPoint?>(null) } var dialogPosition by remember { mutableStateOf<GeoPoint?>(null) }
var railwayLayerVisible by remember { mutableStateOf(true) } var railwayLayerVisibleState by remember(railwayLayerVisible) { mutableStateOf(railwayLayerVisible) }
fun updateMarkers() {
val mapView = mapViewRef.value ?: return
mapView.overlays.removeAll { it is Marker }
validRecords.forEach { record ->
record.getCoordinates()?.let { point ->
val marker = Marker(mapView).apply {
position = point
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
val recordMap = record.toMap()
title = recordMap["train"]?.toString() ?: "列车"
val latStr = String.format("%.4f", point.latitude)
val lonStr = String.format("%.4f", point.longitude)
val coordStr = "${latStr}°N, ${lonStr}°E"
snippet = coordStr
setInfoWindowAnchor(Marker.ANCHOR_CENTER, 0f)
setOnMarkerClickListener { clickedMarker, _ ->
selectedRecord = record
dialogPosition = point
showDetailDialog = true
true
}
}
mapView.overlays.add(marker)
marker.showInfoWindow()
}
}
mapView.invalidate()
}
LaunchedEffect(records) {
if (isMapInitialized) {
updateMarkers()
}
}
DisposableEffect(lifecycleOwner) { DisposableEffect(lifecycleOwner) {
@@ -128,50 +178,7 @@ fun MapScreen(
} }
} }
fun updateMarkers() {
val mapView = mapViewRef.value ?: return
mapView.overlays.removeAll { it is Marker }
validRecords.forEach { record ->
record.getCoordinates()?.let { point ->
val marker = Marker(mapView).apply {
position = point
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
val recordMap = record.toMap()
title = recordMap["train"]?.toString() ?: "列车"
val latStr = String.format("%.4f", point.latitude)
val lonStr = String.format("%.4f", point.longitude)
val coordStr = "${latStr}°N, ${lonStr}°E"
snippet = coordStr
setInfoWindowAnchor(Marker.ANCHOR_CENTER, 0f)
setOnMarkerClickListener { clickedMarker, _ ->
selectedRecord = record
dialogPosition = point
showDetailDialog = true
true
}
}
mapView.overlays.add(marker)
marker.showInfoWindow()
}
}
mapView.invalidate()
}
fun updateRailwayLayerVisibility(visible: Boolean) { fun updateRailwayLayerVisibility(visible: Boolean) {
@@ -277,14 +284,21 @@ fun MapScreen(
} }
if (validRecords.isNotEmpty()) { centerPosition?.let { (lat, lon) ->
validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint -> controller.setCenter(GeoPoint(lat, lon))
controller.setCenter(lastPoint) controller.setZoom(zoomLevel)
controller.setZoom(12.0) isMapInitialized = true
Log.d("MapScreen", "Map initialized with saved state: lat=$lat, lon=$lon, zoom=$zoomLevel")
} ?: run {
if (validRecords.isNotEmpty()) {
validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint ->
controller.setCenter(lastPoint)
controller.setZoom(12.0)
}
} else {
controller.setCenter(defaultPosition)
controller.setZoom(10.0)
} }
} else {
controller.setCenter(defaultPosition)
controller.setZoom(10.0)
} }
@@ -292,7 +306,7 @@ fun MapScreen(
val locationProvider = GpsMyLocationProvider(ctx).apply { val locationProvider = GpsMyLocationProvider(ctx).apply {
locationUpdateMinDistance = 10f locationUpdateMinDistance = 10f
locationUpdateMinTime = 1000 locationUpdateMinTime = 5000
} }
@@ -304,30 +318,30 @@ fun MapScreen(
myLocation?.let { location -> myLocation?.let { location ->
currentLocation = GeoPoint(location.latitude, location.longitude) currentLocation = GeoPoint(location.latitude, location.longitude)
if (!isMapInitialized) { if (!isMapInitialized && centerPosition == null) {
controller.setCenter(location) controller.setCenter(location)
controller.setZoom(15.0) controller.setZoom(15.0)
isMapInitialized = true isMapInitialized = true
Log.d("MapScreen", "Map initialized with GPS position: $location") Log.d("MapScreen", "Map initialized with GPS position: $location")
} }
} ?: run { } ?: run {
if (!isMapInitialized) { if (!isMapInitialized && centerPosition == null) {
if (validRecords.isNotEmpty()) { if (validRecords.isNotEmpty()) {
validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint -> validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint ->
controller.setCenter(lastPoint) controller.setCenter(lastPoint)
controller.setZoom(12.0) controller.setZoom(12.0)
isMapInitialized = true isMapInitialized = true
Log.d("MapScreen", "Map initialized with last record position: $lastPoint") Log.d("MapScreen", "Map initialized with last record position: $lastPoint")
} }
} else { } else {
controller.setCenter(defaultPosition) controller.setCenter(defaultPosition)
isMapInitialized = true isMapInitialized = true
} }
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
if (!isMapInitialized) { if (!isMapInitialized && centerPosition == null) {
if (validRecords.isNotEmpty()) { if (validRecords.isNotEmpty()) {
validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint -> validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint ->
controller.setCenter(lastPoint) controller.setCenter(lastPoint)
@@ -357,6 +371,31 @@ fun MapScreen(
setAlignBottom(true) setAlignBottom(true)
setLineWidth(2.0f) setLineWidth(2.0f)
}.also { overlays.add(it) } }.also { overlays.add(it) }
addMapListener(object : MapListener {
override fun onScroll(event: ScrollEvent?): Boolean {
val center = mapCenter
val zoom = zoomLevelDouble
onStateChange(
center.latitude to center.longitude,
zoom,
railwayLayerVisibleState
)
return true
}
override fun onZoom(event: ZoomEvent?): Boolean {
val center = mapCenter
val zoom = zoomLevelDouble
onStateChange(
center.latitude to center.longitude,
zoom,
railwayLayerVisibleState
)
return true
}
})
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
onLocationError("Map component initialization failed: ${e.localizedMessage}") onLocationError("Map component initialization failed: ${e.localizedMessage}")
@@ -381,7 +420,7 @@ fun MapScreen(
coroutineScope.launch { coroutineScope.launch {
updateMarkers() updateMarkers()
updateRailwayLayerVisibility(railwayLayerVisible) updateRailwayLayerVisibility(railwayLayerVisibleState)
} }
} }
) )
@@ -430,15 +469,26 @@ fun MapScreen(
FloatingActionButton( FloatingActionButton(
onClick = { onClick = {
railwayLayerVisible = !railwayLayerVisible railwayLayerVisibleState = !railwayLayerVisibleState
updateRailwayLayerVisibility(railwayLayerVisible) updateRailwayLayerVisibility(railwayLayerVisibleState)
mapViewRef.value?.let { mapView ->
val center = mapView.mapCenter
val zoom = mapView.zoomLevelDouble
onStateChange(
center.latitude to center.longitude,
zoom,
railwayLayerVisibleState
)
}
}, },
modifier = Modifier.size(40.dp), modifier = Modifier.size(40.dp),
containerColor = if (railwayLayerVisible) containerColor = if (railwayLayerVisibleState)
MaterialTheme.colorScheme.primary.copy(alpha = 0.9f) MaterialTheme.colorScheme.primary.copy(alpha = 0.9f)
else else
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f), MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f),
contentColor = if (railwayLayerVisible) contentColor = if (railwayLayerVisibleState)
MaterialTheme.colorScheme.onPrimary MaterialTheme.colorScheme.onPrimary
else else
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer

View File

@@ -1,13 +1,18 @@
package org.noxylva.lbjconsole.ui.screens package org.noxylva.lbjconsole.ui.screens
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -28,6 +33,20 @@ fun MonitorScreen(
) { ) {
var showDetailDialog by remember { mutableStateOf(false) } var showDetailDialog by remember { mutableStateOf(false) }
var selectedRecord by remember { mutableStateOf<TrainRecord?>(null) } var selectedRecord by remember { mutableStateOf<TrainRecord?>(null) }
var isPressed by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.98f else 1f,
animationSpec = tween(durationMillis = 120),
label = "content_scale"
)
LaunchedEffect(isPressed) {
if (isPressed) {
delay(100)
isPressed = false
}
}
val timeSinceLastUpdate = remember { mutableStateOf<String?>(null) } val timeSinceLastUpdate = remember { mutableStateOf<String?>(null) }
@@ -41,7 +60,8 @@ fun MonitorScreen(
diffInSec < 3600 -> "${diffInSec / 60}分钟前" diffInSec < 3600 -> "${diffInSec / 60}分钟前"
else -> "${diffInSec / 3600}小时前" else -> "${diffInSec / 3600}小时前"
} }
delay(1000) val updateInterval = if (diffInSec < 60) 500L else if (diffInSec < 3600) 30000L else 300000L
delay(updateInterval)
} }
} }
} }
@@ -75,20 +95,57 @@ fun MonitorScreen(
.fillMaxWidth() .fillMaxWidth()
.weight(1f) .weight(1f)
) { ) {
if (latestRecord != null) { AnimatedContent(
targetState = latestRecord,
transitionSpec = {
fadeIn(
animationSpec = tween(
durationMillis = 300,
easing = FastOutSlowInEasing
)
) + slideInVertically(
initialOffsetY = { it / 4 },
animationSpec = tween(
durationMillis = 300,
easing = FastOutSlowInEasing
)
) togetherWith fadeOut(
animationSpec = tween(
durationMillis = 150,
easing = FastOutLinearInEasing
)
) + slideOutVertically(
targetOffsetY = { -it / 4 },
animationSpec = tween(
durationMillis = 150,
easing = FastOutLinearInEasing
)
)
},
label = "content_animation"
) { record ->
if (record != null) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(8.dp) .clickable(
.clickable { interactionSource = remember { MutableInteractionSource() },
selectedRecord = latestRecord indication = rememberRipple(bounded = true)
) {
isPressed = true
selectedRecord = record
showDetailDialog = true showDetailDialog = true
onRecordClick(latestRecord) onRecordClick(record)
}
.padding(8.dp)
.graphicsLayer {
scaleX = scale
scaleY = scale
} }
) { ) {
val recordMap = latestRecord.toMap() val recordMap = record.toMap()
Row( Row(
@@ -208,6 +265,7 @@ fun MonitorScreen(
} }
} }
} }
}
} }
} }
@@ -247,4 +305,4 @@ private fun InfoItem(
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
} }
} }

View File

@@ -2,56 +2,440 @@ package org.noxylva.lbjconsole.ui.screens
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
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.BackgroundService
import org.noxylva.lbjconsole.NotificationService
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.DisposableEffect
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
deviceName: String, deviceName: String,
onDeviceNameChange: (String) -> Unit, onDeviceNameChange: (String) -> Unit,
onApplySettings: () -> Unit onApplySettings: () -> Unit,
appVersion: String = "Unknown",
mergeSettings: MergeSettings,
onMergeSettingsChange: (MergeSettings) -> Unit,
scrollPosition: Int = 0,
onScrollPositionChange: (Int) -> Unit = {},
specifiedDeviceAddress: String? = null,
searchOrderList: List<String> = emptyList(),
onSpecifiedDeviceSelected: (String?) -> Unit = {},
autoConnectEnabled: Boolean = true,
onAutoConnectEnabledChange: (Boolean) -> Unit = {}
) { ) {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val scrollState = rememberScrollState(initial = scrollPosition)
DisposableEffect(Unit) {
onDispose {
onScrollPositionChange(scrollState.value)
}
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp), .verticalScroll(scrollState)
horizontalAlignment = Alignment.CenterHorizontally .padding(20.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) { ) {
Column( Card(
horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp) colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
),
shape = RoundedCornerShape(16.dp)
) { ) {
OutlinedTextField( Column(
value = deviceName, modifier = Modifier.padding(20.dp),
onValueChange = onDeviceNameChange, verticalArrangement = Arrangement.spacedBy(16.dp)
label = { Text("蓝牙设备名称") },
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = onApplySettings,
modifier = Modifier.fillMaxWidth()
) { ) {
Text("应用设置") Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Default.Bluetooth,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
"蓝牙设备",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
OutlinedTextField(
value = deviceName,
onValueChange = onDeviceNameChange,
label = { Text("设备名称") },
leadingIcon = {
Icon(
imageVector = Icons.Default.DeviceHub,
contentDescription = null
)
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
if (searchOrderList.isNotEmpty()) {
var deviceAddressExpanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = deviceAddressExpanded,
onExpandedChange = { deviceAddressExpanded = !deviceAddressExpanded }
) {
OutlinedTextField(
value = specifiedDeviceAddress ?: "",
onValueChange = {},
readOnly = true,
label = { Text("指定设备地址") },
leadingIcon = {
Icon(
imageVector = Icons.Default.LocationOn,
contentDescription = null
)
},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = deviceAddressExpanded)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
shape = RoundedCornerShape(12.dp)
)
ExposedDropdownMenu(
expanded = deviceAddressExpanded,
onDismissRequest = { deviceAddressExpanded = false }
) {
DropdownMenuItem(
text = { Text("") },
onClick = {
onSpecifiedDeviceSelected(null)
deviceAddressExpanded = false
}
)
searchOrderList.forEach { address ->
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(address)
if (address == specifiedDeviceAddress) {
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = Icons.Default.Check,
contentDescription = "已指定",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
}
}
},
onClick = {
onSpecifiedDeviceSelected(address)
deviceAddressExpanded = false
}
)
}
}
}
}
} }
} }
Spacer(modifier = Modifier.weight(1f)) Card(
modifier = Modifier.fillMaxWidth(),
Text( colors = CardDefaults.cardColors(
text = "LBJ Console v0.0.1 by undef-i", containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
style = MaterialTheme.typography.bodySmall, ),
color = MaterialTheme.colorScheme.onSurfaceVariant, shape = RoundedCornerShape(16.dp)
modifier = Modifier.clickable { ) {
uriHandler.openUri("https://github.com/undef-i") Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
"应用设置",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
val context = LocalContext.current
val notificationService = remember(context) { NotificationService(context) }
var backgroundServiceEnabled by remember { mutableStateOf<Boolean?>(null) }
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(context) {
backgroundServiceEnabled = SettingsActivity.isBackgroundServiceEnabled(context)
}
var notificationEnabled by remember(context, notificationService) {
mutableStateOf(notificationService.isNotificationEnabled())
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"后台保活服务",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
"保持应用在后台运行",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (backgroundServiceEnabled == null) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else {
Switch(
checked = backgroundServiceEnabled!!,
onCheckedChange = { enabled ->
backgroundServiceEnabled = enabled
coroutineScope.launch {
SettingsActivity.setBackgroundServiceEnabled(context, enabled)
if (enabled) {
BackgroundService.startService(context)
} else {
BackgroundService.stopService(context)
}
}
}
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"LBJ消息通知",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
"实时接收列车LBJ消息通知",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = notificationEnabled,
onCheckedChange = { enabled ->
notificationEnabled = enabled
notificationService.setNotificationEnabled(enabled)
}
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"自动连接",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
"自动连接蓝牙设备",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = autoConnectEnabled,
onCheckedChange = onAutoConnectEnabledChange
)
}
} }
) }
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.MergeType,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
"记录合并",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"启用记录合并",
style = MaterialTheme.typography.bodyMedium
)
Switch(
checked = mergeSettings.enabled,
onCheckedChange = { enabled ->
onMergeSettingsChange(mergeSettings.copy(enabled = enabled))
}
)
}
if (mergeSettings.enabled) {
var groupByExpanded by remember { mutableStateOf(false) }
var timeWindowExpanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = groupByExpanded,
onExpandedChange = { groupByExpanded = !groupByExpanded }
) {
OutlinedTextField(
value = mergeSettings.groupBy.displayName,
onValueChange = {},
readOnly = true,
label = { Text("分组方式") },
leadingIcon = {
Icon(
imageVector = Icons.Default.Group,
contentDescription = null
)
},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = groupByExpanded)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
shape = RoundedCornerShape(12.dp)
)
ExposedDropdownMenu(
expanded = groupByExpanded,
onDismissRequest = { groupByExpanded = false }
) {
GroupBy.values().forEach { groupBy ->
DropdownMenuItem(
text = { Text(groupBy.displayName) },
onClick = {
onMergeSettingsChange(mergeSettings.copy(groupBy = groupBy))
groupByExpanded = false
}
)
}
}
}
ExposedDropdownMenuBox(
expanded = timeWindowExpanded,
onExpandedChange = { timeWindowExpanded = !timeWindowExpanded }
) {
OutlinedTextField(
value = mergeSettings.timeWindow.displayName,
onValueChange = {},
readOnly = true,
label = { Text("时间窗口") },
leadingIcon = {
Icon(
imageVector = Icons.Default.Schedule,
contentDescription = null
)
},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = timeWindowExpanded)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
shape = RoundedCornerShape(12.dp)
)
ExposedDropdownMenu(
expanded = timeWindowExpanded,
onDismissRequest = { timeWindowExpanded = false }
) {
TimeWindow.values().forEach { timeWindow ->
DropdownMenuItem(
text = { Text(timeWindow.displayName) },
onClick = {
onMergeSettingsChange(mergeSettings.copy(timeWindow = timeWindow))
timeWindowExpanded = false
}
)
}
}
}
}
}
}
Text(
text = "LBJ Console v$appVersion by undef-i",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable {
uriHandler.openUri("https://github.com/undef-i/LBJ_Console")
}
.padding(12.dp)
)
} }
} }

View File

@@ -24,7 +24,7 @@ private val LightColorScheme = lightColorScheme(
) )
@Composable @Composable
fun LBJReceiverTheme( fun LBJConsoleTheme(
darkTheme: Boolean = true, darkTheme: Boolean = true,
dynamicColor: Boolean = true, dynamicColor: Boolean = true,

View File

@@ -52,7 +52,7 @@ object LocationUtils {
val minuteEndIndex = dmsString.indexOf('') val minuteEndIndex = dmsString.indexOf('')
if (minuteEndIndex == -1) { if (minuteEndIndex == -1) {
return degrees return null
} }
val minutes = dmsString.substring(degreeIndex + 1, minuteEndIndex).toDouble() val minutes = dmsString.substring(degreeIndex + 1, minuteEndIndex).toDouble()

View File

@@ -0,0 +1,66 @@
package org.noxylva.lbjconsole.util
import android.content.Context
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.regex.Pattern
class TrainTypeUtil(private val context: Context) {
private val trainTypePatterns = mutableListOf<Pair<Pattern, String>>()
init {
loadTrainTypePatterns()
}
private fun loadTrainTypePatterns() {
try {
val inputStream = context.assets.open("train_number_info.csv")
val reader = BufferedReader(InputStreamReader(inputStream))
reader.useLines { lines ->
lines.forEach { line ->
if (line.isNotBlank()) {
val firstQuoteEnd = line.indexOf('"', 1)
if (firstQuoteEnd > 0 && firstQuoteEnd < line.length - 1) {
val regex = line.substring(1, firstQuoteEnd)
val remainingPart = line.substring(firstQuoteEnd + 1).trim()
if (remainingPart.startsWith(",\"") && remainingPart.endsWith("\"")) {
val type = remainingPart.substring(2, remainingPart.length - 1)
try {
val pattern = Pattern.compile(regex)
trainTypePatterns.add(Pair(pattern, type))
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
fun getTrainType(locoType: String, train: String): String? {
if (train.isEmpty()) {
return null
}
val actualTrain = if (locoType == "NA") {
train
} else {
locoType + train
}
for ((pattern, type) in trainTypePatterns) {
if (pattern.matcher(actualTrain).matches()) {
return type
}
}
return null
}
}

0
app/src/main/res/drawable/ic_launcher_background.xml Normal file → Executable file
View File

0
app/src/main/res/drawable/ic_launcher_foreground.xml Normal file → Executable file
View File

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@android:color/white">
<path
android:fillColor="@android:color/white"
android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09C6.04,10.33 6,10.66 6,11v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z"/>
</vector>

0
app/src/main/res/drawable/ic_person.xml Normal file → Executable file
View File

View File

@@ -0,0 +1,55 @@
<?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>

View File

@@ -0,0 +1,99 @@
<?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="wrap_content"
android:orientation="vertical"
android:padding="4dp"
android:background="@android:color/transparent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="4dp"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/notification_train_number"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="G1234"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="?android:attr/textColorPrimary"
android:layout_marginEnd="4dp" />
<TextView
android:id="@+id/notification_direction"
android:layout_width="16dp"
android:layout_height="16dp"
android:text="下"
android:textSize="10sp"
android:textStyle="bold"
android:textColor="@android:color/white"
android:background="@android:color/black"
android:gravity="center"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/notification_loco_info"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="CRH380D-1234"
android:textSize="12sp"
android:textColor="?android:attr/textColorPrimary" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/notification_route"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="京沪高铁"
android:textSize="14sp"
android:textColor="?android:attr/textColorPrimary"
android:layout_marginEnd="4dp" />
<TextView
android:id="@+id/notification_position"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="1234K"
android:textSize="14sp"
android:textColor="?android:attr/textColorPrimary" />
</LinearLayout>
<TextView
android:id="@+id/notification_speed"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="300 km/h"
android:textSize="14sp"
android:textColor="?android:attr/textColorPrimary" />
</LinearLayout>
</LinearLayout>

0
app/src/main/res/mipmap-anydpi/ic_launcher.xml Normal file → Executable file
View File

0
app/src/main/res/mipmap-anydpi/ic_launcher_round.xml Normal file → Executable file
View File

0
app/src/main/res/mipmap-hdpi/ic_launcher.webp Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

0
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

0
app/src/main/res/mipmap-mdpi/ic_launcher.webp Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 982 B

0
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

0
app/src/main/res/mipmap-xhdpi/ic_launcher.webp Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

0
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

0
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

0
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

0
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

0
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

0
app/src/main/res/raw/loco_info.csv Normal file → Executable file
View File

0
app/src/main/res/values/colors.xml Normal file → Executable file
View File

0
app/src/main/res/values/strings.xml Normal file → Executable file
View File

2
app/src/main/res/values/themes.xml Normal file → Executable file
View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.LBJReceiver" parent="android:Theme.Material.Light.NoActionBar" /> <style name="Theme.LBJConsole" parent="android:Theme.Material.Light.NoActionBar" />
</resources> </resources>

0
app/src/main/res/xml/backup_rules.xml Normal file → Executable file
View File

0
app/src/main/res/xml/data_extraction_rules.xml Normal file → Executable file
View File

0
app/src/main/res/xml/file_paths.xml Normal file → Executable file
View File

View File

@@ -20,4 +20,4 @@ kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the # Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies, # resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library # thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true android.nonTransitiveRClass=true

View File

@@ -8,6 +8,8 @@ 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"
startup = "1.1.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 +26,14 @@ 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" }
androidx-startup-runtime = { group = "androidx.startup", name = "startup-runtime", version.ref = "startup" }
[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" }

0
gradlew vendored Normal file → Executable file
View File

View File

@@ -19,6 +19,6 @@ dependencyResolutionManagement {
} }
} }
rootProject.name = "LBJ Receiver" rootProject.name = "LBJ_Console"
include(":app") include(":app")