diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 7c600b2..1728901 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index fd027d7..b52b129 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -67,5 +67,16 @@
android:enabled="true"
android:exported="false"
android:foregroundServiceType="connectedDevice|location" />
+
+
+
+
+
diff --git a/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt b/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt
index 3dab5a4..e065612 100644
--- a/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt
+++ b/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt
@@ -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(
diff --git a/app/src/main/kotlin/org/soulstone/vigil/data/TrackerRepository.kt b/app/src/main/kotlin/org/soulstone/vigil/data/TrackerRepository.kt
index ddb0e2c..b9d4b3e 100644
--- a/app/src/main/kotlin/org/soulstone/vigil/data/TrackerRepository.kt
+++ b/app/src/main/kotlin/org/soulstone/vigil/data/TrackerRepository.kt
@@ -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> = 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("""""")
+ sb.appendLine("""""")
+ for (s in db.sightingDao().allGeotagged()) {
+ sb.appendLine(""" """)
+ sb.appendLine(" ")
+ sb.appendLine(" ${trackerShortName(s.trackerId)} ${s.rssi}dBm")
+ sb.appendLine(" ")
+ }
+ sb.appendLine("")
+ 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"
+}
diff --git a/app/src/main/kotlin/org/soulstone/vigil/data/db/VigilDatabase.kt b/app/src/main/kotlin/org/soulstone/vigil/data/db/VigilDatabase.kt
index cbcb59e..1af2942 100644
--- a/app/src/main/kotlin/org/soulstone/vigil/data/db/VigilDatabase.kt
+++ b/app/src/main/kotlin/org/soulstone/vigil/data/db/VigilDatabase.kt
@@ -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
+ @Query("SELECT * FROM sightings WHERE trackerId = :id ORDER BY timestamp")
+ suspend fun allFor(id: String): List
+
+ @Query("SELECT * FROM sightings WHERE lat IS NOT NULL ORDER BY timestamp")
+ suspend fun allGeotagged(): List
+
@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>
+
+ @Query("SELECT * FROM alerts ORDER BY timestamp DESC")
+ suspend fun all(): List
+
+ @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
diff --git a/app/src/main/kotlin/org/soulstone/vigil/ui/HistoryScreen.kt b/app/src/main/kotlin/org/soulstone/vigil/ui/HistoryScreen.kt
new file mode 100644
index 0000000..decd469
--- /dev/null
+++ b/app/src/main/kotlin/org/soulstone/vigil/ui/HistoryScreen.kt
@@ -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,
+ 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()
diff --git a/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt b/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt
index e1def4e..9ee5457 100644
--- a/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt
+++ b/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt
@@ -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(null) }
var finding by remember { mutableStateOf(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,
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..282e644
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+