feat: add BLE disconnection cleanup and enhance record management

This commit is contained in:
Nedifinita
2025-07-22 17:27:38 +08:00
parent d64138cea5
commit 799410eeb2
10 changed files with 161 additions and 83 deletions

View File

@@ -2,10 +2,6 @@
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 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.
## Roadmap
- Record filtering (train number, time range)
- Record management page optimization
- Optional train merge by locomotive/number
# License # License

View File

@@ -12,8 +12,8 @@ android {
applicationId = "org.noxylva.lbjconsole" applicationId = "org.noxylva.lbjconsole"
minSdk = 29 minSdk = 29
targetSdk = 35 targetSdk = 35
versionCode = 5 versionCode = 6
versionName = "0.0.5" versionName = "0.0.6"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -633,4 +633,23 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
fun getConnectionAttempts(): Int = connectionAttempts fun getConnectionAttempts(): Int = connectionAttempts
fun getLastKnownDeviceAddress(): String? = lastKnownDeviceAddress fun getLastKnownDeviceAddress(): String? = lastKnownDeviceAddress
@SuppressLint("MissingPermission")
fun disconnectAndCleanup() {
isConnected = false
autoReconnect = false
bluetoothGatt?.let { gatt ->
try {
gatt.disconnect()
gatt.close()
Log.d(TAG, "GATT connection cleaned up")
} catch (e: Exception) {
Log.e(TAG, "Cleanup error: ${e.message}")
}
}
bluetoothGatt = null
deviceAddress = null
connectionAttempts = 0
Log.d(TAG, "BLE client fully disconnected and cleaned up")
}
} }

View File

@@ -274,6 +274,8 @@ class MainActivity : ComponentActivity() {
onMergeSettingsChange = { newSettings -> onMergeSettingsChange = { newSettings ->
mergeSettings = newSettings mergeSettings = newSettings
trainRecordManager.updateMergeSettings(newSettings) trainRecordManager.updateMergeSettings(newSettings)
historyEditMode = false
historySelectedRecords = emptySet()
saveSettings() saveSettings()
}, },
@@ -334,12 +336,6 @@ class MainActivity : ComponentActivity() {
scope.launch { scope.launch {
val deletedCount = trainRecordManager.deleteRecords(records) val deletedCount = trainRecordManager.deleteRecords(records)
if (deletedCount > 0) { if (deletedCount > 0) {
Toast.makeText(
this@MainActivity,
"已删除 $deletedCount 条记录",
Toast.LENGTH_SHORT
).show()
if (records.contains(latestRecord)) { if (records.contains(latestRecord)) {
latestRecord = null latestRecord = null
} }
@@ -659,6 +655,10 @@ class MainActivity : ComponentActivity() {
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
saveSettings() saveSettings()
if (isFinishing) {
bleClient.disconnectAndCleanup()
Log.d(TAG, "App finishing, BLE cleaned up")
}
Log.d(TAG, "App paused, settings saved") Log.d(TAG, "App paused, settings saved")
} }
} }
@@ -783,8 +783,24 @@ fun MainContent(
if (historyEditMode && currentTab == 0) { if (historyEditMode && currentTab == 0) {
TopAppBar( TopAppBar(
title = { title = {
val totalSelectedCount = historySelectedRecords.sumOf { selectedId ->
allRecords.find { item ->
when (item) {
is TrainRecord -> item.uniqueId == selectedId
is org.noxylva.lbjconsole.model.MergedTrainRecord ->
item.records.any { it.uniqueId == selectedId }
else -> false
}
}?.let { item ->
when (item) {
is TrainRecord -> 1
is org.noxylva.lbjconsole.model.MergedTrainRecord -> item.records.size
else -> 0
}
} ?: 0
}
Text( Text(
"已选择 ${historySelectedRecords.size} 条记录", "已选择 $totalSelectedCount 条记录",
color = MaterialTheme.colorScheme.onPrimary color = MaterialTheme.colorScheme.onPrimary
) )
}, },
@@ -803,24 +819,39 @@ fun MainContent(
IconButton( IconButton(
onClick = { onClick = {
if (historySelectedRecords.isNotEmpty()) { if (historySelectedRecords.isNotEmpty()) {
val recordsToDelete = mutableListOf<TrainRecord>() val recordsToDelete = mutableSetOf<TrainRecord>()
val idToRecordMap = mutableMapOf<String, TrainRecord>()
val idToMergedRecordMap = mutableMapOf<String, org.noxylva.lbjconsole.model.MergedTrainRecord>()
allRecords.forEach { item -> allRecords.forEach { item ->
when (item) { when (item) {
is TrainRecord -> { is TrainRecord -> {
if (historySelectedRecords.contains(item.timestamp.time.toString())) { idToRecordMap[item.uniqueId] = item
recordsToDelete.add(item)
}
} }
is org.noxylva.lbjconsole.model.MergedTrainRecord -> { is org.noxylva.lbjconsole.model.MergedTrainRecord -> {
item.records.forEach { record -> item.records.forEach { record ->
if (historySelectedRecords.contains(record.timestamp.time.toString())) { idToRecordMap[record.uniqueId] = record
recordsToDelete.add(record) idToMergedRecordMap[record.uniqueId] = item
}
} }
} }
} }
} }
onDeleteRecords(recordsToDelete)
val processedMergedRecords = mutableSetOf<org.noxylva.lbjconsole.model.MergedTrainRecord>()
historySelectedRecords.forEach { selectedId ->
val mergedRecord = idToMergedRecordMap[selectedId]
if (mergedRecord != null && !processedMergedRecords.contains(mergedRecord)) {
recordsToDelete.addAll(mergedRecord.records)
processedMergedRecords.add(mergedRecord)
} else if (mergedRecord == null) {
idToRecordMap[selectedId]?.let { record ->
recordsToDelete.add(record)
}
}
}
onDeleteRecords(recordsToDelete.toList())
onHistoryStateChange(false, emptySet(), historyExpandedStates, historyScrollPosition, historyScrollOffset) onHistoryStateChange(false, emptySet(), historyExpandedStates, historyScrollPosition, historyScrollOffset)
} }
} }
@@ -902,7 +933,16 @@ fun MainContent(
) )
3 -> MapScreen( 3 -> MapScreen(
records = if (allRecords.isNotEmpty()) { records = if (allRecords.isNotEmpty()) {
allRecords.filterIsInstance<TrainRecord>() val trainRecords = mutableListOf<TrainRecord>()
allRecords.forEach { item ->
when (item) {
is TrainRecord -> trainRecords.add(item)
is org.noxylva.lbjconsole.model.MergedTrainRecord -> {
trainRecords.addAll(item.records)
}
}
}
trainRecords
} else { } else {
recentRecords recentRecords
}, },

View File

@@ -9,8 +9,15 @@ 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 receivedTimestamp: Date = Date()
var train: String = "" var train: String = ""
@@ -29,10 +36,15 @@ 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"))
} }
@@ -166,6 +178,7 @@ 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("receivedTimestamp", receivedTimestamp.time)
json.put("train", train) json.put("train", train)
@@ -181,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

@@ -207,11 +207,11 @@ class TrainRecordManager(private val context: Context) {
val mergedRecords = processRecordsForMerging(allRecords, mergeSettings) val mergedRecords = processRecordsForMerging(allRecords, mergeSettings)
val mergedRecordIds = mergedRecords.flatMap { merged -> val mergedRecordIds = mergedRecords.flatMap { merged ->
merged.records.map { it.timestamp.time.toString() } merged.records.map { it.uniqueId }
}.toSet() }.toSet()
val singleRecords = allRecords.filter { record -> val singleRecords = allRecords.filter { record ->
!mergedRecordIds.contains(record.timestamp.time.toString()) !mergedRecordIds.contains(record.uniqueId)
} }
val mixedList = mutableListOf<Any>() val mixedList = mutableListOf<Any>()

View File

@@ -74,7 +74,7 @@ fun TrainRecordItem(
if (isInEditMode) { if (isInEditMode) {
onToggleSelection(record) onToggleSelection(record)
} else { } else {
val id = record.timestamp.time.toString() val id = record.uniqueId
expandedStatesMap[id] = !(expandedStatesMap[id] ?: false) expandedStatesMap[id] = !(expandedStatesMap[id] ?: false)
if (record == latestRecord) { if (record == latestRecord) {
onRecordClick(record) onRecordClick(record)
@@ -91,7 +91,7 @@ fun TrainRecordItem(
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp) .padding(horizontal = 16.dp, vertical = 8.dp)
) { ) {
val recordId = record.timestamp.time.toString() val recordId = record.uniqueId
val isExpanded = expandedStatesMap[recordId] == true val isExpanded = expandedStatesMap[recordId] == true
val recordMap = record.toMap(showDetailedTime = true) val recordMap = record.toMap(showDetailedTime = true)
@@ -705,7 +705,7 @@ fun MergedTrainRecordItem(
HorizontalDivider() HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
mergedRecord.records.forEach { recordItem -> mergedRecord.records.filter { it != mergedRecord.latestRecord }.forEach { recordItem ->
val timeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) val timeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
Column( Column(
@@ -834,13 +834,13 @@ fun HistoryScreen(
records.forEach { item -> records.forEach { item ->
when (item) { when (item) {
is TrainRecord -> { is TrainRecord -> {
if (selectedRecords.contains(item.timestamp.time.toString())) { if (selectedRecords.contains(item.uniqueId)) {
add(item) add(item)
} }
} }
is MergedTrainRecord -> { is MergedTrainRecord -> {
item.records.forEach { record -> item.records.forEach { record ->
if (selectedRecords.contains(record.timestamp.time.toString())) { if (selectedRecords.contains(record.uniqueId)) {
add(record) add(record)
} }
} }
@@ -881,20 +881,21 @@ fun HistoryScreen(
LaunchedEffect(isInEditMode, selectedRecordsList.size) { LaunchedEffect(isInEditMode, selectedRecordsList.size) {
val selectedIds = selectedRecordsList.map { it.timestamp.time.toString() }.toSet() val selectedIds = selectedRecordsList.map { it.uniqueId }.toSet()
onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
} }
LaunchedEffect(expandedStatesMap.toMap()) { LaunchedEffect(expandedStatesMap.toMap()) {
if (!isInEditMode) { if (!isInEditMode) {
val selectedIds = selectedRecordsList.map { it.timestamp.time.toString() }.toSet() val selectedIds = selectedRecordsList.map { it.uniqueId }.toSet()
delay(50)
onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
} }
} }
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) { LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
if (!isInEditMode) { if (!isInEditMode) {
val selectedIds = selectedRecordsList.map { it.timestamp.time.toString() }.toSet() val selectedIds = selectedRecordsList.map { it.uniqueId }.toSet()
onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
} }
} }
@@ -911,7 +912,6 @@ fun HistoryScreen(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp)
.weight(1.0f) .weight(1.0f)
) { ) {
if (filteredRecords.isEmpty()) { if (filteredRecords.isEmpty()) {
@@ -945,7 +945,8 @@ fun HistoryScreen(
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp)
) { ) {
items(filteredRecords) { item -> items(filteredRecords) { item ->
when (item) { when (item) {

View File

@@ -99,6 +99,49 @@ fun MapScreen(
var railwayLayerVisibleState by remember(railwayLayerVisible) { mutableStateOf(railwayLayerVisible) } 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) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
@@ -136,49 +179,6 @@ 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) {

View File

@@ -58,7 +58,7 @@ fun MergedHistoryScreen(
val selectedRecordsList = remember(selectedRecords) { val selectedRecordsList = remember(selectedRecords) {
mutableStateListOf<TrainRecord>().apply { mutableStateListOf<TrainRecord>().apply {
addAll(mergedRecords.flatMap { it.records }.filter { addAll(mergedRecords.flatMap { it.records }.filter {
selectedRecords.contains(it.timestamp.time.toString()) selectedRecords.contains(it.uniqueId)
}) })
} }
} }
@@ -72,7 +72,7 @@ fun MergedHistoryScreen(
) )
LaunchedEffect(isInEditMode, selectedRecordsList.size) { LaunchedEffect(isInEditMode, selectedRecordsList.size) {
val selectedIds = selectedRecordsList.map { it.timestamp.time.toString() }.toSet() val selectedIds = selectedRecordsList.map { it.uniqueId }.toSet()
onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(),
listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
} }

View File

@@ -227,7 +227,6 @@ fun SettingsScreen(
} }
} }
Spacer(modifier = Modifier.height(20.dp))
Text( Text(
text = "LBJ Console v$appVersion by undef-i", text = "LBJ Console v$appVersion by undef-i",
@@ -240,7 +239,7 @@ fun SettingsScreen(
.clickable { .clickable {
uriHandler.openUri("https://github.com/undef-i") uriHandler.openUri("https://github.com/undef-i")
} }
.padding(16.dp) .padding(12.dp)
) )
} }
} }