Survivor side (3/4): in-app co-movement trail
Build APK / build (push) Waiting to run

TrailView: an offline schematic of a tracker's geotagged sightings (points + path, scaled to fit) shown on the tracker detail sheet when there are >=2 located sightings. Start point green, latest peach. Not a real map (VIGIL has no network) — the GPX export from Phase 2 opens in one. Release 0.1.10.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
This commit is contained in:
Kara Zajac
2026-07-15 20:02:56 -04:00
parent b8da7e87ff
commit 092552939d
5 changed files with 76 additions and 6 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "org.soulstone.vigil" applicationId = "org.soulstone.vigil"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 10 versionCode = 11
versionName = "0.1.9" versionName = "0.1.10"
} }
// Fixed debug keystore committed to the repo (a debug key is non-secret — its // Fixed debug keystore committed to the repo (a debug key is non-secret — its
@@ -124,7 +124,8 @@ class MainActivity : ComponentActivity() {
onRing = { tracker -> ringTracker(tracker) }, onRing = { tracker -> ringTracker(tracker) },
onClearAll = { lifecycleScope.launch { repo.clearAll() } }, onClearAll = { lifecycleScope.launch { repo.clearAll() } },
onOpenSafety = { showSafety = true }, onOpenSafety = { showSafety = true },
onOpenHistory = { showHistory = true } onOpenHistory = { showHistory = true },
loadTrail = { id -> repo.trailFor(id) }
) )
} }
} }
@@ -14,6 +14,7 @@ import org.soulstone.vigil.model.SeparatedState
import org.soulstone.vigil.model.Sensitivity import org.soulstone.vigil.model.Sensitivity
import org.soulstone.vigil.model.TrackerEcosystem import org.soulstone.vigil.model.TrackerEcosystem
import org.soulstone.vigil.model.TrackerObservation import org.soulstone.vigil.model.TrackerObservation
import org.soulstone.vigil.model.TrailPoint
import org.soulstone.vigil.util.Geohash import org.soulstone.vigil.util.Geohash
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@@ -159,6 +160,13 @@ class TrackerRepository(private val db: VigilDatabase) {
suspend fun clearAlerts() = db.alertDao().clear() suspend fun clearAlerts() = db.alertDao().clear()
/** Geotagged sightings of one tracker, in time order, for the in-app trail. */
suspend fun trailFor(id: String): List<TrailPoint> =
db.sightingDao().allFor(id).mapNotNull { s ->
val la = s.lat; val lo = s.lon
if (la != null && lo != null) TrailPoint(la, lo) else null
}
/** Human-readable evidence report; null if there are no alerts yet. */ /** Human-readable evidence report; null if there are no alerts yet. */
suspend fun buildTextReport(): String? { suspend fun buildTextReport(): String? {
val alerts = db.alertDao().all() val alerts = db.alertDao().all()
@@ -37,3 +37,6 @@ enum class TrackerStatus { SAFE_APPROVED, SAFE_BASELINE, OBSERVED, SUSPICIOUS, A
/** Detection sensitivity — trades time-to-alert against false positives. */ /** Detection sensitivity — trades time-to-alert against false positives. */
enum class Sensitivity { HIGH, MEDIUM, LOW } enum class Sensitivity { HIGH, MEDIUM, LOW }
/** A geotagged point where a tracker was seen, for the in-app co-movement trail. */
data class TrailPoint(val lat: Double, val lon: Double)
@@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import org.soulstone.vigil.service.ScanService import org.soulstone.vigil.service.ScanService
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -60,11 +61,13 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -76,6 +79,7 @@ import org.soulstone.vigil.model.Sensitivity
import org.soulstone.vigil.model.TrackerEcosystem import org.soulstone.vigil.model.TrackerEcosystem
import org.soulstone.vigil.R import org.soulstone.vigil.R
import org.soulstone.vigil.model.TrackerStatus import org.soulstone.vigil.model.TrackerStatus
import org.soulstone.vigil.model.TrailPoint
import org.soulstone.vigil.ui.theme.VigilGreen import org.soulstone.vigil.ui.theme.VigilGreen
import org.soulstone.vigil.ui.theme.VigilPeach import org.soulstone.vigil.ui.theme.VigilPeach
import org.soulstone.vigil.ui.theme.VigilRed import org.soulstone.vigil.ui.theme.VigilRed
@@ -94,7 +98,8 @@ fun MainScreen(
onRing: (TrackerEntity) -> Unit, onRing: (TrackerEntity) -> Unit,
onClearAll: () -> Unit, onClearAll: () -> Unit,
onOpenSafety: () -> Unit, onOpenSafety: () -> Unit,
onOpenHistory: () -> Unit onOpenHistory: () -> Unit,
loadTrail: suspend (String) -> List<TrailPoint>
) { ) {
var detail by remember { mutableStateOf<TrackerEntity?>(null) } var detail by remember { mutableStateOf<TrackerEntity?>(null) }
var finding by remember { mutableStateOf<TrackerEntity?>(null) } var finding by remember { mutableStateOf<TrackerEntity?>(null) }
@@ -247,7 +252,8 @@ fun MainScreen(
onDistrust = { id -> onDistrust(id); detail = null }, onDistrust = { id -> onDistrust(id); detail = null },
onFind = { dev -> ScanService.setFinderTarget(dev.stableId); finding = dev; detail = null }, onFind = { dev -> ScanService.setFinderTarget(dev.stableId); finding = dev; detail = null },
onRing = onRing, onRing = onRing,
onSafety = { onOpenSafety(); detail = null } onSafety = { onOpenSafety(); detail = null },
loadTrail = loadTrail
) )
} }
} }
@@ -398,7 +404,8 @@ private fun TrackerDetail(
onDistrust: (String) -> Unit, onDistrust: (String) -> Unit,
onFind: (TrackerEntity) -> Unit, onFind: (TrackerEntity) -> Unit,
onRing: (TrackerEntity) -> Unit, onRing: (TrackerEntity) -> Unit,
onSafety: () -> Unit onSafety: () -> Unit,
loadTrail: suspend (String) -> List<TrailPoint>
) { ) {
val status = statusOf(t) val status = statusOf(t)
Column(Modifier.fillMaxWidth().padding(24.dp)) { Column(Modifier.fillMaxWidth().padding(24.dp)) {
@@ -440,6 +447,25 @@ private fun TrackerDetail(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
val trail by produceState(initialValue = emptyList<TrailPoint>(), t.stableId) { value = loadTrail(t.stableId) }
if (trail.size >= 2) {
Spacer(Modifier.height(18.dp))
Text("Where it's moved with you", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(8.dp))
TrailView(
trail,
Modifier.fillMaxWidth().height(150.dp).clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
)
Text(
"A schematic of the ${trail.size} points it was seen — not a real map. Use Export for that.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp)
)
}
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilledTonalButton(onClick = { onFind(t) }, modifier = Modifier.weight(1f)) { FilledTonalButton(onClick = { onFind(t) }, modifier = Modifier.weight(1f)) {
@@ -554,6 +580,38 @@ private fun FinderScreen(t: TrackerEntity, onRing: (TrackerEntity) -> Unit, onCl
} }
} }
/** Offline schematic of a tracker's geotagged sightings — points + path, scaled
* to fit. Not a real map (VIGIL has no network); the GPX export opens in one. */
@Composable
private fun TrailView(points: List<TrailPoint>, modifier: Modifier = Modifier) {
val line = Color(0xFF89B4FA)
val startC = Color(0xFFA6E3A1)
val endC = Color(0xFFFAB387)
Canvas(modifier) {
if (points.size < 2) return@Canvas
val pad = 18f
val minLat = points.minOf { it.lat }; val maxLat = points.maxOf { it.lat }
val minLon = points.minOf { it.lon }; val maxLon = points.maxOf { it.lon }
val rLat = (maxLat - minLat).let { if (it > 1e-9) it else 1e-9 }
val rLon = (maxLon - minLon).let { if (it > 1e-9) it else 1e-9 }
val w = size.width - 2 * pad
val h = size.height - 2 * pad
val pts = points.map { p ->
Offset(
pad + ((p.lon - minLon) / rLon).toFloat() * w,
pad + (1f - ((p.lat - minLat) / rLat).toFloat()) * h
)
}
for (i in 0 until pts.size - 1) {
drawLine(line.copy(alpha = 0.55f), pts[i], pts[i + 1], strokeWidth = 3f)
}
pts.forEachIndexed { i, o ->
val c = if (i == 0) startC else if (i == pts.lastIndex) endC else line
drawCircle(c, radius = if (i == 0 || i == pts.lastIndex) 6f else 4f, center = o)
}
}
}
// ---- pure helpers ---------------------------------------------------------- // ---- pure helpers ----------------------------------------------------------
private const val ACTIVE_WINDOW_MS = 10 * 60_000L // trackers not seen this recently drop off "Active" private const val ACTIVE_WINDOW_MS = 10 * 60_000L // trackers not seen this recently drop off "Active"