From b8da7e87ff024947837204a1a396143969ad14f1 Mon Sep 17 00:00:00 2001 From: Kara Zajac Date: Wed, 15 Jul 2026 19:54:18 -0400 Subject: [PATCH] Survivor side (2/4): alert history + evidence export (report + GPX) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5 --- app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 11 +++ .../org/soulstone/vigil/MainActivity.kt | 44 ++++++++- .../soulstone/vigil/data/TrackerRepository.kt | 87 +++++++++++++++++ .../soulstone/vigil/data/db/VigilDatabase.kt | 41 +++++++- .../org/soulstone/vigil/ui/HistoryScreen.kt | 97 +++++++++++++++++++ .../org/soulstone/vigil/ui/MainScreen.kt | 11 ++- app/src/main/res/xml/file_paths.xml | 4 + 8 files changed, 293 insertions(+), 6 deletions(-) create mode 100644 app/src/main/kotlin/org/soulstone/vigil/ui/HistoryScreen.kt create mode 100644 app/src/main/res/xml/file_paths.xml 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 @@ + + + +