BleHrService: survive Bluetooth being turned off (no native crash)
Single Handler for all postDelayed work, cancelled on teardown; a BT ACTION_STATE_CHANGED receiver that cleanly stops the service on STATE_OFF; try/catch around every native scan/connect/GATT call. Prevents the libc destroyed-mutex abort when the adapter is disabled mid-connection.
This commit is contained in:
parent
1df24000a3
commit
13c61f506a
2 changed files with 105 additions and 67 deletions
|
|
@ -53,7 +53,7 @@
|
|||
|------|-------|------------|
|
||||
| `MainActivity.kt` | ~300 | FileObserver IPC, seed DB, safe insets, SAF, BLE commands, OTA check |
|
||||
| `GpsRecorderService.kt` | ~530 | Foreground service GPS, WakeLock, JSONL (один файл на сессию до Стоп, идемпотентный onStartCommand при START_STICKY-перезапуске ОС; `bindSessionIfPending()` дописывает id сессии в имя файла `_s<id>` по `gps_session.txt`), 50Hz акселерометр/гироскоп, dead reckoning, raw sensor files |
|
||||
| `BleHrService.kt` | ~340 | Foreground service BLE HR, FitPro protocol, GATT queue, scan/connect. START_NOT_STICKY; скан не нашёл адрес -> stopSelf (не держит радио). Подключение инициируется ТОЛЬКО вручную (кнопка в настройках), не при старте записи |
|
||||
| `BleHrService.kt` | ~390 | Foreground service BLE HR, FitPro protocol, GATT queue, scan/connect. START_NOT_STICKY; скан не нашёл адрес -> stopSelf (не держит радио). Подключение инициируется ТОЛЬКО вручную (кнопка в настройках), не при старте записи. Выключение BT в любой момент не роняет app: единый `handler` (все postDelayed, снимаются разом), `BroadcastReceiver` на `ACTION_STATE_CHANGED` -> на STATE_OFF чистая остановка (disconnect+stopSelf), все нативные BLE-вызовы (scan/stopScan/connectGatt/writeCharacteristic/discoverServices) в try/catch |
|
||||
| `generated/*.kt` | ~1100 | Tauri-сгенерированные: WebView, IPC, permissions |
|
||||
|
||||
### Инструменты (`tools/`)
|
||||
|
|
|
|||
|
|
@ -7,10 +7,15 @@ import android.app.PendingIntent
|
|||
import android.app.Service
|
||||
import android.bluetooth.*
|
||||
import android.bluetooth.le.*
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.ParcelUuid
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
|
|
@ -59,11 +64,35 @@ class BleHrService : Service() {
|
|||
private var fitproWriteChar: BluetoothGattCharacteristic? = null
|
||||
private var fitproGatt: BluetoothGatt? = null
|
||||
|
||||
// One handler for all postDelayed work, so teardown can cancel every pending callback at once
|
||||
// (a stray callback touching a GATT/scanner after the adapter is off crashes the native BT stack).
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
// When the user turns Bluetooth off, stop touching GATT immediately and shut the service down.
|
||||
private val btStateReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context?, intent: Intent?) {
|
||||
if (intent?.action != BluetoothAdapter.ACTION_STATE_CHANGED) return
|
||||
val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
|
||||
if (state == BluetoothAdapter.STATE_TURNING_OFF || state == BluetoothAdapter.STATE_OFF) {
|
||||
Log.d(TAG, "Bluetooth turning off - stopping BLE service")
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
disconnect()
|
||||
writeData("""{"status":"bt_off","ts":${System.currentTimeMillis()/1000}}""")
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun adapter(): BluetoothAdapter? =
|
||||
(getSystemService(BLUETOOTH_SERVICE) as? android.bluetooth.BluetoothManager)?.adapter
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
try { registerReceiver(btStateReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)) }
|
||||
catch (e: Exception) { Log.e(TAG, "register BT receiver: ${e.message}") }
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
|
|
@ -138,10 +167,10 @@ class BleHrService : Service() {
|
|||
}
|
||||
}
|
||||
|
||||
scanner.startScan(callback)
|
||||
try { scanner.startScan(callback) } catch (e: Exception) { Log.e(TAG, "startScan: ${e.message}"); writeScanResults("[]"); stopSelf(); return }
|
||||
// Stop after 5 seconds
|
||||
android.os.Handler(mainLooper).postDelayed({
|
||||
scanner.stopScan(callback)
|
||||
handler.postDelayed({
|
||||
try { scanner.stopScan(callback) } catch (e: Exception) { Log.e(TAG, "stopScan: ${e.message}") }
|
||||
writeScanResults("[${results.joinToString(",")}]")
|
||||
Log.d(TAG, "Scan complete: ${results.size} devices")
|
||||
// Don't stopSelf() — connect intent may follow immediately
|
||||
|
|
@ -174,8 +203,9 @@ class BleHrService : Service() {
|
|||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
if (result.device.address == address) {
|
||||
Log.d(TAG, "Found target device in scan, connecting...")
|
||||
scanner.stopScan(this)
|
||||
gatt = result.device.connectGatt(this@BleHrService, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||
try { scanner.stopScan(this) } catch (e: Exception) { Log.e(TAG, "stopScan: ${e.message}") }
|
||||
try { gatt = result.device.connectGatt(this@BleHrService, false, gattCallback, BluetoothDevice.TRANSPORT_LE) }
|
||||
catch (e: Exception) { Log.e(TAG, "connectGatt: ${e.message}") }
|
||||
}
|
||||
}
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
|
|
@ -183,10 +213,10 @@ class BleHrService : Service() {
|
|||
directConnect(adapter, address)
|
||||
}
|
||||
}
|
||||
scanner.startScan(scanCallback)
|
||||
try { scanner.startScan(scanCallback) } catch (e: Exception) { Log.e(TAG, "startScan: ${e.message}"); stopSelf(); return }
|
||||
// Timeout: if device not found in 5 seconds, try direct connect
|
||||
android.os.Handler(mainLooper).postDelayed({
|
||||
scanner.stopScan(scanCallback)
|
||||
handler.postDelayed({
|
||||
try { scanner.stopScan(scanCallback) } catch (e: Exception) { Log.e(TAG, "stopScan: ${e.message}") }
|
||||
if (gatt == null) {
|
||||
// Not seen in the scan → the band isn't nearby/on. Do NOT hold a GATT connect to
|
||||
// an absent device (that keeps the radio busy and drains the battery). Give up;
|
||||
|
|
@ -199,9 +229,12 @@ class BleHrService : Service() {
|
|||
}
|
||||
|
||||
private fun directConnect(adapter: BluetoothAdapter, address: String) {
|
||||
val device = adapter.getRemoteDevice(address)
|
||||
Log.d(TAG, "Direct connecting to $address ($deviceType)")
|
||||
gatt = device.connectGatt(this, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||
if (!adapter.isEnabled) { Log.e(TAG, "directConnect: adapter off"); stopSelf(); return }
|
||||
try {
|
||||
val device = adapter.getRemoteDevice(address)
|
||||
Log.d(TAG, "Direct connecting to $address ($deviceType)")
|
||||
gatt = device.connectGatt(this, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||
} catch (e: Exception) { Log.e(TAG, "directConnect: ${e.message}"); stopSelf() }
|
||||
}
|
||||
|
||||
private val gattCallback = object : BluetoothGattCallback() {
|
||||
|
|
@ -209,7 +242,7 @@ class BleHrService : Service() {
|
|||
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
||||
Log.d(TAG, "Connected, discovering services...")
|
||||
updateNotification("Подключён, поиск сервисов...")
|
||||
g.discoverServices()
|
||||
try { g.discoverServices() } catch (e: Exception) { Log.e(TAG, "discoverServices: ${e.message}") }
|
||||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||
Log.d(TAG, "Disconnected")
|
||||
isRunning = false
|
||||
|
|
@ -290,24 +323,24 @@ class BleHrService : Service() {
|
|||
if (service == null) { Log.e(TAG, "HR service not found"); return }
|
||||
val char = service.getCharacteristic(HR_MEASUREMENT_UUID)
|
||||
if (char == null) { Log.e(TAG, "HR characteristic not found"); return }
|
||||
g.setCharacteristicNotification(char, true)
|
||||
val desc = char.getDescriptor(CCCD_UUID)
|
||||
if (desc != null) {
|
||||
desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
g.writeDescriptor(desc)
|
||||
}
|
||||
try {
|
||||
g.setCharacteristicNotification(char, true)
|
||||
val desc = char.getDescriptor(CCCD_UUID)
|
||||
if (desc != null) {
|
||||
desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
g.writeDescriptor(desc)
|
||||
}
|
||||
} catch (e: Exception) { Log.e(TAG, "HR notify setup: ${e.message}"); return }
|
||||
Log.d(TAG, "Standard HR setup complete")
|
||||
// Strap battery (0x180F/0x2A19): read once after the HR subscribe settles, then every 60s.
|
||||
android.os.Handler(mainLooper).also { h ->
|
||||
val readBat = object : Runnable {
|
||||
override fun run() {
|
||||
if (!isRunning) return
|
||||
readBattery(g)
|
||||
h.postDelayed(this, 60000)
|
||||
}
|
||||
val readBat = object : Runnable {
|
||||
override fun run() {
|
||||
if (!isRunning) return
|
||||
readBattery(g)
|
||||
handler.postDelayed(this, 60000)
|
||||
}
|
||||
h.postDelayed(readBat, 2500)
|
||||
}
|
||||
handler.postDelayed(readBat, 2500)
|
||||
}
|
||||
|
||||
private fun readBattery(g: BluetoothGatt) {
|
||||
|
|
@ -334,7 +367,7 @@ class BleHrService : Service() {
|
|||
|
||||
fun onGattOperationComplete() {
|
||||
gattBusy = false
|
||||
android.os.Handler(mainLooper).postDelayed({ processGattQueue() }, 100)
|
||||
handler.postDelayed({ processGattQueue() }, 100)
|
||||
}
|
||||
|
||||
private fun setupFitPro(g: BluetoothGatt) {
|
||||
|
|
@ -347,16 +380,18 @@ class BleHrService : Service() {
|
|||
|
||||
// Queue all operations: enable notify → PAIR → HR_SWITCH → REALTIME → MEASURE
|
||||
enqueueGatt {
|
||||
g.setCharacteristicNotification(notifyChar, true)
|
||||
val desc = notifyChar.getDescriptor(CCCD_UUID)
|
||||
if (desc != null) {
|
||||
desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
g.writeDescriptor(desc)
|
||||
Log.d(TAG, "FitPro: writing CCCD descriptor")
|
||||
} else {
|
||||
Log.d(TAG, "FitPro: no CCCD, skipping")
|
||||
onGattOperationComplete()
|
||||
}
|
||||
try {
|
||||
g.setCharacteristicNotification(notifyChar, true)
|
||||
val desc = notifyChar.getDescriptor(CCCD_UUID)
|
||||
if (desc != null) {
|
||||
desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
g.writeDescriptor(desc)
|
||||
Log.d(TAG, "FitPro: writing CCCD descriptor")
|
||||
} else {
|
||||
Log.d(TAG, "FitPro: no CCCD, skipping")
|
||||
onGattOperationComplete()
|
||||
}
|
||||
} catch (e: Exception) { Log.e(TAG, "FitPro notify setup: ${e.message}"); onGattOperationComplete() }
|
||||
}
|
||||
enqueueGatt { Log.d(TAG, "FitPro: PAIR"); writeCommand(g, writeChar, FITPRO_PAIR) }
|
||||
enqueueGatt { Log.d(TAG, "FitPro: HR_SWITCH"); writeCommand(g, writeChar, FITPRO_HR_SWITCH_ON) }
|
||||
|
|
@ -369,37 +404,36 @@ class BleHrService : Service() {
|
|||
// Re-trigger measurement every 25 seconds
|
||||
fitproWriteChar = writeChar
|
||||
fitproGatt = g
|
||||
android.os.Handler(mainLooper).also { h ->
|
||||
val retrigger = object : Runnable {
|
||||
override fun run() {
|
||||
if (!isRunning) return
|
||||
Log.d(TAG, "FitPro: re-trigger measurement")
|
||||
fitproGatt?.let { gg -> fitproWriteChar?.let { wc ->
|
||||
writeCommand(gg, wc, FITPRO_MEASURE_START)
|
||||
android.os.Handler(mainLooper).postDelayed({
|
||||
writeCommand(gg, wc, FITPRO_MEASURE_OLD)
|
||||
}, 300)
|
||||
}}
|
||||
h.postDelayed(this, 25000)
|
||||
}
|
||||
val retrigger = object : Runnable {
|
||||
override fun run() {
|
||||
if (!isRunning) return
|
||||
Log.d(TAG, "FitPro: re-trigger measurement")
|
||||
fitproGatt?.let { gg -> fitproWriteChar?.let { wc ->
|
||||
writeCommand(gg, wc, FITPRO_MEASURE_START)
|
||||
handler.postDelayed({
|
||||
writeCommand(gg, wc, FITPRO_MEASURE_OLD)
|
||||
}, 300)
|
||||
}}
|
||||
handler.postDelayed(this, 25000)
|
||||
}
|
||||
h.postDelayed(retrigger, 25000)
|
||||
}
|
||||
handler.postDelayed(retrigger, 25000)
|
||||
}
|
||||
|
||||
private fun writeCommand(g: BluetoothGatt, char: BluetoothGattCharacteristic, data: ByteArray) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
g.writeCharacteristic(char, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE)
|
||||
// NO_RESPONSE may not trigger onCharacteristicWrite, so auto-complete after 200ms
|
||||
android.os.Handler(mainLooper).postDelayed({ onGattOperationComplete() }, 200)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
char.value = data
|
||||
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
@Suppress("DEPRECATION")
|
||||
g.writeCharacteristic(char)
|
||||
android.os.Handler(mainLooper).postDelayed({ onGattOperationComplete() }, 200)
|
||||
}
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
g.writeCharacteristic(char, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
char.value = data
|
||||
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
@Suppress("DEPRECATION")
|
||||
g.writeCharacteristic(char)
|
||||
}
|
||||
} catch (e: Exception) { Log.e(TAG, "writeCommand: ${e.message}") }
|
||||
// NO_RESPONSE may not trigger onCharacteristicWrite, so auto-complete after 200ms
|
||||
handler.postDelayed({ onGattOperationComplete() }, 200)
|
||||
}
|
||||
|
||||
private fun parseStandardHr(data: ByteArray) {
|
||||
|
|
@ -467,10 +501,13 @@ class BleHrService : Service() {
|
|||
}
|
||||
|
||||
private fun disconnect() {
|
||||
gatt?.disconnect()
|
||||
gatt?.close()
|
||||
gatt = null
|
||||
isRunning = false
|
||||
handler.removeCallbacksAndMessages(null) // no stray callback may touch GATT after this
|
||||
try { gatt?.disconnect() } catch (e: Exception) { Log.e(TAG, "gatt.disconnect: ${e.message}") }
|
||||
try { gatt?.close() } catch (e: Exception) { Log.e(TAG, "gatt.close: ${e.message}") }
|
||||
gatt = null
|
||||
fitproGatt = null
|
||||
fitproWriteChar = null
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
|
|
@ -499,6 +536,7 @@ class BleHrService : Service() {
|
|||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
try { unregisterReceiver(btStateReceiver) } catch (e: Exception) { /* not registered / already gone */ }
|
||||
disconnect()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue