Implement rotation-clone presence engine (#1) + unit tests + CI tests
Build APK / build (push) Waiting to run
Build APK / build (push) Waiting to run
- detect/PresenceEngine.kt: identity-agnostic streaming detector for key-rotating clones (novel-churn gate, seamless-handover chain, co-movement gate, coherence). Full design in docs/detection-rotation-clone.md. - Wired into ScanService as a parallel signal; CONFIRMED verdict raises a distinct rotating-tracker alert (softer tiers stay silent pending real-capture validation). - PresenceEngineTest: synthetic clone (moving -> CONFIRMED), stationary clone (gated), single real tracker + far-ambient (CLEAR). - CI now runs :app:testDebugUnitTest so the engine has behavioural verification, not just a compile check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
This commit is contained in:
@@ -26,9 +26,13 @@ jobs:
|
|||||||
- name: Set up Android SDK
|
- name: Set up Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
- name: Build debug APK
|
- name: Run unit tests
|
||||||
run: |
|
run: |
|
||||||
chmod +x ./gradlew
|
chmod +x ./gradlew
|
||||||
|
./gradlew :app:testDebugUnitTest --stacktrace --no-daemon
|
||||||
|
|
||||||
|
- name: Build debug APK
|
||||||
|
run: |
|
||||||
./gradlew :app:assembleDebug --stacktrace --no-daemon
|
./gradlew :app:assembleDebug --stacktrace --no-daemon
|
||||||
|
|
||||||
- name: Stage APK
|
- name: Stage APK
|
||||||
|
|||||||
@@ -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,
|
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 —
|
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)**.
|
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
|
## Architecture
|
||||||
|
|
||||||
@@ -127,10 +129,10 @@ an APK to a GitHub Release on `v*` tags.
|
|||||||
## Status
|
## Status
|
||||||
|
|
||||||
Prototype. In place: BLE scan + filters, per-ecosystem parsing, Room temporal
|
Prototype. In place: BLE scan + filters, per-ecosystem parsing, Room temporal
|
||||||
store, co-movement evaluator with RSSI gate, allowlist, learned baseline,
|
store, co-movement evaluator with RSSI gate, allowlist, learned baseline, the
|
||||||
foreground service, Compose UI. **Not yet:** the rotation-clone presence engine
|
rotation-clone presence engine (v1, unit-tested), foreground service, Compose UI.
|
||||||
(designed, not wired), GATT play-sound / DULT get-identifier, and empirical
|
**Not yet:** field-tuning the clone engine on real captures, GATT play-sound /
|
||||||
validation. Before trusting the parser, capture real devices with nRF Connect —
|
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
|
the SmartTag2 offsets are inferred from gen-1, and the Tile/AirTag reversing is a
|
||||||
couple of years old.
|
couple of years old.
|
||||||
|
|
||||||
|
|||||||
@@ -79,5 +79,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.room.ktx)
|
implementation(libs.androidx.room.ktx)
|
||||||
ksp(libs.androidx.room.compiler)
|
ksp(libs.androidx.room.compiler)
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String>()
|
||||||
|
private val seenOrder = ArrayDeque<Pair<String, Long>>()
|
||||||
|
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<String>()
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ import org.soulstone.vigil.data.db.TrackerEntity
|
|||||||
import org.soulstone.vigil.data.db.VigilDatabase
|
import org.soulstone.vigil.data.db.VigilDatabase
|
||||||
import org.soulstone.vigil.data.location.LocationProvider
|
import org.soulstone.vigil.data.location.LocationProvider
|
||||||
import org.soulstone.vigil.data.settings.Settings
|
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.model.TrackerObservation
|
||||||
import org.soulstone.vigil.scan.BleTrackerScanner
|
import org.soulstone.vigil.scan.BleTrackerScanner
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ class ScanService : LifecycleService() {
|
|||||||
private const val CHANNEL_ID = "vigil_watch"
|
private const val CHANNEL_ID = "vigil_watch"
|
||||||
private const val NOTIFICATION_ID = 0x5161 // "VIGIL"
|
private const val NOTIFICATION_ID = 0x5161 // "VIGIL"
|
||||||
private const val PRUNE_INTERVAL_MS = 6 * 3_600_000L
|
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_START = "org.soulstone.vigil.action.START"
|
||||||
const val ACTION_STOP = "org.soulstone.vigil.action.STOP"
|
const val ACTION_STOP = "org.soulstone.vigil.action.STOP"
|
||||||
@@ -71,6 +74,8 @@ class ScanService : LifecycleService() {
|
|||||||
private lateinit var repo: TrackerRepository
|
private lateinit var repo: TrackerRepository
|
||||||
private lateinit var location: LocationProvider
|
private lateinit var location: LocationProvider
|
||||||
private lateinit var scanner: BleTrackerScanner
|
private lateinit var scanner: BleTrackerScanner
|
||||||
|
private val presence = PresenceEngine()
|
||||||
|
private var lastCloneAlertMs = 0L
|
||||||
private var pruneJob: Job? = null
|
private var pruneJob: Job? = null
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
@@ -93,6 +98,7 @@ class ScanService : LifecycleService() {
|
|||||||
|
|
||||||
private fun begin() {
|
private fun begin() {
|
||||||
if (_running.value) return
|
if (_running.value) return
|
||||||
|
presence.reset()
|
||||||
startInForeground()
|
startInForeground()
|
||||||
location.start() // best-effort; scanning still runs without a fix (just no co-movement)
|
location.start() // best-effort; scanning still runs without a fix (just no co-movement)
|
||||||
if (!scanner.start()) {
|
if (!scanner.start()) {
|
||||||
@@ -110,8 +116,23 @@ class ScanService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun onObservation(obs: TrackerObservation) {
|
private fun onObservation(obs: TrackerObservation) {
|
||||||
lifecycleScope.launch {
|
|
||||||
val fix = location.location.value
|
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 result = runCatching {
|
val result = runCatching {
|
||||||
repo.record(obs, fix?.latitude, fix?.longitude, settings.sensitivity.value)
|
repo.record(obs, fix?.latitude, fix?.longitude, settings.sensitivity.value)
|
||||||
}.getOrNull() ?: return@launch
|
}.getOrNull() ?: return@launch
|
||||||
@@ -163,6 +184,17 @@ class ScanService : LifecycleService() {
|
|||||||
Log.w(TAG, "ALERT: ${tracker.stableId} (${tracker.ecosystem})")
|
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() {
|
private fun vibrate() {
|
||||||
val v = currentVibrator() ?: return
|
val v = currentVibrator() ?: return
|
||||||
val effect = VibrationEffect.createWaveform(longArrayOf(0, 250, 120, 250, 120, 400), -1)
|
val effect = VibrationEffect.createWaveform(longArrayOf(0, 250, 120, 250, 120, 400), -1)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ composeBom = "2024.12.01"
|
|||||||
material3 = "1.3.1"
|
material3 = "1.3.1"
|
||||||
playServicesLocation = "21.3.0"
|
playServicesLocation = "21.3.0"
|
||||||
room = "2.6.1"
|
room = "2.6.1"
|
||||||
|
junit = "4.13.2"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||||
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", 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" }
|
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||||
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
+4
-3
@@ -407,7 +407,7 @@ data/db/VigilDatabase.kt Room: trackers, 14-day sightings, baseline places
|
|||||||
data/TrackerRepository.kt ingest + baseline + evaluate on each sighting
|
data/TrackerRepository.kt ingest + baseline + evaluate on each sighting
|
||||||
detect/CoMovementEvaluator the co-movement test + RSSI proximity gate
|
detect/CoMovementEvaluator the co-movement test + RSSI proximity gate
|
||||||
detect/BaselineManager.kt anchor-place learning -> auto-trust household tags
|
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
|
**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.
|
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
|
*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
|
engine (§5) is specified in `docs/detection-rotation-clone.md` and implemented as a
|
||||||
but not yet wired into the shipping build.*
|
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.*
|
||||||
|
|||||||
Reference in New Issue
Block a user