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
@@ -59,11 +59,14 @@ object CoMovementEvaluator {
val sorted = sightings.sortedBy { it.timestamp }
// Debounce: one effective sighting per DEDUP_MS so a chatty tag at 2s
// intervals doesn't trivially clear the count.
// intervals doesn't trivially clear the count. lastCounted is NULLABLE and
// always counts the first sighting — the old `Long.MIN_VALUE` seed overflowed
// (timestamp - Long.MIN_VALUE wraps negative), which pinned `effective` at 0
// and made the detector unable to EVER alert. (Caught by unit test.)
var effective = 0
var lastCounted = Long.MIN_VALUE
var lastCounted: Long? = null
for (s in sorted) {
if (s.timestamp - lastCounted >= DEDUP_MS) {
if (lastCounted == null || s.timestamp - lastCounted >= DEDUP_MS) {
effective++
lastCounted = s.timestamp
}
@@ -137,8 +137,11 @@ class PresenceEngine {
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()
// Coherence GATES the top tier: a crowd of many radios at many distances is
// incoherent (high RSSI variance), so it can reach PROBABLE at most, never
// CONFIRMED — this stops packed-transit false clone alarms.
val tier = when {
s >= 85 -> Tier.CONFIRMED
s >= 85 && coherent -> Tier.CONFIRMED
s >= 70 -> Tier.PROBABLE
s >= 40 -> Tier.WATCHING
else -> Tier.CLEAR