5 Commits

Author SHA1 Message Date
Kara Zajac 4fbf97b93e Drop the DREAMMAKER byline from the footer
Build APK / build (push) Has been cancelled
VIGIL stands on its own — remove the "DREAMMAKER project" attribution.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173RhsXkdMViDR8DMNAG3ms
2026-07-15 20:39:34 -04:00
Kara Zajac c716ab6761 Fix broken SPARC link + verify safety resources
Build APK / build (push) Has been cancelled
- SPARC link was 404ing (/help-for-victims/); corrected to the real victim page /what-to-do-if-you-are-being-stalked/.
- Added VictimConnect (1-855-484-2846) — SPARC's own referral line for stalking victims, broader than the DV hotline (which is intimate-partner-focused).
- Verified: DV Hotline 1-800-799-7233 and text START to 88788 are correct; 911 and dial/sms intents correct.
Release 0.1.12.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 20:23:16 -04:00
Kara Zajac 4b42b4431c Survivor side (4/4): truthful ring confirmation + 'Who owns it?'
Build APK / build (push) Has been cancelled
- TrackerRinger now reads the DULT/Find My indication: reports 'Ringing confirmed' on the tag's response, or (after a 4s wait with no confirmation) 'sent, but the tracker didn't confirm - it only rings when separated from its owner'. AirTag still confirmed by its self-disconnect. No more over-claiming from a bare write-ACK.
- 'Who owns it?' on the tracker detail: guidance to tap the tag with NFC and let the phone open the owner's masked contact page (works via Android's built-in NFC dispatch; VIGIL stays offline).
Release 0.1.11 — completes the survivor-actionability work (safety + onboarding, history + export, trail, ring confirmation + owner info).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 20:12:04 -04:00
Kara Zajac 092552939d Survivor side (3/4): in-app co-movement trail
Build APK / build (push) Has been cancelled
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
2026-07-15 20:02:56 -04:00
Kara Zajac b8da7e87ff Survivor side (2/4): alert history + evidence export (report + GPX)
Build APK / build (push) Has been cancelled
- 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
2026-07-15 19:54:18 -04:00
12 changed files with 436 additions and 32 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "org.soulstone.vigil"
minSdk = 26
targetSdk = 35
versionCode = 9
versionName = "0.1.8"
versionCode = 13
versionName = "0.1.12"
}
// Fixed debug keystore committed to the repo (a debug key is non-secret — its
+11
View File
@@ -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,9 @@ class MainActivity : ComponentActivity() {
},
onRing = { tracker -> ringTracker(tracker) },
onClearAll = { lifecycleScope.launch { repo.clearAll() } },
onOpenSafety = { showSafety = true }
onOpenSafety = { showSafety = true },
onOpenHistory = { showHistory = true },
loadTrail = { id -> repo.trailFor(id) }
)
}
}
@@ -135,6 +150,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,14 @@ 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.model.TrailPoint
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 +140,98 @@ 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()
/** 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. */
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
@@ -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)
@@ -12,6 +12,8 @@ import android.bluetooth.BluetoothProfile
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.TimeoutCancellationException
@@ -98,7 +100,9 @@ object TrackerRinger {
suspendCancellableCoroutine { cont ->
var gatt: BluetoothGatt? = null
var selected: Proto? = null
fun done(msg: String, g: BluetoothGatt) {
val handler = Handler(Looper.getMainLooper())
fun finish(msg: String, g: BluetoothGatt) {
handler.removeCallbacksAndMessages(null)
if (cont.isActive) cont.resume(msg)
g.disconnect()
}
@@ -107,11 +111,14 @@ object TrackerRinger {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> g.discoverServices()
BluetoothProfile.STATE_DISCONNECTED -> {
handler.removeCallbacksAndMessages(null)
// An AirTag self-disconnects (status 19) once it has started ringing.
if (cont.isActive) {
if (selected == AIRTAG && status == STATUS_PEER_DISCONNECT)
cont.resume("Ringing… listen for the AirTag.")
else cont.resume("Tracker disconnected before it could ring.")
cont.resume(
if (selected == AIRTAG && status == STATUS_PEER_DISCONNECT)
"Ringing - listen for the AirTag."
else "Tracker disconnected before it could ring."
)
}
g.close()
}
@@ -120,9 +127,9 @@ object TrackerRinger {
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
val proto = PRIORITY.firstOrNull { g.getService(it.service) != null }
?: return done("This tracker type doesn't expose a remote-ring service.", g)
?: return finish("This tracker type doesn't expose a remote-ring service.", g)
val ch = g.getService(proto.service)?.getCharacteristic(proto.characteristic)
?: return done("Ring characteristic missing on this tracker.", g)
?: return finish("Ring characteristic missing on this tracker.", g)
selected = proto
if (proto.cccd) {
g.setCharacteristicNotification(ch, true)
@@ -131,32 +138,41 @@ object TrackerRinger {
else BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
if (desc == null || !writeDescriptor(g, desc, v)) writeStart(g, ch, proto)
} else {
if (!writeStart(g, ch, proto)) done("Couldn't send the ring command.", g)
if (!writeStart(g, ch, proto)) finish("Couldn't send the ring command.", g)
}
}
override fun onDescriptorWrite(g: BluetoothGatt, desc: BluetoothGattDescriptor, status: Int) {
val proto = selected ?: return
val ch = g.getService(proto.service)?.getCharacteristic(proto.characteristic)
?: return done("Ring characteristic missing on this tracker.", g)
if (!writeStart(g, ch, proto)) done("Couldn't send the ring command.", g)
?: return finish("Ring characteristic missing on this tracker.", g)
if (!writeStart(g, ch, proto)) finish("Couldn't send the ring command.", g)
}
override fun onCharacteristicWrite(g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int) {
// AirTag reports via self-disconnect; others confirm here.
if (selected == AIRTAG) {
done("Ringing… listen for the AirTag.", g)
} else {
done(
if (status == BluetoothGatt.GATT_SUCCESS)
"Ring command sent. A tag only chirps when it's separated from its owner — your own tag that's with you won't."
else "The tracker refused the ring command.", g
)
}
if (selected == AIRTAG) { finish("Ringing - listen for the AirTag.", g); return }
if (status != BluetoothGatt.GATT_SUCCESS) { finish("The tracker refused the ring command.", g); return }
// DULT/FindMy confirm via an indication. Wait briefly; if none arrives, the tag
// likely isn't separated from its owner (so it won't actually ring).
handler.postDelayed({
finish("Ring command sent, but the tracker didn't confirm - it only rings when separated from its owner.", g)
}, 4000)
}
@Suppress("DEPRECATION")
override fun onCharacteristicChanged(g: BluetoothGatt, ch: BluetoothGattCharacteristic) {
finish("Ringing confirmed - listen for the tracker.", g)
}
override fun onCharacteristicChanged(g: BluetoothGatt, ch: BluetoothGattCharacteristic, value: ByteArray) {
finish("Ringing confirmed - listen for the tracker.", g)
}
}
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
cont.invokeOnCancellation { runCatching { gatt?.disconnect(); gatt?.close() } }
cont.invokeOnCancellation {
handler.removeCallbacksAndMessages(null)
runCatching { gatt?.disconnect(); gatt?.close() }
}
}
@Suppress("DEPRECATION")
@@ -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()
@@ -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
@@ -33,11 +34,13 @@ 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
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@@ -49,6 +52,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -59,11 +63,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
@@ -75,6 +81,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
@@ -92,7 +99,9 @@ fun MainScreen(
onDistrust: (String) -> Unit,
onRing: (TrackerEntity) -> Unit,
onClearAll: () -> Unit,
onOpenSafety: () -> Unit
onOpenSafety: () -> Unit,
onOpenHistory: () -> Unit,
loadTrail: suspend (String) -> List<TrailPoint>
) {
var detail by remember { mutableStateOf<TrackerEntity?>(null) }
var finding by remember { mutableStateOf<TrackerEntity?>(null) }
@@ -121,6 +130,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,
@@ -238,7 +254,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
)
}
}
@@ -389,7 +406,8 @@ private fun TrackerDetail(
onDistrust: (String) -> Unit,
onFind: (TrackerEntity) -> Unit,
onRing: (TrackerEntity) -> Unit,
onSafety: () -> Unit
onSafety: () -> Unit,
loadTrail: suspend (String) -> List<TrailPoint>
) {
val status = statusOf(t)
Column(Modifier.fillMaxWidth().padding(24.dp)) {
@@ -431,6 +449,25 @@ private fun TrackerDetail(
style = MaterialTheme.typography.bodyMedium,
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))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilledTonalButton(onClick = { onFind(t) }, modifier = Modifier.weight(1f)) {
@@ -444,6 +481,23 @@ private fun TrackerDetail(
Text("Ring it")
}
}
var showInfo by remember { mutableStateOf(false) }
Spacer(Modifier.height(8.dp))
OutlinedButton(onClick = { showInfo = true }, modifier = Modifier.fillMaxWidth()) {
Text("Who owns it?")
}
if (showInfo) {
AlertDialog(
onDismissRequest = { showInfo = false },
confirmButton = { TextButton(onClick = { showInfo = false }) { Text("Got it") } },
title = { Text("Who owns this tracker?") },
text = {
Text(
"Tap the tracker against the top of your phone (NFC). If it's an AirTag or a DULT tag in lost mode, your phone opens a page with the owner's masked phone or email. VIGIL stays offline - the tag's NFC hands the link to your browser."
)
}
)
}
Spacer(Modifier.height(12.dp))
when (status) {
TrackerStatus.SAFE_APPROVED ->
@@ -545,6 +599,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 ----------------------------------------------------------
private const val ACTIVE_WINDOW_MS = 10 * 60_000L // trackers not seen this recently drop off "Active"
@@ -102,10 +102,22 @@ fun SafetyScreen(onBack: () -> Unit) {
Spacer(Modifier.height(10.dp))
OutlinedButton(
onClick = { go(Intent(Intent.ACTION_VIEW, Uri.parse("https://www.stalkingawareness.org/help-for-victims/"))) },
onClick = { go(Intent(Intent.ACTION_DIAL, Uri.parse("tel:18554842846"))) },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Filled.OpenInNew, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.size(8.dp)); Text("Stalking help & resources (SPARC)")
Icon(Icons.Filled.Call, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text("VictimConnect — 1-855-484-2846 (stalking victims)")
}
Spacer(Modifier.height(10.dp))
OutlinedButton(
onClick = { go(Intent(Intent.ACTION_VIEW, Uri.parse("https://www.stalkingawareness.org/what-to-do-if-you-are-being-stalked/"))) },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Filled.OpenInNew, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text("What to do if you're being stalked (SPARC)")
}
Spacer(Modifier.height(16.dp))
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="exports" path="exports/" />
</paths>
+2 -2
View File
@@ -362,8 +362,8 @@
VIGIL
</div>
<p class="footer-tag">
Temporal counter-tracking for Android. Fully offline, listens only. A
DREAMMAKER project · sibling to OVERWATCH.
Temporal counter-tracking for Android. Fully offline, listens only.
A sibling to OVERWATCH.
</p>
</div>
<div class="footer-col">