Bugfix/logical-flaw sweep: counting, races, baseline, pruning, RSSI
Root cause of the non-monotonic sighting count (60->29->63): MAC-rotating ecosystems mint a new tracker row per rotation, tracker rows were never pruned, and the UI re-sorts constantly, so different rows' counts read as one sequence. Compounded by no ingestion throttle and a lost-update race. Fixes: - ScanService: throttle the persisted path to <=1 sighting/device/15s (presence engine still sees every advert). Stops count inflation + DB hammering. @Volatile clone-alert ts; clear throttle map on stop. - TrackerRepository: Mutex around record() (was a lost-update race: concurrent observations did get()->compute->upsert() with no lock). Always compute the co-movement assessment for display. - VigilDatabase: prune stale non-approved tracker rows (were never deleted); +lastRssi/peakRssi/distinctPlaces/effectiveSightings for grounding; version 2. - BaselineManager: count DWELL (one visit per 10-min window), not per-sighting. Old per-sighting count let a busy street or chatty tracker mint a fake 'home' in seconds and auto-trust a real stalker — a safety flaw. - BleTrackerScanner: Apple filter now matches only Find My type 0x12, not every Apple device (iPhones/Watches/AirPods). - UI: show distinct places + last RSSI on cards; detail sheet shows last/peak RSSI, distinct places, co-movement sightings, adverts logged; Active list filters to devices seen in the last 10 min so rotated identities drop off. - Tests: CoMovementEvaluatorTest locks dedup, the RSSI gate, and the place/separated thresholds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
package org.soulstone.vigil.data
|
package org.soulstone.vigil.data
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import org.soulstone.vigil.data.db.SightingEntity
|
import org.soulstone.vigil.data.db.SightingEntity
|
||||||
import org.soulstone.vigil.data.db.TrackerEntity
|
import org.soulstone.vigil.data.db.TrackerEntity
|
||||||
import org.soulstone.vigil.data.db.VigilDatabase
|
import org.soulstone.vigil.data.db.VigilDatabase
|
||||||
@@ -18,6 +20,10 @@ import org.soulstone.vigil.util.Geohash
|
|||||||
*/
|
*/
|
||||||
class TrackerRepository(private val db: VigilDatabase) {
|
class TrackerRepository(private val db: VigilDatabase) {
|
||||||
|
|
||||||
|
// Serialises record() so the read-modify-write of a tracker row is atomic —
|
||||||
|
// concurrent observations were racing and losing count / first-seen updates.
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
data class RecordResult(val tracker: TrackerEntity, val newlyAlerting: Boolean)
|
data class RecordResult(val tracker: TrackerEntity, val newlyAlerting: Boolean)
|
||||||
|
|
||||||
fun observeTrackers(): Flow<List<TrackerEntity>> = db.trackerDao().observeAll()
|
fun observeTrackers(): Flow<List<TrackerEntity>> = db.trackerDao().observeAll()
|
||||||
@@ -26,7 +32,9 @@ class TrackerRepository(private val db: VigilDatabase) {
|
|||||||
db.trackerDao().setApproved(id, approved)
|
db.trackerDao().setApproved(id, approved)
|
||||||
|
|
||||||
suspend fun prune(retentionDays: Int = RETENTION_DAYS) {
|
suspend fun prune(retentionDays: Int = RETENTION_DAYS) {
|
||||||
db.sightingDao().prune(System.currentTimeMillis() - retentionDays * BaselineManager.DAY_MS)
|
val cutoff = System.currentTimeMillis() - retentionDays * BaselineManager.DAY_MS
|
||||||
|
db.sightingDao().prune(cutoff)
|
||||||
|
db.trackerDao().pruneStale(cutoff)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,7 +46,7 @@ class TrackerRepository(private val db: VigilDatabase) {
|
|||||||
lat: Double?,
|
lat: Double?,
|
||||||
lon: Double?,
|
lon: Double?,
|
||||||
sensitivity: Sensitivity
|
sensitivity: Sensitivity
|
||||||
): RecordResult {
|
): RecordResult = mutex.withLock {
|
||||||
val now = obs.timestampMs
|
val now = obs.timestampMs
|
||||||
val geohash7 = if (lat != null && lon != null) Geohash.encode(lat, lon, 7) else null
|
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
|
val geohash6 = if (lat != null && lon != null) Geohash.encode(lat, lon, 6) else null
|
||||||
@@ -73,16 +81,13 @@ class TrackerRepository(private val db: VigilDatabase) {
|
|||||||
|
|
||||||
val approved = existing?.approved ?: false
|
val approved = existing?.approved ?: false
|
||||||
|
|
||||||
// --- co-movement evaluation (skipped for trusted tags) ---------------
|
// Always compute the co-movement assessment so the UI can show grounded
|
||||||
val riskState: RiskState
|
// evidence; only *escalation* is suppressed for trusted (approved/baseline) tags.
|
||||||
if (approved || baselineSafe) {
|
val since = now - CoMovementEvaluator.WINDOW_MS
|
||||||
riskState = RiskState.OBSERVED
|
val recent = db.sightingDao().recentFor(obs.stableId, since)
|
||||||
} else {
|
val t = CoMovementEvaluator.thresholdsFor(sensitivity)
|
||||||
val since = now - CoMovementEvaluator.WINDOW_MS
|
val assessment = CoMovementEvaluator.evaluate(recent, obs.ecosystem, t)
|
||||||
val recent = db.sightingDao().recentFor(obs.stableId, since)
|
val riskState = if (approved || baselineSafe) RiskState.OBSERVED else assessment.riskState
|
||||||
val t = CoMovementEvaluator.thresholdsFor(sensitivity)
|
|
||||||
riskState = CoMovementEvaluator.evaluate(recent, obs.ecosystem, t).riskState
|
|
||||||
}
|
|
||||||
|
|
||||||
val wasAlerting = existing?.riskState == RiskState.ALERTING.name
|
val wasAlerting = existing?.riskState == RiskState.ALERTING.name
|
||||||
val cooldownOk = now - (existing?.lastAlertMs ?: 0) > CoMovementEvaluator.ALERT_COOLDOWN_MS
|
val cooldownOk = now - (existing?.lastAlertMs ?: 0) > CoMovementEvaluator.ALERT_COOLDOWN_MS
|
||||||
@@ -100,10 +105,14 @@ class TrackerRepository(private val db: VigilDatabase) {
|
|||||||
baselineSafe = baselineSafe,
|
baselineSafe = baselineSafe,
|
||||||
lastAlertMs = if (newlyAlerting) now else (existing?.lastAlertMs ?: 0),
|
lastAlertMs = if (newlyAlerting) now else (existing?.lastAlertMs ?: 0),
|
||||||
lastAnchorDay = lastAnchorDay,
|
lastAnchorDay = lastAnchorDay,
|
||||||
anchorDayCount = anchorDayCount
|
anchorDayCount = anchorDayCount,
|
||||||
|
lastRssi = obs.rssi,
|
||||||
|
peakRssi = maxOf(existing?.peakRssi ?: -127, obs.rssi),
|
||||||
|
distinctPlaces = assessment.distinctPlaces,
|
||||||
|
effectiveSightings = assessment.sightings
|
||||||
)
|
)
|
||||||
db.trackerDao().upsert(updated)
|
db.trackerDao().upsert(updated)
|
||||||
return RecordResult(updated, newlyAlerting)
|
RecordResult(updated, newlyAlerting)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ data class TrackerEntity(
|
|||||||
val lastAlertMs: Long = 0,
|
val lastAlertMs: Long = 0,
|
||||||
// baseline accounting: distinct calendar-days this tracker was seen at an anchor place
|
// baseline accounting: distinct calendar-days this tracker was seen at an anchor place
|
||||||
val lastAnchorDay: Long = -1,
|
val lastAnchorDay: Long = -1,
|
||||||
val anchorDayCount: Int = 0
|
val anchorDayCount: Int = 0,
|
||||||
|
// display / grounding: latest + peak RSSI and the current co-movement evidence
|
||||||
|
val lastRssi: Int = 0,
|
||||||
|
val peakRssi: Int = -127,
|
||||||
|
val distinctPlaces: Int = 0,
|
||||||
|
val effectiveSightings: Int = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
/** One sighting of a tracker at one instant, geotagged when a fix is available. */
|
/** One sighting of a tracker at one instant, geotagged when a fix is available. */
|
||||||
@@ -74,6 +79,11 @@ interface TrackerDao {
|
|||||||
|
|
||||||
@Query("UPDATE trackers SET approved = :approved WHERE stableId = :id")
|
@Query("UPDATE trackers SET approved = :approved WHERE stableId = :id")
|
||||||
suspend fun setApproved(id: String, approved: Boolean)
|
suspend fun setApproved(id: String, approved: Boolean)
|
||||||
|
|
||||||
|
// Rotated identities pile up (MAC rotation mints a new row each rotation); drop
|
||||||
|
// stale, non-approved rows so the table and the UI list don't grow without bound.
|
||||||
|
@Query("DELETE FROM trackers WHERE lastSeen < :cutoff AND approved = 0")
|
||||||
|
suspend fun pruneStale(cutoff: Long)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
@@ -99,7 +109,7 @@ interface PlaceDao {
|
|||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [TrackerEntity::class, SightingEntity::class, PlaceEntity::class],
|
entities = [TrackerEntity::class, SightingEntity::class, PlaceEntity::class],
|
||||||
version = 1,
|
version = 2,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class VigilDatabase : RoomDatabase() {
|
abstract class VigilDatabase : RoomDatabase() {
|
||||||
|
|||||||
@@ -18,23 +18,29 @@ import org.soulstone.vigil.data.db.PlaceEntity
|
|||||||
object BaselineManager {
|
object BaselineManager {
|
||||||
|
|
||||||
const val DAY_MS = 86_400_000L
|
const val DAY_MS = 86_400_000L
|
||||||
const val ANCHOR_MIN_VISITS = 60
|
const val DWELL_BUCKET_MS = 10 * 60_000L // count at most one "visit" per 10-min dwell window
|
||||||
|
const val ANCHOR_MIN_VISITS = 18 // ~3h cumulative dwell before a cell counts as home/work
|
||||||
const val BASELINE_MIN_DAYS = 3
|
const val BASELINE_MIN_DAYS = 3
|
||||||
|
|
||||||
/** Record a location fix in its cell; returns whether that cell is now an anchor. */
|
/**
|
||||||
|
* Note presence in a cell; returns whether it's an anchor. Crucially a cell
|
||||||
|
* accrues at most ONE visit per [DWELL_BUCKET_MS], so the anchor signal tracks
|
||||||
|
* time actually spent there — not how many trackers or adverts were seen.
|
||||||
|
*
|
||||||
|
* The previous per-sighting count was a real safety flaw: a busy street or a
|
||||||
|
* single chatty tracker could mint a fake "home" in seconds, which would then
|
||||||
|
* baseline-trust (silence alerts for) any tracker seen there — including a real
|
||||||
|
* stalking device.
|
||||||
|
*/
|
||||||
suspend fun noteLocation(placeDao: PlaceDao, geohash6: String, now: Long): Boolean {
|
suspend fun noteLocation(placeDao: PlaceDao, geohash6: String, now: Long): Boolean {
|
||||||
val existing = placeDao.get(geohash6)
|
val existing = placeDao.get(geohash6) ?: run {
|
||||||
val visits = (existing?.visitCount ?: 0) + 1
|
placeDao.upsert(PlaceEntity(geohash6 = geohash6, visitCount = 1, lastSeen = now, anchor = false))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (now - existing.lastSeen < DWELL_BUCKET_MS) return existing.anchor
|
||||||
|
val visits = existing.visitCount + 1
|
||||||
val anchor = visits >= ANCHOR_MIN_VISITS
|
val anchor = visits >= ANCHOR_MIN_VISITS
|
||||||
placeDao.upsert(
|
placeDao.upsert(existing.copy(visitCount = visits, lastSeen = now, anchor = anchor))
|
||||||
PlaceEntity(
|
|
||||||
geohash6 = geohash6,
|
|
||||||
label = existing?.label ?: "",
|
|
||||||
visitCount = visits,
|
|
||||||
lastSeen = now,
|
|
||||||
anchor = anchor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return anchor
|
return anchor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,8 +45,16 @@ class BleTrackerScanner(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
private val filters: List<ScanFilter> = buildList {
|
private val filters: List<ScanFilter> = buildList {
|
||||||
// Apple Find My — match presence of Apple manufacturer data.
|
// Apple Find My — match ONLY the offline-finding message type (0x12), not
|
||||||
add(ScanFilter.Builder().setManufacturerData(TrackerSignatures.APPLE_COMPANY_ID, ByteArray(0)).build())
|
// every Apple device (iPhones/Watches/AirPods all advertise company 0x004C).
|
||||||
|
// Data+mask apply from the first manufacturer-data byte, which is the type.
|
||||||
|
add(
|
||||||
|
ScanFilter.Builder().setManufacturerData(
|
||||||
|
TrackerSignatures.APPLE_COMPANY_ID,
|
||||||
|
byteArrayOf(TrackerSignatures.APPLE_TYPE_FINDMY.toByte()),
|
||||||
|
byteArrayOf(0xFF.toByte())
|
||||||
|
).build()
|
||||||
|
)
|
||||||
// FMDN / Samsung / Tile / DULT — match their service UUIDs.
|
// FMDN / Samsung / Tile / DULT — match their service UUIDs.
|
||||||
for (uuid in TrackerSignatures.trackerServiceUuids) {
|
for (uuid in TrackerSignatures.trackerServiceUuids) {
|
||||||
add(ScanFilter.Builder().setServiceUuid(uuid).build())
|
add(ScanFilter.Builder().setServiceUuid(uuid).build())
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import android.os.VibrationEffect
|
|||||||
import android.os.Vibrator
|
import android.os.Vibrator
|
||||||
import android.os.VibratorManager
|
import android.os.VibratorManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.lifecycle.LifecycleService
|
import androidx.lifecycle.LifecycleService
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
@@ -49,6 +50,7 @@ class ScanService : LifecycleService() {
|
|||||||
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
|
private const val CLONE_COOLDOWN_MS = 2 * 3_600_000L
|
||||||
|
private const val MIN_RECORD_INTERVAL_MS = 15_000L // per-device DB throttle
|
||||||
|
|
||||||
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"
|
||||||
@@ -75,7 +77,8 @@ class ScanService : LifecycleService() {
|
|||||||
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 val presence = PresenceEngine()
|
||||||
private var lastCloneAlertMs = 0L
|
private val lastRecordedAt = ConcurrentHashMap<String, Long>()
|
||||||
|
@Volatile private var lastCloneAlertMs = 0L
|
||||||
private var pruneJob: Job? = null
|
private var pruneJob: Job? = null
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
@@ -131,7 +134,16 @@ class ScanService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Temporal co-movement (identity path) — persisted + evaluated off-thread.
|
// Temporal co-movement (identity path). CALLBACK_TYPE_ALL_MATCHES fires on
|
||||||
|
// every advert (many/sec), so throttle the persisted path per device: the
|
||||||
|
// presence engine above still sees every advert, but the DB records at most
|
||||||
|
// one sighting per device per MIN_RECORD_INTERVAL_MS. Keeps the count
|
||||||
|
// meaningful and avoids hammering the database.
|
||||||
|
val last = lastRecordedAt[obs.stableId]
|
||||||
|
if (last != null && obs.timestampMs - last < MIN_RECORD_INTERVAL_MS) return
|
||||||
|
lastRecordedAt[obs.stableId] = obs.timestampMs
|
||||||
|
if (lastRecordedAt.size > 4096) lastRecordedAt.clear() // guard vs MAC-rotation growth
|
||||||
|
|
||||||
lifecycleScope.launch {
|
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)
|
||||||
@@ -145,6 +157,7 @@ class ScanService : LifecycleService() {
|
|||||||
_running.value = false
|
_running.value = false
|
||||||
scanner.stop()
|
scanner.stop()
|
||||||
location.stop()
|
location.stop()
|
||||||
|
lastRecordedAt.clear()
|
||||||
pruneJob?.cancel(); pruneJob = null
|
pruneJob?.cancel(); pruneJob = null
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
|||||||
@@ -76,7 +76,10 @@ fun MainScreen(
|
|||||||
) {
|
) {
|
||||||
var detail by remember { mutableStateOf<TrackerEntity?>(null) }
|
var detail by remember { mutableStateOf<TrackerEntity?>(null) }
|
||||||
|
|
||||||
val active = trackers.filter { !isTrusted(it) }.sortedByDescending { riskRank(it) }
|
val now = System.currentTimeMillis()
|
||||||
|
val active = trackers
|
||||||
|
.filter { !isTrusted(it) && now - it.lastSeen < ACTIVE_WINDOW_MS }
|
||||||
|
.sortedByDescending { riskRank(it) }
|
||||||
val trusted = trackers.filter { isTrusted(it) }
|
val trusted = trackers.filter { isTrusted(it) }
|
||||||
val alerting = active.count { statusOf(it) == TrackerStatus.ALERTING }
|
val alerting = active.count { statusOf(it) == TrackerStatus.ALERTING }
|
||||||
val suspicious = active.count { statusOf(it) == TrackerStatus.SUSPICIOUS }
|
val suspicious = active.count { statusOf(it) == TrackerStatus.SUSPICIOUS }
|
||||||
@@ -302,7 +305,7 @@ private fun TrackerCard(t: TrackerEntity, onClick: () -> Unit, onApprove: (Strin
|
|||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(ecosystemDisplay(t.ecosystem), fontWeight = FontWeight.SemiBold)
|
Text(ecosystemDisplay(t.ecosystem), fontWeight = FontWeight.SemiBold)
|
||||||
Text(
|
Text(
|
||||||
"${statusLabel(status)} · ${t.sightingCount} sightings · ${relative(t.lastSeen)}",
|
"${statusLabel(status)} · ${t.distinctPlaces} places · ${t.lastRssi} dBm · ${relative(t.lastSeen)}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -333,11 +336,14 @@ private fun TrackerDetail(t: TrackerEntity, onApprove: (String, Boolean) -> Unit
|
|||||||
Text(statusLabel(status), color = statusColor(status), fontWeight = FontWeight.SemiBold)
|
Text(statusLabel(status), color = statusColor(status), fontWeight = FontWeight.SemiBold)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
DetailRow("Sightings", t.sightingCount.toString())
|
DetailRow("Signal (last / peak)", "${t.lastRssi} / ${t.peakRssi} dBm")
|
||||||
|
DetailRow("Distinct places", t.distinctPlaces.toString())
|
||||||
|
DetailRow("Co-movement sightings", t.effectiveSightings.toString())
|
||||||
|
DetailRow("Adverts logged", t.sightingCount.toString())
|
||||||
DetailRow("First seen", relative(t.firstSeen))
|
DetailRow("First seen", relative(t.firstSeen))
|
||||||
DetailRow("Last seen", relative(t.lastSeen))
|
DetailRow("Last seen", relative(t.lastSeen))
|
||||||
if (t.anchorDayCount > 0) {
|
if (t.anchorDayCount > 0) {
|
||||||
DetailRow("Days seen at your places", "${t.anchorDayCount} (trusted at 3)")
|
DetailRow("Days at your places", "${t.anchorDayCount} / 3 to trust")
|
||||||
}
|
}
|
||||||
DetailRow("Identity", t.stableId.take(22) + "…")
|
DetailRow("Identity", t.stableId.take(22) + "…")
|
||||||
|
|
||||||
@@ -371,6 +377,8 @@ private fun DetailRow(label: String, value: String) {
|
|||||||
|
|
||||||
// ---- pure helpers ----------------------------------------------------------
|
// ---- pure helpers ----------------------------------------------------------
|
||||||
|
|
||||||
|
private const val ACTIVE_WINDOW_MS = 10 * 60_000L // trackers not seen this recently drop off "Active"
|
||||||
|
|
||||||
private fun isTrusted(t: TrackerEntity) = t.approved || t.baselineSafe
|
private fun isTrusted(t: TrackerEntity) = t.approved || t.baselineSafe
|
||||||
|
|
||||||
private fun riskRank(t: TrackerEntity): Int = when (statusOf(t)) {
|
private fun riskRank(t: TrackerEntity): Int = when (statusOf(t)) {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package org.soulstone.vigil.detect
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
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
|
||||||
|
|
||||||
|
/** Locks the co-movement alert logic: dedup, the RSSI proximity gate, and the
|
||||||
|
* distinct-places / separated-state thresholds. MEDIUM = 3 sightings / 3 places /
|
||||||
|
* 45 min / -85 dBm. */
|
||||||
|
class CoMovementEvaluatorTest {
|
||||||
|
|
||||||
|
private val t = CoMovementEvaluator.thresholdsFor(Sensitivity.MEDIUM)
|
||||||
|
|
||||||
|
private fun s(tsMin: Long, rssi: Int, cell: String?, separated: Boolean) =
|
||||||
|
SightingEntity(trackerId = "x", timestamp = tsMin * 60_000L, rssi = rssi, separated = separated, geohash7 = cell)
|
||||||
|
|
||||||
|
@Test fun emptyIsObserved() {
|
||||||
|
val a = CoMovementEvaluator.evaluate(emptyList(), TrackerEcosystem.APPLE_FIND_MY, t)
|
||||||
|
assertEquals(RiskState.OBSERVED, a.riskState)
|
||||||
|
assertEquals(0, a.distinctPlaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun separatedCloseAcrossPlacesAlerts() {
|
||||||
|
val list = listOf(
|
||||||
|
s(0, -70, "u000001", true),
|
||||||
|
s(30, -68, "u000002", true),
|
||||||
|
s(60, -72, "u000003", true)
|
||||||
|
)
|
||||||
|
val a = CoMovementEvaluator.evaluate(list, TrackerEcosystem.APPLE_FIND_MY, t)
|
||||||
|
assertEquals(RiskState.ALERTING, a.riskState)
|
||||||
|
assertEquals(3, a.distinctPlaces)
|
||||||
|
assertEquals(3, a.sightings)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun farSignalIsGatedOut() {
|
||||||
|
val list = listOf(
|
||||||
|
s(0, -95, "u000001", true),
|
||||||
|
s(30, -96, "u000002", true),
|
||||||
|
s(60, -97, "u000003", true)
|
||||||
|
)
|
||||||
|
// Seen across 3 places over an hour, but never close -> the RSSI gate holds it back.
|
||||||
|
assertEquals(RiskState.OBSERVED, CoMovementEvaluator.evaluate(list, TrackerEcosystem.APPLE_FIND_MY, t).riskState)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun tooFewPlacesIsSuspiciousNotAlerting() {
|
||||||
|
val list = listOf(
|
||||||
|
s(0, -70, "u000001", true),
|
||||||
|
s(30, -70, "u000001", true),
|
||||||
|
s(60, -70, "u000002", true)
|
||||||
|
)
|
||||||
|
val a = CoMovementEvaluator.evaluate(list, TrackerEcosystem.APPLE_FIND_MY, t)
|
||||||
|
assertEquals(RiskState.SUSPICIOUS, a.riskState)
|
||||||
|
assertEquals(2, a.distinctPlaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun nearOwnerNeverEscalates() {
|
||||||
|
val list = listOf(
|
||||||
|
s(0, -70, "u000001", false),
|
||||||
|
s(30, -70, "u000002", false),
|
||||||
|
s(60, -70, "u000003", false)
|
||||||
|
)
|
||||||
|
assertEquals(RiskState.OBSERVED, CoMovementEvaluator.evaluate(list, TrackerEcosystem.APPLE_FIND_MY, t).riskState)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun tileHasNoSeparatedFlagButStillCounts() {
|
||||||
|
val list = listOf(
|
||||||
|
s(0, -70, "u000001", false),
|
||||||
|
s(30, -70, "u000002", false),
|
||||||
|
s(60, -70, "u000003", false)
|
||||||
|
)
|
||||||
|
// Tile emits no separated flag, so any Tile is a live candidate.
|
||||||
|
assertEquals(RiskState.ALERTING, CoMovementEvaluator.evaluate(list, TrackerEcosystem.TILE, t).riskState)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user