From 817a4ac1c901fa559071764c5b83c1740fca62c9 Mon Sep 17 00:00:00 2001 From: Kara Zajac Date: Wed, 15 Jul 2026 00:11:00 -0400 Subject: [PATCH] =?UTF-8?q?VIGIL=200.1.0=20=E2=80=94=20temporal=20counter-?= =?UTF-8?q?tracking=20(prototype)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5 --- .github/workflows/build.yml | 50 +++ .gitignore | 12 + README.md | 137 +++++++ app/build.gradle.kts | 83 ++++ app/proguard-rules.pro | 3 + app/src/main/AndroidManifest.xml | 71 ++++ .../org/soulstone/vigil/MainActivity.kt | 113 ++++++ .../soulstone/vigil/data/TrackerRepository.kt | 112 ++++++ .../soulstone/vigil/data/db/VigilDatabase.kt | 121 ++++++ .../vigil/data/location/LocationProvider.kt | 89 +++++ .../soulstone/vigil/data/settings/Settings.kt | 36 ++ .../soulstone/vigil/detect/BaselineManager.kt | 40 ++ .../vigil/detect/CoMovementEvaluator.kt | 89 +++++ .../kotlin/org/soulstone/vigil/model/Types.kt | 39 ++ .../soulstone/vigil/scan/BleTrackerScanner.kt | 116 ++++++ .../org/soulstone/vigil/scan/TrackerParser.kt | 112 ++++++ .../soulstone/vigil/scan/TrackerSignatures.kt | 42 ++ .../soulstone/vigil/service/ScanService.kt | 214 ++++++++++ .../org/soulstone/vigil/ui/MainScreen.kt | 178 +++++++++ .../org/soulstone/vigil/ui/theme/Theme.kt | 40 ++ .../org/soulstone/vigil/util/Geohash.kt | 37 ++ .../res/drawable/ic_launcher_foreground.xml | 13 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/values/colors.xml | 11 + app/src/main/res/values/strings.xml | 7 + app/src/main/res/values/themes.xml | 8 + app/src/main/res/xml/backup_rules.xml | 3 + .../main/res/xml/data_extraction_rules.xml | 5 + build.gradle.kts | 6 + debug.keystore | Bin 0 -> 2666 bytes docs/detection-rotation-clone.md | 367 ++++++++++++++++++ gradle.properties | 8 + gradle/libs.versions.toml | 34 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48966 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 ++++++++++++ gradlew.bat | 93 +++++ local.properties.example | 3 + settings.gradle.kts | 28 ++ 40 files changed, 2585 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt create mode 100644 app/src/main/kotlin/org/soulstone/vigil/data/TrackerRepository.kt create mode 100644 app/src/main/kotlin/org/soulstone/vigil/data/db/VigilDatabase.kt create mode 100644 app/src/main/kotlin/org/soulstone/vigil/data/location/LocationProvider.kt create mode 100644 app/src/main/kotlin/org/soulstone/vigil/data/settings/Settings.kt create mode 100644 app/src/main/kotlin/org/soulstone/vigil/detect/BaselineManager.kt create mode 100644 app/src/main/kotlin/org/soulstone/vigil/detect/CoMovementEvaluator.kt create mode 100644 app/src/main/kotlin/org/soulstone/vigil/model/Types.kt create mode 100644 app/src/main/kotlin/org/soulstone/vigil/scan/BleTrackerScanner.kt create mode 100644 app/src/main/kotlin/org/soulstone/vigil/scan/TrackerParser.kt create mode 100644 app/src/main/kotlin/org/soulstone/vigil/scan/TrackerSignatures.kt create mode 100644 app/src/main/kotlin/org/soulstone/vigil/service/ScanService.kt create mode 100644 app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt create mode 100644 app/src/main/kotlin/org/soulstone/vigil/ui/theme/Theme.kt create mode 100644 app/src/main/kotlin/org/soulstone/vigil/util/Geohash.kt create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 build.gradle.kts create mode 100644 debug.keystore create mode 100644 docs/detection-rotation-clone.md create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 local.properties.example create mode 100644 settings.gradle.kts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..2b2feb7 --- /dev/null +++ b/.github/workflows/build.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d61454 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +*.iml +.gradle/ +/local.properties +/.idea/ +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +app/build/ +build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..497ddc0 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..8b377db --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..9749925 --- /dev/null +++ b/app/proguard-rules.pro @@ -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. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..fd027d7 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt b/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt new file mode 100644 index 0000000..a454479 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt @@ -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 + 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) + ) + } +} diff --git a/app/src/main/kotlin/org/soulstone/vigil/data/TrackerRepository.kt b/app/src/main/kotlin/org/soulstone/vigil/data/TrackerRepository.kt new file mode 100644 index 0000000..8cf20d9 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/vigil/data/TrackerRepository.kt @@ -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> = 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 + } +} diff --git a/app/src/main/kotlin/org/soulstone/vigil/data/db/VigilDatabase.kt b/app/src/main/kotlin/org/soulstone/vigil/data/db/VigilDatabase.kt new file mode 100644 index 0000000..c2e0280 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/vigil/data/db/VigilDatabase.kt @@ -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> + + @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 + + @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 } + } + } +} diff --git a/app/src/main/kotlin/org/soulstone/vigil/data/location/LocationProvider.kt b/app/src/main/kotlin/org/soulstone/vigil/data/location/LocationProvider.kt new file mode 100644 index 0000000..9526218 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/vigil/data/location/LocationProvider.kt @@ -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(null) + val location: StateFlow = _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 + } +} diff --git a/app/src/main/kotlin/org/soulstone/vigil/data/settings/Settings.kt b/app/src/main/kotlin/org/soulstone/vigil/data/settings/Settings.kt new file mode 100644 index 0000000..577ea50 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/vigil/data/settings/Settings.kt @@ -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.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 } + } + } +} diff --git a/app/src/main/kotlin/org/soulstone/vigil/detect/BaselineManager.kt b/app/src/main/kotlin/org/soulstone/vigil/detect/BaselineManager.kt new file mode 100644 index 0000000..c8034b0 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/vigil/detect/BaselineManager.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/org/soulstone/vigil/detect/CoMovementEvaluator.kt b/app/src/main/kotlin/org/soulstone/vigil/detect/CoMovementEvaluator.kt new file mode 100644 index 0000000..ba77f78 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/vigil/detect/CoMovementEvaluator.kt @@ -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, + 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) + } +} diff --git a/app/src/main/kotlin/org/soulstone/vigil/model/Types.kt b/app/src/main/kotlin/org/soulstone/vigil/model/Types.kt new file mode 100644 index 0000000..c8dbda8 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/vigil/model/Types.kt @@ -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 } diff --git a/app/src/main/kotlin/org/soulstone/vigil/scan/BleTrackerScanner.kt b/app/src/main/kotlin/org/soulstone/vigil/scan/BleTrackerScanner.kt new file mode 100644 index 0000000..65e2d79 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/vigil/scan/BleTrackerScanner.kt @@ -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 = 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) { + results.forEach { r -> TrackerParser.parse(r)?.let(onObservation) } + } + + override fun onScanFailed(errorCode: Int) { + Log.e(TAG, "BLE scan failed: $errorCode") + running = false + } + } +} diff --git a/app/src/main/kotlin/org/soulstone/vigil/scan/TrackerParser.kt b/app/src/main/kotlin/org/soulstone/vigil/scan/TrackerParser.kt new file mode 100644 index 0000000..27cfac3 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/vigil/scan/TrackerParser.kt @@ -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() +} diff --git a/app/src/main/kotlin/org/soulstone/vigil/scan/TrackerSignatures.kt b/app/src/main/kotlin/org/soulstone/vigil/scan/TrackerSignatures.kt new file mode 100644 index 0000000..014ba9a --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/vigil/scan/TrackerSignatures.kt @@ -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 = 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))) +} diff --git a/app/src/main/kotlin/org/soulstone/vigil/service/ScanService.kt b/app/src/main/kotlin/org/soulstone/vigil/service/ScanService.kt new file mode 100644 index 0000000..b166ee1 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/vigil/service/ScanService.kt @@ -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 = _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) + } + ) + } +} diff --git a/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt b/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt new file mode 100644 index 0000000..5d8b33e --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt @@ -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, + 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() diff --git a/app/src/main/kotlin/org/soulstone/vigil/ui/theme/Theme.kt b/app/src/main/kotlin/org/soulstone/vigil/ui/theme/Theme.kt new file mode 100644 index 0000000..6e29569 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/vigil/ui/theme/Theme.kt @@ -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) +} diff --git a/app/src/main/kotlin/org/soulstone/vigil/util/Geohash.kt b/app/src/main/kotlin/org/soulstone/vigil/util/Geohash.kt new file mode 100644 index 0000000..6a6f964 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/vigil/util/Geohash.kt @@ -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() + } +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..3e4e766 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..ad95a53 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..ad95a53 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..8c31e08 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,11 @@ + + + #1FAA59 + #F4C20D + #F26B0F + #D7263D + #0B0E12 + #161A21 + #F4F6FA + #9AA3B2 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..ba2fb0a --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + VIGIL + Tracker watch + Ongoing scan for personal trackers following you + Watching for trackers moving with you… + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..92b145e --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..a608293 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..288f690 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..0ae4042 --- /dev/null +++ b/build.gradle.kts @@ -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 +} diff --git a/debug.keystore b/debug.keystore new file mode 100644 index 0000000000000000000000000000000000000000..c9439212ad2568d3c0809f1a586eb98372f80c70 GIT binary patch literal 2666 zcma)8X*kpk_nsLuvW>05gi4rX?Kk#OJY*e0H1@HKrBIeErODRVvkjFkWs9fBoO9=XyWA?}u~U=iK+X&&T^Z0Ez7y1k41G*tnTlrJ@a@ci`Y- z;9L?L7)oM207$HR014Lm9}=tzN`jRi(Zxqq$jtt47Y7>{l1qYV9+4M;YkwRtIA8;) z{xfm_(oh6fa;*X-XdmM#={&S4K8&N*TZcj*65J552*AP2^8XhhtS}I8l9|;h+5qgv z1O-b$xpg?zO_n}=ud20odCnxCbO0qm-$NI!#4Q>0@2ptxcWnEOWz4Sqc4C~NCn;)D zQ+(3+8`37#<4iwntuHscO};E(b6Z1_+4OmgzfNgBLqcOKe^@WXUtX;;am5%)dhp5o zRwr@`4RXX7pJlQR2go=@$dq0aFQFH}`Pp1(~m%ByZ1bM0-@YLS=0(<=AI*i*`=Y_I`FeK5cGwwWM zW$`0iaHzq^L-BG%+ebpMW9FAsceSBbTVD^lKB7p{SKCw>ph@wuunZ|pd@u3@nyt?X z&j%Hap)@g=-wLAx#kxf*_xZwvZciKeHYAnW%}*#U*H;z9meCyO(-W;_2b099^eYNv zOGrLn@pS!h|B`cEg+4lWiWT&e#=XBpi3qa4_J5ktua=fE-@9Ni=L&4~tsOnaQ}n zF}#q0VlI9E%_E4Puz%i*iqT4_!Zl+PZmxP9mt8>}n=OwUD}>ku`w31IKEZ~LkHtTt zSj?gaQQekb0s@9S+tz9q3*bncbXJTNEMKp~@SJn{U0U$F3TvW*O`9ltnb0rjOk1m0 zzQ6Zo$Oh4^V7!mf`{D`h#{^gEJ~=xJ&X^2Qj-n5KI zP)upfgGs6V$MUMPc{@UciEt5--fxb;fScpC($_Z)=+JzipQbg?%`ylhuU& zzNokvOxxI0cSndt5T!f_m(bmqIBuTB`x=isge?!mD40>{OtEy$&f*3tG6?L=li0!> z+Ns^*Soyq<_Z@PVAg^ql_}!M{m_D8dYSg1+l^uu<++D1T;*8>r7J9os&6l0%f*`+z8twQ&D?ow2c_&>-N|3 zV*8_+3!axth$8_vAQiJ>>`iA-WfZPK)Ty(o`D$ls_grPm#Ux;uS4+BFWt8!^?l=Q; zTx(WJR=!y6z|(hELPlw5TQ0{LWngmZ2`K6j&i(0rhtR|Eb?w&TYXxpO2$@5>?91+l zg{xj#WvkswX{E4Wqs_O3``(<|j1;zqZ8|HD22QK>-HKMdy4{n2-FR6ySLY?ioRxUg&sK(?}5)sqe3g7)_8iF$`0=%EeNp93($9 z?PXTu9Pz{X6D7KF8CN7lgx+{>$NVPG5Xm_}9nFBpa_V@6Hk~-_eDn*mcyK>CJOkQ+DYh1z({#oZk zBcQxE4=**!feoWl`0i(d z96bq$4>(mInJH!Yx}TMvET7649P0d__$**cLb;J8YtF^QA@_xKZ;Q#E*c8L4m}8m)WBbk4wBkbC&zDy)-*+XBH=8gKJ#cMPI!=l+qkHRV^=W|yd_ zSyjl_Op}#){Ij)CE(8)^^SGXzULax2R5nA?wS-+tmY*u}i?kdb1xjLW?q@YHYwVBj z7`m>lR&e-?wS)2H?*{U@-6T4OO{KoN7&o4w=%}um-4)-=Ru0#BF2}q@yDu^6S#IR5 zv}Xvv(FJJ|JfA+IrjdaA!BY7`NQ4?@!-8uN=3<*TP#Dhq>t{MI&_LvT;?k%@caO!p zI{5U9zDy#nB=(PJb=|4d)yT@xqdARXz-x}!JS(|?#jLbJC~s5{HE!N{mAAqq?C*?j zN;6#zLHxMittfM9l3){dwLO}AKgcO|=#I;jrfqnh1}?sJut9(3Sc?S5%ed_Lyo|zg z5}gB`g`ZBO;!cwF>py94xC^*v>_(XW*!V1dooV~!_skL8f_65IFhnA|7+vWjGb!<# zr83vaW`XZnRX>EUn!V|aEh($c>&G7t__grrN1<7VKOFJCiYZ}Y>)3-xVf^RL$%{x8 zu>W(nZNX}LHPnE0;rdQ^OfjK?(1E~}_>U7WAs_0mNXGhvdK!z0Y_tXV`ZQ<5kv(=M zbA!W?NZ~nuZSzH>fnT)=?DI^8iG+a1MiFQNR-s^oj1{a+ZsYlhrXmksRc!mwVk!Kt z!ygo~GplVDe2rQoZExrqMb~g;tG|Z?+9YRu=?Ug$E3BS1J)DyU)DFnK4DvOn$m3kl zn3Y$poa4EZcP6DZ9JK#(6klGP&^pfkVw(IWeW5RMn8}p=&`AmNF^G)AMB!)~5C~^Bt_X<5w1j)k6OJyKxMA zqtqO1QXO{7H!w@-*%M~JufEE?{A3oxDhmd62xD~NCfRv=y4&Io-Z-&9N$x~%+`Zge z$hyV#C2>v>wW?OQI?jB)d^H`B|A2qkS--;7{LlXd0mDF05qdMX4^-f4xvq)& zNtLE=LVqdNiu0a@zmHJAV2e3@F9wRZX*Uw%Kv%B6hl?;WF5+lPA;VMtFCzX0+hxwN literal 0 HcmV?d00001 diff --git a/docs/detection-rotation-clone.md b/docs/detection-rotation-clone.md new file mode 100644 index 0000000..7835f3e --- /dev/null +++ b/docs/detection-rotation-clone.md @@ -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 min–24 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 (t−W, 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 → 0–100 +``` + +### 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, 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 · `40–69` WATCHING (soft) · `70–84` 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 +> ~3–5 min, seen ≥3× across a location change) **and** the **churn path** (this +doc; catches novel-seamless rates above ~0.5–1/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 | med–high | +| 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 → List`; 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 ≤ 10–15 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 ~10–15 +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 diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..39b65de --- /dev/null +++ b/gradle.properties @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..944983c --- /dev/null +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d997cfc60f4cff0e7451d19d49a82fa986695d07 GIT binary patch literal 48966 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

`Lem|V$A>eR0uN8Y&sdjtu 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

1bvi{DS7E@q1{$_8->K{_OWv2 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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..739907d --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e509b2d --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/local.properties.example b/local.properties.example new file mode 100644 index 0000000..42a0ff1 --- /dev/null +++ b/local.properties.example @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..7f8e554 --- /dev/null +++ b/settings.gradle.kts @@ -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")