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
+83
View File
@@ -0,0 +1,83 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
}
android {
namespace = "org.soulstone.vigil"
compileSdk = 35
defaultConfig {
applicationId = "org.soulstone.vigil"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
}
// Fixed debug keystore committed to the repo (a debug key is non-secret — its
// password is the well-known "android") so CI and local builds sign
// identically and updates install in place. Mirrors OVERWATCH.
signingConfigs {
getByName("debug") {
storeFile = rootProject.file("debug.keystore")
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
jvmToolchain(17)
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.service)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.play.services.location)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
debugImplementation(libs.androidx.compose.ui.tooling)
}
+3
View File
@@ -0,0 +1,3 @@
# VIGIL is not minified in release (isMinifyEnabled = false), so these rules are
# a placeholder for when shrinking is enabled. Room + Compose are keep-safe by
# their own consumer rules; add app-specific keeps here if minification is turned on.
+71
View File
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- BLE scanning (Android 12+). NOTE: unlike OVERWATCH we deliberately do
NOT set usesPermissionFlags="neverForLocation" — VIGIL correlates each
detection with a GPS fix to reason about co-movement, and that flag
both forbids deriving location AND silently filters beacon-shaped
advertisements from scan results (the exact payloads we parse). -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<!-- Only used later to GATT-connect to a suspect tag for Play-Sound / Get-Identifier (DULT). -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- BLE legacy (Android 11 and below) -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<!-- Location — required for scan results pre-S and to geotag sightings for
the temporal co-movement engine. Background variant lets the scan keep
tagging locations with the screen off (paired with the FGS). -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- Foreground service so scanning + location tagging survive the screen
being off. connectedDevice covers BLE; location covers the GPS tagging. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Alert the user on a confirmed follower -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Keep scanning across reboots is opt-in and added later; not requested yet. -->
<!-- PRIVACY: VIGIL requests NO INTERNET permission. Everything — the tracker
database, the learned baseline, the co-movement engine — runs and stays
on-device. There is no server and no telemetry. -->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Vigil"
tools:targetApi="34">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/Theme.Vigil">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.ScanService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="connectedDevice|location" />
</application>
</manifest>
@@ -0,0 +1,113 @@
package org.soulstone.vigil
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings as AndroidSettings
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.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.soulstone.vigil.data.TrackerRepository
import org.soulstone.vigil.data.db.VigilDatabase
import org.soulstone.vigil.data.settings.Settings
import org.soulstone.vigil.service.ScanService
import org.soulstone.vigil.ui.MainScreen
import org.soulstone.vigil.ui.theme.VigilTheme
class MainActivity : ComponentActivity() {
private lateinit var settings: Settings
private lateinit var repo: TrackerRepository
private val permissionsGranted = mutableStateOf(false)
private val requiredPermissions: Array<String>
get() = buildList {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
add(Manifest.permission.BLUETOOTH_SCAN)
add(Manifest.permission.BLUETOOTH_CONNECT)
} else {
add(Manifest.permission.BLUETOOTH)
add(Manifest.permission.BLUETOOTH_ADMIN)
}
add(Manifest.permission.ACCESS_FINE_LOCATION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
add(Manifest.permission.POST_NOTIFICATIONS)
}
// NOTE: ACCESS_BACKGROUND_LOCATION ("Allow all the time") must be
// granted separately from app settings; scanning still runs via the
// foreground service while the app is open. Prompt for it later.
}.toTypedArray()
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
val granted = result.all { it.value }
permissionsGranted.value = granted
if (granted) ScanService.start(this)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
settings = Settings.get(this)
repo = TrackerRepository(VigilDatabase.get(this))
permissionsGranted.value = hasAllPermissions()
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) }
}
)
}
}
}
override fun onResume() {
super.onResume()
permissionsGranted.value = hasAllPermissions()
}
private fun hasAllPermissions(): Boolean = requiredPermissions.all {
ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
}
@Suppress("unused")
private fun openAppSettings() {
startActivity(
Intent(
AndroidSettings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", packageName, null)
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
@@ -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 }
}
}
}
@@ -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)
}
}
@@ -0,0 +1,39 @@
package org.soulstone.vigil.model
/** The tracker ecosystems VIGIL parses off the air. */
enum class TrackerEcosystem(val display: String) {
APPLE_FIND_MY("Apple Find My"),
GOOGLE_FMDN("Google Find My Device"),
SAMSUNG_SMARTTAG("Samsung SmartTag"),
TILE("Tile"),
DULT("DULT tracker"),
UNKNOWN("Unknown tracker")
}
/**
* Whether a tracker is signalling it is away from its owner. SEPARATED is the
* detectable/dangerous state (slow ID rotation, reportable payload). Tile has no
* separated flag, so Tile sightings are UNKNOWN and rely on persistence alone.
*/
enum class SeparatedState { SEPARATED, NEAR_OWNER, UNKNOWN }
/** One parsed BLE sighting of a tracker at one instant. */
data class TrackerObservation(
/** Best-effort stable identity within a rotation epoch (payload key / static MAC). */
val stableId: String,
val ecosystem: TrackerEcosystem,
val mac: String,
val rssi: Int,
val separated: SeparatedState,
val label: String,
val timestampMs: Long = System.currentTimeMillis()
)
/** Risk lifecycle for a tracked device (hysteretic; see CoMovementEvaluator). */
enum class RiskState { OBSERVED, SUSPICIOUS, ALERTING }
/** User-facing status, folding in the allowlist + learned baseline. */
enum class TrackerStatus { SAFE_APPROVED, SAFE_BASELINE, OBSERVED, SUSPICIOUS, ALERTING }
/** Detection sensitivity — trades time-to-alert against false positives. */
enum class Sensitivity { HIGH, MEDIUM, LOW }
@@ -0,0 +1,116 @@
package org.soulstone.vigil.scan
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat
import org.soulstone.vigil.model.TrackerObservation
/**
* BLE scanner for tracker advertisements. Unlike OVERWATCH's unfiltered scan, this
* uses hardware [ScanFilter]s for the tracker signatures — the controller wakes us
* only on a match, which is what makes screen-off/background scanning deliverable
* and battery-tolerable (research brief §5).
*
* Each match is parsed by [TrackerParser]; anything that parses is handed to
* [onObservation]. All correlation/temporal logic lives downstream.
*/
class BleTrackerScanner(
private val context: Context,
private val onObservation: (TrackerObservation) -> Unit
) {
companion object {
private const val TAG = "BleTrackerScanner"
}
private val adapter: BluetoothAdapter? by lazy {
(context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
}
private var leScanner: BluetoothLeScanner? = null
private var running = false
private val settings: ScanSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
.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.
for (uuid in TrackerSignatures.trackerServiceUuids) {
add(ScanFilter.Builder().setServiceUuid(uuid).build())
}
}
fun hasScanPermission(): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) ==
PackageManager.PERMISSION_GRANTED
} else {
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH) ==
PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
}
@SuppressLint("MissingPermission")
fun start(): Boolean {
if (running) return true
if (!hasScanPermission()) {
Log.w(TAG, "BLE scan permission missing")
return false
}
val a = adapter ?: return false
if (!a.isEnabled) {
Log.w(TAG, "Bluetooth disabled")
return false
}
leScanner = a.bluetoothLeScanner ?: return false
return try {
leScanner?.startScan(filters, settings, callback)
running = true
Log.i(TAG, "Tracker scan started (${filters.size} filters)")
true
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException starting scan", e)
false
}
}
@SuppressLint("MissingPermission")
fun stop() {
if (!running) return
try {
leScanner?.stopScan(callback)
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException stopping scan", e)
}
running = false
}
private val callback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
TrackerParser.parse(result)?.let(onObservation)
}
override fun onBatchScanResults(results: MutableList<ScanResult>) {
results.forEach { r -> TrackerParser.parse(r)?.let(onObservation) }
}
override fun onScanFailed(errorCode: Int) {
Log.e(TAG, "BLE scan failed: $errorCode")
running = false
}
}
}
@@ -0,0 +1,112 @@
package org.soulstone.vigil.scan
import android.annotation.SuppressLint
import android.bluetooth.le.ScanResult
import org.soulstone.vigil.model.SeparatedState
import org.soulstone.vigil.model.TrackerEcosystem
import org.soulstone.vigil.model.TrackerObservation
import org.soulstone.vigil.scan.TrackerSignatures as Sig
/**
* Parses a raw [ScanResult] into a [TrackerObservation] if it matches one of the
* known tracker wire formats, else null. Pure/stateless.
*
* Identity strategy per ecosystem (see research brief §2 "what you can correlate"):
* - Apple: hash the advertised public-key bytes — stable across a ~24h epoch.
* - Google FMDN / Samsung / DULT: MAC is ~24h-static once separated; key on it.
* - Tile: MAC is permanently static; key on it directly.
*/
object TrackerParser {
@SuppressLint("MissingPermission")
fun parse(result: ScanResult): TrackerObservation? {
val record = result.scanRecord ?: return null
val mac = result.device?.address ?: return null
val rssi = result.rssi
// --- 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
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
return TrackerObservation(
stableId = "apple:" + toHex(keyBytes),
ecosystem = TrackerEcosystem.APPLE_FIND_MY,
mac = mac, rssi = rssi, separated = separated,
label = "AirTag / Find My"
)
}
}
val sd = record.serviceData ?: emptyMap()
// --- Google Find My Device network (service data under FEAA) ---
sd[Sig.FMDN_UUID]?.let { d ->
val frame = if (d.isNotEmpty()) d[0].toInt() and 0xFF else -1
if (frame == Sig.FMDN_FRAME_NORMAL || frame == Sig.FMDN_FRAME_SEPARATED) {
val separated = if (frame == Sig.FMDN_FRAME_SEPARATED)
SeparatedState.SEPARATED else SeparatedState.NEAR_OWNER
return TrackerObservation(
stableId = "fmdn:$mac",
ecosystem = TrackerEcosystem.GOOGLE_FMDN,
mac = mac, rssi = rssi, separated = separated,
label = "Find My Device tag"
)
}
}
// --- Samsung Galaxy SmartTag (service data under FD5A) ---
sd[Sig.SAMSUNG_UUID]?.let { d ->
val state = if (d.isNotEmpty()) (d[0].toInt() shr 5) and 0x07 else -1
val separated = when (state) {
2, 3 -> SeparatedState.SEPARATED // lost / overmature-lost
4, 5, 6 -> SeparatedState.NEAR_OWNER // paired / connected
else -> SeparatedState.UNKNOWN
}
return TrackerObservation(
stableId = "samsung:$mac",
ecosystem = TrackerEcosystem.SAMSUNG_SMARTTAG,
mac = mac, rssi = rssi, separated = separated,
label = "Galaxy SmartTag"
)
}
// --- Tile (service data under FEED/FEEC/FE84). No separated flag; static MAC. ---
(sd[Sig.TILE_ACTIVE_UUID] ?: sd[Sig.TILE_PREACT_UUID] ?: sd[Sig.TILE_LEGACY_UUID])?.let {
return TrackerObservation(
stableId = "tile:$mac",
ecosystem = TrackerEcosystem.TILE,
mac = mac, rssi = rssi, separated = SeparatedState.UNKNOWN,
label = "Tile"
)
}
// --- DULT unified (service data under FCB2; near-owner bit = byte14 LSB) ---
sd[Sig.DULT_UUID]?.let { d ->
val nearOwner = d.size > 14 && (d[14].toInt() and 0x01) != 0
val separated = if (nearOwner) SeparatedState.NEAR_OWNER else SeparatedState.SEPARATED
return TrackerObservation(
stableId = "dult:$mac",
ecosystem = TrackerEcosystem.DULT,
mac = mac, rssi = rssi, separated = separated,
label = "DULT tracker"
)
}
return null
}
private fun toHex(bytes: ByteArray): String {
val sb = StringBuilder(bytes.size * 2)
for (b in bytes) sb.append(HEX[(b.toInt() ushr 4) and 0xF]).append(HEX[b.toInt() and 0xF])
return sb.toString()
}
private val HEX = "0123456789abcdef".toCharArray()
}
@@ -0,0 +1,42 @@
package org.soulstone.vigil.scan
import android.os.ParcelUuid
import java.util.UUID
/**
* BLE wire-format constants for the tracker ecosystems, per the VIGIL research
* brief (2026-07-14). These are the scan targets. Offsets/values marked below
* are the ones flagged for empirical re-capture (SmartTag2, 2024+ Tile, current
* AirTag firmware) before they should be trusted absolutely.
*/
object TrackerSignatures {
// Apple Find My — manufacturer-specific data under company 0x004C.
const val APPLE_COMPANY_ID = 0x004C
const val APPLE_TYPE_FINDMY = 0x12 // Apple message type: offline finding
const val APPLE_STATUS_MAINTAINED_BIT = 0x04 // status byte bit2 set => near owner
// Samsung — company id (family-wide, not tag-unique; the FD5A service is the anchor).
const val SAMSUNG_COMPANY_ID = 0x0075
// Google Find My Device network frame types (first service-data byte under FEAA).
const val FMDN_FRAME_NORMAL = 0x40
const val FMDN_FRAME_SEPARATED = 0x41 // unwanted-tracking / separated mode
// 16-bit service UUIDs, expanded to the full Bluetooth base UUID.
val FMDN_UUID: ParcelUuid = uuid16(0xFEAA) // Google FMDN (Eddystone svc UUID)
val SAMSUNG_UUID: ParcelUuid = uuid16(0xFD5A) // Galaxy SmartTag offline finding
val TILE_ACTIVE_UUID: ParcelUuid = uuid16(0xFEED)
val TILE_PREACT_UUID: ParcelUuid = uuid16(0xFEEC)
val TILE_LEGACY_UUID: ParcelUuid = uuid16(0xFE84)
val DULT_UUID: ParcelUuid = uuid16(0xFCB2) // unified DULT (near-owner bit = byte14 LSB)
/** Service UUIDs used to build ScanFilters (enables screen-off scanning). */
val trackerServiceUuids: List<ParcelUuid> = listOf(
FMDN_UUID, SAMSUNG_UUID, TILE_ACTIVE_UUID, TILE_PREACT_UUID, TILE_LEGACY_UUID, DULT_UUID
)
/** Expand a 16-bit assigned number to the 128-bit `0000xxxx-0000-1000-8000-00805f9b34fb`. */
fun uuid16(v: Int): ParcelUuid =
ParcelUuid(UUID.fromString(String.format("%08x-0000-1000-8000-00805f9b34fb", 0x0000FFFF and v)))
}
@@ -0,0 +1,214 @@
package org.soulstone.vigil.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.soulstone.vigil.MainActivity
import org.soulstone.vigil.R
import org.soulstone.vigil.data.TrackerRepository
import org.soulstone.vigil.data.db.TrackerEntity
import org.soulstone.vigil.data.db.VigilDatabase
import org.soulstone.vigil.data.location.LocationProvider
import org.soulstone.vigil.data.settings.Settings
import org.soulstone.vigil.model.TrackerObservation
import org.soulstone.vigil.scan.BleTrackerScanner
/**
* Foreground service that owns the BLE scanner, the location provider, and the
* temporal repository. Every parsed observation is geotagged and handed to the
* repository, which persists it and re-evaluates co-movement; a fresh ALERTING
* verdict raises a high-priority notification and vibrates.
*
* Everything is on-device. START_NOT_STICKY — the user explicitly starts/stops.
*/
class ScanService : LifecycleService() {
companion object {
private const val TAG = "ScanService"
private const val CHANNEL_ID = "vigil_watch"
private const val NOTIFICATION_ID = 0x5161 // "VIGIL"
private const val PRUNE_INTERVAL_MS = 6 * 3_600_000L
const val ACTION_START = "org.soulstone.vigil.action.START"
const val ACTION_STOP = "org.soulstone.vigil.action.STOP"
private val _running = MutableStateFlow(false)
val running: StateFlow<Boolean> = _running.asStateFlow()
fun start(context: Context) {
val intent = Intent(context, ScanService::class.java).apply { action = ACTION_START }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
context.startService(Intent(context, ScanService::class.java).apply { action = ACTION_STOP })
}
}
private lateinit var settings: Settings
private lateinit var repo: TrackerRepository
private lateinit var location: LocationProvider
private lateinit var scanner: BleTrackerScanner
private var pruneJob: Job? = null
override fun onCreate() {
super.onCreate()
settings = Settings.get(this)
repo = TrackerRepository(VigilDatabase.get(this))
location = LocationProvider(this)
scanner = BleTrackerScanner(this, onObservation = ::onObservation)
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
when (intent?.action) {
ACTION_START -> begin()
ACTION_STOP -> { end(); stopSelf() }
}
return START_NOT_STICKY
}
private fun begin() {
if (_running.value) return
startInForeground()
location.start() // best-effort; scanning still runs without a fix (just no co-movement)
if (!scanner.start()) {
Log.w(TAG, "scanner failed to start (permission/adapter) — stopping")
end(); stopSelf(); return
}
_running.value = true
pruneJob?.cancel()
pruneJob = lifecycleScope.launch {
while (true) {
delay(PRUNE_INTERVAL_MS)
runCatching { repo.prune() }.onFailure { Log.w(TAG, "prune failed: ${it.message}") }
}
}
}
private fun onObservation(obs: TrackerObservation) {
lifecycleScope.launch {
val fix = location.location.value
val result = runCatching {
repo.record(obs, fix?.latitude, fix?.longitude, settings.sensitivity.value)
}.getOrNull() ?: return@launch
if (result.newlyAlerting) raiseAlert(result.tracker)
}
}
private fun end() {
if (!_running.value) return
_running.value = false
scanner.stop()
location.stop()
pruneJob?.cancel(); pruneJob = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION") stopForeground(true)
}
}
override fun onDestroy() {
end()
super.onDestroy()
}
private fun startInForeground() {
val n = buildNotification(
title = getString(R.string.app_name),
text = getString(R.string.notification_text),
high = false
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
startForeground(NOTIFICATION_ID, n, type)
} else {
startForeground(NOTIFICATION_ID, n)
}
}
private fun raiseAlert(tracker: TrackerEntity) {
val n = buildNotification(
title = "Tracker following you",
text = "${tracker.label} has been moving with you",
high = true
)
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, n)
vibrate()
Log.w(TAG, "ALERT: ${tracker.stableId} (${tracker.ecosystem})")
}
private fun vibrate() {
val v = currentVibrator() ?: return
val effect = VibrationEffect.createWaveform(longArrayOf(0, 250, 120, 250, 120, 400), -1)
runCatching { v.vibrate(effect) }
}
private fun currentVibrator(): Vibrator? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
} else {
@Suppress("DEPRECATION") getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
private fun buildNotification(title: String, text: String, high: Boolean): Notification {
val openIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pi = PendingIntent.getActivity(
this, 0, openIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setContentText(text)
.setSmallIcon(android.R.drawable.ic_menu_view)
.setOngoing(true)
.setContentIntent(pi)
.setCategory(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)
}
)
}
}
@@ -0,0 +1,178 @@
package org.soulstone.vigil.ui
import android.text.format.DateUtils
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.soulstone.vigil.data.db.TrackerEntity
import org.soulstone.vigil.model.Sensitivity
import org.soulstone.vigil.model.TrackerEcosystem
import org.soulstone.vigil.model.TrackerStatus
import org.soulstone.vigil.ui.theme.VigilGreen
import org.soulstone.vigil.ui.theme.VigilPeach
import org.soulstone.vigil.ui.theme.VigilRed
@Composable
fun MainScreen(
running: Boolean,
trackers: List<TrackerEntity>,
sensitivity: Sensitivity,
permissionMessage: String?,
onStartStop: () -> Unit,
onSetSensitivity: (Sensitivity) -> Unit,
onApprove: (String, Boolean) -> Unit
) {
val alerting = trackers.count { statusOf(it) == TrackerStatus.ALERTING }
val suspicious = trackers.count { statusOf(it) == TrackerStatus.SUSPICIOUS }
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(Modifier.fillMaxSize().padding(16.dp)) {
Text("VIGIL", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text(
"what's been following you",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(16.dp))
StatusBanner(running, alerting, suspicious)
Spacer(Modifier.height(16.dp))
Button(
onClick = onStartStop,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = if (running) MaterialTheme.colorScheme.surfaceVariant
else MaterialTheme.colorScheme.primary
)
) {
Text(if (running) "STOP WATCHING" else "START WATCHING")
}
permissionMessage?.let {
Spacer(Modifier.height(8.dp))
Text(it, style = MaterialTheme.typography.bodySmall, color = VigilPeach)
}
Spacer(Modifier.height(20.dp))
Text("Sensitivity", style = MaterialTheme.typography.labelLarge)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Sensitivity.entries.forEach { s ->
FilterChip(
selected = s == sensitivity,
onClick = { onSetSensitivity(s) },
label = { Text(s.name.lowercase().replaceFirstChar { it.uppercase() }) }
)
}
}
Spacer(Modifier.height(20.dp))
Text("Trackers seen (${trackers.size})", style = MaterialTheme.typography.labelLarge)
Spacer(Modifier.height(6.dp))
if (trackers.isEmpty()) {
Text(
if (running) "Listening… nothing near you yet." else "Not scanning.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(trackers, key = { it.stableId }) { t -> TrackerRow(t, onApprove) }
}
}
}
}
}
@Composable
private fun StatusBanner(running: Boolean, alerting: Int, suspicious: Int) {
val (label, color) = when {
!running -> "Idle" to MaterialTheme.colorScheme.surfaceVariant
alerting > 0 -> "$alerting tracker(s) following you" to VigilRed
suspicious > 0 -> "Watching $suspicious possible follower(s)" to VigilPeach
else -> "Clear — nothing is following you" to VigilGreen
}
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = color)
) {
Text(
label,
Modifier.padding(16.dp),
color = Color(0xFF11111B),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
}
@Composable
private fun TrackerRow(t: TrackerEntity, onApprove: (String, Boolean) -> Unit) {
val status = statusOf(t)
Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
Row(
Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text(ecosystemDisplay(t.ecosystem), fontWeight = FontWeight.SemiBold)
Text(
"${statusLabel(status)} · ${t.sightingCount} sightings · ${relative(t.lastSeen)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (status == TrackerStatus.SAFE_APPROVED) {
TextButton(onClick = { onApprove(t.stableId, false) }) { Text("Un-approve") }
} else if (status != TrackerStatus.SAFE_BASELINE) {
TextButton(onClick = { onApprove(t.stableId, true) }) { Text("This is mine") }
}
}
}
}
private fun statusOf(t: TrackerEntity): TrackerStatus = when {
t.approved -> TrackerStatus.SAFE_APPROVED
t.baselineSafe -> TrackerStatus.SAFE_BASELINE
else -> when (t.riskState) {
"ALERTING" -> TrackerStatus.ALERTING
"SUSPICIOUS" -> TrackerStatus.SUSPICIOUS
else -> TrackerStatus.OBSERVED
}
}
private fun statusLabel(s: TrackerStatus): String = when (s) {
TrackerStatus.SAFE_APPROVED -> "Approved (yours)"
TrackerStatus.SAFE_BASELINE -> "Known (home)"
TrackerStatus.ALERTING -> "⚠ Following you"
TrackerStatus.SUSPICIOUS -> "Watching"
TrackerStatus.OBSERVED -> "Seen once"
}
private fun ecosystemDisplay(name: String): String =
runCatching { TrackerEcosystem.valueOf(name).display }.getOrDefault(name)
private fun relative(ts: Long): String =
DateUtils.getRelativeTimeSpanString(ts, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS).toString()
@@ -0,0 +1,40 @@
package org.soulstone.vigil.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// Catppuccin Mocha — VIGIL matches OVERWATCH's palette.
private val Base = Color(0xFF1E1E2E)
private val Mantle = Color(0xFF181825)
private val Surface0 = Color(0xFF313244)
private val Surface1 = Color(0xFF45475A)
private val Text = Color(0xFFCDD6F4)
private val Subtext = Color(0xFFA6ADC8)
private val Blue = Color(0xFF89B4FA)
private val Mauve = Color(0xFFCBA6F7)
val VigilGreen = Color(0xFFA6E3A1)
val VigilPeach = Color(0xFFFAB387)
val VigilRed = Color(0xFFF38BA8)
private val MochaScheme = darkColorScheme(
primary = Blue,
onPrimary = Base,
secondary = Mauve,
background = Base,
onBackground = Text,
surface = Mantle,
onSurface = Text,
surfaceVariant = Surface0,
onSurfaceVariant = Subtext,
outline = Surface1,
error = VigilRed,
onError = Base
)
@Composable
fun VigilTheme(content: @Composable () -> Unit) {
// VIGIL is dark/Mocha regardless of system setting, matching OVERWATCH.
MaterialTheme(colorScheme = MochaScheme, content = content)
}
@@ -0,0 +1,37 @@
package org.soulstone.vigil.util
/**
* Minimal geohash encoder. Used to bucket sightings into "distinct places" for
* the co-movement test and to anchor baseline locations.
*
* Precision reference: length 7 ≈ 153 m × 153 m cell; length 6 ≈ 1.2 km × 0.6 km.
* VIGIL uses length 7 for "distinct place" counting and length 6 for baseline anchors.
*/
object Geohash {
private const val BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"
fun encode(lat: Double, lon: Double, precision: Int = 7): String {
var latMin = -90.0; var latMax = 90.0
var lonMin = -180.0; var lonMax = 180.0
val sb = StringBuilder(precision)
var bit = 0
var ch = 0
var even = true
while (sb.length < precision) {
if (even) {
val mid = (lonMin + lonMax) / 2
if (lon >= mid) { ch = ch or (1 shl (4 - bit)); lonMin = mid } else { lonMax = mid }
} else {
val mid = (latMin + latMax) / 2
if (lat >= mid) { ch = ch or (1 shl (4 - bit)); latMin = mid } else { latMax = mid }
}
even = !even
if (bit < 4) {
bit++
} else {
sb.append(BASE32[ch]); bit = 0; ch = 0
}
}
return sb.toString()
}
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<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" />
<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" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/bg_dark" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/bg_dark" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="threat_green">#1FAA59</color>
<color name="threat_yellow">#F4C20D</color>
<color name="threat_orange">#F26B0F</color>
<color name="threat_red">#D7263D</color>
<color name="bg_dark">#0B0E12</color>
<color name="bg_card">#161A21</color>
<color name="text_primary">#F4F6FA</color>
<color name="text_secondary">#9AA3B2</color>
</resources>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">VIGIL</string>
<string name="notification_channel_name">Tracker watch</string>
<string name="notification_channel_desc">Ongoing scan for personal trackers following you</string>
<string name="notification_text">Watching for trackers moving with you…</string>
</resources>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Vigil" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@color/bg_dark</item>
<item name="android:navigationBarColor">@color/bg_dark</item>
<item name="android:windowBackground">@color/bg_dark</item>
</style>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
</full-backup-content>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
</cloud-backup>
</data-extraction-rules>