diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2b2feb7..3966e1c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,9 +26,13 @@ jobs: - name: Set up Android SDK uses: android-actions/setup-android@v3 - - name: Build debug APK + - name: Run unit tests run: | chmod +x ./gradlew + ./gradlew :app:testDebugUnitTest --stacktrace --no-daemon + + - name: Build debug APK + run: | ./gradlew :app:assembleDebug --stacktrace --no-daemon - name: Stage APK diff --git a/README.md b/README.md index 947a59b..69e10ba 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,9 @@ 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. +It's now implemented as a first version in `detect/PresenceEngine.kt` (unit-tested +against synthetic clone/ambient traces) and wired into the scan service to raise a +distinct rotating-tracker alert; field-tuning against real captures is what remains. ## Architecture @@ -127,10 +129,10 @@ 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 — +store, co-movement evaluator with RSSI gate, allowlist, learned baseline, the +rotation-clone presence engine (v1, unit-tested), foreground service, Compose UI. +**Not yet:** field-tuning the clone engine on real captures, 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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8b377db..3af94ee 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -79,5 +79,7 @@ dependencies { implementation(libs.androidx.room.ktx) ksp(libs.androidx.room.compiler) + testImplementation(libs.junit) + debugImplementation(libs.androidx.compose.ui.tooling) } diff --git a/app/src/main/kotlin/org/soulstone/vigil/detect/PresenceEngine.kt b/app/src/main/kotlin/org/soulstone/vigil/detect/PresenceEngine.kt new file mode 100644 index 0000000..1e1d0fa --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/vigil/detect/PresenceEngine.kt @@ -0,0 +1,157 @@ +package org.soulstone.vigil.detect + +import org.soulstone.vigil.util.Geohash +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.roundToInt +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * The rotation-clone detector (problem #1) — the thing AirGuard and the platform + * detectors structurally cannot do. It ignores device identity and instead tracks + * whether ONE physical radio maintains an unbroken, close-range, co-moving RF + * *presence* even as its advertised identity churns thousands of times faster than + * any DULT-compliant tracker is allowed to (24h separated rotation). + * + * Streaming and pure-ish: feed separated-state sightings in time order via + * [onSighting]; it returns a [Verdict]. Session-scoped — call [reset] on start. + * Full design + rationale: docs/detection-rotation-clone.md. + * + * Key discriminator: the **seamless novel-handover chain** — a single radio whose + * id changes yields handovers into *novel* ids with no RSSI discontinuity and no + * time gap; a crowd of many radios yields gappy, RSSI-incoherent handovers. Novel + * churn is a *gate*, not just a score term: without it this is not a rotating + * clone and the (separate) identity detector owns the case. + */ +class PresenceEngine { + + enum class Tier { CLEAR, WATCHING, PROBABLE, CONFIRMED } + + data class Verdict( + val score: Int, + val tier: Tier, + val seamlessNovelHandovers: Int, + val distinctCells: Int, + val netMeters: Double, + val novelIds: Int + ) + + companion object { + const val R_CLOSE = -65 // dBm; the on-body/in-bag close band + const val G_BREAK_MS = 20_000L // gap that ends a presence run + const val G_SEAM_MS = 6_000L // handover must be near-immediate to be "seamless" + const val EPS_RSSI = 8.0 // dB; one radio's step-to-step RSSI + const val ALPHA = 0.3 // RSSI EWMA factor + const val NOVELTY_HORIZON_MS = 30 * 60_000L + const val D_NET_M = 500.0 // co-movement: net displacement + const val K_CELLS = 3 // co-movement: distinct geohash-7 cells + const val T_MIN_MIN = 10.0 // dwell for full duration credit + const val N_HANDOVER = 12 // seamless novel handovers for full credit / gate + const val RHO_NOVEL_PER_MIN = 0.7 // min sustained novel-seamless rate + } + + private val seen = HashSet() + private val seenOrder = ArrayDeque>() + private var novelCount = 0 + + private var active = false + private var startTs = 0L + private var lastTs = 0L + private var lastId: String? = null + private var ewmaRssi = 0.0 + private var ewmaAbsResid = 0.0 + private var seamlessNovel = 0 + private val cells = HashSet() + private var startLat = 0.0 + private var startLon = 0.0 + private var haveStart = false + private var netMeters = 0.0 + + @Synchronized + fun reset() { + seen.clear(); seenOrder.clear(); novelCount = 0; endRun() + } + + @Synchronized + fun onSighting(id: String, rssi: Int, ts: Long, lat: Double?, lon: Double?): Verdict { + // Novelty bookkeeping over the horizon. + while (seenOrder.isNotEmpty() && ts - seenOrder.first().second > NOVELTY_HORIZON_MS) { + seen.remove(seenOrder.removeFirst().first) + } + val novel = id !in seen + if (novel) { seen.add(id); seenOrder.addLast(id to ts); novelCount++ } + + // Only the close band builds a presence run. + if (rssi < R_CLOSE) return verdict() + if (!active || ts - lastTs > G_BREAK_MS) { startRun(id, rssi, ts, lat, lon); return verdict() } + + val resid = rssi - ewmaRssi + if (id != lastId) { + if (abs(resid) <= EPS_RSSI && (ts - lastTs) <= G_SEAM_MS && novel) seamlessNovel++ + } + ewmaRssi = ALPHA * rssi + (1 - ALPHA) * ewmaRssi + ewmaAbsResid = ALPHA * abs(resid) + (1 - ALPHA) * ewmaAbsResid + lastTs = ts; lastId = id + if (lat != null && lon != null) { + cells.add(Geohash.encode(lat, lon, 7)) + if (haveStart) netMeters = haversineMeters(startLat, startLon, lat, lon) + } + return verdict() + } + + private fun startRun(id: String, rssi: Int, ts: Long, lat: Double?, lon: Double?) { + active = true; startTs = ts; lastTs = ts; lastId = id + ewmaRssi = rssi.toDouble(); ewmaAbsResid = 0.0; seamlessNovel = 0 + cells.clear(); netMeters = 0.0 + if (lat != null && lon != null) { + startLat = lat; startLon = lon; haveStart = true; cells.add(Geohash.encode(lat, lon, 7)) + } else haveStart = false + } + + private fun endRun() { + active = false; lastId = null; seamlessNovel = 0; cells.clear(); netMeters = 0.0; haveStart = false + } + + private fun verdict(): Verdict { + if (!active) return Verdict(0, Tier.CLEAR, 0, 0, 0.0, novelCount) + val durMin = (lastTs - startTs) / 60_000.0 + + // Novel-churn is a GATE: no impossible rotation rate => not a clone, the + // identity detector owns it. + val churnAnom = durMin > 0 && seamlessNovel >= N_HANDOVER && + (seamlessNovel / durMin) >= RHO_NOVEL_PER_MIN + if (!churnAnom) return Verdict(0, Tier.CLEAR, seamlessNovel, cells.size, netMeters, novelCount) + + val comoves = netMeters >= D_NET_M && cells.size >= K_CELLS + if (!comoves) { + // Impossible churn but co-movement not yet established (e.g. dense area) — soft only. + return Verdict(50, Tier.WATCHING, seamlessNovel, cells.size, netMeters, novelCount) + } + + val coherent = ewmaAbsResid <= EPS_RSSI + var score = 0.0 + score += (durMin / T_MIN_MIN).coerceIn(0.0, 1.0) * 30 + score += (seamlessNovel.toDouble() / N_HANDOVER).coerceIn(0.0, 1.0) * 30 + score += (cells.size.toDouble() / (2.0 * K_CELLS)).coerceIn(0.0, 1.0) * 20 + score += (if (coherent) 1.0 else 0.4) * 20 + val s = score.roundToInt() + val tier = when { + s >= 85 -> Tier.CONFIRMED + s >= 70 -> Tier.PROBABLE + s >= 40 -> Tier.WATCHING + else -> Tier.CLEAR + } + return Verdict(s, tier, seamlessNovel, cells.size, netMeters, novelCount) + } +} + +private fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val r = 6_371_000.0 + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + val a = sin(dLat / 2) * sin(dLat / 2) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2) * sin(dLon / 2) + return r * 2 * atan2(sqrt(a), sqrt(1 - a)) +} diff --git a/app/src/main/kotlin/org/soulstone/vigil/service/ScanService.kt b/app/src/main/kotlin/org/soulstone/vigil/service/ScanService.kt index b166ee1..11e8d35 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/service/ScanService.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/service/ScanService.kt @@ -28,6 +28,8 @@ 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.detect.PresenceEngine +import org.soulstone.vigil.model.SeparatedState import org.soulstone.vigil.model.TrackerObservation import org.soulstone.vigil.scan.BleTrackerScanner @@ -46,6 +48,7 @@ class ScanService : LifecycleService() { private const val CHANNEL_ID = "vigil_watch" private const val NOTIFICATION_ID = 0x5161 // "VIGIL" private const val PRUNE_INTERVAL_MS = 6 * 3_600_000L + private const val CLONE_COOLDOWN_MS = 2 * 3_600_000L const val ACTION_START = "org.soulstone.vigil.action.START" const val ACTION_STOP = "org.soulstone.vigil.action.STOP" @@ -71,6 +74,8 @@ class ScanService : LifecycleService() { private lateinit var repo: TrackerRepository private lateinit var location: LocationProvider private lateinit var scanner: BleTrackerScanner + private val presence = PresenceEngine() + private var lastCloneAlertMs = 0L private var pruneJob: Job? = null override fun onCreate() { @@ -93,6 +98,7 @@ class ScanService : LifecycleService() { private fun begin() { if (_running.value) return + presence.reset() startInForeground() location.start() // best-effort; scanning still runs without a fix (just no co-movement) if (!scanner.start()) { @@ -110,8 +116,23 @@ class ScanService : LifecycleService() { } private fun onObservation(obs: TrackerObservation) { + val fix = location.location.value + + // Rotation-clone presence engine (problem #1) — fed synchronously so its + // streaming state stays ordered. Only a CONFIRMED verdict raises a user + // alert; softer tiers stay silent until validated on real captures. + if (obs.separated == SeparatedState.SEPARATED) { + val v = presence.onSighting(obs.stableId, obs.rssi, obs.timestampMs, fix?.latitude, fix?.longitude) + if (v.tier == PresenceEngine.Tier.CONFIRMED && + obs.timestampMs - lastCloneAlertMs > CLONE_COOLDOWN_MS + ) { + lastCloneAlertMs = obs.timestampMs + raiseCloneAlert() + } + } + + // Temporal co-movement (identity path) — persisted + evaluated off-thread. lifecycleScope.launch { - val fix = location.location.value val result = runCatching { repo.record(obs, fix?.latitude, fix?.longitude, settings.sensitivity.value) }.getOrNull() ?: return@launch @@ -163,6 +184,17 @@ class ScanService : LifecycleService() { Log.w(TAG, "ALERT: ${tracker.stableId} (${tracker.ecosystem})") } + private fun raiseCloneAlert() { + val n = buildNotification( + title = "Possible hidden tracker", + text = "A rotating-ID tracker appears to be moving with you", + high = true + ) + getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID + 1, n) + vibrate() + Log.w(TAG, "CLONE ALERT: rotating-id presence confirmed") + } + private fun vibrate() { val v = currentVibrator() ?: return val effect = VibrationEffect.createWaveform(longArrayOf(0, 250, 120, 250, 120, 400), -1) diff --git a/app/src/test/kotlin/org/soulstone/vigil/detect/PresenceEngineTest.kt b/app/src/test/kotlin/org/soulstone/vigil/detect/PresenceEngineTest.kt new file mode 100644 index 0000000..ce1a604 --- /dev/null +++ b/app/src/test/kotlin/org/soulstone/vigil/detect/PresenceEngineTest.kt @@ -0,0 +1,82 @@ +package org.soulstone.vigil.detect + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Behavioural tests for the rotation-clone [PresenceEngine], driven by synthetic + * traces standing in for the record/replay harness (design doc §5). Trackers + * advertise every ~2 s; a rotating clone changes its advertised id every 30 s. + */ +class PresenceEngineTest { + + private val stepMs = 2_000L + private val steps = 900 // 30 min at 2 s + private val cloneRotationMs = 30_000L // Find You default + + private fun maxTier( + idAt: (Int) -> String, + rssiAt: (Int) -> Int, + latAt: (Int) -> Double?, + lonAt: (Int) -> Double? + ): PresenceEngine.Tier { + val engine = PresenceEngine() + engine.reset() + var max = PresenceEngine.Tier.CLEAR + for (i in 0 until steps) { + val v = engine.onSighting(idAt(i), rssiAt(i), i * stepMs, latAt(i), lonAt(i)) + if (v.tier.ordinal > max.ordinal) max = v.tier + } + return max + } + + // A rotating clone that moves with the victim must be CONFIRMED. + @Test + fun cloneMovingIsConfirmed() { + val tier = maxTier( + idAt = { i -> "clone-${(i * stepMs) / cloneRotationMs}" }, // new id every 30 s + rssiAt = { i -> -55 + (i % 3 - 1) }, // close, coherent + latAt = { 40.0 }, + lonAt = { i -> -74.0 + i * 0.00003 } // marches ~2.4 km + ) + assertEquals(PresenceEngine.Tier.CONFIRMED, tier) + } + + // Same rotating churn but stationary: co-movement gate must hold it below CONFIRMED. + @Test + fun cloneStationaryIsNotConfirmed() { + val tier = maxTier( + idAt = { i -> "clone-${(i * stepMs) / cloneRotationMs}" }, + rssiAt = { i -> -55 + (i % 3 - 1) }, + latAt = { 40.0 }, + lonAt = { -74.0 } // fixed => no co-movement + ) + assertTrue("stationary clone should not confirm, was $tier", tier.ordinal < PresenceEngine.Tier.CONFIRMED.ordinal) + } + + // A single non-rotating tracker moving with you is NOT the presence engine's job + // (the identity detector owns it); the churn gate must keep this CLEAR. + @Test + fun singleRealTrackerIsClear() { + val tier = maxTier( + idAt = { "realtag" }, // one id, never rotates + rssiAt = { i -> -55 + (i % 3 - 1) }, + latAt = { 40.0 }, + lonAt = { i -> -74.0 + i * 0.00003 } + ) + assertEquals(PresenceEngine.Tier.CLEAR, tier) + } + + // Lots of novel ids but always far (out of the close band): no presence, CLEAR. + @Test + fun ambientFarIsClear() { + val tier = maxTier( + idAt = { i -> "ambient-$i" }, // novel every time + rssiAt = { -85 }, // below the close band + latAt = { i -> 40.0 + i * 0.00003 }, + lonAt = { -74.0 } + ) + assertEquals(PresenceEngine.Tier.CLEAR, tier) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e88e191..061f1d6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ composeBom = "2024.12.01" material3 = "1.3.1" playServicesLocation = "21.3.0" room = "2.6.1" +junit = "4.13.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -26,6 +27,7 @@ play-services-location = { group = "com.google.android.gms", name = "play-servic 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" } +junit = { group = "junit", name = "junit", version.ref = "junit" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/paper/vigil.md b/paper/vigil.md index c8981df..107fd39 100644 --- a/paper/vigil.md +++ b/paper/vigil.md @@ -407,7 +407,7 @@ data/db/VigilDatabase.kt Room: trackers, 14-day sightings, baseline places data/TrackerRepository.kt ingest + baseline + evaluate on each sighting detect/CoMovementEvaluator the co-movement test + RSSI proximity gate detect/BaselineManager.kt anchor-place learning -> auto-trust household tags -detect/PresenceEngine.kt (next) the rotation-clone engine of §5 +detect/PresenceEngine.kt the rotation-clone engine of §5 (v1, unit-tested) ``` **Privacy properties.** The manifest declares **no `INTERNET` permission**; VIGIL @@ -546,5 +546,6 @@ prototype and an open design, and invite the empirical evaluation §7 lays out. 15. M. Datar, A. Gionis, P. Indyk, R. Motwani. "Maintaining Stream Statistics over Sliding Windows." *SODA* 2002. *This working paper accompanies the VIGIL reference implementation. The rotation-clone -engine (§5) is fully specified in `docs/detection-rotation-clone.md` and is designed -but not yet wired into the shipping build.* +engine (§5) is specified in `docs/detection-rotation-clone.md` and implemented as a +first version in `detect/PresenceEngine.kt` (unit-tested against synthetic clone/ambient +traces) and wired into the scan service; field-tuning against real captures remains.*