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:
4aiman 2026-10-19 13:43:55 +03:00
commit 13c61f506a
2 changed files with 105 additions and 67 deletions

View file

@ -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/`)

View file

@ -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()
}