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
@@ -55,9 +55,12 @@ class BleTrackerScanner(
byteArrayOf(0xFF.toByte())
).build()
)
// FMDN / Samsung / Tile / DULT — match their service UUIDs.
// FMDN / Samsung / Tile / DULT — match by SERVICE DATA presence, not the
// service-UUID list. These carry their payload in service data and may not
// advertise the plain UUID-list entry, so setServiceUuid can miss them
// entirely (AirGuard uses service-data presence filters for exactly this).
for (uuid in TrackerSignatures.trackerServiceUuids) {
add(ScanFilter.Builder().setServiceUuid(uuid).build())
add(ScanFilter.Builder().setServiceData(uuid, ByteArray(0)).build())
}
}
@@ -26,15 +26,16 @@ object TrackerParser {
// --- Apple Find My (manufacturer data, company 0x004C) ---
record.getManufacturerSpecificData(Sig.APPLE_COMPANY_ID)?.let { d ->
if (d.isNotEmpty() && (d[0].toInt() and 0xFF) == Sig.APPLE_TYPE_FINDMY) {
// Android strips the 2-byte company id, so d = [type(0x12),
// length(0x19), status, key(22), keyTopBits, hint]. The maintained
// bit lives in the status byte (index 2): set => near owner,
// cleared/absent => treat as separated.
val status = if (d.size > 2) d[2].toInt() and 0xFF else 0
// Require the FULL offline-finding frame. After Android strips the company
// id, d = [type, len, status, key(22), keyTopBits, hint] (~27 bytes). Short
// "nearby" frames carry no key — ignore them rather than fabricate a shared
// identity from the type/status bytes, which merged many distinct devices
// into one phantom "tracker" and misclassified it as separated.
if (d.size >= 25 && (d[0].toInt() and 0xFF) == Sig.APPLE_TYPE_FINDMY) {
val status = d[2].toInt() and 0xFF // maintained bit set => near owner
val separated = if ((status and Sig.APPLE_STATUS_MAINTAINED_BIT) != 0)
SeparatedState.NEAR_OWNER else SeparatedState.SEPARATED
val keyBytes = if (d.size >= 25) d.copyOfRange(3, 25) else d
val keyBytes = d.copyOfRange(3, 25)
return TrackerObservation(
stableId = "apple:" + toHex(keyBytes),
ecosystem = TrackerEcosystem.APPLE_FIND_MY,