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,112 @@
package org.soulstone.vigil.data
import kotlinx.coroutines.flow.Flow
import org.soulstone.vigil.data.db.SightingEntity
import org.soulstone.vigil.data.db.TrackerEntity
import org.soulstone.vigil.data.db.VigilDatabase
import org.soulstone.vigil.detect.BaselineManager
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.TrackerObservation
import org.soulstone.vigil.util.Geohash
/**
* The temporal core. Persists sightings, maintains the learned baseline, and runs
* the co-movement evaluation on every observation. All state is on-device.
*/
class TrackerRepository(private val db: VigilDatabase) {
data class RecordResult(val tracker: TrackerEntity, val newlyAlerting: Boolean)
fun observeTrackers(): Flow<List<TrackerEntity>> = db.trackerDao().observeAll()
suspend fun setApproved(id: String, approved: Boolean) =
db.trackerDao().setApproved(id, approved)
suspend fun prune(retentionDays: Int = RETENTION_DAYS) {
db.sightingDao().prune(System.currentTimeMillis() - retentionDays * BaselineManager.DAY_MS)
}
/**
* Ingest one sighting. Persists it, updates the baseline, and re-evaluates the
* tracker's risk. Returns the updated row and whether this crossed into ALERTING.
*/
suspend fun record(
obs: TrackerObservation,
lat: Double?,
lon: Double?,
sensitivity: Sensitivity
): RecordResult {
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
db.sightingDao().insert(
SightingEntity(
trackerId = obs.stableId,
timestamp = now,
rssi = obs.rssi,
separated = obs.separated == SeparatedState.SEPARATED,
lat = lat, lon = lon, geohash7 = geohash7
)
)
val existing = db.trackerDao().get(obs.stableId)
// --- baseline: learn tags that live where you live -------------------
var lastAnchorDay = existing?.lastAnchorDay ?: -1L
var anchorDayCount = existing?.anchorDayCount ?: 0
var baselineSafe = existing?.baselineSafe ?: false
if (geohash6 != null) {
val isAnchor = BaselineManager.noteLocation(db.placeDao(), geohash6, now)
if (isAnchor) {
val day = now / BaselineManager.DAY_MS
if (day != lastAnchorDay) {
lastAnchorDay = day
anchorDayCount += 1
}
if (anchorDayCount >= BaselineManager.BASELINE_MIN_DAYS) baselineSafe = true
}
}
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 wasAlerting = existing?.riskState == RiskState.ALERTING.name
val cooldownOk = now - (existing?.lastAlertMs ?: 0) > CoMovementEvaluator.ALERT_COOLDOWN_MS
val newlyAlerting = riskState == RiskState.ALERTING && !wasAlerting && cooldownOk
val updated = TrackerEntity(
stableId = obs.stableId,
ecosystem = obs.ecosystem.name,
label = obs.label,
firstSeen = existing?.firstSeen ?: now,
lastSeen = now,
sightingCount = (existing?.sightingCount ?: 0) + 1,
riskState = riskState.name,
approved = approved,
baselineSafe = baselineSafe,
lastAlertMs = if (newlyAlerting) now else (existing?.lastAlertMs ?: 0),
lastAnchorDay = lastAnchorDay,
anchorDayCount = anchorDayCount
)
db.trackerDao().upsert(updated)
return RecordResult(updated, newlyAlerting)
}
companion object {
const val RETENTION_DAYS = 14
}
}
@@ -0,0 +1,121 @@
package org.soulstone.vigil.data.db
import android.content.Context
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Index
import androidx.room.Insert
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
/**
* One tracked physical device, keyed by a best-effort stable identity. Carries
* the running risk state plus the two "this is fine" signals: [approved] (user
* allowlist) and [baselineSafe] (learned — routinely present at an anchor place).
*/
@Entity(tableName = "trackers")
data class TrackerEntity(
@PrimaryKey val stableId: String,
val ecosystem: String,
val label: String,
val firstSeen: Long,
val lastSeen: Long,
val sightingCount: Int,
val riskState: String,
val approved: Boolean = false,
val baselineSafe: Boolean = false,
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
)
/** One sighting of a tracker at one instant, geotagged when a fix is available. */
@Entity(
tableName = "sightings",
indices = [Index(value = ["trackerId", "timestamp"])]
)
data class SightingEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val trackerId: String,
val timestamp: Long,
val rssi: Int,
val separated: Boolean,
val lat: Double? = null,
val lon: Double? = null,
val geohash7: String? = null
)
/** A geohash-6 cell the user frequents; promoted to [anchor] once well-visited (e.g. home). */
@Entity(tableName = "places")
data class PlaceEntity(
@PrimaryKey val geohash6: String,
val label: String = "",
val visitCount: Int = 0,
val lastSeen: Long = 0,
val anchor: Boolean = false
)
@Dao
interface TrackerDao {
@Query("SELECT * FROM trackers WHERE stableId = :id")
suspend fun get(id: String): TrackerEntity?
@Upsert
suspend fun upsert(tracker: TrackerEntity)
@Query("SELECT * FROM trackers ORDER BY lastSeen DESC")
fun observeAll(): Flow<List<TrackerEntity>>
@Query("UPDATE trackers SET approved = :approved WHERE stableId = :id")
suspend fun setApproved(id: String, approved: Boolean)
}
@Dao
interface SightingDao {
@Insert
suspend fun insert(sighting: SightingEntity)
@Query("SELECT * FROM sightings WHERE trackerId = :id AND timestamp >= :since ORDER BY timestamp")
suspend fun recentFor(id: String, since: Long): List<SightingEntity>
@Query("DELETE FROM sightings WHERE timestamp < :cutoff")
suspend fun prune(cutoff: Long)
}
@Dao
interface PlaceDao {
@Query("SELECT * FROM places WHERE geohash6 = :cell")
suspend fun get(cell: String): PlaceEntity?
@Upsert
suspend fun upsert(place: PlaceEntity)
}
@Database(
entities = [TrackerEntity::class, SightingEntity::class, PlaceEntity::class],
version = 1,
exportSchema = false
)
abstract class VigilDatabase : RoomDatabase() {
abstract fun trackerDao(): TrackerDao
abstract fun sightingDao(): SightingDao
abstract fun placeDao(): PlaceDao
companion object {
@Volatile private var INSTANCE: VigilDatabase? = null
fun get(context: Context): VigilDatabase = INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
VigilDatabase::class.java,
"vigil.db"
).fallbackToDestructiveMigration().build().also { INSTANCE = it }
}
}
}
@@ -0,0 +1,89 @@
package org.soulstone.vigil.data.location
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Wraps [FusedLocationProviderClient] and exposes the latest fix as a [StateFlow].
* Ported from OVERWATCH. BALANCED power (~100 m) is plenty for geohash-7 place
* bucketing and keeps the always-on scan easy on the battery.
*/
class LocationProvider(private val context: Context) {
companion object {
private const val TAG = "LocationProvider"
private const val INTERVAL_MS = 15_000L
private const val MIN_INTERVAL_MS = 5_000L
}
private val client: FusedLocationProviderClient by lazy {
LocationServices.getFusedLocationProviderClient(context)
}
private val _location = MutableStateFlow<Location?>(null)
val location: StateFlow<Location?> = _location.asStateFlow()
private val request: LocationRequest = LocationRequest.Builder(
Priority.PRIORITY_BALANCED_POWER_ACCURACY,
INTERVAL_MS
)
.setMinUpdateIntervalMillis(MIN_INTERVAL_MS)
.setWaitForAccurateLocation(false)
.build()
private val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
if (!running) return
_location.value = result.lastLocation ?: return
}
}
@Volatile private var running = false
fun hasPermission(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
@SuppressLint("MissingPermission")
fun start(): Boolean {
if (running) return true
if (!hasPermission()) {
Log.w(TAG, "ACCESS_FINE_LOCATION not granted")
return false
}
return try {
running = true
client.requestLocationUpdates(request, callback, Looper.getMainLooper())
client.lastLocation.addOnSuccessListener { last ->
if (running && last != null && _location.value == null) _location.value = last
}
true
} catch (e: SecurityException) {
running = false
Log.e(TAG, "SecurityException starting location updates", e)
false
}
}
fun stop() {
if (!running) return
client.removeLocationUpdates(callback)
running = false
_location.value = null
}
}
@@ -0,0 +1,36 @@
package org.soulstone.vigil.data.settings
import android.content.Context
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.soulstone.vigil.model.Sensitivity
/**
* Lightweight SharedPreferences-backed settings, exposed as StateFlows for
* Compose. Deliberately tiny — no DataStore dependency for the prototype.
*/
class Settings private constructor(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences("vigil", Context.MODE_PRIVATE)
private val _sensitivity = MutableStateFlow(
runCatching { Sensitivity.valueOf(prefs.getString(KEY_SENSITIVITY, null) ?: "") }
.getOrDefault(Sensitivity.MEDIUM)
)
val sensitivity: StateFlow<Sensitivity> = _sensitivity.asStateFlow()
fun setSensitivity(value: Sensitivity) {
prefs.edit().putString(KEY_SENSITIVITY, value.name).apply()
_sensitivity.value = value
}
companion object {
private const val KEY_SENSITIVITY = "sensitivity"
@Volatile private var INSTANCE: Settings? = null
fun get(context: Context): Settings = INSTANCE ?: synchronized(this) {
INSTANCE ?: Settings(context).also { INSTANCE = it }
}
}
}