diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1728901..97160cc 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 = 10 - versionName = "0.1.9" + versionCode = 11 + versionName = "0.1.10" } // Fixed debug keystore committed to the repo (a debug key is non-secret — its diff --git a/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt b/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt index e065612..7873fbe 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt @@ -124,7 +124,8 @@ class MainActivity : ComponentActivity() { onRing = { tracker -> ringTracker(tracker) }, onClearAll = { lifecycleScope.launch { repo.clearAll() } }, onOpenSafety = { showSafety = true }, - onOpenHistory = { showHistory = true } + onOpenHistory = { showHistory = true }, + loadTrail = { id -> repo.trailFor(id) } ) } } 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 b9d4b3e..159fb00 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/data/TrackerRepository.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/data/TrackerRepository.kt @@ -14,6 +14,7 @@ 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.model.TrailPoint import org.soulstone.vigil.util.Geohash import java.text.SimpleDateFormat import java.util.Date @@ -159,6 +160,13 @@ class TrackerRepository(private val db: VigilDatabase) { 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 = + 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. */ suspend fun buildTextReport(): String? { val alerts = db.alertDao().all() diff --git a/app/src/main/kotlin/org/soulstone/vigil/model/Types.kt b/app/src/main/kotlin/org/soulstone/vigil/model/Types.kt index c8dbda8..1dc6554 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/model/Types.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/model/Types.kt @@ -37,3 +37,6 @@ enum class TrackerStatus { SAFE_APPROVED, SAFE_BASELINE, OBSERVED, SUSPICIOUS, A /** Detection sensitivity — trades time-to-alert against false positives. */ 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) 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 9ee5457..bb11daa 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.lerp import androidx.compose.ui.res.painterResource import org.soulstone.vigil.service.ScanService import androidx.compose.foundation.Image +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -60,11 +61,13 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector 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.R 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.VigilPeach import org.soulstone.vigil.ui.theme.VigilRed @@ -94,7 +98,8 @@ fun MainScreen( onRing: (TrackerEntity) -> Unit, onClearAll: () -> Unit, onOpenSafety: () -> Unit, - onOpenHistory: () -> Unit + onOpenHistory: () -> Unit, + loadTrail: suspend (String) -> List ) { var detail by remember { mutableStateOf(null) } var finding by remember { mutableStateOf(null) } @@ -247,7 +252,8 @@ fun MainScreen( onDistrust = { id -> onDistrust(id); detail = null }, onFind = { dev -> ScanService.setFinderTarget(dev.stableId); finding = dev; detail = null }, onRing = onRing, - onSafety = { onOpenSafety(); detail = null } + onSafety = { onOpenSafety(); detail = null }, + loadTrail = loadTrail ) } } @@ -398,7 +404,8 @@ private fun TrackerDetail( onDistrust: (String) -> Unit, onFind: (TrackerEntity) -> Unit, onRing: (TrackerEntity) -> Unit, - onSafety: () -> Unit + onSafety: () -> Unit, + loadTrail: suspend (String) -> List ) { val status = statusOf(t) Column(Modifier.fillMaxWidth().padding(24.dp)) { @@ -440,6 +447,25 @@ private fun TrackerDetail( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) + + val trail by produceState(initialValue = emptyList(), 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)) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { 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, 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 ---------------------------------------------------------- private const val ACTIVE_WINDOW_MS = 10 * 60_000L // trackers not seen this recently drop off "Active"