4 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
8 changed files with 147 additions and 30 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "org.soulstone.vigil"
minSdk = 26
targetSdk = 35
versionCode = 10
versionName = "0.1.9"
versionCode = 13
versionName = "0.1.12"
}
// 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) },
onClearAll = { lifecycleScope.launch { repo.clearAll() } },
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.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<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()
@@ -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) {
cont.resume(
if (selected == AIRTAG && status == STATUS_PEER_DISCONNECT)
cont.resume("Ringing listen for the AirTag.")
else cont.resume("Tracker disconnected before it could ring.")
"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")
@@ -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
@@ -39,6 +40,7 @@ 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
@@ -50,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
@@ -60,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
@@ -76,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
@@ -94,7 +100,8 @@ fun MainScreen(
onRing: (TrackerEntity) -> Unit,
onClearAll: () -> Unit,
onOpenSafety: () -> Unit,
onOpenHistory: () -> Unit
onOpenHistory: () -> Unit,
loadTrail: suspend (String) -> List<TrailPoint>
) {
var detail by remember { mutableStateOf<TrackerEntity?>(null) }
var finding by remember { mutableStateOf<TrackerEntity?>(null) }
@@ -247,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
)
}
}
@@ -398,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)) {
@@ -440,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)) {
@@ -453,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 ->
@@ -554,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))
+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">