Compare commits
4 commits
762221d186
...
a68c298946
| Author | SHA1 | Date | |
|---|---|---|---|
| a68c298946 | |||
| c70cc1e4a4 | |||
| 738c4e81a1 | |||
| 1410062d85 |
9 changed files with 486 additions and 3 deletions
|
|
@ -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).
|
||||
- **Вес, сон, добавки** — логирование с историей.
|
||||
|
||||
|
|
|
|||
72
docs/session-logs/2026-10-26_live-location-and-sms.md
Normal file
72
docs/session-logs/2026-10-26_live-location-and-sms.md
Normal 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 по интернету.
|
||||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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, "<");
|
||||
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, """)}" 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, """)}" 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, """)}" 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue