Survivor side (2/4): alert history + evidence export (report + GPX)
Build APK / build (push) Waiting to run
Build APK / build (push) Waiting to run
- Room 'alerts' table records each new ALERTING event (ecosystem, time, distinct places, peak RSSI, location). - History screen: log of past alerts + a one-tap Export that shares a timestamped text report and a GPX of every geotagged sighting (open in a maps app). Files written to cache and shared via FileProvider — no network. - History icon added to the header. Release 0.1.9. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
This commit is contained in:
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "org.soulstone.vigil"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 9
|
||||
versionName = "0.1.8"
|
||||
versionCode = 10
|
||||
versionName = "0.1.9"
|
||||
}
|
||||
|
||||
// Fixed debug keystore committed to the repo (a debug key is non-secret — its
|
||||
|
||||
@@ -67,5 +67,16 @@
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice|location" />
|
||||
|
||||
<!-- Shares exported evidence files (report + GPX) with other apps. No network. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -17,14 +17,17 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import org.soulstone.vigil.data.TrackerRepository
|
||||
import org.soulstone.vigil.data.db.TrackerEntity
|
||||
import org.soulstone.vigil.data.db.VigilDatabase
|
||||
import org.soulstone.vigil.ring.TrackerRinger
|
||||
import org.soulstone.vigil.data.settings.Settings
|
||||
import org.soulstone.vigil.service.ScanService
|
||||
import org.soulstone.vigil.ui.HistoryScreen
|
||||
import org.soulstone.vigil.ui.MainScreen
|
||||
import org.soulstone.vigil.ui.OnboardingScreen
|
||||
import org.soulstone.vigil.ui.SafetyScreen
|
||||
@@ -74,12 +77,22 @@ class MainActivity : ComponentActivity() {
|
||||
VigilTheme {
|
||||
val onboarded by settings.onboarded.collectAsState()
|
||||
var showSafety by remember { mutableStateOf(false) }
|
||||
var showHistory by remember { mutableStateOf(false) }
|
||||
when {
|
||||
!onboarded -> OnboardingScreen(onFinish = {
|
||||
settings.setOnboarded(true)
|
||||
if (!hasEssentialPermissions()) permissionLauncher.launch(requiredPermissions)
|
||||
})
|
||||
showSafety -> SafetyScreen(onBack = { showSafety = false })
|
||||
showHistory -> {
|
||||
val alerts by repo.observeAlerts().collectAsState(initial = emptyList())
|
||||
HistoryScreen(
|
||||
alerts = alerts,
|
||||
onExport = { exportEvidence() },
|
||||
onClear = { lifecycleScope.launch { repo.clearAlerts() } },
|
||||
onBack = { showHistory = false }
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val running by ScanService.running.collectAsState()
|
||||
val trackers by repo.observeTrackers().collectAsState(initial = emptyList())
|
||||
@@ -110,7 +123,8 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onRing = { tracker -> ringTracker(tracker) },
|
||||
onClearAll = { lifecycleScope.launch { repo.clearAll() } },
|
||||
onOpenSafety = { showSafety = true }
|
||||
onOpenSafety = { showSafety = true },
|
||||
onOpenHistory = { showHistory = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -135,6 +149,34 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportEvidence() {
|
||||
lifecycleScope.launch {
|
||||
val report = repo.buildTextReport()
|
||||
if (report == null) {
|
||||
Toast.makeText(this@MainActivity, "No alerts to export yet.", Toast.LENGTH_SHORT).show()
|
||||
return@launch
|
||||
}
|
||||
runCatching {
|
||||
val dir = File(cacheDir, "exports").apply { mkdirs() }
|
||||
val txt = File(dir, "vigil-report.txt").apply { writeText(report) }
|
||||
val gpx = File(dir, "vigil-track.gpx").apply { writeText(repo.buildGpx()) }
|
||||
val auth = "$packageName.fileprovider"
|
||||
val uris = arrayListOf(
|
||||
FileProvider.getUriForFile(this@MainActivity, auth, txt),
|
||||
FileProvider.getUriForFile(this@MainActivity, auth, gpx)
|
||||
)
|
||||
val share = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
|
||||
type = "*/*"
|
||||
putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
startActivity(Intent.createChooser(share, "Export VIGIL evidence"))
|
||||
}.onFailure {
|
||||
Toast.makeText(this@MainActivity, "Export failed: ${it.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun openAppSettings() {
|
||||
startActivity(
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.soulstone.vigil.data
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.soulstone.vigil.data.db.AlertEntity
|
||||
import org.soulstone.vigil.data.db.SightingEntity
|
||||
import org.soulstone.vigil.data.db.TrackerEntity
|
||||
import org.soulstone.vigil.data.db.VigilDatabase
|
||||
@@ -11,8 +12,13 @@ import org.soulstone.vigil.detect.CoMovementEvaluator
|
||||
import org.soulstone.vigil.model.RiskState
|
||||
import org.soulstone.vigil.model.SeparatedState
|
||||
import org.soulstone.vigil.model.Sensitivity
|
||||
import org.soulstone.vigil.model.TrackerEcosystem
|
||||
import org.soulstone.vigil.model.TrackerObservation
|
||||
import org.soulstone.vigil.util.Geohash
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* The temporal core. Persists sightings, maintains the learned baseline, and runs
|
||||
@@ -133,10 +139,91 @@ class TrackerRepository(private val db: VigilDatabase) {
|
||||
lastMac = obs.mac
|
||||
)
|
||||
db.trackerDao().upsert(updated)
|
||||
if (newlyAlerting) {
|
||||
db.alertDao().insert(
|
||||
AlertEntity(
|
||||
trackerId = obs.stableId,
|
||||
ecosystem = obs.ecosystem.name,
|
||||
label = obs.label,
|
||||
timestamp = now,
|
||||
distinctPlaces = assessment.distinctPlaces,
|
||||
peakRssi = updated.peakRssi,
|
||||
lat = lat, lon = lon
|
||||
)
|
||||
)
|
||||
}
|
||||
RecordResult(updated, newlyAlerting)
|
||||
}
|
||||
|
||||
fun observeAlerts(): Flow<List<AlertEntity>> = db.alertDao().observeAll()
|
||||
|
||||
suspend fun clearAlerts() = db.alertDao().clear()
|
||||
|
||||
/** Human-readable evidence report; null if there are no alerts yet. */
|
||||
suspend fun buildTextReport(): String? {
|
||||
val alerts = db.alertDao().all()
|
||||
if (alerts.isEmpty()) return null
|
||||
val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", Locale.US)
|
||||
val sb = StringBuilder()
|
||||
sb.appendLine("VIGIL — tracker evidence report")
|
||||
sb.appendLine("Generated: ${fmt.format(Date())}")
|
||||
sb.appendLine("Times are this phone's local time; locations are approximate (phone GPS).")
|
||||
sb.appendLine("=".repeat(52))
|
||||
for ((trackerId, group) in alerts.groupBy { it.trackerId }) {
|
||||
val head = group.first()
|
||||
sb.appendLine()
|
||||
sb.appendLine("${ecoLabel(head.ecosystem)} — ${head.label}")
|
||||
sb.appendLine("Identity: $trackerId")
|
||||
sb.appendLine("Flagged ${group.size} time(s):")
|
||||
for (a in group) {
|
||||
val where = if (a.lat != null && a.lon != null) "%.5f, %.5f".format(a.lat, a.lon) else "no location"
|
||||
sb.appendLine(" - ${fmt.format(Date(a.timestamp))} : ${a.distinctPlaces} places, peak ${a.peakRssi} dBm, at $where")
|
||||
}
|
||||
val geo = db.sightingDao().allFor(trackerId).filter { it.lat != null && it.lon != null }
|
||||
if (geo.isNotEmpty()) {
|
||||
sb.appendLine(" Seen with you at ${geo.size} location(s):")
|
||||
for (s in geo) {
|
||||
sb.appendLine(" ${fmt.format(Date(s.timestamp))} ${"%.5f, %.5f".format(s.lat, s.lon)} ${s.rssi} dBm")
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.appendLine()
|
||||
sb.appendLine("=".repeat(52))
|
||||
sb.appendLine("Recorded by VIGIL, an offline anti-tracking app — a log of Bluetooth trackers detected moving with this phone.")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/** GPX of every geotagged sighting, to open in a maps app. */
|
||||
suspend fun buildGpx(): String {
|
||||
val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
val sb = StringBuilder()
|
||||
sb.appendLine("""<?xml version="1.0" encoding="UTF-8"?>""")
|
||||
sb.appendLine("""<gpx version="1.1" creator="VIGIL" xmlns="http://www.topografix.com/GPX/1/1">""")
|
||||
for (s in db.sightingDao().allGeotagged()) {
|
||||
sb.appendLine(""" <wpt lat="${s.lat}" lon="${s.lon}">""")
|
||||
sb.appendLine(" <time>${fmt.format(Date(s.timestamp))}</time>")
|
||||
sb.appendLine(" <name>${trackerShortName(s.trackerId)} ${s.rssi}dBm</name>")
|
||||
sb.appendLine(" </wpt>")
|
||||
}
|
||||
sb.appendLine("</gpx>")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val RETENTION_DAYS = 14
|
||||
}
|
||||
}
|
||||
|
||||
private fun ecoLabel(name: String): String =
|
||||
runCatching { TrackerEcosystem.valueOf(name).display }.getOrDefault(name)
|
||||
|
||||
private fun trackerShortName(stableId: String): String = when (stableId.substringBefore(':')) {
|
||||
"apple" -> "AppleFindMy"
|
||||
"fmdn" -> "GoogleFMD"
|
||||
"samsung" -> "SmartTag"
|
||||
"tile" -> "Tile"
|
||||
"dult" -> "DULT"
|
||||
else -> "Tracker"
|
||||
}
|
||||
|
||||
@@ -68,6 +68,21 @@ data class PlaceEntity(
|
||||
val anchor: Boolean = false
|
||||
)
|
||||
|
||||
/** A recorded alert — when a tracker first crossed into ALERTING. Kept for the
|
||||
* history list and the evidence export. */
|
||||
@Entity(tableName = "alerts")
|
||||
data class AlertEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val trackerId: String,
|
||||
val ecosystem: String,
|
||||
val label: String,
|
||||
val timestamp: Long,
|
||||
val distinctPlaces: Int,
|
||||
val peakRssi: Int,
|
||||
val lat: Double? = null,
|
||||
val lon: Double? = null
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface TrackerDao {
|
||||
@Query("SELECT * FROM trackers WHERE stableId = :id")
|
||||
@@ -104,6 +119,12 @@ interface SightingDao {
|
||||
@Query("SELECT * FROM sightings WHERE trackerId = :id AND timestamp >= :since ORDER BY timestamp")
|
||||
suspend fun recentFor(id: String, since: Long): List<SightingEntity>
|
||||
|
||||
@Query("SELECT * FROM sightings WHERE trackerId = :id ORDER BY timestamp")
|
||||
suspend fun allFor(id: String): List<SightingEntity>
|
||||
|
||||
@Query("SELECT * FROM sightings WHERE lat IS NOT NULL ORDER BY timestamp")
|
||||
suspend fun allGeotagged(): List<SightingEntity>
|
||||
|
||||
@Query("DELETE FROM sightings WHERE timestamp < :cutoff")
|
||||
suspend fun prune(cutoff: Long)
|
||||
|
||||
@@ -120,15 +141,31 @@ interface PlaceDao {
|
||||
suspend fun upsert(place: PlaceEntity)
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface AlertDao {
|
||||
@Insert
|
||||
suspend fun insert(alert: AlertEntity)
|
||||
|
||||
@Query("SELECT * FROM alerts ORDER BY timestamp DESC")
|
||||
fun observeAll(): Flow<List<AlertEntity>>
|
||||
|
||||
@Query("SELECT * FROM alerts ORDER BY timestamp DESC")
|
||||
suspend fun all(): List<AlertEntity>
|
||||
|
||||
@Query("DELETE FROM alerts")
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
@Database(
|
||||
entities = [TrackerEntity::class, SightingEntity::class, PlaceEntity::class],
|
||||
version = 3,
|
||||
entities = [TrackerEntity::class, SightingEntity::class, PlaceEntity::class, AlertEntity::class],
|
||||
version = 4,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class VigilDatabase : RoomDatabase() {
|
||||
abstract fun trackerDao(): TrackerDao
|
||||
abstract fun sightingDao(): SightingDao
|
||||
abstract fun placeDao(): PlaceDao
|
||||
abstract fun alertDao(): AlertDao
|
||||
|
||||
companion object {
|
||||
@Volatile private var INSTANCE: VigilDatabase? = null
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package org.soulstone.vigil.ui
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.soulstone.vigil.data.db.AlertEntity
|
||||
import org.soulstone.vigil.model.TrackerEcosystem
|
||||
|
||||
/** Log of past alerts, with a one-tap evidence export (report + GPX). */
|
||||
@Composable
|
||||
fun HistoryScreen(
|
||||
alerts: List<AlertEntity>,
|
||||
onExport: () -> Unit,
|
||||
onClear: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") }
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Text("Alert history", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = onExport, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Filled.Share, null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.size(6.dp))
|
||||
Text("Export evidence")
|
||||
}
|
||||
OutlinedButton(onClick = onClear, enabled = alerts.isNotEmpty()) { Text("Clear") }
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
"Export saves a timestamped report and a GPX track (open it in a maps app). Files stay on your phone until you share them.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
if (alerts.isEmpty()) {
|
||||
Text(
|
||||
"No alerts yet. When a tracker is confirmed following you, it'll be logged here.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(alerts, key = { it.id }) { a -> AlertRow(a) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlertRow(a: AlertEntity) {
|
||||
Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
|
||||
Column(Modifier.padding(14.dp)) {
|
||||
Text(ecoName(a.ecosystem), fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"${a.distinctPlaces} places · peak ${a.peakRssi} dBm · ${rel(a.timestamp)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ecoName(name: String) = runCatching { TrackerEcosystem.valueOf(name).display }.getOrDefault(name)
|
||||
private fun rel(ts: Long) = DateUtils.getRelativeTimeSpanString(ts, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS).toString()
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.GppMaybe
|
||||
import androidx.compose.material.icons.filled.HealthAndSafety
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Radar
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
@@ -92,7 +93,8 @@ fun MainScreen(
|
||||
onDistrust: (String) -> Unit,
|
||||
onRing: (TrackerEntity) -> Unit,
|
||||
onClearAll: () -> Unit,
|
||||
onOpenSafety: () -> Unit
|
||||
onOpenSafety: () -> Unit,
|
||||
onOpenHistory: () -> Unit
|
||||
) {
|
||||
var detail by remember { mutableStateOf<TrackerEntity?>(null) }
|
||||
var finding by remember { mutableStateOf<TrackerEntity?>(null) }
|
||||
@@ -121,6 +123,13 @@ fun MainScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onOpenHistory) {
|
||||
Icon(
|
||||
Icons.Filled.History,
|
||||
contentDescription = "Alert history",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onOpenSafety) {
|
||||
Icon(
|
||||
Icons.Filled.HealthAndSafety,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="exports" path="exports/" />
|
||||
</paths>
|
||||
Reference in New Issue
Block a user