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