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:
Kara Zajac
2026-07-15 09:00:53 -04:00
parent ad81964398
commit de58944ce7
7 changed files with 168 additions and 37 deletions
@@ -1,6 +1,8 @@
package org.soulstone.vigil.data
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.TrackerEntity
import org.soulstone.vigil.data.db.VigilDatabase
@@ -18,6 +20,10 @@ import org.soulstone.vigil.util.Geohash
*/
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)
fun observeTrackers(): Flow<List<TrackerEntity>> = db.trackerDao().observeAll()
@@ -26,7 +32,9 @@ class TrackerRepository(private val db: VigilDatabase) {
db.trackerDao().setApproved(id, approved)
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?,
lon: Double?,
sensitivity: Sensitivity
): RecordResult {
): RecordResult = mutex.withLock {
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
@@ -73,16 +81,13 @@ class TrackerRepository(private val db: VigilDatabase) {
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
}
// Always compute the co-movement assessment so the UI can show grounded
// evidence; only *escalation* is suppressed for trusted (approved/baseline) tags.
val since = now - CoMovementEvaluator.WINDOW_MS
val recent = db.sightingDao().recentFor(obs.stableId, since)
val t = CoMovementEvaluator.thresholdsFor(sensitivity)
val assessment = CoMovementEvaluator.evaluate(recent, obs.ecosystem, t)
val riskState = if (approved || baselineSafe) RiskState.OBSERVED else assessment.riskState
val wasAlerting = existing?.riskState == RiskState.ALERTING.name
val cooldownOk = now - (existing?.lastAlertMs ?: 0) > CoMovementEvaluator.ALERT_COOLDOWN_MS
@@ -100,10 +105,14 @@ class TrackerRepository(private val db: VigilDatabase) {
baselineSafe = baselineSafe,
lastAlertMs = if (newlyAlerting) now else (existing?.lastAlertMs ?: 0),
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)
return RecordResult(updated, newlyAlerting)
RecordResult(updated, newlyAlerting)
}
companion object {
@@ -32,7 +32,12 @@ data class TrackerEntity(
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
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. */
@@ -74,6 +79,11 @@ interface TrackerDao {
@Query("UPDATE trackers SET approved = :approved WHERE stableId = :id")
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
@@ -99,7 +109,7 @@ interface PlaceDao {
@Database(
entities = [TrackerEntity::class, SightingEntity::class, PlaceEntity::class],
version = 1,
version = 2,
exportSchema = false
)
abstract class VigilDatabase : RoomDatabase() {