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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user