Workout HR: log samples natively so recording survives screen-off

The JS 2s poll that stored workout HR samples is frozen by Android when the
screen is off, so a sleep-long recording kept only the on-screen stretches
(the strap and live display were fine — the persist path was the gap). The
foreground service now appends every reading to ble_hr_log.jsonl; ble_read_hr_log
exposes it; on stop the workout rebuilds its samples from that log (falling back
to the poll array). ble_clear_hr truncates the log at start.
This commit is contained in:
4aiman 2026-10-20 23:09:50 +03:00
commit ee47af4479
6 changed files with 98 additions and 5 deletions

View file

@ -59,6 +59,7 @@ class BleHrService : Service() {
private var gatt: BluetoothGatt? = null
private var deviceType = "hr_standard"
private var dataFile: File? = null
private var logFile: File? = null // append-only HR history (ble_hr_log.jsonl); survives screen-off
private var lastBattery: Int = -1 // strap charge %, -1 = unknown
private var scanResultsFile: File? = null
private var fitproWriteChar: BluetoothGattCharacteristic? = null
@ -115,6 +116,7 @@ class BleHrService : Service() {
isRunning = true
val appDataDir = filesDir.parentFile ?: filesDir
dataFile = File(appDataDir, "ble_hr_data.json")
logFile = File(appDataDir, "ble_hr_log.jsonl")
connectDevice(address)
}
return START_NOT_STICKY // connect is user-initiated; no sticky restart
@ -498,6 +500,12 @@ class BleHrService : Service() {
} catch (e: Exception) {
Log.e(TAG, "Write data failed", e)
}
// Append real HR readings (not status lines) to the history log. This runs in the
// foreground service, so it keeps recording with the screen off — the workout
// recorder rebuilds its samples from here (ble_read_hr_log) instead of the JS poll.
if (json.contains("\"hr\":")) {
try { logFile?.appendText(json + "\n") } catch (e: Exception) { Log.e(TAG, "Append hr log failed", e) }
}
}
private fun disconnect() {

View file

@ -465,6 +465,18 @@ pub fn ble_get_hr(
{ android::read_hr_data(&app) }
}
// Full HR history for the current workout. The Kotlin foreground service appends every
// reading to ble_hr_log.jsonl (one {"hr","ts"[,"bat"]} per line) — that keeps logging with
// the screen off, unlike the JS poll which Android freezes in the background. The workout
// recorder reads this at finalize to rebuild a gap-free sample series. Returns "" if none.
#[tauri::command]
pub fn ble_read_hr_log(app: tauri::AppHandle) -> Result<String, String> {
#[cfg(not(target_os = "android"))]
{ let _ = &app; Ok(String::new()) }
#[cfg(target_os = "android")]
{ Ok(android::read_file(&app, "ble_hr_log.jsonl").unwrap_or_default()) }
}
#[tauri::command]
pub fn ble_is_connected(
app: tauri::AppHandle,
@ -488,10 +500,14 @@ pub fn ble_is_connected(
pub fn ble_clear_hr(app: tauri::AppHandle) -> Result<(), String> {
use tauri::Manager;
let data_dir = app.path().app_data_dir().map_err(|e: tauri::Error| e.to_string())?;
// Clear HR data file so stale values don't show on next session
// Clear HR data file so stale values don't show on next session, and truncate the
// native HR history log (the foreground service appends every reading there — see
// ble_read_hr_log) so a new workout starts from an empty log.
let _ = std::fs::write(data_dir.join("ble_hr_data.json"), "{}");
let _ = std::fs::write(data_dir.join("ble_hr_log.jsonl"), "");
if let Some(root) = data_dir.parent().and_then(|p| p.parent()) {
let _ = std::fs::write(root.join("ble_hr_data.json"), "{}");
let _ = std::fs::write(root.join("ble_hr_log.jsonl"), "");
}
#[cfg(not(target_os = "android"))]
{

View file

@ -1162,7 +1162,7 @@ pub fn run() {
.add_migrations("sqlite:baldav.db", migrations)
.build(),
)
.invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, export_db, import_db, restart_app, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr, ble::ble_request_enable, read_state_file])
.invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, export_db, import_db, restart_app, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr, ble::ble_read_hr_log, ble::ble_request_enable, read_state_file])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}