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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user