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:
parent
762221d186
commit
1410062d85
8 changed files with 415 additions and 1 deletions
|
|
@ -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,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) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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