HR connect: one BT-enable prompt per start, only on user action

setHrOn gains an 'interactive' flag; it pops the OS enable-Bluetooth dialog
only on a user tap (record-screen toggle). Auto-calls at recording start no
longer re-request enable after the single ensureSystemReady prompt, so a
decline is not immediately followed by the system dialog and is not re-asked
until the next start.
This commit is contained in:
4aiman 2026-10-19 13:43:55 +03:00
commit eeca9a7d94
3 changed files with 69 additions and 4 deletions

View file

@ -0,0 +1,54 @@
# Сессия 2026-10-19 - краш при выключении Bluetooth + один запрос на включение вместо нескольких
## Контекст
Пользователь: "при выключении блютуса балдав падает", и попапы про включение BT лезут
повторно ("если запретил - не просить до нового трекинга; просить ОДИН раз, а не 20").
Действовали по утверждённому плану `~/.claude/plans/merry-discovering-dream.md` (финальный
вариант; сохранён в docs/plans не был - план короткий, воспроизведён в этом логе).
## Разбор (две независимые причины)
1. Краш. Крэш-буфер телефона: нативный abort libc FORTIFY "pthread_mutex_lock called on a
destroyed mutex" (pid 19579), не Kotlin-исключение. Причина: живой GATT/scanner трогают
уже после выключения BT-адаптера. В `BleHrService.kt` не было приёмника состояния BT, а
отложенные `scanner.stopScan`, fitpro-ретриггер и периодические хендлеры продолжали дёргать
GATT. Точный фрейм без tombstone не прибивался - фикс сделан defense-in-depth (решение
пользователя: сразу защита + проверка воспроизведением).
2. Двойной запрос. `ensureSystemReady` (main.js) спрашивает один раз через appConfirm, но
`startRecording` авто-зовёт `setHrOn(true)`, а тот при BT off САМ дёргал нативный
`ble_request_enable` (gps.js:899), игнорируя отказ -> второе (системное) окно сразу после
отказа, и так каждый старт.
## Сделано (2 коммита)
Коммит 1 (краш, Kotlin `BleHrService.kt`):
- Единый `handler = Handler(Looper.getMainLooper())`; все postDelayed через него;
`handler.removeCallbacksAndMessages(null)` в `disconnect()` и `onDestroy()`.
- `BroadcastReceiver` на `BluetoothAdapter.ACTION_STATE_CHANGED`: на STATE_TURNING_OFF/OFF -
снять хендлеры, disconnect, `writeData(status:bt_off)`, stopSelf. Снятие в onDestroy в try.
- try/catch на всех нативных BLE-вызовах: startScan/stopScan/connectGatt/discoverServices/
writeCharacteristic/setCharacteristicNotification/writeDescriptor; directConnect проверяет
adapter.isEnabled.
Коммит 2 (попапы, JS):
- `GPS.setHrOn(on, interactive = false)` (gps.js): системное окно `ble_request_enable` только
при `interactive === true`. Авто-режим при BT off: hrOn=false, не подключаться, без окна.
- Авто-вызовы на старте (gps.js 1433 зарядка, 1462 трек) остаются `setHrOn(true)` (без
interactive) -> единственный авто-запрос при старте = один appConfirm в ensureSystemReady.
- Ручной тоггл ЧСС (main.js:5148) -> `setHrOn(..., true)`: одно системное окно на тап.
Ручное "Подключить" (настройки) не трогали - оно и так user-initiated.
## Проверка
Синтаксис JS ок (node -c обоих файлов). На телефоне (сборка `./build.sh -i -d android`):
1) ЧСС подключён, выключить BT в записи -> app НЕ падает, ЧСС гаснет, трек идёт; в logcat
`BleHR` виден STATE_OFF и чистый stop, нет нового FATAL/FORTIFY.
2) BT off, старт трека -> ровно одно окно "Включить Bluetooth?"; отказ -> системное окно НЕ
появляется, до конца записи повторов нет; новый старт -> снова один вопрос.
3) Тап по тогглу ЧСС при BT off -> одно системное окно.
4) Регрессии рядом: зарядка с ЧСС считает kcal; "Не спрашивать" гасят свои запросы;
"Подключить" в настройках работает на ходу.
## Отдельно (read-only, без правок)
Разобрал `wind_marks` в 5 треках из 228: отметки встречного ветра нигде не используются
(коммент в коде - "ground truth для оценки ветра из трека", которой в коде нет), и
эмпирически сигнал противоречив (в 2 из 4 велотреков "в ветер" скорость даже выше). Вывод:
пользы нет; кнопки ветра уже нет, новые треки пишут пустой wind_marks, помех нет - оставили
как есть.

View file

@ -884,7 +884,12 @@ GPS._stopHrMonitor = function() {
// Turn HR on/off during a recording (the toggle on the record screen). ON connects to the
// paired strap (Kotlin scans ~5s and stops cleanly if it's absent — so no constant scanning /
// drain when the strap isn't there) and polls; OFF stops the poll and disconnects.
GPS.setHrOn = async function(on) {
//
// `interactive` = the user tapped something (the record-screen toggle, "Подключить"). Only then
// may we pop the OS "turn on Bluetooth?" dialog when BT is off. Auto-calls at recording start
// pass interactive=false: if BT is off they just leave HR off (no second popup after the single
// start-time prompt in ensureSystemReady), and a later manual connect still works.
GPS.setHrOn = async function(on, interactive = false) {
GPS.recording.hrOn = !!on;
if (!on) {
GPS._stopHrMonitor();
@ -896,8 +901,14 @@ GPS.setHrOn = async function(on) {
if (!addr) { GPS.recording.hrOn = false; return false; }
try {
const bt = await window.__TAURI__.core.invoke("ble_check_bluetooth");
if (bt === "off") { try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {} }
else if (bt !== "no_adapter") { try { await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type || "hr_standard" }); } catch {} }
if (bt === "off") {
// Auto path never nags; only a user tap asks the OS to enable BT. Either way we can't
// connect with the radio off — leave HR off so the toggle shows grey, not a fake "on".
if (interactive) { try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {} }
GPS.recording.hrOn = false;
return false;
}
if (bt !== "no_adapter") { try { await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type || "hr_standard" }); } catch {} }
} catch {}
try { await GPS._startHrMonitor(); } catch {}
return true;

View file

@ -5145,7 +5145,7 @@ async function renderRecordingInline(container) {
let hrToggle = container.querySelector("#rec-hr-toggle");
if (hrToggle) hrToggle.onclick = async () => {
hrToggle.disabled = true;
await GPS.setHrOn(!GPS.recording.hrOn);
await GPS.setHrOn(!GPS.recording.hrOn, true); // user tap → may ask OS to enable BT (once)
hrToggle.classList.toggle("active", !!GPS.recording.hrOn);
if (!GPS.recording.hrOn) { let n = container.querySelector("#rec-hr-num"); if (n) n.textContent = ""; setHrBatteryFill(hrToggle, null); } // off -> icon only, no fill
hrToggle.disabled = false;