Critical fixes from logical-flaw sweep (round 2)

The evaluator test caught a real critical bug and the independent review found more:

- CoMovementEvaluator: DEDUP seed was Long.MIN_VALUE; timestamp - MIN_VALUE overflows negative, so the first sighting never counted, effective was ALWAYS 0, and the detector could NEVER alert. Now nullable-seeded. (Unit test now passes.)
- ScanService: alerts used the LOW-importance foreground channel, so on Android 8+ a real 'following you' warning made NO sound/heads-up and overwrote the ongoing notification. Added a dedicated HIGH-importance alert channel + distinct, dismissable notification IDs.
- TrackerParser (Apple): required the full offline-finding frame (>=25B). Short 'nearby' frames were falling back to using the type/status bytes as identity, merging many devices into one phantom 'tracker' that could false-alert forever.
- TrackerRepository: assess co-movement BEFORE baseline; NEVER baseline-trust a co-moving tag (a planted stalker tag co-moves), and auto-revoke trust if a trusted tag starts moving with you. setApproved/clearBaseline now under the same Mutex so record() can't clobber the user's choice.
- BaselineManager already switched to dwell (round 1); UI now offers an un-trust ('Not mine') control for baseline rows (clearBaseline DAO).
- BleTrackerScanner: match by service DATA presence, not service-UUID list (Samsung/Tile may not advertise the UUID entry — were potentially invisible).
- PresenceEngine: RSSI coherence now GATES the CONFIRMED tier (crowd/transit can't false-confirm).
- ScanService: fixed location-update leak on failed scan start (idempotent end()); prune runs at start not only after 6h.
- MainActivity: POST_NOTIFICATIONS denial no longer blocks scanning (BLE+location are essential; notifications optional).

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:14:17 -04:00
parent de58944ce7
commit 98edf4345a
9 changed files with 150 additions and 70 deletions
@@ -28,8 +28,15 @@ class TrackerRepository(private val db: VigilDatabase) {
fun observeTrackers(): Flow<List<TrackerEntity>> = db.trackerDao().observeAll()
suspend fun setApproved(id: String, approved: Boolean) =
// Under the same lock as record() so a concurrent observation can't clobber the
// user's choice back to its stale value.
suspend fun setApproved(id: String, approved: Boolean) = mutex.withLock {
db.trackerDao().setApproved(id, approved)
}
suspend fun clearBaseline(id: String) = mutex.withLock {
db.trackerDao().clearBaseline(id)
}
suspend fun prune(retentionDays: Int = RETENTION_DAYS) {
val cutoff = System.currentTimeMillis() - retentionDays * BaselineManager.DAY_MS
@@ -62,6 +69,15 @@ class TrackerRepository(private val db: VigilDatabase) {
)
val existing = db.trackerDao().get(obs.stableId)
val approved = existing?.approved ?: false
// Co-movement assessment first — it decides both display AND whether this
// tag is safe to baseline. Always computed (UI shows the evidence numbers).
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 coMoving = assessment.riskState != RiskState.OBSERVED
// --- baseline: learn tags that live where you live -------------------
var lastAnchorDay = existing?.lastAnchorDay ?: -1L
@@ -69,7 +85,10 @@ class TrackerRepository(private val db: VigilDatabase) {
var baselineSafe = existing?.baselineSafe ?: false
if (geohash6 != null) {
val isAnchor = BaselineManager.noteLocation(db.placeDao(), geohash6, now)
if (isAnchor) {
// ONLY baseline-trust a tag that is NOT co-moving with you. A planted
// stalker tag co-moves (that's the whole detection), so it stays
// SUSPICIOUS/ALERTING and must never be auto-trusted into silence.
if (isAnchor && !coMoving) {
val day = now / BaselineManager.DAY_MS
if (day != lastAnchorDay) {
lastAnchorDay = day
@@ -78,15 +97,9 @@ class TrackerRepository(private val db: VigilDatabase) {
if (anchorDayCount >= BaselineManager.BASELINE_MIN_DAYS) baselineSafe = true
}
}
// Safety revoke: if a previously-trusted tag ever starts co-moving, drop trust.
if (baselineSafe && coMoving) baselineSafe = false
val approved = existing?.approved ?: false
// 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
@@ -80,6 +80,11 @@ interface TrackerDao {
@Query("UPDATE trackers SET approved = :approved WHERE stableId = :id")
suspend fun setApproved(id: String, approved: Boolean)
// User says a baseline-trusted tag is "not mine" — drop trust and restart the
// day clock so it doesn't immediately re-trust.
@Query("UPDATE trackers SET baselineSafe = 0, anchorDayCount = 0, lastAnchorDay = -1 WHERE stableId = :id")
suspend fun clearBaseline(id: String)
// 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")