21 Commits

Author SHA1 Message Date
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
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
Kara Zajac a2f293a486 Release 0.1.2: Find it (hot/cold) + Make it ring
Build APK / build (push) Has been cancelled
Adds the passive RSSI hot/cold finder and user-initiated GATT play-sound (AirTag/DULT/Find My; Samsung/Tile point to their apps). README notes ringing as the one active operation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 09:53:40 -04:00
Kara Zajac 4af3a2380a Fix Surface import; implement real per-ecosystem GATT ring protocols
- MainScreen: add missing Surface import (polished screen switched to Scaffold and dropped it; FinderScreen uses Surface) — was the compile break.
- TrackerRinger: real multi-protocol play-sound from AirGuard's verified source: AirTag native (single 0xAF byte, self-disconnects = success via status 19), DULT (0x0300, enable indications first), Find My legacy fd44 (enable notifications, [0x01,0x00,0x03]); tried in priority order by which service the tag exposes. Samsung SmartTag + Tile have no non-owner ring path -> tell the user to use SmartThings / the Tile app. API-version-aware characteristic + descriptor writes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 09:47:59 -04:00
Kara Zajac 0efc205220 Add 'Find it' hot/cold RSSI finder + 'Make it ring' (GATT)
- FinderScreen: live smoothed RSSI as a growing, colour-shifting proximity meter (works on silent/modified tags that refuse to ring). Fed by ScanService.finderRssi (every advert, ahead of the DB throttle).
- TrackerRinger: user-initiated GATT play-sound — VIGIL's one active operation (detection stays passive). DULT cross-vendor path (Google FMDN + DULT partners) implemented; Apple/others report gracefully pending per-ecosystem UUIDs (GATT protocol research in progress). UUIDs/opcode pending on-device verification.
- Detail sheet: 'Find it' + 'Ring it' buttons; TrackerEntity.lastMac added (needed to GATT-connect); DB version 3.
- BLUETOOTH_CONNECT was already declared for this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 09:43:00 -04:00
Kara Zajac f952a8e7ca Release 0.1.1: critical bugfix sweep
Build APK / build (push) Has been cancelled
Ships the two rounds of logical-flaw fixes: the evaluator overflow (never-alerted), silent alert channel, Apple phantom-tracker parser bug, stalker-auto-trust baseline flaw, ingestion throttle, lost-update race, unbounded tracker rows, and RSSI/evidence surfaced in the UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 09:19:22 -04:00
Kara Zajac 98edf4345a Critical fixes from logical-flaw sweep (round 2)
The evaluator test caught a real critical bug and the independent review found more:

- CoMovementEvaluator: DEDUP seed was Long.MIN_VALUE; timestamp - MIN_VALUE overflows negative, so the first sighting never counted, effective was ALWAYS 0, and the detector could NEVER alert. Now nullable-seeded. (Unit test now passes.)
- ScanService: alerts used the LOW-importance foreground channel, so on Android 8+ a real 'following you' warning made NO sound/heads-up and overwrote the ongoing notification. Added a dedicated HIGH-importance alert channel + distinct, dismissable notification IDs.
- TrackerParser (Apple): required the full offline-finding frame (>=25B). Short 'nearby' frames were falling back to using the type/status bytes as identity, merging many devices into one phantom 'tracker' that could false-alert forever.
- TrackerRepository: assess co-movement BEFORE baseline; NEVER baseline-trust a co-moving tag (a planted stalker tag co-moves), and auto-revoke trust if a trusted tag starts moving with you. setApproved/clearBaseline now under the same Mutex so record() can't clobber the user's choice.
- BaselineManager already switched to dwell (round 1); UI now offers an un-trust ('Not mine') control for baseline rows (clearBaseline DAO).
- BleTrackerScanner: match by service DATA presence, not service-UUID list (Samsung/Tile may not advertise the UUID entry — were potentially invisible).
- PresenceEngine: RSSI coherence now GATES the CONFIRMED tier (crowd/transit can't false-confirm).
- ScanService: fixed location-update leak on failed scan start (idempotent end()); prune runs at start not only after 6h.
- MainActivity: POST_NOTIFICATIONS denial no longer blocks scanning (BLE+location are essential; notifications optional).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 09:14:17 -04:00
Kara Zajac de58944ce7 Bugfix/logical-flaw sweep: counting, races, baseline, pruning, RSSI
Root cause of the non-monotonic sighting count (60->29->63): MAC-rotating ecosystems mint a new tracker row per rotation, tracker rows were never pruned, and the UI re-sorts constantly, so different rows' counts read as one sequence. Compounded by no ingestion throttle and a lost-update race.

Fixes:
- ScanService: throttle the persisted path to <=1 sighting/device/15s (presence engine still sees every advert). Stops count inflation + DB hammering. @Volatile clone-alert ts; clear throttle map on stop.
- TrackerRepository: Mutex around record() (was a lost-update race: concurrent observations did get()->compute->upsert() with no lock). Always compute the co-movement assessment for display.
- VigilDatabase: prune stale non-approved tracker rows (were never deleted); +lastRssi/peakRssi/distinctPlaces/effectiveSightings for grounding; version 2.
- BaselineManager: count DWELL (one visit per 10-min window), not per-sighting. Old per-sighting count let a busy street or chatty tracker mint a fake 'home' in seconds and auto-trust a real stalker — a safety flaw.
- BleTrackerScanner: Apple filter now matches only Find My type 0x12, not every Apple device (iPhones/Watches/AirPods).
- UI: show distinct places + last RSSI on cards; detail sheet shows last/peak RSSI, distinct places, co-movement sightings, adverts logged; Active list filters to devices seen in the last 10 min so rotated identities drop off.
- Tests: CoMovementEvaluatorTest locks dedup, the RSSI gate, and the place/separated thresholds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 09:00:53 -04:00
41 changed files with 3047 additions and 135 deletions
+35 -3
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,12 +28,27 @@ 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
in an on-device SQLite database and never leave the phone. It listens only — it
never transmits, probes, or interferes with any device.
in an on-device SQLite database and never leave the phone. **Detection is entirely
passive — it listens only.** The one exception is the user-initiated **"Make it
ring"** action, which connects to a tracker you already suspect and asks it to play
a sound (the DULT-standard way for a victim to locate a hidden tag); nothing is
transmitted unless you tap it.
---
@@ -71,6 +92,17 @@ Two trust signals suppress false alarms:
auto-marked **Known (home)**. So the household tags that are always around you
fall silent on their own, entirely on-device.
## Finding a tracker
Tap any tracker for two ways to physically locate it:
- **Make it ring** — connects over GATT and plays the tracker's own sound. Works for
AirTags (native `0xAF` sound), Google Find My Device, and DULT tags (Chipolo,
Pebblebee, eufy, Motorola). Samsung SmartTag and Tile expose no non-owner ring, so
VIGIL points you to the SmartThings / Tile app instead.
- **Hot/cold finder** — a passive proximity meter that turns live signal strength into
a warmer/colder readout. It still works on **silent or modified tags that refuse to
ring**, which is exactly when you need it most.
## The hard part — catching clones (problem #1)
Every shipping detector (AirGuard, iOS, Android's built-in) keys on **device
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "org.soulstone.vigil"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
versionCode = 11
versionName = "0.1.10"
}
// 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>
@@ -7,20 +7,30 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings as AndroidSettings
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
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.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
import org.soulstone.vigil.ui.theme.VigilTheme
class MainActivity : ComponentActivity() {
@@ -49,8 +59,10 @@ class MainActivity : ComponentActivity() {
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
val granted = result.all { it.value }
) { _ ->
// Scanning needs BLE + location; POST_NOTIFICATIONS is optional and must not
// block protection if the user declines it.
val granted = hasEssentialPermissions()
permissionsGranted.value = granted
if (granted) ScanService.start(this)
}
@@ -59,46 +71,111 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
settings = Settings.get(this)
repo = TrackerRepository(VigilDatabase.get(this))
permissionsGranted.value = hasAllPermissions()
permissionsGranted.value = hasEssentialPermissions()
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
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) }
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())
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) },
onClearAll = { lifecycleScope.launch { repo.clearAll() } },
onOpenSafety = { showSafety = true },
onOpenHistory = { showHistory = true },
loadTrail = { id -> repo.trailFor(id) }
)
}
}
}
}
}
override fun onResume() {
super.onResume()
permissionsGranted.value = hasAllPermissions()
permissionsGranted.value = hasEssentialPermissions()
}
private fun hasAllPermissions(): Boolean = requiredPermissions.all {
ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
private fun hasEssentialPermissions(): Boolean = requiredPermissions
.filter { it != Manifest.permission.POST_NOTIFICATIONS }
.all { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED }
private fun ringTracker(tracker: TrackerEntity) {
Toast.makeText(this, "Trying to ring ${tracker.label}", Toast.LENGTH_SHORT).show()
lifecycleScope.launch {
val msg = TrackerRinger.ring(this@MainActivity, tracker.lastMac, tracker.ecosystem)
Toast.makeText(this@MainActivity, msg, Toast.LENGTH_LONG).show()
}
}
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")
@@ -1,6 +1,9 @@
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
@@ -9,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
@@ -18,15 +27,35 @@ import org.soulstone.vigil.util.Geohash
*/
class TrackerRepository(private val db: VigilDatabase) {
// Serialises record() so the read-modify-write of a tracker row is atomic —
// concurrent observations were racing and losing count / first-seen updates.
private val mutex = Mutex()
data class RecordResult(val tracker: TrackerEntity, val newlyAlerting: Boolean)
fun observeTrackers(): Flow<List<TrackerEntity>> = db.trackerDao().observeAll()
suspend fun setApproved(id: String, approved: Boolean) =
// Under the same lock as record() so a concurrent observation can't clobber the
// user's choice back to its stale value.
suspend fun setApproved(id: String, approved: Boolean) = mutex.withLock {
db.trackerDao().setApproved(id, approved)
}
suspend fun clearBaseline(id: String) = mutex.withLock {
db.trackerDao().clearBaseline(id)
}
suspend fun prune(retentionDays: Int = RETENTION_DAYS) {
db.sightingDao().prune(System.currentTimeMillis() - retentionDays * BaselineManager.DAY_MS)
val cutoff = System.currentTimeMillis() - retentionDays * BaselineManager.DAY_MS
db.sightingDao().prune(cutoff)
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()
}
/**
@@ -38,7 +67,7 @@ class TrackerRepository(private val db: VigilDatabase) {
lat: Double?,
lon: Double?,
sensitivity: Sensitivity
): RecordResult {
): RecordResult = mutex.withLock {
val now = obs.timestampMs
val geohash7 = if (lat != null && lon != null) Geohash.encode(lat, lon, 7) else null
val geohash6 = if (lat != null && lon != null) Geohash.encode(lat, lon, 6) else null
@@ -54,6 +83,15 @@ class TrackerRepository(private val db: VigilDatabase) {
)
val existing = db.trackerDao().get(obs.stableId)
val approved = existing?.approved ?: false
// Co-movement assessment first — it decides both display AND whether this
// tag is safe to baseline. Always computed (UI shows the evidence numbers).
val since = now - CoMovementEvaluator.WINDOW_MS
val recent = db.sightingDao().recentFor(obs.stableId, since)
val t = CoMovementEvaluator.thresholdsFor(sensitivity)
val assessment = CoMovementEvaluator.evaluate(recent, obs.ecosystem, t)
val coMoving = assessment.riskState != RiskState.OBSERVED
// --- baseline: learn tags that live where you live -------------------
var lastAnchorDay = existing?.lastAnchorDay ?: -1L
@@ -61,7 +99,10 @@ class TrackerRepository(private val db: VigilDatabase) {
var baselineSafe = existing?.baselineSafe ?: false
if (geohash6 != null) {
val isAnchor = BaselineManager.noteLocation(db.placeDao(), geohash6, now)
if (isAnchor) {
// ONLY baseline-trust a tag that is NOT co-moving with you. A planted
// stalker tag co-moves (that's the whole detection), so it stays
// SUSPICIOUS/ALERTING and must never be auto-trusted into silence.
if (isAnchor && !coMoving) {
val day = now / BaselineManager.DAY_MS
if (day != lastAnchorDay) {
lastAnchorDay = day
@@ -70,19 +111,10 @@ class TrackerRepository(private val db: VigilDatabase) {
if (anchorDayCount >= BaselineManager.BASELINE_MIN_DAYS) baselineSafe = true
}
}
// Safety revoke: if a previously-trusted tag ever starts co-moving, drop trust.
if (baselineSafe && coMoving) baselineSafe = false
val approved = existing?.approved ?: false
// --- co-movement evaluation (skipped for trusted tags) ---------------
val riskState: RiskState
if (approved || baselineSafe) {
riskState = RiskState.OBSERVED
} else {
val since = now - CoMovementEvaluator.WINDOW_MS
val recent = db.sightingDao().recentFor(obs.stableId, since)
val t = CoMovementEvaluator.thresholdsFor(sensitivity)
riskState = CoMovementEvaluator.evaluate(recent, obs.ecosystem, t).riskState
}
val riskState = if (approved || baselineSafe) RiskState.OBSERVED else assessment.riskState
val wasAlerting = existing?.riskState == RiskState.ALERTING.name
val cooldownOk = now - (existing?.lastAlertMs ?: 0) > CoMovementEvaluator.ALERT_COOLDOWN_MS
@@ -100,13 +132,106 @@ class TrackerRepository(private val db: VigilDatabase) {
baselineSafe = baselineSafe,
lastAlertMs = if (newlyAlerting) now else (existing?.lastAlertMs ?: 0),
lastAnchorDay = lastAnchorDay,
anchorDayCount = anchorDayCount
anchorDayCount = anchorDayCount,
lastRssi = obs.rssi,
peakRssi = maxOf(existing?.peakRssi ?: -127, obs.rssi),
distinctPlaces = assessment.distinctPlaces,
effectiveSightings = assessment.sightings,
lastMac = obs.mac
)
db.trackerDao().upsert(updated)
return RecordResult(updated, newlyAlerting)
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"
}
@@ -32,7 +32,14 @@ data class TrackerEntity(
val lastAlertMs: Long = 0,
// baseline accounting: distinct calendar-days this tracker was seen at an anchor place
val lastAnchorDay: Long = -1,
val anchorDayCount: Int = 0
val anchorDayCount: Int = 0,
// display / grounding: latest + peak RSSI and the current co-movement evidence
val lastRssi: Int = 0,
val peakRssi: Int = -127,
val distinctPlaces: Int = 0,
val effectiveSightings: Int = 0,
// last-seen BLE MAC — needed to GATT-connect for "make it ring"
val lastMac: String = ""
)
/** One sighting of a tracker at one instant, geotagged when a fix is available. */
@@ -61,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")
@@ -74,6 +96,19 @@ interface TrackerDao {
@Query("UPDATE trackers SET approved = :approved WHERE stableId = :id")
suspend fun setApproved(id: String, approved: Boolean)
// User says a baseline-trusted tag is "not mine" — drop trust and restart the
// day clock so it doesn't immediately re-trust.
@Query("UPDATE trackers SET baselineSafe = 0, anchorDayCount = 0, lastAnchorDay = -1 WHERE stableId = :id")
suspend fun clearBaseline(id: String)
// Rotated identities pile up (MAC rotation mints a new row each rotation); drop
// 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
@@ -84,8 +119,17 @@ 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)
@Query("DELETE FROM sightings")
suspend fun clearAll()
}
@Dao
@@ -97,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 = 1,
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
@@ -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) {
@@ -18,23 +18,29 @@ import org.soulstone.vigil.data.db.PlaceEntity
object BaselineManager {
const val DAY_MS = 86_400_000L
const val ANCHOR_MIN_VISITS = 60
const val DWELL_BUCKET_MS = 10 * 60_000L // count at most one "visit" per 10-min dwell window
const val ANCHOR_MIN_VISITS = 18 // ~3h cumulative dwell before a cell counts as home/work
const val BASELINE_MIN_DAYS = 3
/** Record a location fix in its cell; returns whether that cell is now an anchor. */
/**
* Note presence in a cell; returns whether it's an anchor. Crucially a cell
* accrues at most ONE visit per [DWELL_BUCKET_MS], so the anchor signal tracks
* time actually spent there — not how many trackers or adverts were seen.
*
* The previous per-sighting count was a real safety flaw: a busy street or a
* single chatty tracker could mint a fake "home" in seconds, which would then
* baseline-trust (silence alerts for) any tracker seen there — including a real
* stalking device.
*/
suspend fun noteLocation(placeDao: PlaceDao, geohash6: String, now: Long): Boolean {
val existing = placeDao.get(geohash6)
val visits = (existing?.visitCount ?: 0) + 1
val existing = placeDao.get(geohash6) ?: run {
placeDao.upsert(PlaceEntity(geohash6 = geohash6, visitCount = 1, lastSeen = now, anchor = false))
return false
}
if (now - existing.lastSeen < DWELL_BUCKET_MS) return existing.anchor
val visits = existing.visitCount + 1
val anchor = visits >= ANCHOR_MIN_VISITS
placeDao.upsert(
PlaceEntity(
geohash6 = geohash6,
label = existing?.label ?: "",
visitCount = visits,
lastSeen = now,
anchor = anchor
)
)
placeDao.upsert(existing.copy(visitCount = visits, lastSeen = now, anchor = anchor))
return anchor
}
}
@@ -59,11 +59,14 @@ object CoMovementEvaluator {
val sorted = sightings.sortedBy { it.timestamp }
// Debounce: one effective sighting per DEDUP_MS so a chatty tag at 2s
// intervals doesn't trivially clear the count.
// intervals doesn't trivially clear the count. lastCounted is NULLABLE and
// always counts the first sighting — the old `Long.MIN_VALUE` seed overflowed
// (timestamp - Long.MIN_VALUE wraps negative), which pinned `effective` at 0
// and made the detector unable to EVER alert. (Caught by unit test.)
var effective = 0
var lastCounted = Long.MIN_VALUE
var lastCounted: Long? = null
for (s in sorted) {
if (s.timestamp - lastCounted >= DEDUP_MS) {
if (lastCounted == null || s.timestamp - lastCounted >= DEDUP_MS) {
effective++
lastCounted = s.timestamp
}
@@ -137,8 +137,11 @@ class PresenceEngine {
score += (cells.size.toDouble() / (2.0 * K_CELLS)).coerceIn(0.0, 1.0) * 20
score += (if (coherent) 1.0 else 0.4) * 20
val s = score.roundToInt()
// Coherence GATES the top tier: a crowd of many radios at many distances is
// incoherent (high RSSI variance), so it can reach PROBABLE at most, never
// CONFIRMED — this stops packed-transit false clone alarms.
val tier = when {
s >= 85 -> Tier.CONFIRMED
s >= 85 && coherent -> Tier.CONFIRMED
s >= 70 -> Tier.PROBABLE
s >= 40 -> Tier.WATCHING
else -> Tier.CLEAR
@@ -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)
@@ -0,0 +1,191 @@
package org.soulstone.vigil.ring
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import org.soulstone.vigil.model.TrackerEcosystem
import java.util.UUID
import kotlin.coroutines.resume
/**
* User-initiated "make it ring" over BLE GATT — VIGIL's ONE active operation
* (detection stays fully passive). Connects to a separated tracker and writes the
* play-sound command, then disconnects. No pairing/bonding is used (non-owner sound
* needs none), matching AirGuard.
*
* Protocols (from AirGuard's AppleFindMy.kt + the IETF DULT draft, tried in order by
* whichever service the tag exposes):
* - AirTag native: write a single 0xAF byte, no CCCD; the tag rings and self-disconnects.
* - DULT (Google FMDN + Chipolo/Pebblebee/eufy/Motorola): enable indications, write 0x0300.
* - Find My legacy (fd44): enable notifications, write [0x01,0x00,0x03].
* Samsung SmartTag and Tile expose NO non-owner ring — the app tells the user to use
* the vendor app instead.
*/
object TrackerRinger {
private const val TAG = "TrackerRinger"
private const val TIMEOUT_MS = 15_000L
private const val STATUS_PEER_DISCONNECT = 19 // GATT_CONN_TERMINATE_PEER_USER — AirTag "done ringing"
private data class Proto(
val name: String,
val service: UUID,
val characteristic: UUID,
val start: ByteArray,
val cccd: Boolean,
val indicate: Boolean
)
private val AIRTAG = Proto(
"AirTag", uuid("7DFC9000-7D1C-4951-86AA-8D9728F8D66C"),
uuid("7DFC9001-7D1C-4951-86AA-8D9728F8D66C"), byteArrayOf(0xAF.toByte()), cccd = false, indicate = false
)
private val DULT = Proto(
"DULT", uuid("15190001-12F4-C226-88ED-2AC5579F2A85"),
uuid("8E0C0001-1D68-FB92-BF61-48377421680E"), byteArrayOf(0x00, 0x03), cccd = true, indicate = true
)
private val FINDMY = Proto(
"FindMy", uuid("0000FD44-0000-1000-8000-00805F9B34FB"),
uuid("4F860003-943B-49EF-BED4-2F730304427A"), byteArrayOf(0x01, 0x00, 0x03), cccd = true, indicate = false
)
private val PRIORITY = listOf(AIRTAG, DULT, FINDMY)
private val CCCD = uuid("00002902-0000-1000-8000-00805F9B34FB")
suspend fun ring(context: Context, mac: String, ecosystem: String): String {
when (runCatching { TrackerEcosystem.valueOf(ecosystem) }.getOrNull()) {
TrackerEcosystem.SAMSUNG_SMARTTAG ->
return "Samsung SmartTags can only be rung from the SmartThings app (owner-only)."
TrackerEcosystem.TILE ->
return "Tiles can only be rung from the Tile app (owner-only)."
else -> {}
}
if (mac.isBlank()) return "No recent address for this tracker — keep watching and try again."
if (!hasConnectPermission(context)) return "Grant Bluetooth (Connect) to ring trackers."
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
?: return "Bluetooth unavailable."
if (!adapter.isEnabled) return "Turn on Bluetooth to ring trackers."
val device = try {
adapter.getRemoteDevice(mac)
} catch (e: IllegalArgumentException) {
return "Invalid device address."
}
return try {
withTimeout(TIMEOUT_MS) { attemptRing(context, device) }
} catch (e: TimeoutCancellationException) {
"Couldn't reach the tracker — it may be out of range, or a silent/modified tag that ignores ring commands."
} catch (e: Exception) {
Log.w(TAG, "ring failed", e)
"Ring failed: ${e.message}"
}
}
@SuppressLint("MissingPermission")
private suspend fun attemptRing(context: Context, device: BluetoothDevice): String =
suspendCancellableCoroutine { cont ->
var gatt: BluetoothGatt? = null
var selected: Proto? = null
fun done(msg: String, g: BluetoothGatt) {
if (cont.isActive) cont.resume(msg)
g.disconnect()
}
val callback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> g.discoverServices()
BluetoothProfile.STATE_DISCONNECTED -> {
// 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.")
}
g.close()
}
}
}
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)
val ch = g.getService(proto.service)?.getCharacteristic(proto.characteristic)
?: return done("Ring characteristic missing on this tracker.", g)
selected = proto
if (proto.cccd) {
g.setCharacteristicNotification(ch, true)
val desc = ch.getDescriptor(CCCD)
val v = if (proto.indicate) BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
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)
}
}
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)
}
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
)
}
}
}
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
cont.invokeOnCancellation { runCatching { gatt?.disconnect(); gatt?.close() } }
}
@Suppress("DEPRECATION")
@SuppressLint("MissingPermission")
private fun writeStart(g: BluetoothGatt, ch: BluetoothGattCharacteristic, proto: Proto): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
g.writeCharacteristic(ch, proto.start, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) ==
BluetoothGatt.GATT_SUCCESS
} else {
ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
ch.value = proto.start
g.writeCharacteristic(ch)
}
@Suppress("DEPRECATION")
@SuppressLint("MissingPermission")
private fun writeDescriptor(g: BluetoothGatt, desc: BluetoothGattDescriptor, value: ByteArray): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
g.writeDescriptor(desc, value) == BluetoothGatt.GATT_SUCCESS
} else {
desc.value = value
g.writeDescriptor(desc)
}
private fun hasConnectPermission(context: Context): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED
} else true
private fun uuid(s: String): UUID = UUID.fromString(s)
}
@@ -45,11 +45,22 @@ class BleTrackerScanner(
.build()
private val filters: List<ScanFilter> = buildList {
// Apple Find My — match presence of Apple manufacturer data.
add(ScanFilter.Builder().setManufacturerData(TrackerSignatures.APPLE_COMPANY_ID, ByteArray(0)).build())
// FMDN / Samsung / Tile / DULT — match their service UUIDs.
// Apple Find My — match ONLY the offline-finding message type (0x12), not
// every Apple device (iPhones/Watches/AirPods all advertise company 0x004C).
// Data+mask apply from the first manufacturer-data byte, which is the type.
add(
ScanFilter.Builder().setManufacturerData(
TrackerSignatures.APPLE_COMPANY_ID,
byteArrayOf(TrackerSignatures.APPLE_TYPE_FINDMY.toByte()),
byteArrayOf(0xFF.toByte())
).build()
)
// FMDN / Samsung / Tile / DULT — match by SERVICE DATA presence, not the
// service-UUID list. These carry their payload in service data and may not
// advertise the plain UUID-list entry, so setServiceUuid can miss them
// entirely (AirGuard uses service-data presence filters for exactly this).
for (uuid in TrackerSignatures.trackerServiceUuids) {
add(ScanFilter.Builder().setServiceUuid(uuid).build())
add(ScanFilter.Builder().setServiceData(uuid, ByteArray(0)).build())
}
}
@@ -26,15 +26,16 @@ object TrackerParser {
// --- Apple Find My (manufacturer data, company 0x004C) ---
record.getManufacturerSpecificData(Sig.APPLE_COMPANY_ID)?.let { d ->
if (d.isNotEmpty() && (d[0].toInt() and 0xFF) == Sig.APPLE_TYPE_FINDMY) {
// Android strips the 2-byte company id, so d = [type(0x12),
// length(0x19), status, key(22), keyTopBits, hint]. The maintained
// bit lives in the status byte (index 2): set => near owner,
// cleared/absent => treat as separated.
val status = if (d.size > 2) d[2].toInt() and 0xFF else 0
// Require the FULL offline-finding frame. After Android strips the company
// id, d = [type, len, status, key(22), keyTopBits, hint] (~27 bytes). Short
// "nearby" frames carry no key — ignore them rather than fabricate a shared
// identity from the type/status bytes, which merged many distinct devices
// into one phantom "tracker" and misclassified it as separated.
if (d.size >= 25 && (d[0].toInt() and 0xFF) == Sig.APPLE_TYPE_FINDMY) {
val status = d[2].toInt() and 0xFF // maintained bit set => near owner
val separated = if ((status and Sig.APPLE_STATUS_MAINTAINED_BIT) != 0)
SeparatedState.NEAR_OWNER else SeparatedState.SEPARATED
val keyBytes = if (d.size >= 25) d.copyOfRange(3, 25) else d
val keyBytes = d.copyOfRange(3, 25)
return TrackerObservation(
stableId = "apple:" + toHex(keyBytes),
ecosystem = TrackerEcosystem.APPLE_FIND_MY,
@@ -12,6 +12,7 @@ import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log
import java.util.concurrent.ConcurrentHashMap
import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
@@ -49,6 +50,10 @@ class ScanService : LifecycleService() {
private const val NOTIFICATION_ID = 0x5161 // "VIGIL"
private const val PRUNE_INTERVAL_MS = 6 * 3_600_000L
private const val CLONE_COOLDOWN_MS = 2 * 3_600_000L
private const val MIN_RECORD_INTERVAL_MS = 15_000L // per-device DB throttle
private const val ALERT_CHANNEL_ID = "vigil_alerts"
private const val ALERT_NOTIF_ID = 0x5162
private const val CLONE_NOTIF_ID = 0x5163
const val ACTION_START = "org.soulstone.vigil.action.START"
const val ACTION_STOP = "org.soulstone.vigil.action.STOP"
@@ -56,6 +61,16 @@ class ScanService : LifecycleService() {
private val _running = MutableStateFlow(false)
val running: StateFlow<Boolean> = _running.asStateFlow()
// Live RSSI stream for the "find it" hot/cold screen. The UI sets a target
// stableId; every matching advert (not the throttled DB path) updates this.
@Volatile private var finderTarget: String? = null
private val _finderRssi = MutableStateFlow<Int?>(null)
val finderRssi: StateFlow<Int?> = _finderRssi.asStateFlow()
fun setFinderTarget(id: String?) {
finderTarget = id
_finderRssi.value = null
}
fun start(context: Context) {
val intent = Intent(context, ScanService::class.java).apply { action = ACTION_START }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -75,7 +90,8 @@ class ScanService : LifecycleService() {
private lateinit var location: LocationProvider
private lateinit var scanner: BleTrackerScanner
private val presence = PresenceEngine()
private var lastCloneAlertMs = 0L
private val lastRecordedAt = ConcurrentHashMap<String, Long>()
@Volatile private var lastCloneAlertMs = 0L
private var pruneJob: Job? = null
override fun onCreate() {
@@ -109,8 +125,10 @@ class ScanService : LifecycleService() {
pruneJob?.cancel()
pruneJob = lifecycleScope.launch {
while (true) {
delay(PRUNE_INTERVAL_MS)
// Prune first (frequent restarts previously meant the 6h-delayed
// prune rarely ran, so stale rotated identities accumulated).
runCatching { repo.prune() }.onFailure { Log.w(TAG, "prune failed: ${it.message}") }
delay(PRUNE_INTERVAL_MS)
}
}
}
@@ -118,6 +136,9 @@ class ScanService : LifecycleService() {
private fun onObservation(obs: TrackerObservation) {
val fix = location.location.value
// Live RSSI for the "find it" screen — every advert, ahead of any throttle.
if (obs.stableId == finderTarget) _finderRssi.value = obs.rssi
// Rotation-clone presence engine (problem #1) — fed synchronously so its
// streaming state stays ordered. Only a CONFIRMED verdict raises a user
// alert; softer tiers stay silent until validated on real captures.
@@ -131,7 +152,16 @@ class ScanService : LifecycleService() {
}
}
// Temporal co-movement (identity path) — persisted + evaluated off-thread.
// Temporal co-movement (identity path). CALLBACK_TYPE_ALL_MATCHES fires on
// every advert (many/sec), so throttle the persisted path per device: the
// presence engine above still sees every advert, but the DB records at most
// one sighting per device per MIN_RECORD_INTERVAL_MS. Keeps the count
// meaningful and avoids hammering the database.
val last = lastRecordedAt[obs.stableId]
if (last != null && obs.timestampMs - last < MIN_RECORD_INTERVAL_MS) return
lastRecordedAt[obs.stableId] = obs.timestampMs
if (lastRecordedAt.size > 4096) lastRecordedAt.clear() // guard vs MAC-rotation growth
lifecycleScope.launch {
val result = runCatching {
repo.record(obs, fix?.latitude, fix?.longitude, settings.sensitivity.value)
@@ -141,10 +171,13 @@ class ScanService : LifecycleService() {
}
private fun end() {
if (!_running.value) return
// No early-return guard: all stops below are idempotent, and begin() calls
// end() on a failed scanner start (when _running was never set) to make sure
// the location updates and foreground notification are released, not leaked.
_running.value = false
scanner.stop()
location.stop()
lastRecordedAt.clear()
pruneJob?.cancel(); pruneJob = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
@@ -177,9 +210,9 @@ class ScanService : LifecycleService() {
val n = buildNotification(
title = "Tracker following you",
text = "${tracker.label} has been moving with you",
high = true
high = true, channelId = ALERT_CHANNEL_ID, ongoing = false
)
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, n)
getSystemService(NotificationManager::class.java)?.notify(ALERT_NOTIF_ID, n)
vibrate()
Log.w(TAG, "ALERT: ${tracker.stableId} (${tracker.ecosystem})")
}
@@ -188,9 +221,9 @@ class ScanService : LifecycleService() {
val n = buildNotification(
title = "Possible hidden tracker",
text = "A rotating-ID tracker appears to be moving with you",
high = true
high = true, channelId = ALERT_CHANNEL_ID, ongoing = false
)
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID + 1, n)
getSystemService(NotificationManager::class.java)?.notify(CLONE_NOTIF_ID, n)
vibrate()
Log.w(TAG, "CLONE ALERT: rotating-id presence confirmed")
}
@@ -208,7 +241,13 @@ class ScanService : LifecycleService() {
@Suppress("DEPRECATION") getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
private fun buildNotification(title: String, text: String, high: Boolean): Notification {
private fun buildNotification(
title: String,
text: String,
high: Boolean,
channelId: String = CHANNEL_ID,
ongoing: Boolean = true
): Notification {
val openIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
@@ -216,31 +255,48 @@ class ScanService : LifecycleService() {
this, 0, openIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, CHANNEL_ID)
return NotificationCompat.Builder(this, channelId)
.setContentTitle(title)
.setContentText(text)
.setSmallIcon(android.R.drawable.ic_menu_view)
.setOngoing(true)
.setOngoing(ongoing)
.setAutoCancel(!ongoing)
.setContentIntent(pi)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setCategory(if (high) NotificationCompat.CATEGORY_ALARM else NotificationCompat.CATEGORY_SERVICE)
.setPriority(if (high) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_LOW)
.setOnlyAlertOnce(!high)
.build()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = getSystemService(NotificationManager::class.java) ?: return
if (mgr.getNotificationChannel(CHANNEL_ID) != null) return
mgr.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.notification_channel_desc)
setShowBadge(false)
}
)
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
mgr.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.notification_channel_desc)
setShowBadge(false)
}
)
}
// Alerts get their OWN high-importance channel so a real "following you"
// warning actually makes noise + a heads-up banner. On Android 8+ channel
// importance overrides per-notification priority, so the ongoing LOW status
// channel could never sound an alert.
if (mgr.getNotificationChannel(ALERT_CHANNEL_ID) == null) {
mgr.createNotificationChannel(
NotificationChannel(
ALERT_CHANNEL_ID,
"Tracker alerts",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "A tracker appears to be following you"
enableVibration(true)
}
)
}
}
}
@@ -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()
@@ -1,6 +1,18 @@
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
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.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -19,7 +31,10 @@ 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.History
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Radar
import androidx.compose.material.icons.filled.Shield
@@ -33,9 +48,11 @@ 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
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -44,11 +61,13 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
@@ -58,7 +77,9 @@ 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.model.TrailPoint
import org.soulstone.vigil.ui.theme.VigilGreen
import org.soulstone.vigil.ui.theme.VigilPeach
import org.soulstone.vigil.ui.theme.VigilRed
@@ -72,11 +93,21 @@ fun MainScreen(
permissionMessage: String?,
onStartStop: () -> Unit,
onSetSensitivity: (Sensitivity) -> Unit,
onApprove: (String, Boolean) -> Unit
onApprove: (String, Boolean) -> Unit,
onDistrust: (String) -> Unit,
onRing: (TrackerEntity) -> Unit,
onClearAll: () -> Unit,
onOpenSafety: () -> Unit,
onOpenHistory: () -> Unit,
loadTrail: suspend (String) -> List<TrailPoint>
) {
var detail by remember { mutableStateOf<TrackerEntity?>(null) }
var finding by remember { mutableStateOf<TrackerEntity?>(null) }
val active = trackers.filter { !isTrusted(it) }.sortedByDescending { riskRank(it) }
val now = System.currentTimeMillis()
val active = trackers
.filter { !isTrusted(it) && now - it.lastSeen < ACTIVE_WINDOW_MS }
.sortedByDescending { riskRank(it) }
val trusted = trackers.filter { isTrusted(it) }
val alerting = active.count { statusOf(it) == TrackerStatus.ALERTING }
val suspicious = active.count { statusOf(it) == TrackerStatus.SUSPICIOUS }
@@ -87,12 +118,39 @@ 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 = onOpenHistory) {
Icon(
Icons.Filled.History,
contentDescription = "Alert history",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
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,
@@ -159,14 +217,14 @@ fun MainScreen(
}
} else {
items(active, key = { it.stableId }) { t ->
TrackerCard(t, onClick = { detail = t }, onApprove = onApprove)
TrackerCard(t, onClick = { detail = t }, onApprove = onApprove, onDistrust = onDistrust)
}
}
if (trusted.isNotEmpty()) {
item { SectionHeader("Trusted (${trusted.size})") }
items(trusted, key = { it.stableId }) { t ->
TrackerCard(t, onClick = { detail = t }, onApprove = onApprove)
TrackerCard(t, onClick = { detail = t }, onApprove = onApprove, onDistrust = onDistrust)
}
}
@@ -188,9 +246,25 @@ fun MainScreen(
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface
) {
TrackerDetail(t, onApprove = { id, a -> onApprove(id, a); detail = null })
TrackerDetail(
t,
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,
onSafety = { onOpenSafety(); detail = null },
loadTrail = loadTrail
)
}
}
finding?.let { dev ->
FinderScreen(
dev,
onRing = onRing,
onClose = { ScanService.setFinderTarget(null); finding = null }
)
}
}
@Composable
@@ -283,7 +357,12 @@ private fun SectionHeader(text: String) {
}
@Composable
private fun TrackerCard(t: TrackerEntity, onClick: () -> Unit, onApprove: (String, Boolean) -> Unit) {
private fun TrackerCard(
t: TrackerEntity,
onClick: () -> Unit,
onApprove: (String, Boolean) -> Unit,
onDistrust: (String) -> Unit
) {
val status = statusOf(t)
val accent = statusColor(status)
Card(
@@ -302,7 +381,7 @@ private fun TrackerCard(t: TrackerEntity, onClick: () -> Unit, onApprove: (Strin
Column(Modifier.weight(1f)) {
Text(ecosystemDisplay(t.ecosystem), fontWeight = FontWeight.SemiBold)
Text(
"${statusLabel(status)} · ${t.sightingCount} sightings · ${relative(t.lastSeen)}",
"${statusLabel(status)} · ${t.distinctPlaces} places · ${t.lastRssi} dBm · ${relative(t.lastSeen)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -310,10 +389,8 @@ private fun TrackerCard(t: TrackerEntity, onClick: () -> Unit, onApprove: (Strin
when (status) {
TrackerStatus.SAFE_APPROVED ->
TextButton(onClick = { onApprove(t.stableId, false) }) { Text("Undo") }
TrackerStatus.SAFE_BASELINE -> Icon(
Icons.Filled.Verified, null,
tint = VigilGreen, modifier = Modifier.size(20.dp)
)
TrackerStatus.SAFE_BASELINE ->
TextButton(onClick = { onDistrust(t.stableId) }) { Text("Not mine") }
else -> TextButton(onClick = { onApprove(t.stableId, true) }) { Text("It's mine") }
}
}
@@ -321,7 +398,15 @@ private fun TrackerCard(t: TrackerEntity, onClick: () -> Unit, onApprove: (Strin
}
@Composable
private fun TrackerDetail(t: TrackerEntity, onApprove: (String, Boolean) -> Unit) {
private fun TrackerDetail(
t: TrackerEntity,
onApprove: (String, Boolean) -> Unit,
onDistrust: (String) -> Unit,
onFind: (TrackerEntity) -> Unit,
onRing: (TrackerEntity) -> Unit,
onSafety: () -> Unit,
loadTrail: suspend (String) -> List<TrailPoint>
) {
val status = statusOf(t)
Column(Modifier.fillMaxWidth().padding(24.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -331,13 +416,28 @@ private fun TrackerDetail(t: TrackerEntity, onApprove: (String, Boolean) -> Unit
}
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("Sightings", t.sightingCount.toString())
DetailRow("Signal (last / peak)", "${t.lastRssi} / ${t.peakRssi} dBm")
DetailRow("Distinct places", t.distinctPlaces.toString())
DetailRow("Co-movement sightings", t.effectiveSightings.toString())
DetailRow("Adverts logged", t.sightingCount.toString())
DetailRow("First seen", relative(t.firstSeen))
DetailRow("Last seen", relative(t.lastSeen))
if (t.anchorDayCount > 0) {
DetailRow("Days seen at your places", "${t.anchorDayCount} (trusted at 3)")
DetailRow("Days at your places", "${t.anchorDayCount} / 3 to trust")
}
DetailRow("Identity", t.stableId.take(22) + "")
@@ -347,16 +447,53 @@ private fun TrackerDetail(t: TrackerEntity, onApprove: (String, Boolean) -> Unit
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))
if (status == TrackerStatus.SAFE_APPROVED) {
Button(onClick = { onApprove(t.stableId, false) }, modifier = Modifier.fillMaxWidth()) {
Text("Remove from approved")
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilledTonalButton(onClick = { onFind(t) }, modifier = Modifier.weight(1f)) {
Icon(Icons.Filled.Sensors, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(6.dp))
Text("Find it")
}
} else if (status != TrackerStatus.SAFE_BASELINE) {
Button(onClick = { onApprove(t.stableId, true) }, modifier = Modifier.fillMaxWidth()) {
Text("This is mine — stop alerting")
FilledTonalButton(onClick = { onRing(t) }, modifier = Modifier.weight(1f)) {
Icon(Icons.Filled.NotificationsActive, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(6.dp))
Text("Ring it")
}
}
Spacer(Modifier.height(12.dp))
when (status) {
TrackerStatus.SAFE_APPROVED ->
Button(onClick = { onApprove(t.stableId, false) }, modifier = Modifier.fillMaxWidth()) {
Text("Remove from approved")
}
TrackerStatus.SAFE_BASELINE ->
Button(onClick = { onDistrust(t.stableId) }, modifier = Modifier.fillMaxWidth()) {
Text("Not mine — re-check this tracker")
}
else ->
Button(onClick = { onApprove(t.stableId, true) }, modifier = Modifier.fillMaxWidth()) {
Text("This is mine — stop alerting")
}
}
Spacer(Modifier.height(24.dp))
}
}
@@ -369,8 +506,116 @@ private fun DetailRow(label: String, value: String) {
}
}
/** Passive hot/cold finder — smoothed live RSSI as a growing, colour-shifting
* 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 } }
val hasSignal = rssi != null
val p = ((smoothed.value + 100f) / 60f).coerceIn(0f, 1f) // -100 dBm..-40 dBm -> 0..1
val label = when {
!hasSignal -> "Searching… walk around"
p > 0.8f -> "Right here"
p > 0.6f -> "Very close"
p > 0.4f -> "Close"
p > 0.2f -> "Getting warmer"
else -> "Far"
}
val color = lerp(VigilRed, VigilGreen, p)
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(
Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
ecosystemDisplay(t.ecosystem),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Spacer(Modifier.height(32.dp))
Box(
Modifier.size((120 + p * 160).dp).clip(CircleShape).background(
if (hasSignal) color.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceVariant
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Filled.Sensors, null,
tint = if (hasSignal) color else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(64.dp)
)
}
Spacer(Modifier.height(32.dp))
Text(
label,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = if (hasSignal) color else MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
if (hasSignal) "$rssi dBm" else "no signal yet",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(40.dp))
Button(onClick = { onRing(t) }, modifier = Modifier.fillMaxWidth().height(52.dp)) {
Icon(Icons.Filled.NotificationsActive, null)
Spacer(Modifier.size(8.dp))
Text("Make it ring")
}
Spacer(Modifier.height(8.dp))
TextButton(onClick = onClose, modifier = Modifier.fillMaxWidth()) { Text("Done") }
Spacer(Modifier.height(12.dp))
Text(
"If it won't ring, it may be a silent or modified tracker — use the signal above to home in on it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
/** 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"
private fun isTrusted(t: TrackerEntity) = t.approved || t.baselineSafe
private fun riskRank(t: TrackerEntity): Int = when (statusOf(t)) {
@@ -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>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="exports" path="exports/" />
</paths>
@@ -0,0 +1,77 @@
package org.soulstone.vigil.detect
import org.junit.Assert.assertEquals
import org.junit.Test
import org.soulstone.vigil.data.db.SightingEntity
import org.soulstone.vigil.model.RiskState
import org.soulstone.vigil.model.Sensitivity
import org.soulstone.vigil.model.TrackerEcosystem
/** Locks the co-movement alert logic: dedup, the RSSI proximity gate, and the
* distinct-places / separated-state thresholds. MEDIUM = 3 sightings / 3 places /
* 45 min / -85 dBm. */
class CoMovementEvaluatorTest {
private val t = CoMovementEvaluator.thresholdsFor(Sensitivity.MEDIUM)
private fun s(tsMin: Long, rssi: Int, cell: String?, separated: Boolean) =
SightingEntity(trackerId = "x", timestamp = tsMin * 60_000L, rssi = rssi, separated = separated, geohash7 = cell)
@Test fun emptyIsObserved() {
val a = CoMovementEvaluator.evaluate(emptyList(), TrackerEcosystem.APPLE_FIND_MY, t)
assertEquals(RiskState.OBSERVED, a.riskState)
assertEquals(0, a.distinctPlaces)
}
@Test fun separatedCloseAcrossPlacesAlerts() {
val list = listOf(
s(0, -70, "u000001", true),
s(30, -68, "u000002", true),
s(60, -72, "u000003", true)
)
val a = CoMovementEvaluator.evaluate(list, TrackerEcosystem.APPLE_FIND_MY, t)
assertEquals(RiskState.ALERTING, a.riskState)
assertEquals(3, a.distinctPlaces)
assertEquals(3, a.sightings)
}
@Test fun farSignalIsGatedOut() {
val list = listOf(
s(0, -95, "u000001", true),
s(30, -96, "u000002", true),
s(60, -97, "u000003", true)
)
// Seen across 3 places over an hour, but never close -> the RSSI gate holds it back.
assertEquals(RiskState.OBSERVED, CoMovementEvaluator.evaluate(list, TrackerEcosystem.APPLE_FIND_MY, t).riskState)
}
@Test fun tooFewPlacesIsSuspiciousNotAlerting() {
val list = listOf(
s(0, -70, "u000001", true),
s(30, -70, "u000001", true),
s(60, -70, "u000002", true)
)
val a = CoMovementEvaluator.evaluate(list, TrackerEcosystem.APPLE_FIND_MY, t)
assertEquals(RiskState.SUSPICIOUS, a.riskState)
assertEquals(2, a.distinctPlaces)
}
@Test fun nearOwnerNeverEscalates() {
val list = listOf(
s(0, -70, "u000001", false),
s(30, -70, "u000002", false),
s(60, -70, "u000003", false)
)
assertEquals(RiskState.OBSERVED, CoMovementEvaluator.evaluate(list, TrackerEcosystem.APPLE_FIND_MY, t).riskState)
}
@Test fun tileHasNoSeparatedFlagButStillCounts() {
val list = listOf(
s(0, -70, "u000001", false),
s(30, -70, "u000002", false),
s(60, -70, "u000003", false)
)
// Tile emits no separated flag, so any Tile is a live candidate.
assertEquals(RiskState.ALERTING, CoMovementEvaluator.evaluate(list, TrackerEcosystem.TILE, t).riskState)
}
}
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