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
+50
View File
@@ -0,0 +1,50 @@
name: Build APK
# Builds a debug APK on every push/PR (CI compile check) and attaches an APK to
# a GitHub Release on version tags (v*). The committed debug keystore means CI
# and local builds sign identically, so updates install in place.
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Build debug APK
run: |
chmod +x ./gradlew
./gradlew :app:assembleDebug --stacktrace --no-daemon
- name: Stage APK
run: |
mkdir -p out
cp app/build/outputs/apk/debug/app-debug.apk "out/VIGIL-debug.apk"
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: vigil-apk
path: out/*.apk
- name: Publish Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
files: out/*.apk
generate_release_notes: true
+12
View File
@@ -0,0 +1,12 @@
*.iml
.gradle/
/local.properties
/.idea/
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
app/build/
build/
+137
View File
@@ -0,0 +1,137 @@
# VIGIL
**What's been following you?**
A native Android (Kotlin) app that watches for personal item-trackers — Apple
AirTags, Tile, Samsung Galaxy SmartTags, and Google Find My Device / DULT tags —
that are **travelling with you over time**. It is the temporal counterpart to
[OVERWATCH](https://github.com/KaraZajac/OVERWATCH):
> **OVERWATCH is spatial** — *what surveillance is watching this place, right now.*
> **VIGIL is temporal** — *what has been with me, across time and places.*
A tracker being *near* you means nothing — trackers are everywhere. The signal is
**persistence**: the same device seen at many of *your* distinct locations, over a
sustained window, while close enough to actually be on you. VIGIL is built around
that idea.
> ⚠️ **Prototype / work in progress.** The scanning, parsing, temporal store,
> co-movement engine, allowlist, and learned baseline are in place; the app builds
> in CI. Wire-format offsets and thresholds are from the research brief and need
> validation against real hardware (see [Status](#status)).
---
## Privacy first — like OVERWATCH
VIGIL requests **no `INTERNET` permission at all.** There is no server, no account,
no telemetry. Every tracker, every sighting, and the entire learned baseline live
in an on-device SQLite database and never leave the phone. It listens only — it
never transmits, probes, or interferes with any device.
---
## What it detects
Four native BLE wire formats today, plus the emerging unified DULT format. VIGIL
recognises each ecosystem and, where the ecosystem signals it, filters to the
**separated-from-owner** state — the only state in which a following tracker is
detectable (see the research brief).
| Ecosystem | BLE signature | Separated-state signal | Passive re-link window |
|---|---|---|---|
| **Apple Find My / AirTag** | mfg data, company `0x004C`, type `0x12` | status byte "maintained" bit cleared | ~24 h (key static per day) |
| **Google Find My Device** | service data `0xFEAA`, frame `0x40`/`0x41` | frame `0x41` = separated (cleartext) | ~24 h once separated |
| **Samsung Galaxy SmartTag** | service data `0xFD5A` | state byte (lost / overmature-lost) | ~24 h once overmature |
| **Tile** | service data `0xFEED` / `0xFEEC` | none — static MAC, always findable | indefinite (static MAC) |
| **DULT (unified, emerging)** | service data `0xFCB2` | near-owner bit (byte 14 LSB) | ~24 h separated |
Chipolo, Pebblebee, eufy, Motorola, etc. inherit the signature of whichever
network (Apple or Google) their SKU joined — VIGIL detects the **network**.
## How it decides
Each parsed sighting is geotagged with a coarse fix and written to the temporal
store. A tracker is escalated `OBSERVED → SUSPICIOUS → ALERTING` only when it
clears the **co-movement test**:
- **≥ 3 sightings** (debounced to one per 15 min), across
- **≥ N distinct places** (geohash-7 cells; N = 2/3/4 by sensitivity), over
- **≥ T minutes** (30/45/90 by sensitivity), **and**
- an **RSSI proximity gate** — it must have been genuinely close (on-body/in-bag)
at least once. This is the piece AirGuard omits, and it rejects "a Tile in a
passing car."
Two trust signals suppress false alarms:
- **Allowlist ("This is mine").** Tap a tracker to mark it approved — your own
AirTag, your partner's Tile — and it never alerts again.
- **Learned offline baseline.** VIGIL learns the places you dwell (home, work) as
*anchors*, and a tracker seen at an anchor across several distinct days is
auto-marked **Known (home)**. So the household tags that are always around you
fall silent on their own, entirely on-device.
## The hard part — catching clones (problem #1)
Every shipping detector (AirGuard, iOS, Android's built-in) keys on **device
identity**. A key-rotating clone (e.g. Positive Security's *Find You*: ~2,000
Find My keys, a new one every 30 s) looks like 2,000 one-off devices and evades
all of them — it tracked a phone for 5 days with zero alerts.
VIGIL's headline goal is to detect the **attack, not the device**: a rotating
clone is one physical radio holding an unbroken, close-range, co-moving RF
*presence* even as its identity churns thousands of times faster than any
standards-compliant tracker is allowed to. The full algorithm — a CUSUM churn
trigger, an identity-agnostic presence-track confirmer, and a co-movement gate,
plus the "identity-path × churn-path squeeze" that leaves no safe rotation rate —
is designed in **[docs/detection-rotation-clone.md](docs/detection-rotation-clone.md)**.
The `detect/` package ships the co-movement v1; the presence engine slots in next.
## Architecture
VIGIL reuses OVERWATCH's proven scanning stack and diverges where the temporal
mission demands it (a persistent database instead of an in-memory store).
```
scan/TrackerSignatures.kt BLE hex signatures for every ecosystem
scan/TrackerParser.kt ScanResult -> TrackerObservation (four wire formats)
scan/BleTrackerScanner.kt filtered BLE scan (screen-off capable) -> observations
service/ScanService.kt foreground service; geotags + persists + alerts
data/db/VigilDatabase.kt Room: trackers, sightings (14-day), baseline places
data/TrackerRepository.kt ingest + baseline + evaluate on every sighting
detect/CoMovementEvaluator the temporal co-movement test + RSSI proximity gate
detect/BaselineManager.kt learns anchor places -> auto-trusts household tags
data/location/LocationProvider.kt fused location (ported from OVERWATCH)
ui/ + MainActivity.kt Compose UI (Catppuccin Mocha)
```
## Build
Standard Android/Gradle. A committed debug keystore signs CI and local builds
identically.
```bash
./gradlew :app:assembleDebug
# APK -> app/build/outputs/apk/debug/app-debug.apk
```
CI (`.github/workflows/build.yml`) builds a debug APK on every push and attaches
an APK to a GitHub Release on `v*` tags.
## Status
Prototype. In place: BLE scan + filters, per-ecosystem parsing, Room temporal
store, co-movement evaluator with RSSI gate, allowlist, learned baseline,
foreground service, Compose UI. **Not yet:** the rotation-clone presence engine
(designed, not wired), GATT play-sound / DULT get-identifier, and empirical
validation. Before trusting the parser, capture real devices with nRF Connect —
the SmartTag2 offsets are inferred from gen-1, and the Tile/AirTag reversing is a
couple of years old.
## Credits & prior art
Stands on the shoulders of **[AirGuard](https://github.com/seemoo-lab/AirGuard)**
(TU Darmstadt / Seemoo-lab) and the SEEMOO Find My research, the IETF **DULT**
working group, Adam Catley's AirTag teardown, and Positive Security's *Find You*
clone research. VIGIL's aim is to go *beyond* AirGuard on the attacks it
structurally cannot catch — see the design doc.
+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>
+6
View File
@@ -0,0 +1,6 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false
}
BIN
View File
Binary file not shown.
+367
View File
@@ -0,0 +1,367 @@
# VIGIL — Detecting Key-Rotating Tracker Clones by Presence, Not Identity
**Design document · the core novel detection algorithm (problem #1) · v1 · 2026-07-14**
Target: on-device Android (Kotlin), no server. Slots into VIGIL's `scan/` +
`detect/` architecture; the co-movement v1 in `detect/CoMovementEvaluator.kt`
ships now, the presence engine below (`detect/PresenceEngine.kt`) is next.
---
## 0. Thesis (TL;DR)
Every deployed anti-stalking detector — Apple *Tracker Detect*, iOS *Find My*
unwanted-tracking alerts, and TU-Darmstadt's **AirGuard** — keys on **device
identity**: it counts repeat sightings of one BLE identifier and alarms at a
threshold (AirGuard: the *same* identifier seen **≥ 3 times across a location
change**). A key-rotating clone (Positive Security's **Find You**: an ESP32
cycling **2000** Find My public keys, a new one every **~30 s**) presents each
sighting as a brand-new device, so the repeat count never accumulates. In the
authors' 5-day carry test, **no tool raised any passive alert.**
VIGIL's differentiator: **stop tracking identities, start tracking the *presence*.**
Ask *"is there one physical radio maintaining an unbroken, close-range, co-moving
RF presence, even though no single identifier survives?"* This is detectable
because the attack, to function as a tracker, must emit an **unbroken stream of
separated-state beacons at roughly stable close-range RSSI that moves with the
victim across locations where the ambient tracker population fully turns over.**
Identity churn cannot hide that.
The most exploitable fact: **the standards mandate slow rotation.** IETF DULT,
Apple, and Google all specify that a separated accessory rotates its address/key
**once per 24 hours** (AirTag: static MAC+key for 24 h, re-rolling at 04:00; DULT
accessory-protocol: rotate every 24 h; Google FMDN: 24 h in unwanted-tracking
mode). A real separated tracker following you produces **one identity for a full
day**. A clone rotating every 30 s runs **~2880× faster than any spec-compliant
device** — not "suspicious," but *categorically impossible* for a legitimate
separated tracker. VIGIL anomaly-detects against that standardized baseline.
---
## 1. Formalization: the observable signal
### 1.1 Per-sighting record
```
Sighting {
t, wallT : Long // monotonic + epoch ms
eco : Ecosystem // APPLE_FM | GOOGLE_FMDN | SAMSUNG_ST | TILE
eid : ByteArray // ephemeral identity (§1.2) — the churning label
separated : Boolean // separated/lost-state beacon? (§1.3)
rssi : Int // dBm
txPower : Int? // advertised TX power if present (path-loss norm.)
lat,lon : Double; gpsAcc, speed : Float // last fused fix + speed
}
```
### 1.2 The identity that churns (`eid`)
| Ecosystem | `eid` | Spec rotation (separated) |
|---|---|---|
| Apple Find My | 28-byte public key (MAC ⧺ payload key bytes); MAC as cheap proxy | **24 h** (re-roll 04:00) |
| Google FMDN | rotating EID (`Rx`) | ~1024 s normal; **24 h** unwanted-tracking mode |
| Samsung SmartTag | rotating region of offline-finding payload | ~15 min24 h |
| Tile | rotating id (but MAC is static) | vendor-specific |
Structural fact for Apple: **the MAC is derived from the key, so both rotate
together** — you cannot even fall back to MAC-tracking. Identity churn is total.
### 1.3 The `separated` predicate
Only separated-state beacons are relevant: a tracker with its owner is
nearby/connectable (no threat). A tracker following you without its owner is by
definition separated and broadcasts its full key every 2 s. VIGIL filters to
`separated == true` — this cuts noise and is exactly the population the clone
must join.
### 1.4 The three core computable quantities
Close band: `rssi ≥ R_close` (starter **65 dBm**).
**(a) Novel-identifier churn rate `λ_novel`.** Keep a set `Seen` of `eid`s over
horizon `H` (30 min). A sighting is *novel* if `eid ∉ Seen`.
`λ_novel(t) = |distinct eid first seen in (tW, t]| / W` (W = 5 min).
Real separated tracker → ~0.0007/min. Find You @30 s → ~2/min. A ~2800× gap.
**(b) Presence continuity `PC`** — is the close band *continuously occupied*,
regardless of *who* occupies it?
`o(t)=1 iff ∃ separated sighting in (t−δ,t] with rssi≥R_close` (δ = 10 s);
`PC(W) = duty cycle of o`; `Runmax = longest run with no gap > g_break` (20 s).
Clone → `PC≈1`, `Runmax` = whole session. A crowd also keeps it occupied, so
`PC` alone is insufficient — it must be tied to coherence (§1.5) and co-movement.
**(c) Co-movement `CM`** — did the presence follow you across a **population
turnover**? The physics that makes this a *hard* discriminator — the **ambient
dwell bound**: a fixed tag or a passer stays in your close band (radius R≈10 m)
for at most `dwell ≤ 2R / v_rel`. At 13 m/s (driving) → ~1.5 s; walking → ~14 s;
only a co-*moving* emitter (v_rel≈0) has unbounded dwell. So **conditioned on
measured v̄ > 0, a close-band presence whose dwell ≫ 2R/v̄ is co-moving by
construction.** Over K ≥ 3 non-adjacent cells at speed, no single ambient tag
can appear — the presence is provably not any one ambient source.
### 1.5 The unifying reframe: presence tracking without identity
Discard `eid` as a *tracking* key; keep it only as a *novelty* signal. Treat
close-band separated sightings as points in `(t, rssi)` and ask: does **one
continuous, low-variance trajectory** explain them (one radio), or a **diffuse
high-variance cloud** (a crowd)? At each **handover** (close-band identity
changing) test continuity:
```
seamless handover: |rssi_new ewma_rssi| ≤ ε_rssi (8 dB) AND Δt ≤ g_seam (6 s)
```
A rotating clone emits a **long chain of seamless handovers into novel
identities** — the radio never leaves, only its name does. A crowd emits gappy,
RSSI-discontinuous handovers. This **seamless-novel-handover chain** is the
algorithmic heart of VIGIL and the quantity no identity churn can fake while
remaining a functioning tracker.
---
## 2. Algorithm
### 2.1 Weighing four candidate approaches
| Approach | Catches clone? | Cost | FP in dense RF | Verdict |
|---|---|---|---|---|
| Sliding-window novelty count | Yes | Trivial | Bad (busy street churns) | cheap *trigger* only |
| CUSUM change-point on `λ_novel` | Yes, bounded latency | Trivial | Better (regime vs baseline) | good *trigger*, must gate |
| Density on `(rssi,t)` | Yes | Heavier | Separates single-radio track from crowd | do the online single-track version |
| Presence-occupancy + seamless chain | Yes | Moderate O(1)/sighting | **Best** (coherence + co-move) | **core confirmer** |
None alone is deployable. The design is a **hybrid pipeline**: a cheap CUSUM
churn *trigger* wakes an expensive presence-coherence *confirmer*, which only
alarms behind a *co-movement gate*.
### 2.2 Pipeline
```
Stage A CUSUM on λ_novel (always on, O(1)) → trigger
Stage B presence-track confirmer (armed on trigger): identity-agnostic single
(t,rssi) track; counts novel *seamless* handovers, RSSI residual σ,
presence-run duration → candidate
Stage C co-movement gate + scoring: requires Dnet, distinct cells, v̄>0 → 0100
```
### 2.3 / 2.4 State + update rule
```kotlin
class ChurnCusum(val mu0: Double, val sigma0: Double, val k: Double, val h: Double) {
var S = 0.0
fun update(novelInLastMin: Int): Boolean { // per minute
val z = (novelInLastMin - mu0) / sigma0
S = maxOf(0.0, S + z - k)
return S > h // sustained regime shift
}
}
data class PresenceRun(
var startT: Long, var startLat: Double, var startLon: Double,
var lastT: Long, var lastEid: ByteArray,
var ewmaRssi: Double, var ewmaAbsResid: Double, // coherence
var handovers: Int, var seamlessNovelHandovers: Int,
var cells: MutableSet<Long>, var dnet: Double, var vSum: Double, var vN: Int,
var active: Boolean
)
fun onSeparatedSighting(s: Sighting, st: State) {
val novel = !st.seen.contains(s.eid); if (novel) st.seen.add(s.eid)
st.novelThisMinute += if (novel) 1 else 0
if (s.rssi < R_CLOSE) return // only close band builds presence
val p = st.run
if (!p.active || s.t - p.lastT > G_BREAK) { // (re)start a run
if (p.active) evaluate(p, st)
st.run = PresenceRun(s.t, s.lat, s.lon, s.t, s.eid, s.rssi.toDouble(), 0.0,
0, 0, mutableSetOf(cellOf(s.lat,s.lon)), 0.0, s.speed.toDouble(), 1, true)
return
}
val resid = s.rssi - p.ewmaRssi
if (!s.eid.contentEquals(p.lastEid)) { // handover
p.handovers++
if (abs(resid) <= EPS_RSSI && (s.t - p.lastT) <= G_SEAM && novel)
p.seamlessNovelHandovers++ // ← THE tell
}
p.ewmaRssi = ALPHA*s.rssi + (1-ALPHA)*p.ewmaRssi
p.ewmaAbsResid = ALPHA*abs(resid) + (1-ALPHA)*p.ewmaAbsResid // ≈ RSSI σ
p.lastT = s.t; p.lastEid = s.eid
p.cells.add(cellOf(s.lat,s.lon)); p.dnet = haversine(p.startLat,p.startLon,s.lat,s.lon)
p.vSum += s.speed; p.vN++
scoreLive(p, st)
}
fun scoreLive(p: PresenceRun): Int {
val durMin = (p.lastT - p.startT)/60_000.0; val vbar = p.vSum/p.vN
val moving = vbar > V_MIN // 1 m/s
val comoves = p.dnet >= D_NET && p.cells.size >= K_CELLS // 500 m & 3 cells
if (!(moving && comoves)) return 0 // Stage-C gate: silent
val coherent = p.ewmaAbsResid <= SIGMA_MAX // single-radio tightness
val churnAnom = (p.seamlessNovelHandovers/max(durMin,1e-3)) >= RHO_NOVEL
val seamlessFrac = if (p.handovers>0) p.seamlessNovelHandovers.toDouble()/p.handovers else 0.0
var sc = 0.0
sc += clamp(durMin/T_MIN,0.0,1.0)*30
sc += clamp(p.seamlessNovelHandovers/N_HANDOVER,0.0,1.0)*30
sc += clamp(p.cells.size/(2.0*K_CELLS),0.0,1.0)*20
sc += (if (coherent) 1.0 else 0.3)*(if (churnAnom) 1.0 else 0.5)*(0.5+0.5*seamlessFrac)*20
return sc.roundToInt()
}
```
Tiers (VIGIL 4-tier): `<40` clear · `4069` WATCHING (soft) · `7084` probable
clone · `85+` persisted across ≥ K_CELLS cells / mode change.
### 2.5 Starter parameters
`R_CLOSE 65 dBm · δ 10 s · G_BREAK 20 s · G_SEAM 6 s · EPS_RSSI 8 dB · SIGMA_MAX
8 dB · H 30 min · W 5 min · CUSUM k 0.5, h 5, μ0/σ0 = rolling personal baseline ·
α 0.3 · ρ (min novel-seamless rate) 0.7/min · D_NET 500 m · K_CELLS 3×250 m ·
V_MIN 1 m/s · T_MIN 10 min (ORANGE)/3 min (WATCHING) · N_HANDOVER 12`.
### 2.6 Fit to VIGIL
Runs entirely on-device over the existing scan path. `scan/TrackerParser.kt`
already emits per-ecosystem separated sightings; add speed to the record via
`LocationProvider`. New `detect/PresenceEngine.kt` holds `ChurnCusum` +
`PresenceRun` + the novelty set as a pure streaming function (offline-testable,
§5). Keep a bounded ring buffer over horizon `H`; multi-session mode (§4.3) is
opt-in.
---
## 3. False positives (the crux)
The enemy is dense urban RF where legitimate separated-Find-My churn is naturally
high. FPs are where this feature lives or dies.
| Scenario | Fires | Killed by |
|---|---|---|
| Dense-urban walk / festival | high λ_novel, PC≈1 | **seamless chain fails** — a crowd is many radios ⇒ gappy, RSSI-discontinuous handovers, high ewmaAbsResid |
| Apartment / at home | high PC, stationary | **co-movement gate** (v̄≈0, 1 cell) ⇒ score 0 |
| One stranger's real AirTag on your bus | co-moving, close, stable | not the clone path (it has 1 id/24 h) — handled by the identity detector + dismiss/whitelist UX |
| Busload of strangers' real tags | co-moving, PC≈1 | **novel-churn ≈ 0** (stable set of stable ids) ⇒ clone path silent |
| Rush-hour subway w/ heavy boarding churn | A + B + partial C | **the genuinely hard case — §3.3** |
**Discriminators, strongest first:** (1) seamless-novel-handover chain — only one
physical emitter yields `|Δrssi|≤8 dB, Δt≤6 s` across identity changes,
repeatedly; (2) co-movement across turnover (dwell bound); (3) RSSI coherence;
(4) stationary suppression (removes the whole home/apartment class).
**Where it breaks: live rush-hour transit.** People board/alight with real
separated tags, so λ_novel is high, the vehicle co-moves you across cells, PC≈1 —
three signals align. What still separates it: those tags are at many distances
(high ewmaAbsResid, low seamless fraction) and turnover is bursty/stop-synced, not
a 30 s metronome. The decisive gate: **persistence across a mode change** — a
transit crowd cannot follow you *off the train and down the street*. Requiring the
coherent co-moving presence to survive a context transition drops transit FPs to
~0, **at the cost of latency**. That is the honest central trade: lower transit FP
is bought with detection latency. The MVP keeps transit at WATCHING (silent) and
escalates only after a mode change or ≥ T_MIN beyond the transit segment.
---
## 4. Robustness
### 4.1 The rotation-rate squeeze (the strongest claim)
VIGIL runs the **identity path** (classic AirGuard-style; catches any key held
> ~35 min, seen ≥3× across a location change) **and** the **churn path** (this
doc; catches novel-seamless rates above ~0.51/min) together:
```
rotation period: 30s ──── 2min ──── 5min ─────────── 24h
└ churn path ┘
└ overlap seam ┘
└────── identity path ──────┘
```
**No rotation period evades both.** Fast → churn path; slow → identity path;
middle → both. Moreover the presence-continuity/co-movement path is
rotation-rate-agnostic — slowing rotation lowers λ_novel but does nothing to the
unbroken coherent co-moving close-band track. **The attacker's only real lever is
continuity, not rate.**
### 4.2 Adaptations
| Move | Effect | Response | Residual |
|---|---|---|---|
| Slower rotation | λ_novel drops | identity path re-engages | high (squeeze) |
| Variable TX power | RSSI jitter weakens coherence | normalize by advertised txPower; lean on continuity+co-move+churn | medhigh |
| Intermittent duty-cycling | gaps reset runs | reacquire logic: a gap < G_reacq (3 min) resuming at coherent RSSI along your path = same presence; accumulate across gaps | medium (this is the biting attack) |
| Blend into transit | crowd cover | mode-change gate (§3.3) | medium (needs latency) |
### 4.3 The true floor
Every evasion degrades the attacker's own tracker (slower/duty-cycled = fewer
fixes; lower power = shorter range). Below some duty cycle (a few random beacons
per 15 min, random power) the emitter is information-theoretically
indistinguishable from sparse ambient pass-bys **in a single session**, and
detection must move to **cross-session recurrence** — the same anomalous weakly-
co-moving presence recurring day after day along your routes (a per-day coarse
RSSI/timing fingerprint matched across sessions). That trades **days of latency**
and needs opt-in persistent storage. **The honest floor: a patient, low-duty-cycle
adaptive attacker pushes VIGIL from minutes-latency single-session detection to
days-latency multi-session detection.** Say so; don't claim an impossible
guarantee.
---
## 5. Validation plan (on-device Kotlin, no server, no real stalker)
1. **Red-team emitter.** Parameterized OpenHaystack / Find You ESP32 clone
(base: `positive-security/find-you`), sweeping `rotationPeriod ∈ {30 s … 24 h}`,
`txPower ∈ {fixed, ±k dB}`, `dutyCycle ∈ {100%, 50%, 20%, bursty}`,
`keyPoolSize`. Carried, it is a real co-moving emitter → ground-truth-positive
without endangering anyone.
2. **Ambient baselines** (ground-truth-negative), by class: rural drive · suburban
· dense-urban walk · bus · subway/rush-hour · highway · apartment · café. Paired
runs (same route, emitter on/off).
3. **Record/replay harness** — make `PresenceEngine` a pure
`List<Sighting> → List<ScoredEvent>`; record raw sightings once, replay JSONL to
A/B thresholds deterministically offline (unit tests / in-app debug screen).
Synthesize attack-in-context by merging a clean run with an emitter-only trace.
4. **Metrics:** TPR @ ≤ 1 FP / active-carry-day per class; detection latency
(attach → first ORANGE), target ≤ 1015 min for default Find You; ROC/AUC;
robustness curves (TPR vs rotationPeriod / txPower / dutyCycle); ablations
(churn-only → +coherence → +co-move gate → full).
---
## 6. Verdict
**Feasible enough to be VIGIL's headline — for a precisely scoped claim, with
honest caveats.**
Real, not hype: the attack must maintain a continuous coherent co-moving presence
to function; the standards mandate 24 h rotation, giving a *categorical* anomaly
baseline; the identity×churn squeeze leaves no safe rate; and VIGIL strictly
dominates incumbents on this attack (they raise zero alerts on Find You, VIGIL
raises one). Research-grade long shots: robust low-FP detection in live rush-hour
transit, and a patient low-duty-cycle adaptive attacker (provably forced to
multi-session/days latency).
**Minimum viable version (ship first).** Target the published Find You config
(30 s, fixed power, ~100% duty) — the exact attack that renders AirGuard silent:
Stage A CUSUM trigger; Stage B presence track counting novel *seamless*
handovers with RSSI coherence; Stage C hard co-movement gate (ORANGE after ≥10
min coherent co-moving presence, ≥12 novel seamless handovers, ≥3 cells while
v̄>1 m/s; RED after a cell/mode change). Run the classic identity detector in
parallel. New tier distinct from the identity-based alert, with a dismiss/whitelist
UX for the stranger-on-your-commute case. Detects the real-world clone in ~1015
min at a near-zero FP rate outside rush-hour transit — and is honest about the
adaptive-attacker floor.
---
## 7. References
- Positive Security, "Find You: Building a stealth AirTag clone" — https://positive.security/blog/find-you · https://github.com/positive-security/find-you
- Heinrich et al., "Who Can Find My Devices?" PETS 2021 — https://petsymposium.org/popets/2021/popets-2021-0045.pdf
- Heinrich et al., "AirGuard", WiSec 2022 — https://arxiv.org/pdf/2202.11813
- A. Catley, AirTag reverse engineering — https://adamcatley.com/AirTag.html
- IETF DULT — accessory-protocol + threat-model — https://datatracker.ietf.org/wg/dult/
- Google Fast Pair FMDN spec — https://developers.google.com/nearby/fast-pair/specifications/extensions/fmdn
- SEEMOO OpenHaystack — https://github.com/seemoo-lab/openhaystack
- E. S. Page, "Continuous Inspection Schemes" (Biometrika 1954) — CUSUM; Basseville & Nikiforov, *Detection of Abrupt Changes* (1993)
- Datar/Gionis/Indyk/Motwani sliding-window distinct counting; Flajolet et al. HyperLogLog
- Becker et al., "Tracking Anonymized Bluetooth Devices", PETS 2019
- DP-3T / Google-Apple Exposure Notification — RSSI attenuation-bucket presence reasoning
+8
View File
@@ -0,0 +1,8 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.configuration-cache=true
org.gradle.caching=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
+34
View File
@@ -0,0 +1,34 @@
[versions]
agp = "8.7.3"
kotlin = "2.0.21"
ksp = "2.0.21-1.0.28"
coreKtx = "1.13.1"
lifecycle = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.12.01"
material3 = "1.3.1"
playServicesLocation = "21.3.0"
room = "2.6.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version.ref = "lifecycle" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+93
View File
@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+3
View File
@@ -0,0 +1,3 @@
# Copy to local.properties and point at your Android SDK. This file is
# git-ignored because the path is machine-specific.
sdk.dir=/home/youruser/Android/Sdk
+28
View File
@@ -0,0 +1,28 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "VIGIL"
include(":app")