13 Commits

Author SHA1 Message Date
Kara Zajac 24a2dc83e5 Survivor side (1/4): safety guidance + first-run onboarding
Build APK / build (push) Has been cancelled
- OnboardingScreen: first-launch explainer (what it does, the offline promise, why it needs each permission), then requests permissions.
- SafetyScreen: 'if a tracker is following you' guidance — get safe, think before removing, document, report — with Call 911, the US DV Hotline (call / text START), and SPARC stalking resources. Every action hands off to the dialer/messages/browser; VIGIL stays offline.
- Entry points: a Safety icon in the top bar, and a red 'What should I do?' button on ALERTING/SUSPICIOUS trackers.
Release 0.1.8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 19:39:32 -04:00
Kara Zajac 1c0a6f216d Cache-bust style.css so the new .shots gallery rules load
Cloudflare was serving a stale cached style.css without the screenshots
gallery CSS; version the link (?v=2) to fetch the fresh stylesheet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173RhsXkdMViDR8DMNAG3ms
2026-07-15 19:17:17 -04:00
Kara Zajac a71a83891a Add screenshots gallery to the VIGIL showcase page
New "Screens" section (3-up phone gallery: active tracker list with
"It's mine" triage, locate-mode searching, and homing at -52 dBm /
ring), plus three optimized app screenshots, a nav anchor, and .shots CSS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173RhsXkdMViDR8DMNAG3ms
2026-07-15 19:12:21 -04:00
Kara Zajac b72faea8ce Add app screenshots to README
docs/screenshots/: main watch list, hot/cold finder (searching), and finder homing in with Make it ring. Added a Screenshots section to the README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 19:09:08 -04:00
Kara Zajac ee13e10173 docs: cache-bust the main screenshot 2026-07-15 18:59:58 -04:00
Kara Zajac 51cd474573 docs: cleaner main-screen screenshot (no device chrome)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 18:59:18 -04:00
Kara Zajac 6e5b565e5e docs: add VIGIL showcase page (vigil.netslum.io)
Static landing page reusing the OVERWATCH/SKELETONKEY design system:
hero + main-screen screenshot, the five tracker ecosystems, the
co-movement decision model + OBSERVED/SUSPICIOUS/ALERTING tiers, the
rotating-clone problem, and the fully-offline privacy story.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 18:51:04 -04:00
Kara Zajac 52797cf52f README: drop the 'like OVERWATCH' privacy claim
VIGIL is fully offline (no INTERNET permission), but OVERWATCH reaches out for Waze/Citizen/DeFlock data — so equating the two on privacy was inaccurate. Header now states VIGIL's actual property ('fully offline'); the OVERWATCH spatial/temporal framing and code-reuse mentions are unchanged (those are accurate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 18:16:53 -04:00
Kara Zajac f4635967c7 Use Radar Eye logo in the app header + README
Build APK / build (push) Has been cancelled
- App: top-bar brand mark is now the Radar Eye (drawable/vigil_logo.xml) instead of a generic shield.
- README: centered logo header (docs/logo.png, rendered from docs/logo.svg).
Release 0.1.7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 18:03:10 -04:00
Kara Zajac 41ab2f21a4 New app icon: Radar Eye (adaptive + themed monochrome)
Build APK / build (push) Has been cancelled
Replaces the inherited OVERWATCH shield with VIGIL's own mark: a watchful eye with a radar-target iris and a green centre contact, in Catppuccin Mocha. Foreground vector (safe-zone sized) + radial-gradient background layer + monochrome layer for Android 13+ themed icons. Release 0.1.6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 17:56:00 -04:00
Kara Zajac 333dd291cb Honest ring feedback (write-ACK != actually ringing)
Build APK / build (push) Has been cancelled
The DULT/FMDN path reported 'Ringing…' off the GATT write acknowledgement, not the tag's real response — so a tag that ACKs but declines to ring (e.g. because it's not separated from its owner) still showed success. Reworded to 'Ring command sent — a tag only chirps when separated from its owner.' Release 0.1.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 13:22:32 -04:00
Kara Zajac bf15ead1ef Add 'Clear all' button to wipe the tracker list
Build APK / build (push) Has been cancelled
Trash action in the top bar (shown when the list is non-empty) clears all tracker rows + sighting history via repo.clearAll(). Devices re-populate as they're re-detected; learned baseline places are kept. Release 0.1.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 12:42:06 -04:00
Kara Zajac 6c460cbff7 Fix: back button on finder screen exits app instead of closing finder
Build APK / build (push) Has been cancelled
The hot/cold FinderScreen is a full-screen overlay with no BackHandler, so system-back fell through to the Activity and finished it. Added BackHandler(onBack = onClose) so back closes the finder and returns to the main list. Release 0.1.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 10:02:55 -04:00
30 changed files with 2046 additions and 42 deletions
+19 -1
View File
@@ -1,7 +1,13 @@
<div align="center">
<img src="docs/logo.png" alt="VIGIL" width="96" height="96" />
# VIGIL
**What's been following you?**
</div>
A native Android (Kotlin) app that watches for personal item-trackers — Apple
AirTags, Tile, Samsung Galaxy SmartTags, and Google Find My Device / DULT tags —
that are **travelling with you over time**. It is the temporal counterpart to
@@ -22,7 +28,19 @@ that idea.
---
## Privacy first — like OVERWATCH
## Screenshots
<p align="center">
<img src="docs/screenshots/main.png" width="230" alt="VIGIL watch list — whether anything has been following you" />
&nbsp;
<img src="docs/screenshots/finder-searching.png" width="230" alt="Hot/cold finder searching for a tracker" />
&nbsp;
<img src="docs/screenshots/finder-close.png" width="230" alt="Finder homing in on a device, with Make it ring" />
</p>
<p align="center"><sub>The watch list &nbsp;·&nbsp; the hot/cold finder searching &nbsp;·&nbsp; homing in on a tracker, with “Make it ring”</sub></p>
## Privacy first — fully offline
VIGIL requests **no `INTERNET` permission at all.** There is no server, no account,
no telemetry. Every tracker, every sighting, and the entire learned baseline live
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "org.soulstone.vigil"
minSdk = 26
targetSdk = 35
versionCode = 3
versionName = "0.1.2"
versionCode = 9
versionName = "0.1.8"
}
// Fixed debug keystore committed to the repo (a debug key is non-secret — its
@@ -14,6 +14,8 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
@@ -24,6 +26,8 @@ import org.soulstone.vigil.ring.TrackerRinger
import org.soulstone.vigil.data.settings.Settings
import org.soulstone.vigil.service.ScanService
import org.soulstone.vigil.ui.MainScreen
import org.soulstone.vigil.ui.OnboardingScreen
import org.soulstone.vigil.ui.SafetyScreen
import org.soulstone.vigil.ui.theme.VigilTheme
class MainActivity : ComponentActivity() {
@@ -68,35 +72,48 @@ class MainActivity : ComponentActivity() {
setContent {
VigilTheme {
val running by ScanService.running.collectAsState()
val trackers by repo.observeTrackers().collectAsState(initial = emptyList())
val sensitivity by settings.sensitivity.collectAsState()
val granted by permissionsGranted
val onboarded by settings.onboarded.collectAsState()
var showSafety by remember { mutableStateOf(false) }
when {
!onboarded -> OnboardingScreen(onFinish = {
settings.setOnboarded(true)
if (!hasEssentialPermissions()) permissionLauncher.launch(requiredPermissions)
})
showSafety -> SafetyScreen(onBack = { showSafety = false })
else -> {
val running by ScanService.running.collectAsState()
val trackers by repo.observeTrackers().collectAsState(initial = emptyList())
val sensitivity by settings.sensitivity.collectAsState()
val granted by permissionsGranted
MainScreen(
running = running,
trackers = trackers,
sensitivity = sensitivity,
permissionMessage = if (granted) null
else "Grant Bluetooth + location to start watching",
onStartStop = {
if (running) {
ScanService.stop(this)
} else if (granted) {
ScanService.start(this)
} else {
permissionLauncher.launch(requiredPermissions)
}
},
onSetSensitivity = { settings.setSensitivity(it) },
onApprove = { id, approved ->
lifecycleScope.launch { repo.setApproved(id, approved) }
},
onDistrust = { id ->
lifecycleScope.launch { repo.clearBaseline(id) }
},
onRing = { tracker -> ringTracker(tracker) }
)
MainScreen(
running = running,
trackers = trackers,
sensitivity = sensitivity,
permissionMessage = if (granted) null
else "Grant Bluetooth + location to start watching",
onStartStop = {
if (running) {
ScanService.stop(this)
} else if (granted) {
ScanService.start(this)
} else {
permissionLauncher.launch(requiredPermissions)
}
},
onSetSensitivity = { settings.setSensitivity(it) },
onApprove = { id, approved ->
lifecycleScope.launch { repo.setApproved(id, approved) }
},
onDistrust = { id ->
lifecycleScope.launch { repo.clearBaseline(id) }
},
onRing = { tracker -> ringTracker(tracker) },
onClearAll = { lifecycleScope.launch { repo.clearAll() } },
onOpenSafety = { showSafety = true }
)
}
}
}
}
}
@@ -44,6 +44,13 @@ class TrackerRepository(private val db: VigilDatabase) {
db.trackerDao().pruneStale(cutoff)
}
/** Wipe the whole tracker list + sighting history (they re-populate as devices
* are re-detected). Keeps learned baseline places. */
suspend fun clearAll() = mutex.withLock {
db.trackerDao().clearAll()
db.sightingDao().clearAll()
}
/**
* Ingest one sighting. Persists it, updates the baseline, and re-evaluates the
* tracker's risk. Returns the updated row and whether this crossed into ALERTING.
@@ -91,6 +91,9 @@ interface TrackerDao {
// stale, non-approved rows so the table and the UI list don't grow without bound.
@Query("DELETE FROM trackers WHERE lastSeen < :cutoff AND approved = 0")
suspend fun pruneStale(cutoff: Long)
@Query("DELETE FROM trackers")
suspend fun clearAll()
}
@Dao
@@ -103,6 +106,9 @@ interface SightingDao {
@Query("DELETE FROM sightings WHERE timestamp < :cutoff")
suspend fun prune(cutoff: Long)
@Query("DELETE FROM sightings")
suspend fun clearAll()
}
@Dao
@@ -25,8 +25,16 @@ class Settings private constructor(context: Context) {
_sensitivity.value = value
}
private val _onboarded = MutableStateFlow(prefs.getBoolean(KEY_ONBOARDED, false))
val onboarded: StateFlow<Boolean> = _onboarded.asStateFlow()
fun setOnboarded(value: Boolean) {
prefs.edit().putBoolean(KEY_ONBOARDED, value).apply()
_onboarded.value = value
}
companion object {
private const val KEY_SENSITIVITY = "sensitivity"
private const val KEY_ONBOARDED = "onboarded"
@Volatile private var INSTANCE: Settings? = null
fun get(context: Context): Settings = INSTANCE ?: synchronized(this) {
@@ -148,7 +148,8 @@ object TrackerRinger {
done("Ringing… listen for the AirTag.", g)
} else {
done(
if (status == BluetoothGatt.GATT_SUCCESS) "Ringing… listen for the tracker."
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
)
}
@@ -1,6 +1,7 @@
package org.soulstone.vigil.ui
import android.text.format.DateUtils
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.Sensors
@@ -8,7 +9,9 @@ import androidx.compose.material3.FilledTonalButton
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -27,7 +30,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
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.Lock
import androidx.compose.material.icons.filled.Radar
import androidx.compose.material.icons.filled.Shield
@@ -41,6 +46,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
@@ -67,6 +73,7 @@ import org.soulstone.vigil.BuildConfig
import org.soulstone.vigil.data.db.TrackerEntity
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.ui.theme.VigilGreen
import org.soulstone.vigil.ui.theme.VigilPeach
@@ -83,7 +90,9 @@ fun MainScreen(
onSetSensitivity: (Sensitivity) -> Unit,
onApprove: (String, Boolean) -> Unit,
onDistrust: (String) -> Unit,
onRing: (TrackerEntity) -> Unit
onRing: (TrackerEntity) -> Unit,
onClearAll: () -> Unit,
onOpenSafety: () -> Unit
) {
var detail by remember { mutableStateOf<TrackerEntity?>(null) }
var finding by remember { mutableStateOf<TrackerEntity?>(null) }
@@ -102,12 +111,32 @@ fun MainScreen(
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Shield, null, tint = MaterialTheme.colorScheme.primary)
Image(
painterResource(R.drawable.vigil_logo),
contentDescription = null,
modifier = Modifier.size(26.dp)
)
Spacer(Modifier.size(8.dp))
Text("VIGIL", fontWeight = FontWeight.Bold)
}
},
actions = {
IconButton(onClick = onOpenSafety) {
Icon(
Icons.Filled.HealthAndSafety,
contentDescription = "Safety & help",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (trackers.isNotEmpty()) {
IconButton(onClick = onClearAll) {
Icon(
Icons.Filled.DeleteSweep,
contentDescription = "Clear all trackers",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 12.dp)) {
Icon(
Icons.Filled.Lock, null,
@@ -208,7 +237,8 @@ fun MainScreen(
onApprove = { id, a -> onApprove(id, a); detail = null },
onDistrust = { id -> onDistrust(id); detail = null },
onFind = { dev -> ScanService.setFinderTarget(dev.stableId); finding = dev; detail = null },
onRing = onRing
onRing = onRing,
onSafety = { onOpenSafety(); detail = null }
)
}
}
@@ -358,7 +388,8 @@ private fun TrackerDetail(
onApprove: (String, Boolean) -> Unit,
onDistrust: (String) -> Unit,
onFind: (TrackerEntity) -> Unit,
onRing: (TrackerEntity) -> Unit
onRing: (TrackerEntity) -> Unit,
onSafety: () -> Unit
) {
val status = statusOf(t)
Column(Modifier.fillMaxWidth().padding(24.dp)) {
@@ -369,6 +400,18 @@ private fun TrackerDetail(
}
Spacer(Modifier.height(4.dp))
Text(statusLabel(status), color = statusColor(status), fontWeight = FontWeight.SemiBold)
if (status == TrackerStatus.ALERTING || status == TrackerStatus.SUSPICIOUS) {
Spacer(Modifier.height(14.dp))
Button(
onClick = onSafety,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = VigilRed, contentColor = Color(0xFF11111B))
) {
Icon(Icons.Filled.HealthAndSafety, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text("What should I do?", fontWeight = FontWeight.Bold)
}
}
Spacer(Modifier.height(16.dp))
DetailRow("Signal (last / peak)", "${t.lastRssi} / ${t.peakRssi} dBm")
@@ -432,6 +475,8 @@ private fun DetailRow(label: String, value: String) {
* proximity meter. Works even on silent/modified tags that refuse to ring. */
@Composable
private fun FinderScreen(t: TrackerEntity, onRing: (TrackerEntity) -> Unit, onClose: () -> Unit) {
// Intercept system back so it closes the finder instead of exiting the app.
BackHandler(onBack = onClose)
val rssi by ScanService.finderRssi.collectAsState()
val smoothed = remember { mutableStateOf(-100f) }
LaunchedEffect(rssi) { rssi?.let { smoothed.value = 0.35f * it + 0.65f * smoothed.value } }
@@ -0,0 +1,87 @@
package org.soulstone.vigil.ui
import androidx.compose.foundation.Image
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Sensors
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.soulstone.vigil.R
/** First-run: what VIGIL does, its offline promise, and why it needs its permissions. */
@Composable
fun OnboardingScreen(onFinish: () -> Unit) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.height(32.dp))
Image(painterResource(R.drawable.vigil_logo), null, modifier = Modifier.size(76.dp))
Spacer(Modifier.height(14.dp))
Text("VIGIL", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text(
"Know what's been following you.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(36.dp))
Point(
Icons.Filled.Sensors,
"Finds what follows you",
"Detects AirTags, Tile, Samsung SmartTags and Find My trackers that travel with you over time — not just whatever happens to be nearby."
)
Point(
Icons.Filled.Lock,
"Stays on your phone",
"No account, no internet, no telemetry. Everything VIGIL learns lives on this device and never leaves it."
)
Point(
Icons.Filled.LocationOn,
"Why it needs permissions",
"Bluetooth to hear trackers, location to tell one is moving with you across places, and notifications to warn you. That's the whole reason it asks."
)
Spacer(Modifier.height(32.dp))
Button(onClick = onFinish, modifier = Modifier.fillMaxWidth().height(52.dp)) {
Text("Get started", fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(24.dp))
}
}
}
@Composable
private fun Point(icon: ImageVector, title: String, body: String) {
Row(Modifier.fillMaxWidth().padding(vertical = 12.dp)) {
Icon(icon, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(26.dp).padding(top = 2.dp))
Spacer(Modifier.size(16.dp))
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(title, fontWeight = FontWeight.SemiBold)
Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@@ -0,0 +1,135 @@
package org.soulstone.vigil.ui
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.soulstone.vigil.ui.theme.VigilRed
/** Survivor-centred guidance shown when a tracker may be following you. Every
* action hands off to the dialer / messages / browser — VIGIL stays offline. */
@Composable
fun SafetyScreen(onBack: () -> Unit) {
BackHandler(onBack = onBack)
val ctx = LocalContext.current
fun go(intent: Intent) = runCatching { ctx.startActivity(intent) }
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") }
Spacer(Modifier.size(4.dp))
Text("If a tracker is following you", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(8.dp))
Text(
"VIGIL is a detection tool, not a substitute for professional help. If you're in immediate danger, call emergency services.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(20.dp))
Step(1, "Get somewhere safe", "If you feel in danger, head to a public place or someone you trust before doing anything else.")
Step(2, "Think before you remove it", "Turning a tracker off can alert whoever placed it and escalate the risk. If you might be in danger, consider documenting it and getting help first.")
Step(3, "Document what you can", "Save when and where it was seen (use Export), and photograph the device if you find it. A dated record helps police and advocates.")
Step(4, "Report it", "Contact local police — 911 if you're in immediate danger. If this involves a partner or ex, a domestic-violence advocate can help you make a safety plan.")
Spacer(Modifier.height(24.dp))
Text("Get help", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
Spacer(Modifier.height(10.dp))
Button(
onClick = { go(Intent(Intent.ACTION_DIAL, Uri.parse("tel:911"))) },
modifier = Modifier.fillMaxWidth().height(50.dp),
colors = ButtonDefaults.buttonColors(containerColor = VigilRed, contentColor = Color(0xFF11111B))
) {
Icon(Icons.Filled.Call, null); Spacer(Modifier.size(8.dp)); Text("Call 911 (emergency)", fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(10.dp))
Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("U.S. National Domestic Violence Hotline", fontWeight = FontWeight.SemiBold)
Text("Free, confidential, 24/7 — help with stalking and abuse, including safety planning.",
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { go(Intent(Intent.ACTION_DIAL, Uri.parse("tel:18007997233"))) }, modifier = Modifier.weight(1f)) {
Icon(Icons.Filled.Call, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.size(6.dp)); Text("Call")
}
OutlinedButton(onClick = { go(Intent(Intent.ACTION_SENDTO, Uri.parse("smsto:88788")).putExtra("sms_body", "START")) }, modifier = Modifier.weight(1f)) {
Icon(Icons.Filled.Chat, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.size(6.dp)); Text("Text START")
}
}
}
}
Spacer(Modifier.height(10.dp))
OutlinedButton(
onClick = { go(Intent(Intent.ACTION_VIEW, Uri.parse("https://www.stalkingawareness.org/help-for-victims/"))) },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Filled.OpenInNew, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.size(8.dp)); Text("Stalking help & resources (SPARC)")
}
Spacer(Modifier.height(16.dp))
Text(
"These open your phone, messages, or browser — VIGIL itself never connects to the internet. Resources shown are U.S.; check what's available where you are.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(24.dp))
}
}
}
@Composable
private fun Step(n: Int, title: String, body: String) {
Row(Modifier.fillMaxWidth().padding(vertical = 9.dp)) {
Box(
Modifier.size(26.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center
) { Text("$n", color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.labelMedium) }
Spacer(Modifier.size(14.dp))
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, fontWeight = FontWeight.SemiBold)
Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Mocha radial ground: a subtle lift toward the top-centre, falling to crust. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M0,0h108v108h-108z">
<aapt:attr name="android:fillColor">
<gradient
android:type="radial"
android:centerX="54"
android:centerY="42"
android:gradientRadius="82"
android:startColor="#2A2A40"
android:endColor="#11111B" />
</aapt:attr>
</path>
</vector>
@@ -1,13 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- VIGIL "Radar Eye" — a watchful eye whose iris is a radar target. Content sits
within the adaptive-icon safe zone (centre ~66dp of the 108dp canvas). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- eye outline -->
<path
android:fillColor="#1FAA59"
android:pathData="M54,30 m-18,0 a18,18 0 1,0 36,0 a18,18 0 1,0 -36,0" />
android:pathData="M26,54 Q54,30 82,54 Q54,78 26,54 Z"
android:strokeColor="#89B4FA"
android:strokeWidth="4.5"
android:strokeLineJoin="round"
android:fillColor="#00000000" />
<!-- radar iris — outer ring -->
<path
android:fillColor="#F4F6FA"
android:pathData="M54,30 m-6,0 a6,6 0 1,0 12,0 a6,6 0 1,0 -12,0" />
android:pathData="M54,54 m-11,0 a11,11 0 1,0 22,0 a11,11 0 1,0 -22,0"
android:strokeColor="#89B4FA"
android:strokeWidth="3.2"
android:fillColor="#00000000" />
<!-- radar iris — inner ring -->
<path
android:pathData="M54,54 m-6.5,0 a6.5,6.5 0 1,0 13,0 a6.5,6.5 0 1,0 -13,0"
android:strokeColor="#B4BEFE"
android:strokeWidth="2.8"
android:fillColor="#00000000" />
<!-- centre contact (the detected device) -->
<path
android:pathData="M54,54 m-3.2,0 a3.2,3.2 0 1,0 6.4,0 a3.2,3.2 0 1,0 -6.4,0"
android:fillColor="#A6E3A1" />
</vector>
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- VIGIL wordmark glyph (Radar Eye), tight-cropped for in-app use (top bar). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:pathData="M4,24 Q24,8 44,24 Q24,40 4,24 Z"
android:strokeColor="#89B4FA"
android:strokeWidth="3"
android:strokeLineJoin="round"
android:fillColor="#00000000" />
<path
android:pathData="M24,24 m-7.5,0 a7.5,7.5 0 1,0 15,0 a7.5,7.5 0 1,0 -15,0"
android:strokeColor="#89B4FA"
android:strokeWidth="2.4"
android:fillColor="#00000000" />
<path
android:pathData="M24,24 m-4.4,0 a4.4,4.4 0 1,0 8.8,0 a4.4,4.4 0 1,0 -8.8,0"
android:strokeColor="#B4BEFE"
android:strokeWidth="2"
android:fillColor="#00000000" />
<path
android:pathData="M24,24 m-2.2,0 a2.2,2.2 0 1,0 4.4,0 a2.2,2.2 0 1,0 -4.4,0"
android:fillColor="#A6E3A1" />
</vector>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/bg_dark" />
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/bg_dark" />
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
View File
+46
View File
@@ -0,0 +1,46 @@
/* OVERWATCH landing — count-up stats + scroll reveal. No network, no deps. */
(function () {
'use strict';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/* count-up numbers when the stats row scrolls into view */
function countUp(el) {
const target = parseInt(el.dataset.target, 10);
if (isNaN(target)) return;
if (!target || reduceMotion) { el.textContent = String(target); return; }
const dur = 1100, start = performance.now();
(function tick(now) {
const p = Math.min(1, (now - start) / dur);
el.textContent = String(Math.round(target * (1 - Math.pow(1 - p, 3))));
if (p < 1) requestAnimationFrame(tick);
})(performance.now());
}
const statsRow = document.getElementById('stats-row');
if (statsRow) {
const nums = statsRow.querySelectorAll('.num[data-target]');
if ('IntersectionObserver' in window && !reduceMotion) {
const io = new IntersectionObserver((entries, obs) => {
entries.forEach((e) => {
if (e.isIntersecting) { nums.forEach(countUp); obs.disconnect(); }
});
}, { threshold: 0.4 });
io.observe(statsRow);
} else {
nums.forEach((el) => { el.textContent = el.dataset.target; });
}
}
/* scroll-triggered reveal */
const reveals = document.querySelectorAll('.reveal');
if (!('IntersectionObserver' in window) || reduceMotion) {
reveals.forEach((el) => el.classList.add('in'));
} else {
const ro = new IntersectionObserver((entries, obs) => {
entries.forEach((e) => {
if (e.isIntersecting) { e.target.classList.add('in'); obs.unobserve(e.target); }
});
}, { threshold: 0.12, rootMargin: '0px 0px -40px 0px' });
reveals.forEach((el) => ro.observe(el));
}
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+407
View File
@@ -0,0 +1,407 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VIGIL — what's been following you?</title>
<meta name="description" content="A native Android app that watches for personal item-trackers — AirTags, Tile, Samsung SmartTags, Google Find My — travelling with you over time. Fully offline, no INTERNET permission. The temporal counterpart to OVERWATCH.">
<meta property="og:title" content="VIGIL — what's been following you?">
<meta property="og:description" content="Android app that flags AirTags, Tile, SmartTags & Find My trackers travelling WITH you over time — even rotating-key clones. Fully offline, listens only.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://vigil.netslum.io/">
<meta property="og:image" content="https://vigil.netslum.io/og.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://vigil.netslum.io/og.png">
<meta name="theme-color" content="#07070d">
<link rel="icon" type="image/svg+xml" href="logo.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=2">
</head>
<body>
<div class="bg-mesh" aria-hidden="true">
<div class="mesh-blob mesh-blob-1"></div>
<div class="mesh-blob mesh-blob-2"></div>
<div class="mesh-blob mesh-blob-3"></div>
</div>
<nav class="nav">
<div class="container nav-inner">
<a class="nav-brand" href="#">
<img class="nav-logo" src="logo.svg" width="30" height="30" alt="" aria-hidden="true">
VIGIL
</a>
<div class="nav-links">
<a href="#detects">What it detects</a>
<a href="#decides">How it decides</a>
<a href="#screens">Screens</a>
<a href="#clones">Clones</a>
<a href="#download">Download</a>
<a class="nav-github" href="https://github.com/KaraZajac/VIGIL" aria-label="GitHub">
<svg height="18" viewBox="0 0 16 16" width="18" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
</svg>
</a>
</div>
</div>
</nav>
<!-- ──────────────── HERO ──────────────── -->
<header class="hero">
<div class="container hero-inner">
<div class="hero-eyebrow">
<span class="dot dot-pulse"></span>
v0.1.7 · Android · prototype
</div>
<h1 class="hero-title">
<span class="display-wordmark">VIGIL</span>
</h1>
<p class="hero-tag">
<strong>What's been following you?</strong> A native Android app that
watches for personal item-trackers — AirTags, Tile, Samsung SmartTags,
Google Find My — that are <strong>travelling with you over time</strong>.
<span class="hero-tag-pop">A tracker being near you means nothing. The signal is persistence.</span>
</p>
<div class="phone-hero">
<img class="phone" src="img/vigil-main.png?v=2" width="1096" height="2280"
alt="VIGIL main screen: a green &quot;You're clear — nothing has been following you&quot; card, a Stop Watching button, and a High/Medium/Low sensitivity selector." loading="eager">
</div>
<div class="stats-row" id="stats-row">
<div class="stat-chip"><span class="num" data-target="5">0</span><span>tracker ecosystems</span></div>
<div class="stat-chip stat-vfy"><span class="num" data-target="3">0</span><span>escalation tiers</span></div>
<div class="stat-chip"><span class="num" data-target="0">0</span><span>internet permission</span></div>
<div class="stat-chip"><span class="num">v0.1.7</span><span>latest APK</span></div>
</div>
<div class="cta-row">
<a class="btn btn-primary" href="https://github.com/KaraZajac/VIGIL/releases/latest">
↓ Download APK
</a>
<a class="btn" href="#clones">The clone problem</a>
<a class="btn btn-ghost" href="https://github.com/KaraZajac/VIGIL">
<svg height="16" viewBox="0 0 16 16" width="16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
Source on GitHub
</a>
</div>
<p class="hero-warn">Prototype / work in progress. Debug-signed APK — sideload; no Play Store. <a href="https://github.com/KaraZajac/VIGIL">Read the README.</a></p>
</div>
</header>
<!-- ──────────────── OVERWATCH SIBLING ──────────────── -->
<section class="section section-callout reveal">
<div class="container">
<div class="callout">
<div class="callout-mark">🛰</div>
<div>
<h3>The temporal counterpart to <a href="https://overwatch.netslum.io">OVERWATCH</a></h3>
<p>
<strong>OVERWATCH is spatial</strong> — what surveillance is watching
<em>this place</em>, right now. <strong>VIGIL is temporal</strong>
what has been with <em>you</em>, across time and places. VIGIL reuses
OVERWATCH's proven passive-scanning stack and adds a persistent
on-device store to reason about a tracker's history.
</p>
</div>
</div>
</div>
</section>
<!-- ──────────────── WHAT IT DETECTS ──────────────── -->
<section id="detects" class="section section-bento reveal">
<div class="container">
<div class="section-head">
<span class="section-tag">what it detects</span>
<h2>Five ecosystems. One tell: separated from its owner.</h2>
<p class="lead">VIGIL recognises each BLE wire format and, where the
ecosystem signals it, filters to the <strong>separated-from-owner</strong>
state — the only state in which a <em>following</em> tracker is even
detectable. Chipolo, Pebblebee, eufy and the rest inherit whichever
network their SKU joined, so VIGIL detects the <em>network</em>.</p>
</div>
<div class="bento">
<article class="bento-card">
<div class="bento-icon">🍎</div>
<h3>Apple Find My / AirTag</h3>
<p>Mfg data, company <code>0x004C</code>, type <code>0x12</code>. Separated when the "maintained" status bit is cleared. ~24 h re-link window.</p>
</article>
<article class="bento-card">
<div class="bento-icon">🟢</div>
<h3>Google Find My Device</h3>
<p>Service data <code>0xFEAA</code>, frame <code>0x40/0x41</code>. Frame <code>0x41</code> is the cleartext separated state.</p>
</article>
<article class="bento-card">
<div class="bento-icon">🔵</div>
<h3>Samsung Galaxy SmartTag</h3>
<p>Service data <code>0xFD5A</code>; the state byte flags lost / overmature-lost — the following state.</p>
</article>
<article class="bento-card">
<div class="bento-icon">🟩</div>
<h3>Tile</h3>
<p>Service data <code>0xFEED / 0xFEEC</code>. No separated signal and a static MAC — always findable, indefinitely.</p>
</article>
<article class="bento-card bento-lg">
<div class="bento-icon">🔗</div>
<h3>DULT (unified, emerging)</h3>
<p>Service data <code>0xFCB2</code>; the near-owner bit (byte 14 LSB) marks separation. The cross-industry standard the whole ecosystem is converging on — VIGIL parses it today.</p>
</article>
</div>
</div>
</section>
<!-- ──────────────── HOW IT DECIDES ──────────────── -->
<section id="decides" class="section reveal">
<div class="container">
<div class="section-head">
<span class="section-tag">how it decides</span>
<h2>Persistence, not proximity.</h2>
<p class="lead">A tracker is escalated only when it clears the
<strong>co-movement test</strong> — the same device, seen at many of
<em>your</em> distinct places, over a sustained window, while close enough
to actually be on you.</p>
</div>
<div class="bento">
<article class="bento-card">
<div class="bento-icon">📍</div>
<h3>≥ N distinct places</h3>
<p>Geohash-7 cells; N = 2 / 3 / 4 by sensitivity. A tracker seen only where you dwell isn't following — it lives there.</p>
</article>
<article class="bento-card">
<div class="bento-icon"></div>
<h3>≥ 3 sightings over ≥ T minutes</h3>
<p>Debounced to one per 15 min; T = 30 / 45 / 90 by sensitivity. Persistence across time, not a single blip.</p>
</article>
<article class="bento-card">
<div class="bento-icon">📶</div>
<h3>RSSI proximity gate</h3>
<p>It must have been genuinely close — on-body / in-bag — at least once. This is the piece AirGuard omits; it rejects "a Tile in a passing car."</p>
</article>
</div>
<div class="tiers" style="margin-top:2.4rem">
<div class="tier tier-green">
<div class="tier-name" style="font-size:1.15rem">OBSERVED</div>
<p>Seen, logged, geotagged — but hasn't cleared the co-movement test. No alarm.</p>
</div>
<div class="tier tier-yellow">
<div class="tier-name" style="font-size:1.15rem">SUSPICIOUS</div>
<p>Co-moving across your places. VIGIL is watching it closely.</p>
</div>
<div class="tier tier-red">
<div class="tier-name" style="font-size:1.15rem">ALERTING</div>
<p>Confirmed following you. Surfaced with the "Make it ring" and hot/cold finder tools.</p>
</div>
</div>
<div class="feature-split" style="margin-top:2.8rem">
<div class="explain-annotations" style="grid-column:1 / -1">
<div class="annotation">
<span class="anno-num"></span>
<div>
<strong>Allowlist — "This is mine"</strong>
<p>Tap a tracker to approve it — your own AirTag, your partner's Tile — and it never alerts again.</p>
</div>
</div>
<div class="annotation">
<span class="anno-num"></span>
<div>
<strong>Learned offline baseline</strong>
<p>VIGIL learns the places you dwell (home, work) as anchors; a tracker seen at an anchor across several days is auto-marked <strong>Known (home)</strong> — so household tags fall silent on their own, entirely on-device.</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ──────────────── SCREENSHOTS ──────────────── -->
<section id="screens" class="section reveal">
<div class="container">
<div class="section-head">
<span class="section-tag">screenshots</span>
<h2>Spot it, then walk it down.</h2>
<p class="lead">No account, no map tiles, no cloud — it all runs on the
phone. See what's moving with you, wave off your own gear, and when
something's left over, home in on it.</p>
</div>
<div class="shots">
<figure class="shot">
<img class="phone" src="img/vigil-active.png" width="1096" height="2560"
alt="VIGIL active list: an ACTIVE (6) list of nearby Apple Find My and Google Find My Device trackers, each showing places seen, signal in dBm, time since last seen, and an It's-mine button." loading="lazy">
<figcaption><strong>The active list</strong>Every tracker seen moving with you — signal, places, and how long ago. Tap &ldquo;It's mine&rdquo; and your own gear drops out.</figcaption>
</figure>
<figure class="shot">
<img class="phone" src="img/vigil-locate.png" width="1096" height="2560"
alt="VIGIL locate screen for an Apple Find My tracker: a large pulsing radar icon reading Searching… walk around, no signal yet, with a Make-it-ring button." loading="lazy">
<figcaption><strong>Locate mode</strong>Pick a suspect and walk — the ripple strengthens as you close in. No signal yet? Keep moving.</figcaption>
</figure>
<figure class="shot">
<img class="phone" src="img/vigil-signal.png" width="1096" height="2560"
alt="VIGIL locate screen for a Google Find My Device tracker reading Close, minus 52 dBm, with a Make-it-ring button and a Ringing… listen for the tracker toast." loading="lazy">
<figcaption><strong>Getting warmer</strong>Live signal strength as you approach — &minus;52 dBm is close. &ldquo;Make it ring&rdquo; forces a silent tracker to give itself up.</figcaption>
</figure>
</div>
</div>
</section>
<!-- ──────────────── CLONES ──────────────── -->
<section id="clones" class="section section-feature reveal">
<div class="container">
<div class="section-head">
<span class="section-tag">the hard part</span>
<h2>Catching the clone.</h2>
<p class="lead">
Every shipping detector — AirGuard, iOS, Android's built-in — keys on
<strong>device identity</strong>. A key-rotating clone (Positive
Security's <em>Find You</em>: ~2,000 Find My keys, a new one every 30 s)
looks like 2,000 one-off devices and evades them all — it tracked a
phone for five days with zero alerts.
</p>
</div>
<div class="callout" style="border-color:rgba(239,68,68,.32)">
<div class="callout-mark">🎯</div>
<div>
<h3>Detect the attack, not the device</h3>
<p>
A rotating clone is <em>one</em> physical radio holding an unbroken,
close-range, co-moving RF presence — even as its identity churns
thousands of times faster than any standards-compliant tracker is
allowed to. VIGIL's <code>PresenceEngine</code> pairs a CUSUM churn
trigger with an identity-agnostic presence-track confirmer and the
co-movement gate — an "identity-path × churn-path squeeze" that leaves
no safe rotation rate. First version implemented and unit-tested
against synthetic clone/ambient traces; field-tuning is what remains.
</p>
<a href="https://github.com/KaraZajac/VIGIL/blob/main/docs/detection-rotation-clone.md" class="audience-link" style="margin-top:.8rem;display:inline-block">Read the algorithm →</a>
</div>
</div>
</div>
</section>
<!-- ──────────────── FINDING + PRIVACY ──────────────── -->
<section class="section section-bento reveal">
<div class="container">
<div class="section-head">
<span class="section-tag">on-device, private</span>
<h2>Fully offline. It only listens.</h2>
</div>
<div class="bento">
<article class="bento-card bento-lg">
<div class="bento-icon">🔒</div>
<h3>No <code>INTERNET</code> permission at all</h3>
<p>
There is no server, no account, no telemetry. Every tracker, every
sighting, and the entire learned baseline live in an on-device SQLite
database and <strong>never leave the phone</strong>. Detection is
entirely passive — it listens only.
</p>
</article>
<article class="bento-card">
<div class="bento-icon">🔔</div>
<h3>Make it ring</h3>
<p>Tap a suspected tracker to connect over GATT and play its own sound — the DULT-standard way for a victim to locate a hidden AirTag / Find My / Chipolo tag.</p>
</article>
<article class="bento-card">
<div class="bento-icon">🌡</div>
<h3>Hot / cold finder</h3>
<p>A passive proximity meter — warmer/colder from live signal. Works even on <strong>silent or modified tags that refuse to ring</strong>, which is exactly when you need it.</p>
</article>
</div>
</div>
</section>
<!-- ──────────────── DOWNLOAD ──────────────── -->
<section id="download" class="section section-timeline reveal">
<div class="container">
<div class="section-head">
<span class="section-tag">get it</span>
<h2>Sideload in three steps.</h2>
</div>
<div class="timeline dl-steps" style="grid-template-columns:repeat(3,1fr) !important">
<div class="tl-col tl-shipped">
<div class="tl-tag">1 · download</div>
<ul><li>Grab the latest debug-signed APK from <a href="https://github.com/KaraZajac/VIGIL/releases/latest">Releases</a> (currently <strong>v0.1.7</strong>).</li></ul>
</div>
<div class="tl-col tl-active">
<div class="tl-tag">2 · install</div>
<ul><li>Sideload — allow "install unknown apps" for your browser or files app, then open the APK.</li></ul>
</div>
<div class="tl-col tl-next">
<div class="tl-tag">3 · grant + scan</div>
<ul><li>Grant nearby-devices + location + notifications, pick a sensitivity, and let it run in the background.</li></ul>
</div>
</div>
<p class="tl-foot">
Build from source, read the paper, or file issues:
<a href="https://github.com/KaraZajac/VIGIL">github.com/KaraZajac/VIGIL</a>
·
<a href="https://github.com/KaraZajac/VIGIL/blob/main/paper/vigil.md">the paper</a>
</p>
</div>
</section>
<!-- ──────────────── FOOTER ──────────────── -->
<footer class="footer">
<div class="container footer-inner">
<div class="footer-col">
<div class="footer-brand">
<img class="nav-logo" src="logo.svg" width="26" height="26" alt="" aria-hidden="true">
VIGIL
</div>
<p class="footer-tag">
Temporal counter-tracking for Android. Fully offline, listens only. A
DREAMMAKER project · sibling to OVERWATCH.
</p>
</div>
<div class="footer-col">
<h4>Project</h4>
<ul>
<li><a href="https://github.com/KaraZajac/VIGIL">Source</a></li>
<li><a href="https://github.com/KaraZajac/VIGIL/releases">Releases</a></li>
<li><a href="https://github.com/KaraZajac/VIGIL/blob/main/paper/vigil.md">The paper</a></li>
</ul>
</div>
<div class="footer-col">
<h4>Detects</h4>
<ul>
<li>Apple Find My / AirTag</li>
<li>Google Find My · Tile</li>
<li>Samsung SmartTag · DULT</li>
</ul>
</div>
<div class="footer-col">
<h4>More</h4>
<ul>
<li><a href="https://overwatch.netslum.io">OVERWATCH</a></li>
<li><a href="https://netslum.io">netslum.io</a></li>
</ul>
</div>
</div>
<div class="container footer-bottom">
<p>
VIGIL is a passive, on-device situational-awareness tool. It does not
transmit, probe, or interfere — except the user-initiated "Make it ring".
</p>
<p class="footer-meta">
v0.1.7 · <a href="https://github.com/KaraZajac/VIGIL">github.com/KaraZajac/VIGIL</a>
</p>
</div>
</footer>
<script src="app.js" defer></script>
</body>
</html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108 108" width="108" height="108" role="img" aria-label="VIGIL">
<defs>
<radialGradient id="bg" cx="50%" cy="36%" r="78%">
<stop offset="0" stop-color="#2a2a40"/>
<stop offset="1" stop-color="#11111b"/>
</radialGradient>
</defs>
<rect x="4" y="4" width="100" height="100" rx="26" fill="url(#bg)" stroke="#313244"/>
<path d="M26 54 Q54 30 82 54 Q54 78 26 54 Z" fill="none" stroke="#89b4fa" stroke-width="4.5" stroke-linejoin="round"/>
<circle cx="54" cy="54" r="11" fill="none" stroke="#89b4fa" stroke-width="3.2"/>
<circle cx="54" cy="54" r="6.5" fill="none" stroke="#b4befe" stroke-width="2.8"/>
<circle cx="54" cy="54" r="3.2" fill="#a6e3a1"/>
</svg>

After

Width:  |  Height:  |  Size: 740 B

+23
View File
@@ -0,0 +1,23 @@
<!doctype html><html lang="en"><head><meta charset="utf-8">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&family=Space+Grotesk:wght@700&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{width:1200px;height:630px;color:#cdd6f4;font-family:Inter,sans-serif;position:relative;overflow:hidden;
background:radial-gradient(130% 130% at 12% 6%,#26263c 0%,#1e1e2e 52%,#181825 100%)}
.wrap{padding:76px 82px;height:100%;display:flex;flex-direction:column;justify-content:center}
.brand{display:flex;align-items:center;gap:24px;margin-bottom:26px}
.brand img{width:92px;height:92px}
.name{font-family:"Space Grotesk",sans-serif;font-weight:700;font-size:94px;letter-spacing:.04em;color:#fff;line-height:1}
.tag{font-size:33px;line-height:1.36;color:#bac2de;font-weight:500;max-width:980px}
.tag b{color:#fff;font-weight:700}
.meta{position:absolute;bottom:82px;left:82px;font-family:"JetBrains Mono",monospace;font-size:19px;color:#9399b2}
.meta b{color:#89b4fa}
.url{position:absolute;bottom:50px;right:82px;font-family:"JetBrains Mono",monospace;font-size:20px;color:#6c7086}
</style></head><body>
<div class="wrap">
<div class="brand"><img src="logo.svg" alt=""><span class="name">VIGIL</span></div>
<div class="tag"><b>What's been following you?</b> A passive Android app that flags AirTags, Tile, SmartTags &amp; Find My trackers travelling <b>with you over time</b> — even rotating-key clones.</div>
</div>
<div class="meta">Apple · Google · Samsung · Tile · <b>DULT</b> &nbsp;&nbsp; fully offline, listens only</div>
<div class="url">vigil.netslum.io</div>
</body></html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

+1123
View File
File diff suppressed because it is too large Load Diff