Hydration reminder: level-based drink alerts during a ride or VR

A shared Hydration singleton (ticked by the GPS ride service and the HR/VR
service, deduped in one process) accrues the body's water need over an active
session and subtracts what you logged via the "+вода" button. It stays silent
while you keep up — can be quiet for hours — and only beeps LOUD on the alarm
stream (cuts through media volume) once the running deficit passes a threshold,
then re-beeps on a cooldown. Not a fixed timer.

Rust set_hydration_config / hydration_drink bridge JS settings and the "+вода"
button to hydration.json / water_drink.txt that the native service reads.
This commit is contained in:
4aiman 2026-10-24 01:13:43 +03:00
commit 6183f0c529
13 changed files with 271 additions and 6 deletions

2
src-tauri/Cargo.lock generated
View file

@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "baldav"
version = "0.1.515"
version = "0.1.519"
dependencies = [
"base64 0.22.1",
"btleplug",

View file

@ -1,6 +1,6 @@
[package]
name = "baldav"
version = "0.1.515"
version = "0.1.519"
edition = "2021"
[lib]

View file

@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />

View file

@ -83,6 +83,10 @@ class BleHrService : Service() {
// (a stray callback touching a GATT/scanner after the adapter is off crashes the native BT stack).
private val handler = Handler(Looper.getMainLooper())
// Hydration reminder loop — delegates to the shared Hydration singleton (also driven by the GPS
// ride service; the singleton dedups so a ride WITH the strap doesn't double-beep).
private var hydrationTick: Runnable? = null
// 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?) {
@ -306,6 +310,7 @@ class BleHrService : Service() {
"fitpro" -> setupFitPro(g)
}
updateNotification("Пульсометр подключён")
startHydrationReminder() // workout is on (strap connected) → arm the drink reminder
}
override fun onCharacteristicChanged(g: BluetoothGatt, char: BluetoothGattCharacteristic, value: ByteArray) {
@ -613,8 +618,29 @@ class BleHrService : Service() {
nm.notify(NOTIFICATION_ID, buildNotification(text))
}
// --- hydration reminder: tick the shared Hydration singleton every 30 s while recording ------
private fun startHydrationReminder() {
Hydration.reset()
hydrationTick?.let { handler.removeCallbacks(it) }
val r = object : Runnable {
override fun run() {
if (stopping || !isRunning) return
Hydration.tick(this@BleHrService, handler)
handler.postDelayed(this, 30_000L)
}
}
hydrationTick = r
handler.postDelayed(r, 30_000L)
}
private fun stopHydrationReminder() {
hydrationTick?.let { handler.removeCallbacks(it) }
hydrationTick = null
}
override fun onDestroy() {
stopping = true
stopHydrationReminder()
try { unregisterReceiver(btStateReceiver) } catch (e: Exception) { /* not registered / already gone */ }
disconnect()
super.onDestroy()

View file

@ -222,9 +222,12 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener {
wakeLock?.acquire(6 * 60 * 60 * 1000L)
Log.w(TAG, "WakeLock was released — re-acquired")
}
// Drink reminder (shared singleton; dedups with the HR service).
if (isRunning) Hydration.tick(applicationContext, flushHandler)
if (isRunning) scheduleFlush()
}, 10000)
}
Hydration.reset() // full first interval from the ride's start
scheduleFlush()
// Dead reckoning: if GPS silent 60+ sec but accel shows movement → emit estimated point

View file

@ -0,0 +1,119 @@
package dev.baldav.app
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.media.AudioManager
import android.media.ToneGenerator
import android.os.Build
import android.os.Handler
import android.os.VibrationEffect
import android.os.Vibrator
import android.util.Log
import java.io.File
import org.json.JSONObject
// Drink reminder shared by GpsRecorderService (rides) and BleHrService (VR/workout). LEVEL-based,
// not a fixed timer: during an active session the body's water need accrues (sweat); the quick
// "+вода" button (water_drink.txt) subtracts what you drank. It stays silent while you keep up —
// can be quiet for hours — and only beeps LOUD (ALARM stream) when the running deficit exceeds the
// threshold, then re-beeps on a cooldown until you drink. Both services run in one process, so this
// singleton dedups. Config in hydration.json (app root).
object Hydration {
private const val TAG = "Hydration"
private const val CH = "baldav_hydration"
private var accruedMl = 0.0 // need accrued since session start, minus what was drunk
private var lastTickMs = 0L
private var lastBeepMs = 0L
private var lastCfgMs = 0L
private var enabled = false
private var sweatMlPerMin = 10.0 // need rate during an active session (600 ml/h default)
private var alertMl = 600.0 // beep once the deficit reaches this
private var reAlertMs = 15 * 60 * 1000L
private fun readCfg(ctx: Context) {
try {
val f = File(ctx.filesDir.parentFile ?: ctx.filesDir, "hydration.json")
if (f.exists()) {
val j = JSONObject(f.readText())
enabled = j.optBoolean("enabled", false)
sweatMlPerMin = j.optInt("sweatMlPerHour", 600).coerceIn(60, 2000) / 60.0
alertMl = j.optInt("alertMl", 600).coerceIn(100, 3000).toDouble()
reAlertMs = j.optInt("reAlertMin", 15).coerceIn(2, 120) * 60_000L
} else enabled = false
} catch (e: Exception) { Log.e(TAG, "cfg: ${e.message}"); enabled = false }
}
// Call at session start: clear the deficit so a fresh ride/workout starts from zero.
fun reset() { accruedMl = 0.0; lastTickMs = 0L; lastBeepMs = 0L }
// "+вода" writes the drunk ml here (Rust hydration_drink); subtract and consume.
private fun drainDrinks(ctx: Context) {
try {
val f = File(ctx.filesDir.parentFile ?: ctx.filesDir, "water_drink.txt")
if (f.exists()) {
val ml = f.readText().trim().toDoubleOrNull() ?: 0.0
f.delete()
accruedMl -= ml
if (accruedMl < 0) accruedMl = 0.0
}
} catch (e: Exception) { Log.e(TAG, "drink: ${e.message}") }
}
// Call often (every 10-30 s) from any running service. Accrues need, honours drinks, beeps only
// when behind. At most one beep per re-alert window across all callers (process-wide singleton).
fun tick(ctx: Context, handler: Handler) {
val now = System.currentTimeMillis()
if (now - lastCfgMs > 30_000L) { readCfg(ctx); lastCfgMs = now }
if (!enabled) return
if (lastTickMs == 0L) { lastTickMs = now; return }
val dtMin = (now - lastTickMs) / 60_000.0
lastTickMs = now
accruedMl += sweatMlPerMin * dtMin
drainDrinks(ctx)
if (accruedMl >= alertMl && now - lastBeepMs >= reAlertMs) {
lastBeepMs = now
beep(handler); vibrate(ctx); notify(ctx, accruedMl.toInt())
}
}
// Three loud beeps on the ALARM stream — play regardless of media/ring volume (music, VR audio).
private fun beep(handler: Handler) {
try {
val tg = ToneGenerator(AudioManager.STREAM_ALARM, 100)
tg.startTone(ToneGenerator.TONE_CDMA_HIGH_L, 500)
handler.postDelayed({ try { tg.startTone(ToneGenerator.TONE_CDMA_HIGH_L, 500) } catch (e: Exception) {} }, 700)
handler.postDelayed({
try { tg.startTone(ToneGenerator.TONE_CDMA_HIGH_L, 500) } catch (e: Exception) {}
handler.postDelayed({ try { tg.release() } catch (e: Exception) {} }, 700)
}, 1400)
} catch (e: Exception) { Log.e(TAG, "beep: ${e.message}") }
}
private fun vibrate(ctx: Context) {
try {
val v = ctx.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator ?: return
val pattern = longArrayOf(0, 400, 200, 400)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
v.vibrate(VibrationEffect.createWaveform(pattern, -1))
else @Suppress("DEPRECATION") v.vibrate(pattern, -1)
} catch (e: Exception) {}
}
private fun notify(ctx: Context, deficitMl: Int) {
try {
val nm = ctx.getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(CH) == null) {
nm.createNotificationChannel(NotificationChannel(CH, "Напоминание пить", NotificationManager.IMPORTANCE_HIGH))
}
val n = Notification.Builder(ctx, CH)
.setContentTitle("Попей воды!")
.setContentText("Отстаёшь примерно на $deficitMl мл — попей")
.setSmallIcon(android.R.drawable.ic_menu_compass)
.setAutoCancel(true)
.build()
nm.notify(776601, n)
} catch (e: Exception) { Log.e(TAG, "notify: ${e.message}") }
}
}

View file

@ -496,6 +496,45 @@ fn gps_service_command(app: tauri::AppHandle, cmd: String) -> Result<String, Str
Ok(format!("sent:{}", cmd))
}
// Write hydration.json to the app root, where the Hydration singleton reads it: the level-based
// drink reminder (accrue sweat during a session, beep when the deficit exceeds alert_ml).
#[tauri::command]
fn set_hydration_config(app: tauri::AppHandle, enabled: bool, alert_ml: u32, sweat_ml_per_hour: u32, re_alert_min: u32) -> Result<String, String> {
let json = format!(
"{{\"enabled\":{},\"alertMl\":{},\"sweatMlPerHour\":{},\"reAlertMin\":{}}}",
enabled, alert_ml.clamp(100, 3000), sweat_ml_per_hour.clamp(60, 2000), re_alert_min.clamp(2, 120)
);
write_app_root_file(&app, "hydration.json", &json);
Ok(json)
}
// "+вода": add drunk ml to water_drink.txt (read-add-write), which the Hydration singleton consumes
// on its next tick to reduce the deficit. Lands in both candidate app-root paths so Kotlin finds it.
#[tauri::command]
fn hydration_drink(app: tauri::AppHandle, ml: u32) -> Result<String, String> {
// Read the current pending amount from the explicit app root (= Kotlin filesDir.parentFile),
// the same file the Hydration singleton deletes, so the running total stays consistent.
let explicit_root = app.path().app_data_dir().ok()
.and_then(|d| d.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()));
let cur: f64 = explicit_root.as_ref()
.and_then(|r| std::fs::read_to_string(r.join("water_drink.txt")).ok())
.and_then(|s| s.trim().parse().ok()).unwrap_or(0.0);
let total = cur + ml as f64;
write_app_root_file(&app, "water_drink.txt", &format!("{}", total));
Ok(format!("{}", total))
}
// Write a small file to the app root — both where native_root resolves and the explicit
// app_data_dir/../.. (= Kotlin filesDir.parentFile) — so the Kotlin side always finds it.
fn write_app_root_file(app: &tauri::AppHandle, name: &str, contents: &str) {
let _ = std::fs::write(native_root(app).join(name), contents);
if let Ok(data_dir) = app.path().app_data_dir() {
if let Some(root) = data_dir.parent().and_then(|p| p.parent()) {
let _ = std::fs::write(root.join(name), contents);
}
}
}
// Offline routing via the embedded BRouter engine (Kotlin runs btools.router). Same
// file bridge as GPS: write the request + a "route:<id>" command where MainActivity's
// FileObserver watches, then poll for brouter_result_<id>.json. `lonlats` is a JSON
@ -1183,7 +1222,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_read_hr_log, ble::ble_read_strap_uptime, ble::ble_reset_strap_uptime, 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, set_hydration_config, hydration_drink, 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_read_strap_uptime, ble::ble_reset_strap_uptime, ble::ble_request_enable, read_state_file])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View file

@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json",
"productName": "BalDav",
"version": "0.1.515",
"version": "0.1.519",
"identifier": "dev.baldav.app",
"build": {
"frontendDist": "../frontend"