From 799410eeb28fc2c6e3b821def4bc513778269ba9 Mon Sep 17 00:00:00 2001 From: Nedifinita Date: Tue, 22 Jul 2025 17:27:38 +0800 Subject: [PATCH] feat: add BLE disconnection cleanup and enhance record management --- README.md | 4 - app/build.gradle.kts | 4 +- .../java/org/noxylva/lbjconsole/BLEClient.kt | 19 ++++ .../org/noxylva/lbjconsole/MainActivity.kt | 72 ++++++++++++---- .../noxylva/lbjconsole/model/TrainRecord.kt | 25 +++++- .../lbjconsole/model/TrainRecordManager.kt | 4 +- .../lbjconsole/ui/screens/HistoryScreen.kt | 21 ++--- .../lbjconsole/ui/screens/MapScreen.kt | 86 +++++++++---------- .../ui/screens/MergedHistoryScreen.kt | 4 +- .../lbjconsole/ui/screens/SettingsScreen.kt | 5 +- 10 files changed, 161 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index edaf948..dda98e1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c7ffb0b..5ecbc5a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" } diff --git a/app/src/main/java/org/noxylva/lbjconsole/BLEClient.kt b/app/src/main/java/org/noxylva/lbjconsole/BLEClient.kt index 18d30e0..d7bad09 100644 --- a/app/src/main/java/org/noxylva/lbjconsole/BLEClient.kt +++ b/app/src/main/java/org/noxylva/lbjconsole/BLEClient.kt @@ -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") + } } \ No newline at end of file diff --git a/app/src/main/java/org/noxylva/lbjconsole/MainActivity.kt b/app/src/main/java/org/noxylva/lbjconsole/MainActivity.kt index 8428544..cf95af0 100644 --- a/app/src/main/java/org/noxylva/lbjconsole/MainActivity.kt +++ b/app/src/main/java/org/noxylva/lbjconsole/MainActivity.kt @@ -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() + val recordsToDelete = mutableSetOf() + val idToRecordMap = mutableMapOf() + val idToMergedRecordMap = mutableMapOf() + 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() + + 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() + val trainRecords = mutableListOf() + allRecords.forEach { item -> + when (item) { + is TrainRecord -> trainRecords.add(item) + is org.noxylva.lbjconsole.model.MergedTrainRecord -> { + trainRecords.addAll(item.records) + } + } + } + trainRecords } else { recentRecords }, diff --git a/app/src/main/java/org/noxylva/lbjconsole/model/TrainRecord.kt b/app/src/main/java/org/noxylva/lbjconsole/model/TrainRecord.kt index b0163d2..8742f0a 100644 --- a/app/src/main/java/org/noxylva/lbjconsole/model/TrainRecord.kt +++ b/app/src/main/java/org/noxylva/lbjconsole/model/TrainRecord.kt @@ -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() + } } \ No newline at end of file diff --git a/app/src/main/java/org/noxylva/lbjconsole/model/TrainRecordManager.kt b/app/src/main/java/org/noxylva/lbjconsole/model/TrainRecordManager.kt index 23616d8..4cf1935 100644 --- a/app/src/main/java/org/noxylva/lbjconsole/model/TrainRecordManager.kt +++ b/app/src/main/java/org/noxylva/lbjconsole/model/TrainRecordManager.kt @@ -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() diff --git a/app/src/main/java/org/noxylva/lbjconsole/ui/screens/HistoryScreen.kt b/app/src/main/java/org/noxylva/lbjconsole/ui/screens/HistoryScreen.kt index 69844e0..e99be82 100644 --- a/app/src/main/java/org/noxylva/lbjconsole/ui/screens/HistoryScreen.kt +++ b/app/src/main/java/org/noxylva/lbjconsole/ui/screens/HistoryScreen.kt @@ -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) { diff --git a/app/src/main/java/org/noxylva/lbjconsole/ui/screens/MapScreen.kt b/app/src/main/java/org/noxylva/lbjconsole/ui/screens/MapScreen.kt index 8e69801..8e5e867 100644 --- a/app/src/main/java/org/noxylva/lbjconsole/ui/screens/MapScreen.kt +++ b/app/src/main/java/org/noxylva/lbjconsole/ui/screens/MapScreen.kt @@ -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) { diff --git a/app/src/main/java/org/noxylva/lbjconsole/ui/screens/MergedHistoryScreen.kt b/app/src/main/java/org/noxylva/lbjconsole/ui/screens/MergedHistoryScreen.kt index 98c0421..63ca378 100644 --- a/app/src/main/java/org/noxylva/lbjconsole/ui/screens/MergedHistoryScreen.kt +++ b/app/src/main/java/org/noxylva/lbjconsole/ui/screens/MergedHistoryScreen.kt @@ -58,7 +58,7 @@ fun MergedHistoryScreen( val selectedRecordsList = remember(selectedRecords) { mutableStateListOf().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) } diff --git a/app/src/main/java/org/noxylva/lbjconsole/ui/screens/SettingsScreen.kt b/app/src/main/java/org/noxylva/lbjconsole/ui/screens/SettingsScreen.kt index 2fb6995..9991708 100644 --- a/app/src/main/java/org/noxylva/lbjconsole/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/org/noxylva/lbjconsole/ui/screens/SettingsScreen.kt @@ -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) ) } }