VIGIL 0.1.0 — temporal counter-tracking (prototype)

Android app that detects personal item-trackers (AirTag, Tile, Samsung SmartTag, Google Find My Device / DULT) following the user over time. Sibling to OVERWATCH: OVERWATCH is spatial, VIGIL is temporal.

- BLE scan + per-ecosystem wire-format parsing (Apple 0x004C/0x12, FMDN 0xFEAA, Samsung 0xFD5A, Tile 0xFEED/FEEC, DULT 0xFCB2)
- Room temporal store (14-day sightings), co-movement evaluator with RSSI proximity gate
- Allowlist + learned offline baseline (auto-trust household tags)
- Foreground service, Compose UI (Catppuccin Mocha)
- Privacy: NO INTERNET permission — fully on-device
- Reuses OVERWATCH Gradle setup + committed debug keystore
- docs/detection-rotation-clone.md: design for the rotation-clone presence engine (#1)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
This commit is contained in:
Kara Zajac
2026-07-15 00:11:00 -04:00
commit 817a4ac1c9
40 changed files with 2585 additions and 0 deletions
@@ -0,0 +1,40 @@
package org.soulstone.vigil.detect
import org.soulstone.vigil.data.db.PlaceDao
import org.soulstone.vigil.data.db.PlaceEntity
/**
* Learns the user's "safe" RF world so it doesn't cry wolf about the trackers that
* live where they live — their own AirTag, a partner's Tile, a housemate's tag.
*
* v1 heuristic (offline, on-device): count location-tagged sightings per geohash-6
* cell; once a cell crosses [ANCHOR_MIN_VISITS] it's an "anchor" (home/work). A
* tracker seen at an anchor on ≥ [BASELINE_MIN_DAYS] distinct days is marked
* baseline-safe by the repository and excluded from alerting.
*
* This visit-count proxy is intentionally simple; the roadmap replaces it with
* proper stay-point / dwell detection (200 m / 20 min clusters).
*/
object BaselineManager {
const val DAY_MS = 86_400_000L
const val ANCHOR_MIN_VISITS = 60
const val BASELINE_MIN_DAYS = 3
/** Record a location fix in its cell; returns whether that cell is now an anchor. */
suspend fun noteLocation(placeDao: PlaceDao, geohash6: String, now: Long): Boolean {
val existing = placeDao.get(geohash6)
val visits = (existing?.visitCount ?: 0) + 1
val anchor = visits >= ANCHOR_MIN_VISITS
placeDao.upsert(
PlaceEntity(
geohash6 = geohash6,
label = existing?.label ?: "",
visitCount = visits,
lastSeen = now,
anchor = anchor
)
)
return anchor
}
}
@@ -0,0 +1,89 @@
package org.soulstone.vigil.detect
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
/**
* The temporal co-movement test — VIGIL's core question: has this tracker been at
* enough of *my* distinct places, over enough time, while actually close to me?
*
* Thresholds are adapted from AirGuard's field-tuned model (≥3 sightings, N
* distinct locations, T minutes) with one deliberate addition AirGuard omits: an
* **RSSI proximity gate**. A tracker must have been genuinely close at least once
* (max RSSI ≥ floor) before it can alert — this rejects "a Tile in a car beside
* you at two red lights." See research brief §3.5 / §4.
*
* This is the honest, shippable v1. The rotation-robust "detect the attack, not
* the device" layer (problem #1) is being designed separately and slots in as an
* additional signal, not a replacement.
*/
object CoMovementEvaluator {
const val WINDOW_MS = 24 * 3_600_000L // co-movement is judged over the last 24h
const val ALERT_COOLDOWN_MS = 4 * 3_600_000L // don't re-alert the same device within 4h
private const val DEDUP_MS = 15 * 60_000L // count a device at most once per 15 min
data class Thresholds(
val minSightings: Int,
val minPlaces: Int,
val minSpanMin: Int,
val rssiFloorDbm: Int
)
data class Assessment(
val riskState: RiskState,
val sightings: Int,
val distinctPlaces: Int,
val spanMin: Long,
val closeEnough: Boolean,
val separatedSeen: Boolean
)
fun thresholdsFor(s: Sensitivity): Thresholds = when (s) {
// higher sensitivity => fewer places / shorter time / farther RSSI allowed => faster alerts, more FPs
Sensitivity.HIGH -> Thresholds(minSightings = 3, minPlaces = 2, minSpanMin = 30, rssiFloorDbm = -90)
Sensitivity.MEDIUM -> Thresholds(minSightings = 3, minPlaces = 3, minSpanMin = 45, rssiFloorDbm = -85)
Sensitivity.LOW -> Thresholds(minSightings = 3, minPlaces = 4, minSpanMin = 90, rssiFloorDbm = -80)
}
fun evaluate(
sightings: List<SightingEntity>,
ecosystem: TrackerEcosystem,
t: Thresholds
): Assessment {
if (sightings.isEmpty()) {
return Assessment(RiskState.OBSERVED, 0, 0, 0, closeEnough = false, separatedSeen = false)
}
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.
var effective = 0
var lastCounted = Long.MIN_VALUE
for (s in sorted) {
if (s.timestamp - lastCounted >= DEDUP_MS) {
effective++
lastCounted = s.timestamp
}
}
val distinctPlaces = sorted.mapNotNull { it.geohash7 }.toSet().size
val spanMin = (sorted.last().timestamp - sorted.first().timestamp) / 60_000
val maxRssi = sorted.maxOf { it.rssi }
val closeEnough = maxRssi >= t.rssiFloorDbm
// Tile emits no separated-state flag, so any Tile is a live candidate.
val separatedSeen = ecosystem == TrackerEcosystem.TILE || sorted.any { it.separated }
val riskState = when {
!separatedSeen -> RiskState.OBSERVED
effective >= t.minSightings && distinctPlaces >= t.minPlaces &&
spanMin >= t.minSpanMin && closeEnough -> RiskState.ALERTING
effective >= t.minSightings && closeEnough && distinctPlaces >= 1 -> RiskState.SUSPICIOUS
else -> RiskState.OBSERVED
}
return Assessment(riskState, effective, distinctPlaces, spanMin, closeEnough, separatedSeen)
}
}