Compare commits

...

4 commits

Author SHA1 Message Date
a68c298946 SMS: send only the Yandex Maps link so it fits in one message
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.
2026-10-26 19:28:08 +03:00
c70cc1e4a4 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
2026-10-26 19:13:34 +03:00
738c4e81a1 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.
2026-10-26 18:25:07 +03:00
1410062d85 Live location sharing (botovod) + SMS 'я тут' with Yandex Maps link
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.
2026-10-26 16:12:11 +03:00
9 changed files with 486 additions and 3 deletions

View file

@ -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).
- **Вес, сон, добавки** — логирование с историей.

View file

@ -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: контакт в профиле (ручной ввод + «Выбрать из контактов» с поллингом результата пикера);
текст `Я тут: <lat>,<lon> https://yandex.ru/maps/?pt=<lon>,<lat>&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 по интернету.

View file

@ -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 <input type="checkbox">. */
.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);
@ -1279,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); }

View file

@ -123,6 +123,20 @@ 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/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 });
} catch (e) {}
if (typeof DB === "undefined" || typeof DB.getFullProfile !== "function") {
app.innerHTML = `<div class="loading" style="color:#cf6679">
Ошибка: db.js не загрузился.<br>
@ -4702,6 +4716,30 @@ 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. 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(/</g, "&lt;");
let ok = await appConfirm(
`Отправить СМС с местоположением?<br><br><b>Кому:</b> ${esc(number)}<br><br><span style="font-size:12px;color:#9aa4ae;word-break:break-all">${esc(text)}</span>`,
"Отправить");
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; }
@ -4960,6 +4998,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");
@ -8219,7 +8285,7 @@ function showProfileModal() {
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input id="m-tef" type="checkbox" ${(GPS.config.tefFrac ?? 0) > 0 ? "checked" : ""}>
<input id="m-tef" class="toggle" type="checkbox" ${(GPS.config.tefFrac ?? 0) > 0 ? "checked" : ""}>
Учитывать переваривание (TEF 10%)
</label>
<div style="font-size:11px;color:#9aa4ae;margin-top:2px">Часть съеденного тратится на переваривание. Расход масштабируется от еды; на поддержке баланс не меняется.</div>
@ -8230,11 +8296,26 @@ function showProfileModal() {
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input id="m-hydr-on" type="checkbox" ${localStorage.getItem("baldav_hydr_on") !== "0" ? "checked" : ""}>
<input id="m-hydr-on" class="toggle" type="checkbox" ${localStorage.getItem("baldav_hydr_on") !== "0" ? "checked" : ""}>
Следить за питьём на тренировке
</label>
<div style="font-size:11px;color:#9aa4ae;margin-top:2px">Во время заезда/VR копится потребность в воде (пот), а выпитое берётся из того, что ты логируешь в баланс (жидкость по water_pct). Молчит, пока успеваешь пить; громкий сигнал (канал будильника, пробивает громкость) - только когда отстаёшь сверх порога.</div>
<label style="font-size:13px;display:block;margin-top:4px">Сигнал при отставании, мл <input id="m-hydr-alert" type="number" inputmode="numeric" min="100" max="3000" value="${localStorage.getItem("baldav_hydr_alert") || "600"}" style="width:80px"></label>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input id="m-live-on" class="toggle" type="checkbox" ${localStorage.getItem("baldav_live_on") === "1" ? "checked" : ""}>
Делиться местоположением онлайн
</label>
<div style="font-size:11px;color:#9aa4ae;margin-top:2px">Пока включено, во время записи заезда телефон отправляет текущую точку на сервер раз в 10 секунд. Видно только тем, кому дан доступ, и только после входа. Выключается здесь же.</div>
<label style="font-size:13px;display:block;margin-top:4px">Адрес приёма<input id="m-live-url" type="url" value="${((localStorage.getItem("baldav_live_url") || "https://4aiman.top/api/trackme/push").replace("/api/live/push", "/api/trackme/push")).replace(/"/g, "&quot;")}" placeholder="https://4aiman.top/api/trackme/push" style="width:100%;margin-top:2px"></label>
<label style="font-size:13px;display:block;margin-top:4px">Токен<input id="m-live-token" type="password" value="${(localStorage.getItem("baldav_live_token") || "").replace(/"/g, "&quot;")}" placeholder="вставь токен" style="width:100%;margin-top:2px"></label>
</div>
<div class="form-group">
<label>Контакт для СМС «Я тут»</label>
<input id="m-sms-number" type="tel" inputmode="tel" value="${(localStorage.getItem("baldav_sms_number") || "").replace(/"/g, "&quot;")}" placeholder="+79990001122" style="width:100%" />
<button type="button" id="m-sms-pick" class="btn-secondary" style="margin-top:6px">Выбрать из контактов</button>
<div style="font-size:11px;color:#9aa4ae;margin-top:2px">Во время записи появится кнопка «СМС»: по нажатию спросит подтверждение и отправит на этот номер текст с координатами и ссылкой на Яндекс.Карты. Номер можно вписать вручную или выбрать из контактов. Работает без интернета (по сотовой сети).</div>
</div>`,
async overlay => {
let weight = parseFloat(overlay.querySelector("#m-weight").value) || null;
@ -8258,6 +8339,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 +8387,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);
}

View file

@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.SEND_SMS" />
<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

@ -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) {

View file

@ -0,0 +1,101 @@
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) {
// 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()
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) {}
}
}

View file

@ -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)

View file

@ -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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
let mut candidates: Vec<std::path::PathBuf> = 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");
}