diff --git a/app/src/main/kotlin/org/soulstone/vigil/data/TrackerRepository.kt b/app/src/main/kotlin/org/soulstone/vigil/data/TrackerRepository.kt index 8cf20d9..150980f 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/data/TrackerRepository.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/data/TrackerRepository.kt @@ -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> = 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 { diff --git a/app/src/main/kotlin/org/soulstone/vigil/data/db/VigilDatabase.kt b/app/src/main/kotlin/org/soulstone/vigil/data/db/VigilDatabase.kt index c2e0280..8e87e81 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/data/db/VigilDatabase.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/data/db/VigilDatabase.kt @@ -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() { diff --git a/app/src/main/kotlin/org/soulstone/vigil/detect/BaselineManager.kt b/app/src/main/kotlin/org/soulstone/vigil/detect/BaselineManager.kt index c8034b0..632de8a 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/detect/BaselineManager.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/detect/BaselineManager.kt @@ -18,23 +18,29 @@ import org.soulstone.vigil.data.db.PlaceEntity object BaselineManager { const val DAY_MS = 86_400_000L - const val ANCHOR_MIN_VISITS = 60 + const val DWELL_BUCKET_MS = 10 * 60_000L // count at most one "visit" per 10-min dwell window + const val ANCHOR_MIN_VISITS = 18 // ~3h cumulative dwell before a cell counts as home/work const val BASELINE_MIN_DAYS = 3 - /** Record a location fix in its cell; returns whether that cell is now an anchor. */ + /** + * Note presence in a cell; returns whether it's an anchor. Crucially a cell + * accrues at most ONE visit per [DWELL_BUCKET_MS], so the anchor signal tracks + * time actually spent there — not how many trackers or adverts were seen. + * + * The previous per-sighting count was a real safety flaw: a busy street or a + * single chatty tracker could mint a fake "home" in seconds, which would then + * baseline-trust (silence alerts for) any tracker seen there — including a real + * stalking device. + */ suspend fun noteLocation(placeDao: PlaceDao, geohash6: String, now: Long): Boolean { - val existing = placeDao.get(geohash6) - val visits = (existing?.visitCount ?: 0) + 1 + val existing = placeDao.get(geohash6) ?: run { + placeDao.upsert(PlaceEntity(geohash6 = geohash6, visitCount = 1, lastSeen = now, anchor = false)) + return false + } + if (now - existing.lastSeen < DWELL_BUCKET_MS) return existing.anchor + val visits = existing.visitCount + 1 val anchor = visits >= ANCHOR_MIN_VISITS - placeDao.upsert( - PlaceEntity( - geohash6 = geohash6, - label = existing?.label ?: "", - visitCount = visits, - lastSeen = now, - anchor = anchor - ) - ) + placeDao.upsert(existing.copy(visitCount = visits, lastSeen = now, anchor = anchor)) return anchor } } diff --git a/app/src/main/kotlin/org/soulstone/vigil/scan/BleTrackerScanner.kt b/app/src/main/kotlin/org/soulstone/vigil/scan/BleTrackerScanner.kt index 65e2d79..bfe7dcb 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/scan/BleTrackerScanner.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/scan/BleTrackerScanner.kt @@ -45,8 +45,16 @@ class BleTrackerScanner( .build() private val filters: List = buildList { - // Apple Find My — match presence of Apple manufacturer data. - add(ScanFilter.Builder().setManufacturerData(TrackerSignatures.APPLE_COMPANY_ID, ByteArray(0)).build()) + // Apple Find My — match ONLY the offline-finding message type (0x12), not + // every Apple device (iPhones/Watches/AirPods all advertise company 0x004C). + // Data+mask apply from the first manufacturer-data byte, which is the type. + add( + ScanFilter.Builder().setManufacturerData( + TrackerSignatures.APPLE_COMPANY_ID, + byteArrayOf(TrackerSignatures.APPLE_TYPE_FINDMY.toByte()), + byteArrayOf(0xFF.toByte()) + ).build() + ) // FMDN / Samsung / Tile / DULT — match their service UUIDs. for (uuid in TrackerSignatures.trackerServiceUuids) { add(ScanFilter.Builder().setServiceUuid(uuid).build()) diff --git a/app/src/main/kotlin/org/soulstone/vigil/service/ScanService.kt b/app/src/main/kotlin/org/soulstone/vigil/service/ScanService.kt index 11e8d35..2ffcaae 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/service/ScanService.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/service/ScanService.kt @@ -12,6 +12,7 @@ import android.os.VibrationEffect import android.os.Vibrator import android.os.VibratorManager import android.util.Log +import java.util.concurrent.ConcurrentHashMap import androidx.core.app.NotificationCompat import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope @@ -49,6 +50,7 @@ class ScanService : LifecycleService() { private const val NOTIFICATION_ID = 0x5161 // "VIGIL" private const val PRUNE_INTERVAL_MS = 6 * 3_600_000L private const val CLONE_COOLDOWN_MS = 2 * 3_600_000L + private const val MIN_RECORD_INTERVAL_MS = 15_000L // per-device DB throttle const val ACTION_START = "org.soulstone.vigil.action.START" const val ACTION_STOP = "org.soulstone.vigil.action.STOP" @@ -75,7 +77,8 @@ class ScanService : LifecycleService() { private lateinit var location: LocationProvider private lateinit var scanner: BleTrackerScanner private val presence = PresenceEngine() - private var lastCloneAlertMs = 0L + private val lastRecordedAt = ConcurrentHashMap() + @Volatile private var lastCloneAlertMs = 0L private var pruneJob: Job? = null override fun onCreate() { @@ -131,7 +134,16 @@ class ScanService : LifecycleService() { } } - // Temporal co-movement (identity path) — persisted + evaluated off-thread. + // Temporal co-movement (identity path). CALLBACK_TYPE_ALL_MATCHES fires on + // every advert (many/sec), so throttle the persisted path per device: the + // presence engine above still sees every advert, but the DB records at most + // one sighting per device per MIN_RECORD_INTERVAL_MS. Keeps the count + // meaningful and avoids hammering the database. + val last = lastRecordedAt[obs.stableId] + if (last != null && obs.timestampMs - last < MIN_RECORD_INTERVAL_MS) return + lastRecordedAt[obs.stableId] = obs.timestampMs + if (lastRecordedAt.size > 4096) lastRecordedAt.clear() // guard vs MAC-rotation growth + lifecycleScope.launch { val result = runCatching { repo.record(obs, fix?.latitude, fix?.longitude, settings.sensitivity.value) @@ -145,6 +157,7 @@ class ScanService : LifecycleService() { _running.value = false scanner.stop() location.stop() + lastRecordedAt.clear() pruneJob?.cancel(); pruneJob = null if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { stopForeground(STOP_FOREGROUND_REMOVE) diff --git a/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt b/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt index 69fd700..d09c382 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt @@ -76,7 +76,10 @@ fun MainScreen( ) { var detail by remember { mutableStateOf(null) } - val active = trackers.filter { !isTrusted(it) }.sortedByDescending { riskRank(it) } + val now = System.currentTimeMillis() + val active = trackers + .filter { !isTrusted(it) && now - it.lastSeen < ACTIVE_WINDOW_MS } + .sortedByDescending { riskRank(it) } val trusted = trackers.filter { isTrusted(it) } val alerting = active.count { statusOf(it) == TrackerStatus.ALERTING } val suspicious = active.count { statusOf(it) == TrackerStatus.SUSPICIOUS } @@ -302,7 +305,7 @@ private fun TrackerCard(t: TrackerEntity, onClick: () -> Unit, onApprove: (Strin Column(Modifier.weight(1f)) { Text(ecosystemDisplay(t.ecosystem), fontWeight = FontWeight.SemiBold) Text( - "${statusLabel(status)} · ${t.sightingCount} sightings · ${relative(t.lastSeen)}", + "${statusLabel(status)} · ${t.distinctPlaces} places · ${t.lastRssi} dBm · ${relative(t.lastSeen)}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -333,11 +336,14 @@ private fun TrackerDetail(t: TrackerEntity, onApprove: (String, Boolean) -> Unit Text(statusLabel(status), color = statusColor(status), fontWeight = FontWeight.SemiBold) Spacer(Modifier.height(16.dp)) - DetailRow("Sightings", t.sightingCount.toString()) + DetailRow("Signal (last / peak)", "${t.lastRssi} / ${t.peakRssi} dBm") + DetailRow("Distinct places", t.distinctPlaces.toString()) + DetailRow("Co-movement sightings", t.effectiveSightings.toString()) + DetailRow("Adverts logged", t.sightingCount.toString()) DetailRow("First seen", relative(t.firstSeen)) DetailRow("Last seen", relative(t.lastSeen)) if (t.anchorDayCount > 0) { - DetailRow("Days seen at your places", "${t.anchorDayCount} (trusted at 3)") + DetailRow("Days at your places", "${t.anchorDayCount} / 3 to trust") } DetailRow("Identity", t.stableId.take(22) + "…") @@ -371,6 +377,8 @@ private fun DetailRow(label: String, value: String) { // ---- pure helpers ---------------------------------------------------------- +private const val ACTIVE_WINDOW_MS = 10 * 60_000L // trackers not seen this recently drop off "Active" + private fun isTrusted(t: TrackerEntity) = t.approved || t.baselineSafe private fun riskRank(t: TrackerEntity): Int = when (statusOf(t)) { diff --git a/app/src/test/kotlin/org/soulstone/vigil/detect/CoMovementEvaluatorTest.kt b/app/src/test/kotlin/org/soulstone/vigil/detect/CoMovementEvaluatorTest.kt new file mode 100644 index 0000000..b4e95d8 --- /dev/null +++ b/app/src/test/kotlin/org/soulstone/vigil/detect/CoMovementEvaluatorTest.kt @@ -0,0 +1,77 @@ +package org.soulstone.vigil.detect + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.soulstone.vigil.data.db.SightingEntity +import org.soulstone.vigil.model.RiskState +import org.soulstone.vigil.model.Sensitivity +import org.soulstone.vigil.model.TrackerEcosystem + +/** Locks the co-movement alert logic: dedup, the RSSI proximity gate, and the + * distinct-places / separated-state thresholds. MEDIUM = 3 sightings / 3 places / + * 45 min / -85 dBm. */ +class CoMovementEvaluatorTest { + + private val t = CoMovementEvaluator.thresholdsFor(Sensitivity.MEDIUM) + + private fun s(tsMin: Long, rssi: Int, cell: String?, separated: Boolean) = + SightingEntity(trackerId = "x", timestamp = tsMin * 60_000L, rssi = rssi, separated = separated, geohash7 = cell) + + @Test fun emptyIsObserved() { + val a = CoMovementEvaluator.evaluate(emptyList(), TrackerEcosystem.APPLE_FIND_MY, t) + assertEquals(RiskState.OBSERVED, a.riskState) + assertEquals(0, a.distinctPlaces) + } + + @Test fun separatedCloseAcrossPlacesAlerts() { + val list = listOf( + s(0, -70, "u000001", true), + s(30, -68, "u000002", true), + s(60, -72, "u000003", true) + ) + val a = CoMovementEvaluator.evaluate(list, TrackerEcosystem.APPLE_FIND_MY, t) + assertEquals(RiskState.ALERTING, a.riskState) + assertEquals(3, a.distinctPlaces) + assertEquals(3, a.sightings) + } + + @Test fun farSignalIsGatedOut() { + val list = listOf( + s(0, -95, "u000001", true), + s(30, -96, "u000002", true), + s(60, -97, "u000003", true) + ) + // Seen across 3 places over an hour, but never close -> the RSSI gate holds it back. + assertEquals(RiskState.OBSERVED, CoMovementEvaluator.evaluate(list, TrackerEcosystem.APPLE_FIND_MY, t).riskState) + } + + @Test fun tooFewPlacesIsSuspiciousNotAlerting() { + val list = listOf( + s(0, -70, "u000001", true), + s(30, -70, "u000001", true), + s(60, -70, "u000002", true) + ) + val a = CoMovementEvaluator.evaluate(list, TrackerEcosystem.APPLE_FIND_MY, t) + assertEquals(RiskState.SUSPICIOUS, a.riskState) + assertEquals(2, a.distinctPlaces) + } + + @Test fun nearOwnerNeverEscalates() { + val list = listOf( + s(0, -70, "u000001", false), + s(30, -70, "u000002", false), + s(60, -70, "u000003", false) + ) + assertEquals(RiskState.OBSERVED, CoMovementEvaluator.evaluate(list, TrackerEcosystem.APPLE_FIND_MY, t).riskState) + } + + @Test fun tileHasNoSeparatedFlagButStillCounts() { + val list = listOf( + s(0, -70, "u000001", false), + s(30, -70, "u000002", false), + s(60, -70, "u000003", false) + ) + // Tile emits no separated flag, so any Tile is a live candidate. + assertEquals(RiskState.ALERTING, CoMovementEvaluator.evaluate(list, TrackerEcosystem.TILE, t).riskState) + } +}