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

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

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");
}