From 1410062d8518ea5f448f80bb109b3e831bb281fb Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 26 Oct 2026 16:12:11 +0300 Subject: [PATCH 1/4] =?UTF-8?q?Live=20location=20sharing=20(botovod)=20+?= =?UTF-8?q?=20SMS=20'=D1=8F=20=D1=82=D1=83=D1=82'=20with=20Yandex=20Maps?= =?UTF-8?q?=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature 1 - opt-in live location: - set_live_config Rust command writes live_share.json (url/token stay on device) - LiveShare.kt singleton posts the current fix to /api/live/push every ~10s from the GPS flush loop, on a background thread; diagnostics in live_share_log.jsonl - profile toggle + endpoint/token fields, off by default; server side is in botovod Feature 2 - SMS 'я тут': - send_sms / pick_contact / get_contact_result Rust commands - MainActivity: system contact picker (no READ_CONTACTS), direct SmsManager send when SEND_SMS is granted else the prefilled system composer; SEND_SMS in the manifest - contact config in profile (manual + picker), 'СМС' button on the record screen with an in-app confirm; text carries coords + a Yandex Maps pin link README + session log updated. --- README.md | 2 + .../2026-10-26_live-location-and-sms.md | 72 +++++++++++++ frontend/js/main.js | 86 +++++++++++++++ .../android/app/src/main/AndroidManifest.xml | 1 + .../java/dev/baldav/app/GpsRecorderService.kt | 5 + .../src/main/java/dev/baldav/app/LiveShare.kt | 100 ++++++++++++++++++ .../main/java/dev/baldav/app/MainActivity.kt | 95 +++++++++++++++++ src-tauri/src/lib.rs | 55 +++++++++- 8 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 docs/session-logs/2026-10-26_live-location-and-sms.md create mode 100644 src-tauri/gen/android/app/src/main/java/dev/baldav/app/LiveShare.kt diff --git a/README.md b/README.md index 70786a7..71b57fa 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ - **Тёмная карта** — тумблер `☾` на экранах записи, деталей трека и дорог: инвертирует слой тайлов (`invert + hue-rotate 180° + brightness`, трек/маршрут остаются цветными), выбор запоминается. Оверлей дорог получает ТОТ ЖЕ фильтр (пан `roadsPane`), чтобы его цвета сдвигались вместе с тайлами и оставались контрастными на тёмной карте. Работает одинаково на офлайн-тайлах и онлайне, без ключей/квот/перепаковки. - **Метки ветра** — кнопка «Ветер» на экране записи: тап открывает интервал встречного ветра, тап закрывает. Метки хранятся с треком (`wind_marks`) как эталон для проверки оценки ветра по треку; на расчёт калорий/мощности не влияют. Голосовые метки — на будущее. - **Слежение карты (тумблер `⊕`)** — на экране записи карта по умолчанию центрируется на текущей позиции при каждой новой точке. Кнопка `⊕` (подсвечена = слежу) выключает автоцентрирование: карту можно свободно подвигать и осмотреться, когда «где я» непонятно (отклонился от трека или трек ведёт туда, где дороги нет), а новые точки её не возвращают. Повторный тап центрирует на текущей позиции и включает слежение снова. По умолчанию включено при каждом заходе (не запоминается). +- **Трансляция местоположения (опционально)** — галка в профиле: пока включено, во время записи заезда телефон раз в 10с отправляет текущую точку на свой сервер (botovod на VPS, endpoint `/api/live/push`, авторизация токеном - адрес и токен хранятся только на устройстве). Карта `/live` показывает точку и короткий хвост и доступна **только после входа** тем, кто в списке доступа (SRP-авторизация botovod). Выключено по умолчанию, диагностика пушей в `live_share_log.jsonl`. +- **СМС «я тут»** — настраивается контакт (ввод номера вручную ИЛИ выбор из системных контактов); во время записи кнопка **«СМС»** по подтверждению шлёт на него текст с координатами и ссылкой на Яндекс.Карты. Если дано разрешение SEND_SMS - отправляется напрямую по сотовой сети (работает на 2G/EDGE без интернета), иначе открывается системный SMS-композер с предзаполненным текстом. - **Экспорт/импорт** — свои продукты, рецепты и добавки одним файлом (JSON). - **Вес, сон, добавки** — логирование с историей. diff --git a/docs/session-logs/2026-10-26_live-location-and-sms.md b/docs/session-logs/2026-10-26_live-location-and-sms.md new file mode 100644 index 0000000..930367b --- /dev/null +++ b/docs/session-logs/2026-10-26_live-location-and-sms.md @@ -0,0 +1,72 @@ +# 2026-10-26 - Трансляция местоположения (botovod) + СМС «я тут» + +Плана в `docs/plans/` под эти две фичи не было - делали по устной постановке пользователя +в этой сессии. + +## Feature 1: live-локация через botovod (этап 1) + +Постановка (подтверждена): точка со хвостом, отображение ТОЛЬКО после входа, доступ у админа +и выбранных. Отправка раз в 10 секунд через интернет (пользователь поменял с минуты: +«гадать что случилось за 10 секунд легче чем за минуту»). Переиспользуем SRP-авторизацию botovod. + +### Серверная часть (botovod, /home/chaiman/dev/vps/botovod -> на VPS /home/chaiman/botovod) +- `POST /api/live/push` - телефон шлёт `{token,lat,lng,spd,ts}`, авторизация общим токеном (без + сессии, это фоновый сервис). Пишет `db/live.json` (last + хвост, максимум 240 точек ~40 мин). +- `GET /api/live/pos` - отдаёт last+trail+stale, только залогиненному зрителю из списка доступа + (`get_session_user` сверяется с `db/live_config.json.viewers`). Иначе 401. +- `GET /live` - страница карты (`web/live.html`, Leaflet + публичные тайлы OSM, поллинг pos раз + в 5с, точка + хвост, бар статуса в эфире/нет свежих). Без входа - 302 на `/login`. +- Конфиг: `db/live_config.json` `{token, viewers:["4aiman","key55"]}`; состояние `db/live.json`. + Оба в `.gitignore` botovod - токен в репозиторий не попадает. Код закоммичен (botovod + `271d1a6`), секреты - нет. +- Заведён пользователь `key55` (SRP, пароль задал пользователь; отдаст жене и сменит). + +### Деплой и проверка на стенде (4aiman.top) +- Серверная app.js == локальная база (md5 совпал) до правок - затёрли ровно её + свои роуты. + Бэкап: `app.js.bak-pre-live-*` на VPS. Залиты app.js, web/live.html, db/live*.json, + db/users/key55.json (только эти, не вся db/), владелец chaiman, `node -c` на VPS OK, рестарт + botovod.service - active. +- Проверено curl на живом 4aiman.top: неверный токен 401, верный push 200 `{ok:true}` + (точка записалась в live.json), pos без сессии 401, `/live` без сессии 302 -> /login, кривые + координаты 400. Тестовую точку из live.json убрал. + +### Часть в приложении (baldav) +- Rust `set_live_config(enabled,url,token)` -> `live_share.json` в корне приложения + (`write_app_root_file`). url/token только на устройстве. +- Kotlin `LiveShare.kt` (синглтон, как Hydration): читает конфиг, раз в 10с из flush-петли + `GpsRecorderService` шлёт POST в фоновом потоке (HttpURLConnection, таймауты 5с), диагностика + в `live_share_log.jsonl`. Проводка: `lastSpeed`, `LiveShare.tick(...)` в петле, `reset()` на + старте сессии. +- UI: галка + поля адреса/токена в профиле; дефолт адреса `https://4aiman.top/api/live/push`, + выключено по умолчанию. Синк конфига на старте приложения (как гидратация). + +## Feature 2: СМС «я тут» + +Постановка: интент с предзаполненным текстом, ссылка на **Яндекс.Карты** (не гугл); контакт - +и выбор из системных, и ручной ввод; кнопка во время трекинга -> подтверждение -> отправка. +Работает на 2G/EDGE без интернета. + +- Решение по отправке: если дано SEND_SMS - шлём напрямую `SmsManager` (одно подтверждение в + приложении); иначе запрашиваем разрешение на будущее и открываем системный SMS-композер с + предзаполненным текстом. Так работает в любом случае, ОС сама даёт выбор. (Это осознанный + компромисс, не заглушка.) +- Rust: `send_sms(number,text)` (пишет `sms_request.json` + команду `send_sms` в мост), + `pick_contact()` (команда `pick_contact`), `get_contact_result()` (читает+чистит + `contact_result.json`). +- Kotlin (MainActivity): `pickContactLauncher` (ACTION_PICK по Phone.CONTENT_URI, номер читается + из URI без READ_CONTACTS), команды `pick_contact`/`send_sms` в `processGpsCommand`, хелпер + `sendSms` (прямая отправка или фолбэк на композер). Разрешение `SEND_SMS` в манифесте. +- UI: контакт в профиле (ручной ввод + «Выбрать из контактов» с поллингом результата пикера); + текст `Я тут: , https://yandex.ru/maps/?pt=,&z=16&l=map`; кнопка «СМС» в + панели записи (видна, только если номер настроен), подтверждение через `appConfirm`. + +## Статус проверки +- Серверная часть Feature 1 - проверена на стенде (4aiman.top), работает. +- Приложение (обе фичи) - собрано чисто, **build 0.1.524**, Rust+Kotlin компилируются. На + устройстве НЕ проверено: телефон не был подключён на момент сборки. Поставить и проверить: + (1) пуш координат и карту `/live` под key55; (2) выбор контакта и отправку СМС. + +## TODO (следующий этап live-локации, записано со слов пользователя) +- Отправлять на VPS все сохранённые маршруты из меню baldav + слать id маршрута вместе с + координатами; опционально показывать сохранённые «проезженные дороги» как в baldav; key55 + должен видеть то же, что на экране трекинга; шарить статические данные из baldav по интернету. diff --git a/frontend/js/main.js b/frontend/js/main.js index 384618c..b3087e3 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -123,6 +123,15 @@ async function init() { window.__TAURI__?.core?.invoke("set_hydration_config", { enabled: on, alertMl, sweatMlPerHour: 600, reAlertMin: 15, startDeficitMl: 0 }); } catch (e) {} + // Live location sharing is OFF by default — keep the native config file in sync with the UI + // toggle so a session always sees the current choice (off unless the user turned it on). + try { + let liveOn = localStorage.getItem("baldav_live_on") === "1"; + let liveUrl = localStorage.getItem("baldav_live_url") || "https://4aiman.top/api/live/push"; + let liveToken = localStorage.getItem("baldav_live_token") || ""; + window.__TAURI__?.core?.invoke("set_live_config", + { enabled: liveOn, url: liveUrl, token: liveToken }); + } catch (e) {} if (typeof DB === "undefined" || typeof DB.getFullProfile !== "function") { app.innerHTML = `
Ошибка: db.js не загрузился.
@@ -4702,6 +4711,29 @@ function renderWorkoutInline(container) { }; } +// Send an SMS with the current position and a Yandex Maps pin link. Direct send if the system +// granted SEND_SMS (the in-app confirm is the only tap), otherwise the native side opens the SMS +// app prefilled. Works over 2G/EDGE with no internet — it is a plain text message. +async function sendLocationSms() { + let number = (localStorage.getItem("baldav_sms_number") || "").trim(); + if (!number) { appAlert("Сначала укажи номер в профиле"); return; } + let pts = GPS.recording.points || []; + let last = GPS.recording.lastMovingPoint || pts[pts.length - 1]; + if (!last || last.lat == null) { appAlert("Нет координат — подожди фикс GPS"); return; } + let lat = (+last.lat).toFixed(5), lon = (+last.lon).toFixed(5); + // Yandex Maps: pt=lon,lat drops a pin at the position + let link = `https://yandex.ru/maps/?pt=${lon},${lat}&z=16&l=map`; + let text = `Я тут: ${lat},${lon} ${link}`; + let esc = s => String(s).replace(/
Кому: ${esc(number)}

${esc(text)}`, + "Отправить"); + if (!ok) return; + try { + await window.__TAURI__.core.invoke("send_sms", { number, text }); + } catch (e) { appAlert("Ошибка отправки: " + e); } +} + async function renderRecordingInline(container) { if (!GPS.recording.active) { renderScreen("gps"); return; } @@ -4735,6 +4767,7 @@ async function renderRecordingInline(container) {
+ ${localStorage.getItem("baldav_sms_number") ? '' : ''}
@@ -5286,6 +5319,10 @@ async function renderRecordingInline(container) { } }; + // SMS "я тут" button — only present when a contact number is configured + let smsBtn = container.querySelector("#rec-sms"); + if (smsBtn) smsBtn.onclick = () => sendLocationSms(); + // Stop button container.querySelector("#rec-stop").onclick = async () => { if (GPS.recording._compassCleanup) GPS.recording._compassCleanup(); @@ -8235,6 +8272,21 @@ function showProfileModal() {
Во время заезда/VR копится потребность в воде (пот), а выпитое берётся из того, что ты логируешь в баланс (жидкость по water_pct). Молчит, пока успеваешь пить; громкий сигнал (канал будильника, пробивает громкость) - только когда отстаёшь сверх порога.
+ +
+ +
Пока включено, во время записи заезда телефон отправляет текущую точку на сервер раз в 10 секунд. Видно только тем, кому дан доступ, и только после входа. Выключается здесь же.
+ + +
+
+ + + +
Во время записи появится кнопка «СМС»: по нажатию спросит подтверждение и отправит на этот номер текст с координатами и ссылкой на Яндекс.Карты. Номер можно вписать вручную или выбрать из контактов. Работает без интернета (по сотовой сети).
`, async overlay => { let weight = parseFloat(overlay.querySelector("#m-weight").value) || null; @@ -8258,6 +8310,17 @@ function showProfileModal() { localStorage.setItem("baldav_hydr_alert", String(hydrAlert)); try { await window.__TAURI__.core.invoke("set_hydration_config", { enabled: hydrOn, alertMl: hydrAlert, sweatMlPerHour: 600, reAlertMin: 15, startDeficitMl: 0 }); } catch (e) { console.error("hydration cfg", e); } + let liveOn = !!overlay.querySelector("#m-live-on")?.checked; + let liveUrl = (overlay.querySelector("#m-live-url")?.value || "").trim(); + let liveToken = (overlay.querySelector("#m-live-token")?.value || "").trim(); + localStorage.setItem("baldav_live_on", liveOn ? "1" : "0"); + localStorage.setItem("baldav_live_url", liveUrl); + localStorage.setItem("baldav_live_token", liveToken); + try { await window.__TAURI__.core.invoke("set_live_config", { enabled: liveOn, url: liveUrl, token: liveToken }); } catch (e) { console.error("live cfg", e); } + + let smsNumber = (overlay.querySelector("#m-sms-number")?.value || "").trim(); + localStorage.setItem("baldav_sms_number", smsNumber); + state.profile = { ...state.profile, weight_kg: weight ? weight : state.profile.weight_kg, height_cm: String(height), birth_year: String(year), sex: selectedSex, @@ -8295,6 +8358,29 @@ function showProfileModal() { let el = overlay.querySelector(id); if (el) el.oninput = updateBmrDisplay; } + // Pick a phone number from the system contacts (fills the SMS number field) + let pickBtn = overlay.querySelector("#m-sms-pick"); + if (pickBtn) pickBtn.onclick = async () => { + let field = overlay.querySelector("#m-sms-number"); + pickBtn.disabled = true; + try { + await window.__TAURI__.core.invoke("pick_contact"); + // The picker is an Activity; poll for its result for a few seconds. + let got = null; + for (let i = 0; i < 40 && !got; i++) { + await new Promise(r => setTimeout(r, 300)); + let raw = await window.__TAURI__.core.invoke("get_contact_result"); + if (raw) got = JSON.parse(raw); + } + if (got && got.number) { + field.value = got.number; + } + } catch (e) { + appAlert("Не удалось выбрать контакт: " + e); + } finally { + pickBtn.disabled = false; + } + }; }, 100); } diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index a7feaf4..8c7f69d 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/GpsRecorderService.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/GpsRecorderService.kt index 385828b..e070808 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/GpsRecorderService.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/GpsRecorderService.kt @@ -224,10 +224,13 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener { } // Drink reminder (shared singleton; dedups with the HR service). if (isRunning) Hydration.tick(applicationContext, flushHandler) + // Live position sharing (opt-in; no-op unless enabled in settings). + if (isRunning) LiveShare.tick(applicationContext, lastLat, lastLon, lastSpeed, lastTime) if (isRunning) scheduleFlush() }, 10000) } Hydration.reset() // full first interval from the ride's start + LiveShare.reset() // re-read live-share config for this session scheduleFlush() // Dead reckoning: if GPS silent 60+ sec but accel shows movement → emit estimated point @@ -282,6 +285,7 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener { private var lastLat = 0.0 private var lastLon = 0.0 private var lastTime = 0L + private var lastSpeed = 0f // m/s of the last fix, for live sharing private var lastGpsFixTime = 0L // wall-clock of last real GPS fix — to prefer GPS over network private var pointCount = 0 @@ -451,6 +455,7 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener { lastLat = location.latitude lastLon = location.longitude lastTime = now + lastSpeed = location.speed lastGoodPointTime = now // Calibrate DR speed from GPS if (location.speed > 0.5f && location.speed < 10f) { diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/LiveShare.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/LiveShare.kt new file mode 100644 index 0000000..48497f4 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/LiveShare.kt @@ -0,0 +1,100 @@ +package dev.baldav.app + +import android.content.Context +import android.util.Log +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import org.json.JSONObject + +// Opt-in live position sharing. When enabled in settings, the GPS service hands the current fix +// here every ~10s (LiveShare.tick) and we POST it to botovod's /api/live/push, authenticated by a +// shared token. Only the latest point is sent; the server keeps a short trail. Everything is +// best-effort and fully off unless the user turned it on. Config lives in live_share.json in the +// app root (written by the set_live_config Rust command); url/token stay on the device only. +object LiveShare { + private const val TAG = "LiveShare" + + @Volatile private var enabled = false + @Volatile private var url = "" + @Volatile private var token = "" + @Volatile private var loaded = false + @Volatile private var lastPushTs = 0L + + private fun root(ctx: Context): File = ctx.filesDir.parentFile ?: ctx.filesDir + + private fun readCfg(ctx: Context) { + try { + val f = File(root(ctx), "live_share.json") + if (!f.exists()) { + enabled = false + } else { + val o = JSONObject(f.readText()) + enabled = o.optBoolean("enabled", false) + url = o.optString("url", "") + token = o.optString("token", "") + } + } catch (e: Exception) { + Log.w(TAG, "readCfg: ${e.message}") + enabled = false + } + loaded = true + } + + // Call at the start of a recording session so config changes take effect next ride. + fun reset() { loaded = false } + + // Latest fix from the GPS flush loop (every ~10s). Non-blocking: the network call runs on a + // throwaway thread so it never stalls the main looper that drives GPS delivery. + fun tick(ctx: Context, lat: Double, lon: Double, speed: Float, fixTs: Long) { + if (!loaded) readCfg(ctx) + if (!enabled || url.isBlank() || token.isBlank()) return + if (lat == 0.0 && lon == 0.0) return // no fix yet + val now = System.currentTimeMillis() + if (now - lastPushTs < 5000) return // one push per flush interval at most + lastPushTs = now + + val payload = JSONObject().apply { + put("token", token) + put("lat", lat) + put("lng", lon) + put("spd", speed) + put("ts", fixTs) + }.toString() + val u = url + + Thread { + var code = -1 + var err: String? = null + try { + val conn = (URL(u).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = 5000 + readTimeout = 5000 + doOutput = true + setRequestProperty("Content-Type", "application/json") + } + conn.outputStream.use { it.write(payload.toByteArray(Charsets.UTF_8)) } + code = conn.responseCode + conn.disconnect() + } catch (e: Exception) { + err = e.message + } + logLine(ctx, now, code, err) + }.start() + } + + // Diagnostics: one line per push attempt in live_share_log.jsonl, readable after a ride to see + // whether it was enabled, whether pushes went out and what the server answered. + private fun logLine(ctx: Context, ts: Long, code: Int, err: String?) { + try { + val line = JSONObject().apply { + put("ts", ts) + put("enabled", enabled) + put("httpCode", code) + if (err != null) put("error", err) + }.toString() + File(root(ctx), "live_share_log.jsonl").appendText(line + "\n") + } catch (_: Exception) {} + } +} diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt index caf488c..12cf948 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt @@ -10,6 +10,8 @@ import android.os.Build import android.provider.Settings import android.os.Bundle import android.os.FileObserver +import android.provider.ContactsContract +import android.telephony.SmsManager import android.util.Log import android.webkit.WebView import androidx.activity.OnBackPressedCallback @@ -136,6 +138,38 @@ class MainActivity : TauriActivity() { } } + // Contact picker for the SMS "я тут" feature. ACTION_PICK on the phone-number table returns a + // data-row URI; we read the number straight from it (the picker grants access to just that row, + // so no READ_CONTACTS permission is needed) and drop it in contact_result.json for the UI. + private val pickContactLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + val uri = result.data?.data + if (result.resultCode == android.app.Activity.RESULT_OK && uri != null) { + try { + contentResolver.query( + uri, + arrayOf( + ContactsContract.CommonDataKinds.Phone.NUMBER, + ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + ), + null, null, null + )?.use { c -> + if (c.moveToFirst()) { + val number = c.getString(0) ?: "" + val name = if (c.columnCount > 1) (c.getString(1) ?: "") else "" + val appDataDir = filesDir.parentFile ?: filesDir + val json = org.json.JSONObject().apply { put("number", number); put("name", name) }.toString() + File(appDataDir, "contact_result.json").writeText(json) + Log.d("BalDav", "Contact picked: $name") + } + } + } catch (e: Exception) { + Log.w("BalDav", "contact pick failed: ${e.message}") + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -426,6 +460,45 @@ class MainActivity : TauriActivity() { return track.formatAsGpx() } + // Send the location SMS. If SEND_SMS was granted, send it straight over the cellular network + // (the in-app confirm was the only tap). Otherwise request the permission for next time and fall + // back to the system SMS composer, prefilled — which needs no permission. Either path works with + // no internet, over 2G/EDGE. + private fun sendSms(number: String, text: String) { + if (number.isBlank()) return + val granted = checkSelfPermission(android.Manifest.permission.SEND_SMS) == PackageManager.PERMISSION_GRANTED + if (granted) { + try { + val sm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + getSystemService(SmsManager::class.java) + else + @Suppress("DEPRECATION") SmsManager.getDefault() + val parts = sm.divideMessage(text) + if (parts.size > 1) sm.sendMultipartTextMessage(number, null, parts, null, null) + else sm.sendTextMessage(number, null, text, null, null) + Log.d("BalDav", "SMS sent directly to $number") + runOnUiThread { + android.widget.Toast.makeText(this, "СМС отправлено", android.widget.Toast.LENGTH_SHORT).show() + } + return + } catch (e: Exception) { + Log.w("BalDav", "direct SMS failed, falling back to composer: ${e.message}") + } + } else { + // Ask for the permission so the next send can go directly; this one still uses the composer. + try { requestPermissions(arrayOf(android.Manifest.permission.SEND_SMS), 4343) } catch (_: Exception) {} + } + try { + val intent = Intent(Intent.ACTION_SENDTO, Uri.parse("smsto:$number")).apply { + putExtra("sms_body", text) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(intent) + } catch (e: Exception) { + Log.w("BalDav", "SMS composer failed: ${e.message}") + } + } + private fun processGpsCommand(appDataDir: File) { val cmdFile = File(appDataDir, "gps_command.txt") if (!cmdFile.exists()) return @@ -478,6 +551,28 @@ class MainActivity : TauriActivity() { cmd == "reqbgloc" -> { requestBackgroundLocation() } + cmd == "pick_contact" -> { + try { + val intent = Intent(Intent.ACTION_PICK, ContactsContract.CommonDataKinds.Phone.CONTENT_URI) + pickContactLauncher.launch(intent) + } catch (e: Exception) { + Log.w("BalDav", "pick_contact launch failed: ${e.message}") + } + } + cmd == "send_sms" -> { + val reqFile = File(appDataDir, "sms_request.json") + if (reqFile.exists()) { + try { + val o = org.json.JSONObject(reqFile.readText()) + val number = o.optString("number", "") + val text = o.optString("text", "") + reqFile.writeText("") + sendSms(number, text) + } catch (e: Exception) { + Log.w("BalDav", "send_sms parse failed: ${e.message}") + } + } + } cmd == "ble_scan" -> { Log.d("BalDav", "Starting BLE scan") writeBtState(appDataDir) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 719a001..814285f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -510,6 +510,59 @@ fn set_hydration_config(app: tauri::AppHandle, enabled: bool, alert_ml: u32, swe Ok(json) } +// Write live_share.json to the app root, where the LiveShare singleton reads it. Opt-in live +// position sharing: when enabled, the GPS service POSTs the current fix to `url` (botovod's +// /api/live/push) every ~10s, authenticated by `token`. url/token are kept only on the device +// (this runtime file) and never committed. From the UI, disabling just flips enabled=false. +#[tauri::command] +fn set_live_config(app: tauri::AppHandle, enabled: bool, url: String, token: String) -> Result { + let json = serde_json::json!({ + "enabled": enabled, + "url": url, + "token": token, + }).to_string(); + write_app_root_file(&app, "live_share.json", &json); + Ok(json) +} + +// SMS "я тут": write the request (number + prefilled text with a Yandex Maps link) where the +// Kotlin side reads it, then trigger the command. MainActivity sends it directly if SEND_SMS was +// granted, otherwise opens the system SMS composer prefilled. Works over 2G/EDGE, no internet. +#[tauri::command] +fn send_sms(app: tauri::AppHandle, number: String, text: String) -> Result { + let json = serde_json::json!({ "number": number, "text": text }).to_string(); + write_app_root_file(&app, "sms_request.json", &json); + gps_service_command(app, "send_sms".to_string()) +} + +// Launch the system contact picker (an Activity); the chosen number lands in contact_result.json, +// which the UI reads via get_contact_result. +#[tauri::command] +fn pick_contact(app: tauri::AppHandle) -> Result { + gps_service_command(app, "pick_contact".to_string()) +} + +// Read and clear the contact picker result (JSON {number,name}); empty string if not ready yet. +#[tauri::command] +fn get_contact_result(app: tauri::AppHandle) -> Result { + let mut candidates: Vec = vec![native_root(&app).join("contact_result.json")]; + if let Ok(data_dir) = app.path().app_data_dir() { + if let Some(root) = data_dir.parent().and_then(|p| p.parent()) { + candidates.push(root.join("contact_result.json")); + } + } + for p in candidates { + if p.exists() { + let content = std::fs::read_to_string(&p).unwrap_or_default(); + let _ = std::fs::remove_file(&p); + if !content.trim().is_empty() { + return Ok(content); + } + } + } + Ok(String::new()) +} + // 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) { @@ -1208,7 +1261,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, set_hydration_config, 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, set_live_config, send_sms, pick_contact, get_contact_result, 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"); } From 738c4e81a1a437231d9781c19b565efec0c57481 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 26 Oct 2026 18:25:07 +0300 Subject: [PATCH 2/4] Profile: render settings checkboxes as toggle switches The .form-group input rule (width:100% + appearance:none) stretched every checkbox into an empty full-width box. Add a .toggle class styling a checkbox as a sliding on/off switch and apply it to the TEF, hydration and live-share toggles in the profile modal. --- frontend/css/styles.css | 36 ++++++++++++++++++++++++++++++++++++ frontend/js/main.js | 6 +++--- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/frontend/css/styles.css b/frontend/css/styles.css index c229785..6a43e94 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -257,6 +257,42 @@ body { appearance: none; } +/* Toggle switch: a checkbox styled as a sliding on/off switch. Overrides the + full-width .form-group input rule above (which otherwise stretches a checkbox + into an empty box). Apply class="toggle" to . */ +.form-group input[type="checkbox"].toggle { + width: 46px; + height: 26px; + flex: 0 0 auto; + padding: 0; + border-radius: 13px; + background: var(--bg); + border: 1px solid var(--border); + position: relative; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.form-group input[type="checkbox"].toggle::after { + content: ""; + position: absolute; + top: 50%; + left: 3px; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--muted); + transform: translateY(-50%); + transition: left 0.15s, background 0.15s; +} +.form-group input[type="checkbox"].toggle:checked { + background: var(--primary); + border-color: var(--primary); +} +.form-group input[type="checkbox"].toggle:checked::after { + left: 23px; + background: #fff; +} + select { background: var(--bg); color: var(--on-surface); diff --git a/frontend/js/main.js b/frontend/js/main.js index b3087e3..4886587 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -8256,7 +8256,7 @@ function showProfileModal() {
Часть съеденного тратится на переваривание. Расход масштабируется от еды; на поддержке баланс не меняется.
@@ -8267,7 +8267,7 @@ function showProfileModal() {
Во время заезда/VR копится потребность в воде (пот), а выпитое берётся из того, что ты логируешь в баланс (жидкость по water_pct). Молчит, пока успеваешь пить; громкий сигнал (канал будильника, пробивает громкость) - только когда отстаёшь сверх порога.
@@ -8275,7 +8275,7 @@ function showProfileModal() {
Пока включено, во время записи заезда телефон отправляет текущую точку на сервер раз в 10 секунд. Видно только тем, кому дан доступ, и только после входа. Выключается здесь же.
From c70cc1e4a44ec903a7b36e5f3501b472a6716122 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 26 Oct 2026 19:13:34 +0300 Subject: [PATCH 3/4] Record screen: LIVE + SMS as square buttons bottom-left; toggle live on the fly - move the SMS button out of the bottom panel into a left-hand square-button group (bottom-left, opposite the right control stack), add a LIVE toggle next to it - LIVE turns location sharing on/off right from the record screen, even if it was never enabled in the profile; warns if the token is still unset - LiveShare re-reads its config every tick so the toggle takes effect within ~10s --- frontend/css/styles.css | 3 ++ frontend/js/main.js | 42 +++++++++++++++---- .../src/main/java/dev/baldav/app/LiveShare.kt | 3 +- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 6a43e94..60e6ebb 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -1315,6 +1315,9 @@ select { zoom controls. Give them slots in the right-hand stack. */ .rec-nogo-btn { right: 8px; bottom: 314px; } .rec-reroute-btn { right: 8px; bottom: 352px; } +/* Left-hand group (bottom-left), opposite the right control stack: SMS + live toggle. */ +.rec-sms-btn { left: 8px; bottom: 48px; font-size: 11px; } +.rec-live-btn { left: 8px; bottom: 86px; font-size: 10px; } /* Dark map: invert ONLY the tile layer (track, route, puck keep their colours). User-tuned filter on OSM standard tiles. Applies to every Leaflet map. */ body.dark-map .leaflet-tile-pane { filter: invert(1) hue-rotate(180deg) brightness(1.5); } diff --git a/frontend/js/main.js b/frontend/js/main.js index 4886587..adc76c7 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -127,7 +127,12 @@ async function init() { // toggle so a session always sees the current choice (off unless the user turned it on). try { let liveOn = localStorage.getItem("baldav_live_on") === "1"; - let liveUrl = localStorage.getItem("baldav_live_url") || "https://4aiman.top/api/live/push"; + let liveUrl = localStorage.getItem("baldav_live_url") || "https://4aiman.top/api/trackme/push"; + // migrate the old /api/live/push endpoint to /api/trackme/push (renamed server-side) + if (liveUrl.endsWith("/api/live/push")) { + liveUrl = liveUrl.replace("/api/live/push", "/api/trackme/push"); + localStorage.setItem("baldav_live_url", liveUrl); + } let liveToken = localStorage.getItem("baldav_live_token") || ""; window.__TAURI__?.core?.invoke("set_live_config", { enabled: liveOn, url: liveUrl, token: liveToken }); @@ -4767,7 +4772,6 @@ async function renderRecordingInline(container) {
- ${localStorage.getItem("baldav_sms_number") ? '' : ''}
@@ -4993,6 +4997,34 @@ async function renderRecordingInline(container) { }; btnWrap.appendChild(followBtn); + // Left-side group (bottom-left, opposite the right controls): live-location toggle + SMS. + // Live sharing can be switched on/off right here even if it was never set in the profile. + let liveBtn = document.createElement("button"); + liveBtn.className = "rec-map-btn rec-live-btn"; + liveBtn.textContent = "LIVE"; + liveBtn.title = "Трансляция местоположения вкл/выкл"; + let liveOn = localStorage.getItem("baldav_live_on") === "1"; + liveBtn.classList.toggle("active", liveOn); + liveBtn.onclick = async () => { + liveOn = !liveOn; + localStorage.setItem("baldav_live_on", liveOn ? "1" : "0"); + liveBtn.classList.toggle("active", liveOn); + let url = localStorage.getItem("baldav_live_url") || "https://4aiman.top/api/trackme/push"; + let token = localStorage.getItem("baldav_live_token") || ""; + try { await window.__TAURI__.core.invoke("set_live_config", { enabled: liveOn, url, token }); } + catch (e) { console.error("live toggle", e); } + if (liveOn && !token) appAlert("Трансляция включена, но токен не задан в профиле - точки не уйдут, пока не впишешь токен."); + }; + btnWrap.appendChild(liveBtn); + + // SMS "я тут" button + let smsBtn = document.createElement("button"); + smsBtn.className = "rec-map-btn rec-sms-btn"; + smsBtn.textContent = "СМС"; + smsBtn.title = "Отправить СМС с местоположением"; + smsBtn.onclick = () => sendLocationSms(); + btnWrap.appendChild(smsBtn); + // Roads toggle button. Roads are viewport-culled onto a canvas pane (only the segments in // view are drawn, redrawn on pan/zoom) so a huge road network doesn't jank the WebView. let roadsBtn = document.createElement("button"); @@ -5319,10 +5351,6 @@ async function renderRecordingInline(container) { } }; - // SMS "я тут" button — only present when a contact number is configured - let smsBtn = container.querySelector("#rec-sms"); - if (smsBtn) smsBtn.onclick = () => sendLocationSms(); - // Stop button container.querySelector("#rec-stop").onclick = async () => { if (GPS.recording._compassCleanup) GPS.recording._compassCleanup(); @@ -8279,7 +8307,7 @@ function showProfileModal() { Делиться местоположением онлайн
Пока включено, во время записи заезда телефон отправляет текущую точку на сервер раз в 10 секунд. Видно только тем, кому дан доступ, и только после входа. Выключается здесь же.
- +
diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/LiveShare.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/LiveShare.kt index 48497f4..a767850 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/LiveShare.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/LiveShare.kt @@ -47,7 +47,8 @@ object LiveShare { // Latest fix from the GPS flush loop (every ~10s). Non-blocking: the network call runs on a // throwaway thread so it never stalls the main looper that drives GPS delivery. fun tick(ctx: Context, lat: Double, lon: Double, speed: Float, fixTs: Long) { - if (!loaded) readCfg(ctx) + // re-read every tick so toggling the record-screen LIVE button takes effect within ~10s + readCfg(ctx) if (!enabled || url.isBlank() || token.isBlank()) return if (lat == 0.0 && lon == 0.0) return // no fix yet val now = System.currentTimeMillis() From a68c298946cbd3a383a18f41745ed1e7f0ad58ea Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 26 Oct 2026 19:28:08 +0300 Subject: [PATCH 4/4] SMS: send only the Yandex Maps link so it fits in one message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the 'Я тут: lat,lon' prefix - the Cyrillic forced UCS-2 (70-char SMS) and the coords were redundant with the pin. The bare ASCII link is GSM-7 and fits in one SMS. --- frontend/js/main.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/js/main.js b/frontend/js/main.js index adc76c7..29cc7a9 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -4726,9 +4726,10 @@ async function sendLocationSms() { let last = GPS.recording.lastMovingPoint || pts[pts.length - 1]; if (!last || last.lat == null) { appAlert("Нет координат — подожди фикс GPS"); return; } let lat = (+last.lat).toFixed(5), lon = (+last.lon).toFixed(5); - // Yandex Maps: pt=lon,lat drops a pin at the position - let link = `https://yandex.ru/maps/?pt=${lon},${lat}&z=16&l=map`; - let text = `Я тут: ${lat},${lon} ${link}`; + // Yandex Maps: pt=lon,lat drops a pin at the position. Link ONLY (ASCII, ~54 chars) + // so the whole message is GSM-7 and fits in a single SMS — any Cyrillic would force + // UCS-2 (70-char limit) and risk splitting into two. + let text = `https://yandex.ru/maps/?pt=${lon},${lat}&z=16&l=map`; let esc = s => String(s).replace(/
Кому: ${esc(number)}

${esc(text)}`,