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

View File

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

View File

@@ -633,4 +633,23 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
fun getConnectionAttempts(): Int = connectionAttempts
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 ->
mergeSettings = newSettings
trainRecordManager.updateMergeSettings(newSettings)
historyEditMode = false
historySelectedRecords = emptySet()
saveSettings()
},
@@ -334,12 +336,6 @@ class MainActivity : ComponentActivity() {
scope.launch {
val deletedCount = trainRecordManager.deleteRecords(records)
if (deletedCount > 0) {
Toast.makeText(
this@MainActivity,
"已删除 $deletedCount 条记录",
Toast.LENGTH_SHORT
).show()
if (records.contains(latestRecord)) {
latestRecord = null
}
@@ -659,6 +655,10 @@ class MainActivity : ComponentActivity() {
override fun onPause() {
super.onPause()
saveSettings()
if (isFinishing) {
bleClient.disconnectAndCleanup()
Log.d(TAG, "App finishing, BLE cleaned up")
}
Log.d(TAG, "App paused, settings saved")
}
}
@@ -783,8 +783,24 @@ fun MainContent(
if (historyEditMode && currentTab == 0) {
TopAppBar(
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(
"已选择 ${historySelectedRecords.size} 条记录",
"已选择 $totalSelectedCount 条记录",
color = MaterialTheme.colorScheme.onPrimary
)
},
@@ -803,24 +819,39 @@ fun MainContent(
IconButton(
onClick = {
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 ->
when (item) {
is TrainRecord -> {
if (historySelectedRecords.contains(item.timestamp.time.toString())) {
recordsToDelete.add(item)
}
idToRecordMap[item.uniqueId] = item
}
is org.noxylva.lbjconsole.model.MergedTrainRecord -> {
item.records.forEach { record ->
if (historySelectedRecords.contains(record.timestamp.time.toString())) {
recordsToDelete.add(record)
}
idToRecordMap[record.uniqueId] = 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)
}
}
@@ -902,7 +933,16 @@ fun MainContent(
)
3 -> MapScreen(
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 {
recentRecords
},

View File

@@ -9,8 +9,15 @@ import org.noxylva.lbjconsole.util.LocationUtils
class TrainRecord(jsonData: JSONObject? = null) {
companion object {
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 receivedTimestamp: Date = Date()
var train: String = ""
@@ -29,10 +36,15 @@ class TrainRecord(jsonData: JSONObject? = null) {
private var _coordinates: GeoPoint? = null
init {
uniqueId = if (jsonData?.has("uniqueId") == true) {
jsonData.getString("uniqueId")
} else {
generateUniqueId()
}
jsonData?.let {
try {
if (jsonData.has("timestamp")) {
timestamp = Date(jsonData.getLong("timestamp"))
}
@@ -166,6 +178,7 @@ class TrainRecord(jsonData: JSONObject? = null) {
fun toJSON(): JSONObject {
val json = JSONObject()
json.put("uniqueId", uniqueId)
json.put("timestamp", timestamp.time)
json.put("receivedTimestamp", receivedTimestamp.time)
json.put("train", train)
@@ -181,4 +194,14 @@ class TrainRecord(jsonData: JSONObject? = null) {
json.put("rssi", rssi)
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 mergedRecordIds = mergedRecords.flatMap { merged ->
merged.records.map { it.timestamp.time.toString() }
merged.records.map { it.uniqueId }
}.toSet()
val singleRecords = allRecords.filter { record ->
!mergedRecordIds.contains(record.timestamp.time.toString())
!mergedRecordIds.contains(record.uniqueId)
}
val mixedList = mutableListOf<Any>()

View File

@@ -74,7 +74,7 @@ fun TrainRecordItem(
if (isInEditMode) {
onToggleSelection(record)
} else {
val id = record.timestamp.time.toString()
val id = record.uniqueId
expandedStatesMap[id] = !(expandedStatesMap[id] ?: false)
if (record == latestRecord) {
onRecordClick(record)
@@ -91,7 +91,7 @@ fun TrainRecordItem(
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
val recordId = record.timestamp.time.toString()
val recordId = record.uniqueId
val isExpanded = expandedStatesMap[recordId] == true
val recordMap = record.toMap(showDetailedTime = true)
@@ -705,7 +705,7 @@ fun MergedTrainRecordItem(
HorizontalDivider()
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())
Column(
@@ -834,13 +834,13 @@ fun HistoryScreen(
records.forEach { item ->
when (item) {
is TrainRecord -> {
if (selectedRecords.contains(item.timestamp.time.toString())) {
if (selectedRecords.contains(item.uniqueId)) {
add(item)
}
}
is MergedTrainRecord -> {
item.records.forEach { record ->
if (selectedRecords.contains(record.timestamp.time.toString())) {
if (selectedRecords.contains(record.uniqueId)) {
add(record)
}
}
@@ -881,20 +881,21 @@ fun HistoryScreen(
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)
}
LaunchedEffect(expandedStatesMap.toMap()) {
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)
}
}
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
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)
}
}
@@ -911,7 +912,6 @@ fun HistoryScreen(
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.weight(1.0f)
) {
if (filteredRecords.isEmpty()) {
@@ -945,7 +945,8 @@ fun HistoryScreen(
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp)
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp)
) {
items(filteredRecords) { item ->
when (item) {

View File

@@ -99,6 +99,49 @@ fun MapScreen(
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) {
val observer = LifecycleEventObserver { _, event ->
@@ -135,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) {

View File

@@ -58,7 +58,7 @@ fun MergedHistoryScreen(
val selectedRecordsList = remember(selectedRecords) {
mutableStateListOf<TrainRecord>().apply {
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) {
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)
}

View File

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