fix: various issues

This commit is contained in:
Nedifinita
2025-07-14 23:15:01 +08:00
parent 89d481f1b8
commit ac59bdcb49
28 changed files with 462 additions and 251 deletions

114
.github/workflows/android-release.yml vendored Normal file
View File

@@ -0,0 +1,114 @@
name: Android Release Build
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Cache Gradle packages
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build Debug APK
run: ./gradlew assembleDebug
- name: Build Release APK
run: ./gradlew assembleRelease
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Upload Debug APK
uses: actions/upload-artifact@v4
with:
name: debug-apk
path: app/build/outputs/apk/debug/*.apk
- name: Upload Release APK
uses: actions/upload-artifact@v4
with:
name: release-apk
path: app/build/outputs/apk/release/*.apk
- name: Create Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v1
with:
files: |
app/build/outputs/apk/debug/*.apk
app/build/outputs/apk/release/*.apk
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Cache Gradle packages
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Run tests
run: ./gradlew test
- name: Run lint
run: ./gradlew lint
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: |
app/build/reports/tests/
app/build/reports/lint-results.html

18
.gitignore vendored
View File

@@ -1,15 +1,15 @@
*.iml *.iml
.gradle .gradle
/local.properties .idea/caches
/.idea/caches .idea/libraries
/.idea/libraries .idea/modules.xml
/.idea/modules.xml .idea/workspace.xml
/.idea/workspace.xml .idea/navEditor.xml
/.idea/navEditor.xml .idea/assetWizardSettings.xml
/.idea/assetWizardSettings.xml
.DS_Store .DS_Store
/build build
/captures captures
.externalNativeBuild .externalNativeBuild
.cxx .cxx
local.properties local.properties
local.properties

2
.idea/.name generated
View File

@@ -1 +1 @@
LBJ_Console LBJ Receiver

View File

@@ -631,7 +631,7 @@ to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found. the "copyright" line and a pointer to where the full notice is found.
LBJ_Console is an Android app designed to receive and display LBJ (Locomotive Bell Jingling) messages via BLE from the SX1276_Receive_LBJ device. LBJ Console is an Android app designed to receive and display LBJ (Locomotive Bell Jingling) messages via BLE from the SX1276_Receive_LBJ device.
Copyright (C) 2025 undef-i Copyright (C) 2025 undef-i
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
@@ -652,7 +652,7 @@ Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode: notice like this when it starts in an interactive mode:
LBJ_Console Copyright (C) 2025 undef-i LBJ Console Copyright (C) 2025 undef-i
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details. under certain conditions; type `show c' for details.

View File

@@ -1,7 +1,15 @@
# 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 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
- Tab state persistence
- Record filtering (train number, time range)
- Record management page optimization
- Optional train merge by locomotive/number
- Offline data storage
- Add record timestamps
# License # 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. 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.

1
app/.gitignore vendored
View File

@@ -1 +0,0 @@
/build

View File

@@ -5,13 +5,13 @@ plugins {
} }
android { android {
namespace = "receiver.lbj" namespace = "org.noxylva.lbjconsole"
compileSdk = 35 compileSdk = 35
defaultConfig { defaultConfig {
applicationId = "receiver.lbj" applicationId = "org.noxylva.lbjconsole"
minSdk = 29 minSdk = 29
targetSdk = 34 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"

View File

@@ -1,20 +0,0 @@
package receiver.lbj
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("receiver.lbj", appContext.packageName)
}
}

View File

@@ -4,12 +4,11 @@
<uses-permission android:name="android.permission.BLUETOOTH"/> <uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" android:usesPermissionFlags="neverForLocation"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" android:usesPermissionFlags="neverForLocation"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<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" />

View File

@@ -1,17 +1,22 @@
package receiver.lbj package org.noxylva.lbjconsole
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.* import android.bluetooth.*
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import org.json.JSONObject import org.json.JSONObject
import java.util.* import java.util.*
class BLEClient(private val context: Context) : BluetoothGattCallback(), class BLEClient(private val context: Context) : BluetoothGattCallback() {
BluetoothAdapter.LeScanCallback {
companion object { companion object {
const val TAG = "LBJ_BT" const val TAG = "LBJ_BT"
const val SCAN_PERIOD = 10000L const val SCAN_PERIOD = 10000L
@@ -35,6 +40,24 @@ class BLEClient(private val context: Context) : BluetoothGattCallback(),
private var trainInfoCallback: ((JSONObject) -> Unit)? = null private var trainInfoCallback: ((JSONObject) -> Unit)? = null
private var handler = Handler(Looper.getMainLooper()) private var handler = Handler(Looper.getMainLooper())
private var targetDeviceName: String? = null private var targetDeviceName: String? = null
private var bluetoothLeScanner: BluetoothLeScanner? = null
private val leScanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device
val deviceName = device.name
if (targetDeviceName != null) {
if (deviceName == null || !deviceName.equals(targetDeviceName, ignoreCase = true)) {
return
}
}
scanCallback?.invoke(device)
}
override fun onScanFailed(errorCode: Int) {
Log.e(TAG, "BLE scan failed code=$errorCode")
}
}
fun setTrainInfoCallback(callback: (JSONObject) -> Unit) { fun setTrainInfoCallback(callback: (JSONObject) -> Unit) {
@@ -42,8 +65,28 @@ class BLEClient(private val context: Context) : BluetoothGattCallback(),
} }
private fun hasBluetoothPermissions(): Boolean {
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ContextCompat.checkSelfPermission(context, android.Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, android.Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
} else {
ContextCompat.checkSelfPermission(context, android.Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, android.Manifest.permission.BLUETOOTH_ADMIN) == PackageManager.PERMISSION_GRANTED
}
val locationPermissions = ContextCompat.checkSelfPermission(context, android.Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, android.Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
return bluetoothPermissions && locationPermissions
}
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
fun scanDevices(targetDeviceName: String? = null, callback: (BluetoothDevice) -> Unit) { fun scanDevices(targetDeviceName: String? = null, callback: (BluetoothDevice) -> Unit) {
if (!hasBluetoothPermissions()) {
Log.e(TAG, "Bluetooth permissions not granted")
return
}
try { try {
scanCallback = callback scanCallback = callback
this.targetDeviceName = targetDeviceName this.targetDeviceName = targetDeviceName
@@ -57,13 +100,19 @@ class BLEClient(private val context: Context) : BluetoothGattCallback(),
return return
} }
bluetoothLeScanner = bluetoothAdapter.bluetoothLeScanner
if (bluetoothLeScanner == null) {
Log.e(TAG, "BluetoothLeScanner unavailable")
return
}
handler.postDelayed({ handler.postDelayed({
stopScan() stopScan()
}, SCAN_PERIOD) }, SCAN_PERIOD)
isScanning = true isScanning = true
Log.d(TAG, "Starting BLE scan target=${targetDeviceName ?: "Any"}") Log.d(TAG, "Starting BLE scan target=${targetDeviceName ?: "Any"}")
bluetoothAdapter.startLeScan(this) 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}")
} catch (e: Exception) { } catch (e: Exception) {
@@ -75,28 +124,25 @@ class BLEClient(private val context: Context) : BluetoothGattCallback(),
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
fun stopScan() { fun stopScan() {
if (isScanning) { if (isScanning) {
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() bluetoothLeScanner?.stopScan(leScanCallback)
bluetoothAdapter.stopLeScan(this)
isScanning = false isScanning = false
} }
} }
override fun onLeScan(device: BluetoothDevice, rssi: Int, scanRecord: ByteArray) {
val deviceName = device.name
if (targetDeviceName != null) {
if (deviceName == null || !deviceName.equals(targetDeviceName, ignoreCase = true)) {
return
}
}
scanCallback?.invoke(device)
}
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
fun connect(address: String, onConnectionStateChange: ((Boolean) -> Unit)? = null): Boolean { fun connect(address: String, onConnectionStateChange: ((Boolean) -> Unit)? = null): Boolean {
Log.d(TAG, "Attempting to connect to device: $address")
if (!hasBluetoothPermissions()) {
Log.e(TAG, "Bluetooth permissions not granted")
handler.post { onConnectionStateChange?.invoke(false) }
return false
}
if (address.isBlank()) { if (address.isBlank()) {
Log.e(TAG, "Connection failed empty address") Log.e(TAG, "Connection failed empty address")
handler.post { onConnectionStateChange?.invoke(false) } handler.post { onConnectionStateChange?.invoke(false) }
@@ -109,6 +155,18 @@ class BLEClient(private val context: Context) : BluetoothGattCallback(),
handler.post { onConnectionStateChange?.invoke(false) } handler.post { onConnectionStateChange?.invoke(false) }
return false return false
} }
if (!bluetoothAdapter.isEnabled) {
Log.e(TAG, "Bluetooth adapter is disabled")
handler.post { onConnectionStateChange?.invoke(false) }
return false
}
if (isConnected) {
Log.w(TAG, "Already connected to device")
handler.post { onConnectionStateChange?.invoke(true) }
return true
}
bluetoothGatt?.close() bluetoothGatt?.close()

View File

@@ -1,4 +1,4 @@
package receiver.lbj package org.noxylva.lbjconsole
import android.Manifest import android.Manifest
import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothAdapter
@@ -6,7 +6,7 @@ import android.bluetooth.BluetoothDevice
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import java.io.File import java.io.File
import android.net.Uri import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
@@ -19,36 +19,30 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material.icons.filled.LocationOn
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.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.json.JSONObject import org.json.JSONObject
import org.osmdroid.config.Configuration import org.osmdroid.config.Configuration
import receiver.lbj.model.TrainRecord import org.noxylva.lbjconsole.model.TrainRecord
import receiver.lbj.model.TrainRecordManager import org.noxylva.lbjconsole.model.TrainRecordManager
import receiver.lbj.ui.screens.HistoryScreen import org.noxylva.lbjconsole.ui.screens.HistoryScreen
import receiver.lbj.ui.screens.MapScreen import org.noxylva.lbjconsole.ui.screens.MapScreen
import receiver.lbj.ui.screens.SettingsScreen import org.noxylva.lbjconsole.ui.screens.SettingsScreen
import receiver.lbj.ui.screens.MapScreen import org.noxylva.lbjconsole.ui.theme.LBJReceiverTheme
import receiver.lbj.ui.theme.LBJReceiverTheme import org.noxylva.lbjconsole.util.LocoInfoUtil
import receiver.lbj.util.LocoInfoUtil
import java.util.* import java.util.*
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope
import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanResult
@@ -80,7 +74,10 @@ class MainActivity : ComponentActivity() {
private var temporaryStatusMessage by mutableStateOf<String?>(null) private var temporaryStatusMessage by mutableStateOf<String?>(null)
private var targetDeviceName = "LBJReceiver" private var targetDeviceName = "LBJReceiver"
private val settingsPrefs by lazy { getSharedPreferences("app_settings", Context.MODE_PRIVATE) }
private val requestPermissions = registerForActivityResult( private val requestPermissions = registerForActivityResult(
@@ -133,17 +130,31 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
requestPermissions.launch(arrayOf( loadSettings()
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_ADMIN,
Manifest.permission.BLUETOOTH_CONNECT, val permissions = mutableListOf<String>()
Manifest.permission.BLUETOOTH_SCAN,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
permissions.addAll(arrayOf(
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_ADVERTISE
))
} else {
permissions.addAll(arrayOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_ADMIN
))
}
permissions.addAll(arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_EXTERNAL_STORAGE
)) ))
requestPermissions.launch(permissions.toTypedArray())
bleClient.setTrainInfoCallback { jsonData -> bleClient.setTrainInfoCallback { jsonData ->
handleTrainInfo(jsonData) handleTrainInfo(jsonData)
@@ -263,17 +274,36 @@ class MainActivity : ComponentActivity() {
deviceName = settingsDeviceName, deviceName = settingsDeviceName,
onDeviceNameChange = { newName -> settingsDeviceName = newName }, onDeviceNameChange = { newName -> settingsDeviceName = newName },
onApplySettings = { onApplySettings = {
saveSettings()
targetDeviceName = settingsDeviceName
Toast.makeText(this, "设备名称 '${settingsDeviceName}' 已保存,下次连接时生效", Toast.LENGTH_LONG).show() Toast.makeText(this, "设备名称 '${settingsDeviceName}' 已保存,下次连接时生效", Toast.LENGTH_LONG).show()
Log.d(TAG, "Applied settings deviceName=${settingsDeviceName}") Log.d(TAG, "Applied settings deviceName=${settingsDeviceName}")
}, },
locoInfoUtil = locoInfoUtil locoInfoUtil = locoInfoUtil
) )
// 显示连接对话框
if (showConnectionDialog) {
ConnectionDialog(
isScanning = isScanning,
devices = foundDevices,
onDismiss = {
showConnectionDialog = false
stopScan()
},
onScan = {
if (isScanning) {
stopScan()
} else {
startScan()
}
},
onConnect = { device ->
showConnectionDialog = false
connectToDevice(device)
}
)
}
} }
} }
} }
@@ -284,13 +314,23 @@ class MainActivity : ComponentActivity() {
deviceStatus = "正在连接..." deviceStatus = "正在连接..."
Log.d(TAG, "Connecting to device name=${device.name ?: "Unknown"} address=${device.address}") Log.d(TAG, "Connecting to device name=${device.name ?: "Unknown"} address=${device.address}")
// 检查蓝牙适配器状态
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled) {
deviceStatus = "蓝牙未启用"
Log.e(TAG, "Bluetooth adapter unavailable or disabled")
return
}
bleClient.connect(device.address) { connected -> bleClient.connect(device.address) { connected ->
if (connected) { runOnUiThread {
deviceStatus = "已连接" if (connected) {
Log.d(TAG, "Connected to device name=${device.name ?: "Unknown"}") deviceStatus = "已连接"
} else { Log.d(TAG, "Connected to device name=${device.name ?: "Unknown"}")
deviceStatus = "连接失败或已断开连接" } else {
Log.e(TAG, "Connection failed name=${device.name ?: "Unknown"}") deviceStatus = "连接失败或已断开连接"
Log.e(TAG, "Connection failed name=${device.name ?: "Unknown"}")
}
} }
} }
@@ -382,6 +422,19 @@ class MainActivity : ComponentActivity() {
private fun startScan() { private fun startScan() {
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
if (bluetoothAdapter == null) {
Log.e(TAG, "Bluetooth adapter unavailable")
deviceStatus = "设备不支持蓝牙"
return
}
if (!bluetoothAdapter.isEnabled) {
Log.e(TAG, "Bluetooth adapter disabled")
deviceStatus = "请启用蓝牙"
return
}
isScanning = true isScanning = true
foundDevices = emptyList() foundDevices = emptyList()
val targetDeviceName = settingsDeviceName.ifBlank { null } val targetDeviceName = settingsDeviceName.ifBlank { null }
@@ -396,8 +449,11 @@ class MainActivity : ComponentActivity() {
Log.d(TAG, "Found target=$targetDeviceName, connecting") Log.d(TAG, "Found target=$targetDeviceName, connecting")
stopScan() stopScan()
connectToDevice(device) connectToDevice(device)
} else if (!foundDevices.any { it.address == device.address }) { } else {
showConnectionDialog = true // 如果没有指定目标设备名称,或者找到的设备不是目标设备,显示连接对话框
if (targetDeviceName == null) {
showConnectionDialog = true
}
} }
} }
} }
@@ -414,6 +470,21 @@ class MainActivity : ComponentActivity() {
private fun updateDeviceList() { private fun updateDeviceList() {
foundDevices = scanResults.map { it.device } foundDevices = scanResults.map { it.device }
} }
private fun loadSettings() {
settingsDeviceName = settingsPrefs.getString("device_name", "LBJReceiver") ?: "LBJReceiver"
targetDeviceName = settingsDeviceName
Log.d(TAG, "Loaded settings deviceName=${settingsDeviceName}")
}
private fun saveSettings() {
settingsPrefs.edit()
.putString("device_name", settingsDeviceName)
.apply()
Log.d(TAG, "Saved settings deviceName=${settingsDeviceName}")
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -478,7 +549,7 @@ fun MainContent(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("LBJReceiver") }, title = { Text("LBJ Console") },
actions = { actions = {
timeSinceLastUpdate.value?.let { time -> timeSinceLastUpdate.value?.let { time ->

View File

@@ -1,11 +1,10 @@
package receiver.lbj.model package org.noxylva.lbjconsole.model
import android.util.Log import android.util.Log
import org.json.JSONObject import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.* import java.util.*
import org.osmdroid.util.GeoPoint import org.osmdroid.util.GeoPoint
import receiver.lbj.util.LocationUtils import org.noxylva.lbjconsole.util.LocationUtils
class TrainRecord(jsonData: JSONObject? = null) { class TrainRecord(jsonData: JSONObject? = null) {
companion object { companion object {

View File

@@ -1,4 +1,4 @@
package receiver.lbj.model package org.noxylva.lbjconsole.model
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences

View File

@@ -1,4 +1,4 @@
package receiver.lbj.ui.components package org.noxylva.lbjconsole.ui.components
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -16,7 +16,7 @@ import androidx.compose.ui.window.DialogProperties
import org.osmdroid.tileprovider.tilesource.TileSourceFactory import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.views.MapView import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker import org.osmdroid.views.overlay.Marker
import receiver.lbj.model.TrainRecord import org.noxylva.lbjconsole.model.TrainRecord
@Composable @Composable
fun TrainDetailDialog( fun TrainDetailDialog(

View File

@@ -1,4 +1,4 @@
package receiver.lbj.ui.components package org.noxylva.lbjconsole.ui.components
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -10,7 +10,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import receiver.lbj.model.TrainRecord import org.noxylva.lbjconsole.model.TrainRecord
@Composable @Composable
fun TrainInfoCard( fun TrainInfoCard(

View File

@@ -1,4 +1,4 @@
package receiver.lbj.ui.components package org.noxylva.lbjconsole.ui.components
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -9,7 +9,6 @@ import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -18,7 +17,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import receiver.lbj.model.TrainRecord import org.noxylva.lbjconsole.model.TrainRecord
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*

View File

@@ -1,10 +1,5 @@
package receiver.lbj.ui.screens package org.noxylva.lbjconsole.ui.screens
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -17,18 +12,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.SignalCellular4Bar
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.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.geometry.Offset
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -42,8 +30,8 @@ import org.osmdroid.views.overlay.Marker
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.views.overlay.TilesOverlay import org.osmdroid.views.overlay.TilesOverlay
import receiver.lbj.model.TrainRecord import org.noxylva.lbjconsole.model.TrainRecord
import receiver.lbj.util.LocoInfoUtil import org.noxylva.lbjconsole.util.LocoInfoUtil
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*

View File

@@ -1,25 +1,15 @@
package receiver.lbj.ui.screens package org.noxylva.lbjconsole.ui.screens
import android.Manifest
import android.content.Context import android.content.Context
import receiver.lbj.R
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.graphics.PorterDuff import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter import android.graphics.PorterDuffColorFilter
import android.location.Location
import android.location.LocationListener
import android.util.Log import android.util.Log
import android.location.LocationManager
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MyLocation import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material.icons.filled.Layers import androidx.compose.material.icons.filled.Layers
import androidx.compose.material3.* import androidx.compose.material3.*
@@ -36,19 +26,15 @@ import androidx.lifecycle.LifecycleEventObserver
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.osmdroid.config.Configuration import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.MapTileProviderBasic import org.osmdroid.tileprovider.MapTileProviderBasic
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.TileSourceFactory import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.tileprovider.tilesource.XYTileSource import org.osmdroid.tileprovider.tilesource.XYTileSource
import org.osmdroid.util.GeoPoint import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.* import org.osmdroid.views.overlay.*
import org.osmdroid.views.overlay.compass.CompassOverlay
import org.osmdroid.views.overlay.compass.InternalCompassOrientationProvider
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 receiver.lbj.model.TrainRecord import org.noxylva.lbjconsole.model.TrainRecord
import java.io.File import java.io.File
import java.util.*
@Composable @Composable
@@ -184,13 +170,7 @@ fun MapScreen(
mapView.invalidate() mapView.invalidate()
if (!isMapInitialized && validRecords.isNotEmpty()) {
validRecords.firstOrNull()?.getCoordinates()?.let { point ->
mapView.controller.setZoom(12.0)
mapView.controller.setCenter(point)
isMapInitialized = true
}
}
} }
@@ -258,7 +238,7 @@ fun MapScreen(
val railwayTileSource = XYTileSource( val railwayTileSource = XYTileSource(
"OpenRailwayMap", "OpenRailwayMap",
0, 24, // 穷尽所有可能的缩放级别 0, 24,
256, 256,
".png", ".png",
arrayOf( arrayOf(
@@ -297,7 +277,15 @@ fun MapScreen(
} }
controller.setZoom(10.0) if (validRecords.isNotEmpty()) {
validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint ->
controller.setCenter(lastPoint)
controller.setZoom(12.0)
}
} else {
controller.setCenter(defaultPosition)
controller.setZoom(10.0)
}
try { try {
@@ -307,28 +295,8 @@ fun MapScreen(
locationUpdateMinTime = 1000 locationUpdateMinTime = 1000
} }
val myLocationOverlay = MyLocationNewOverlay(locationProvider, this).apply { val myLocationOverlay = MyLocationNewOverlay(locationProvider, this).apply {
// 使用我们的自定义人形图标
// 使用原生位置/雷达图标
val personDrawable = ctx.resources.getDrawable(android.R.drawable.ic_menu_mylocation, ctx.theme)
// 设置为黑色
personDrawable.setTint(Color.BLACK)
val bitmap = Bitmap.createBitmap(
personDrawable.intrinsicWidth,
personDrawable.intrinsicHeight,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
personDrawable.setBounds(0, 0, canvas.width, canvas.height)
personDrawable.draw(canvas)
setPersonIcon(bitmap)
// 设置中心对齐
setPersonAnchor(0.5f, 0.5f)
isDrawAccuracyEnabled = false
enableMyLocation() enableMyLocation()
runOnFirstFix { runOnFirstFix {
@@ -337,21 +305,40 @@ fun MapScreen(
currentLocation = GeoPoint(location.latitude, location.longitude) currentLocation = GeoPoint(location.latitude, location.longitude)
if (!isMapInitialized) { if (!isMapInitialized) {
controller.animateTo(location) controller.setCenter(location)
controller.setZoom(15.0)
isMapInitialized = true isMapInitialized = true
} Log.d("MapScreen", "Map initialized with GPS position: $location")
}
} ?: run { } ?: run {
if (!isMapInitialized) { if (!isMapInitialized) {
controller.animateTo(defaultPosition) if (validRecords.isNotEmpty()) {
} validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint ->
controller.setCenter(lastPoint)
controller.setZoom(12.0)
isMapInitialized = true
Log.d("MapScreen", "Map initialized with last record position: $lastPoint")
}
} else {
controller.setCenter(defaultPosition)
isMapInitialized = true
}
}
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
if (!isMapInitialized) { if (!isMapInitialized) {
controller.animateTo(defaultPosition) if (validRecords.isNotEmpty()) {
validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint ->
controller.setCenter(lastPoint)
controller.setZoom(12.0)
isMapInitialized = true
Log.d("MapScreen", "Map fallback to last record position: $lastPoint")
}
} else {
controller.setCenter(defaultPosition)
isMapInitialized = true
}
} }
} }
} }
@@ -425,7 +412,7 @@ fun MapScreen(
overlay.enableFollowLocation() overlay.enableFollowLocation()
overlay.enableMyLocation() overlay.enableMyLocation()
overlay.myLocation?.let { location -> overlay.myLocation?.let { location ->
mapViewRef.value?.controller?.animateTo(location) mapViewRef.value?.controller?.setCenter(location)
} }
} }
}, },
@@ -467,14 +454,22 @@ fun MapScreen(
FloatingActionButton( FloatingActionButton(
onClick = { onClick = {
mapViewRef.value?.let { mapView -> mapViewRef.value?.let { mapView ->
if (validRecords.isNotEmpty()) { myLocationOverlayRef.value?.myLocation?.let { gpsLocation ->
validRecords.firstOrNull()?.getCoordinates()?.let { point -> mapView.controller.setCenter(gpsLocation)
mapView.controller.animateTo(point) mapView.controller.setZoom(15.0)
mapView.controller.setZoom(12.0) Log.d("MapScreen", "Refresh button: GPS position used")
} ?: run {
if (validRecords.isNotEmpty()) {
validRecords.lastOrNull()?.getCoordinates()?.let { point ->
mapView.controller.setCenter(point)
mapView.controller.setZoom(12.0)
Log.d("MapScreen", "Refresh button: last record position used")
}
} else {
mapView.controller.setCenter(defaultPosition)
mapView.controller.setZoom(10.0)
Log.d("MapScreen", "Refresh button: default position used")
} }
} else {
mapView.controller.animateTo(defaultPosition)
mapView.controller.setZoom(10.0)
} }
} }
onCenterMap() onCenterMap()

View File

@@ -1,9 +1,7 @@
package receiver.lbj.ui.screens 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.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
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
@@ -14,8 +12,8 @@ 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
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import receiver.lbj.model.TrainRecord import org.noxylva.lbjconsole.model.TrainRecord
import receiver.lbj.ui.components.TrainDetailDialog import org.noxylva.lbjconsole.ui.components.TrainDetailDialog
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*

View File

@@ -0,0 +1,57 @@
package org.noxylva.lbjconsole.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
deviceName: String,
onDeviceNameChange: (String) -> Unit,
onApplySettings: () -> Unit
) {
val uriHandler = LocalUriHandler.current
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
OutlinedTextField(
value = deviceName,
onValueChange = onDeviceNameChange,
label = { Text("蓝牙设备名称") },
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = onApplySettings,
modifier = Modifier.fillMaxWidth()
) {
Text("应用设置")
}
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "LBJ Console v0.0.1 by undef-i",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clickable {
uriHandler.openUri("https://github.com/undef-i")
}
)
}
}

View File

@@ -1,4 +1,4 @@
package receiver.lbj.ui.theme package org.noxylva.lbjconsole.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color

View File

@@ -1,8 +1,6 @@
package receiver.lbj.ui.theme package org.noxylva.lbjconsole.ui.theme
import android.app.Activity
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme

View File

@@ -1,4 +1,4 @@
package receiver.lbj.ui.theme package org.noxylva.lbjconsole.ui.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle

View File

@@ -1,8 +1,7 @@
package receiver.lbj.util package org.noxylva.lbjconsole.util
import android.util.Log import android.util.Log
import org.osmdroid.util.GeoPoint import org.osmdroid.util.GeoPoint
import kotlin.math.abs
object LocationUtils { object LocationUtils {

View File

@@ -1,4 +1,4 @@
package receiver.lbj.util package org.noxylva.lbjconsole.util
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log

View File

@@ -1,38 +0,0 @@
package receiver.lbj.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
deviceName: String,
onDeviceNameChange: (String) -> Unit,
onApplySettings: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("设置", style = MaterialTheme.typography.headlineMedium)
OutlinedTextField(
value = deviceName,
onValueChange = onDeviceNameChange,
label = { Text("蓝牙设备名称") },
modifier = Modifier.fillMaxWidth()
)
Button(onClick = onApplySettings, modifier = Modifier.fillMaxWidth()) {
Text("应用设备名称")
}
}
}

View File

@@ -1,3 +1,3 @@
<resources> <resources>
<string name="app_name">LBJ Receiver</string> <string name="app_name">LBJ Console</string>
</resources> </resources>

View File

@@ -1,13 +0,0 @@
package receiver.lbj
import org.junit.Test
import org.junit.Assert.*
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}