diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index fe71580..006451f 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -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` по `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/`) diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt index 46b1506..fb9ec27 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt @@ -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() }