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.
This commit is contained in:
4aiman 2026-10-26 16:12:11 +03:00
commit 1410062d85
8 changed files with 415 additions and 1 deletions

View file

@ -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 = `<div class="loading" style="color:#cf6679">
Ошибка: db.js не загрузился.<br>
@ -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(/</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; }
@ -4735,6 +4767,7 @@ async function renderRecordingInline(container) {
</div>
<div class="rec-bottom">
<button id="rec-hr-toggle" class="btn-secondary rec-hr-toggle${GPS.recording.hrOn ? " active" : ""}" title="Пульсометр вкл/выкл" aria-label="ЧСС"><svg viewBox="0 0 24 22" width="22" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20S3 14.5 3 8.3C3 5.6 5 4 7.2 4 9 4 10.6 5.1 12 7 13.4 5.1 15 4 16.8 4 19 4 21 5.6 21 8.3c0 1.3-.4 2.5-1 3.7"/><path d="M2 13h5l2-3 2.4 6 2-3H22"/></svg><span id="rec-hr-num" class="rec-hr-num"></span></button>
${localStorage.getItem("baldav_sms_number") ? '<button id="rec-sms" class="btn-secondary" title="Отправить СМС с местоположением" aria-label="СМС">СМС</button>' : ''}
<button id="rec-pause" class="btn-secondary">${GPS.recording.manualPaused ? "Продолжить" : "Пауза"}</button>
<button id="rec-stop" class="btn-primary gps-rec recording">Стоп</button>
</div>
@ -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() {
</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" 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/live/push").replace(/"/g, "&quot;")}" placeholder="https://4aiman.top/api/live/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 +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);
}